Compare commits

...

137 Commits

Author SHA1 Message Date
Corentin Thomasset
5ce21981a9 chore(release): update versions (#370)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-13 21:53:55 +02:00
Corentin Thomasset
3401cfbfdc feat(config): introduce appBaseUrl for overriding client and server base URLs (#405)
- Added appBaseUrl configuration to allow users to set a base URL that overrides both client and server base URLs.
- Updated documentation to reflect the new configuration variable and its usage.
- Refactored relevant code to utilize appBaseUrl where applicable, ensuring consistent behavior across the application.
- Enhanced tests to verify the correct application of appBaseUrl in various scenarios.
2025-07-13 21:46:07 +02:00
Adrian Ortiz
26015666de feat(i18n): add Spanish language support (#411)
* feat(i18n): add Spanish language support to the app

- Add es.yml localization file with full Spanish translations
- Register 'es' language in the i18n locales constant

* Create shiny-dancers-count.md

* style(es.yml): fix formatting and spacing in es.yml

- Remove unnecessary quotes in 'organizations.create-first.user-name'
- Fix extra space in the '# API keys' comment

* Update .changeset/shiny-dancers-count.md

---------

Co-authored-by: Adrian Ortiz <desarrollador3@en-trega.com>
Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2025-07-09 21:41:04 +00:00
Corentin Thomasset
09e3bc5e15 feat(i18n): finalize Romanian translationsetup (#408)
* feat: Romanian translation

* chore(i18n): finalized Romanian translation

---------

Co-authored-by: Razvan M. <76774976+iRazvan2745@users.noreply.github.com>
2025-07-09 00:32:04 +02:00
Piotr Icikowski
1711ef866d chore(i18n): added Polish translations (#403)
* feat(i18n): add Polish translation

* chore(version): added changeset

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2025-07-08 00:01:18 +02:00
Corentin Thomasset
1d23f40894 fix(docs): update schema URL in configuration examples to use the correct domain (#402) 2025-07-07 12:00:35 +00:00
juoum
40a1f91b67 feat(i18n): Added European Portuguese (pt) translation (#391)
* Add European Portuguese (pt) translation

* chore: auto lint

* chore: added changeset

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2025-07-04 23:12:27 +02:00
Corentin Thomasset
47b69b15f4 fix(organization-settings): update back button link to use organization ID in URL (#399) 2025-07-04 23:03:19 +02:00
Corentin Thomasset
a188af1f88 chore(lint): enabled type-aware linting (#398) 2025-07-04 22:55:42 +02:00
Corentin Thomasset
f28d8245bf feat(auth): added configuration to disable auth by email (#394) 2025-07-02 13:36:19 +02:00
Corentin Thomasset
aad36f3252 fix(documents): corrected weird centering for long file names (#393) 2025-07-01 22:01:53 +00:00
Corentin Thomasset
21a5ccce6d fix(docker): set COREPACK_HOME for rootless image to avoid permission issues (#392) 2025-07-01 23:25:24 +02:00
Corentin Thomasset
42bc3c6698 feat(docs): added api endpoint doc page (#390) 2025-07-01 18:47:41 +02:00
Corentin Thomasset
a9f474dc2d Merge pull request #388 from papra-hq/lecture-integration
chore(setup): integrated lecture package in the monorepo
2025-06-30 21:20:13 +02:00
Corentin Thomasset
ed5a93cb47 chore: update repository URLs and clean up package configurations 2025-06-30 21:17:22 +02:00
Corentin Thomasset
52df988c02 Add 'packages/lecture/' from commit '9b2a4b2ae90de0cc5ba1c7a3f14b308185e9c705'
git-subtree-dir: packages/lecture
git-subtree-mainline: 73b8d08076
git-subtree-split: 9b2a4b2ae9
2025-06-30 21:03:28 +02:00
Corentin Thomasset
73b8d08076 feat(documents): added configuration for the ocr languages (#387) 2025-06-29 22:14:58 +02:00
Corentin Thomasset
9b2a4b2ae9 chore: release v0.0.7 2025-06-29 16:08:54 +02:00
Corentin Thomasset
2a8b88e48a refactor(extractors): added config in high-order extraction methods (#4) 2025-06-29 14:08:11 +00:00
Corentin Thomasset
be1b70a26a chore: release v0.0.6 2025-06-29 15:56:28 +02:00
Corentin Thomasset
1755849483 refactor(config): rename and export ocrLanguages (#3) 2025-06-29 15:54:22 +02:00
Corentin Thomasset
b3693fd9c9 chore: release v0.0.5 2025-06-29 15:01:14 +02:00
Corentin Thomasset
2149b50270 feat(config): added the possibility to configure tesseract ocr (#2) 2025-06-29 15:00:37 +02:00
Lucas Arantes
0b276ee0d5 feat(i18n): add Brazilian Portuguese (pt-BR) translation (#383)
* feat(i18n): add Portuguese (pt-BR) translation

* fix(i18n): remove trailing space in pt-BR.yml

* chore: added changeset

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2025-06-26 22:31:20 +00:00
Corentin Thomasset
56fb9ec2c4 docs(CONTRIBUTING): update dev instructions with package build step (#382) 2025-06-25 13:37:12 +02:00
Corentin Thomasset
6cedc30716 chore(deps): updated dependencies (#379) 2025-06-24 20:52:15 +02:00
Corentin Thomasset
f1e1b4037b feat(tags): add color picker and swatches for tag creation (#378) 2025-06-24 20:27:58 +02:00
Corentin Thomasset
205c6cfd46 feat(preview): improved document preview for text-like files (#377) 2025-06-24 00:11:40 +02:00
Alex
c54a71d2c5 fix(tags): allow for uppercase tag color code (#346)
* Update tags.page.tsx

* Fixes 400 error when submitting tags with uppercase hex colour codes.

Fixes 400 error when submitting tags with uppercase hex colour codes.

* Update .changeset/few-toes-ask.md

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2025-06-19 11:45:06 +02:00
Corentin Thomasset
62b7f0382c chore(release): update versions (#358)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-18 22:11:19 +02:00
Corentin Thomasset
57c6a26657 fix(demo): case insensitive dummy search in demo (#367) 2025-06-18 19:03:10 +00:00
Corentin Thomasset
b8c2bd70e3 feat(tags): allow for adding/removing tags to document using api keys (#366) 2025-06-18 20:58:03 +02:00
Marvin Deuschle
0c2cf698d1 feat(i18n): added German translation (#359)
* feat: Add german translation

* fix: Added changeset entry

* Update apps/papra-client/src/locales/de.yml

* Update apps/papra-client/src/locales/de.yml

* Update apps/papra-client/src/locales/de.yml

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2025-06-15 21:51:13 +02:00
Corentin Thomasset
585c53cd9d chore(changesets): added /llms.txt announcement changesets (#357) 2025-06-14 19:16:28 +02:00
Corentin Thomasset
f035458e16 feat(docs): added descriptions in docs-navigation.json (#354) 2025-06-14 00:37:47 +02:00
Corentin Thomasset
556fd8b167 feat(docs): added navigation json export (#341) 2025-06-10 21:30:56 +02:00
Corentin Thomasset
81e85295ba chore(release): update versions (#334)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-07 17:40:28 +02:00
Corentin Thomasset
1c574b8305 feat(script): ensure local database directory exists before running scripts (#337) 2025-06-07 17:26:28 +02:00
Corentin Thomasset
ff830c234a fix(client): corrected version release link (#333) 2025-06-07 15:09:08 +02:00
Corentin Thomasset
451564f354 docs(readme): updated features statuses (#328) 2025-06-07 14:58:21 +02:00
Corentin Thomasset
ecd6af45c8 docs(README): update project status and add sponsorship section (#327) 2025-06-06 22:04:49 +00:00
Corentin Thomasset
cb652c7166 chore(release): update versions (#323)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-04 21:32:34 +02:00
Corentin Thomasset
17ca8f8f81 fix(documents): update Content-Disposition header to support UTF-8 encoded filenames (#326) 2025-06-04 21:30:06 +02:00
Corentin Thomasset
f54b8e162a feat(docs): auto compute urls from port in dc generator (#322) 2025-06-04 13:47:52 +02:00
Corentin Thomasset
6b435bba79 chore(release): update versions (#305)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-04 00:09:45 +02:00
Corentin Thomasset
8ccdb74834 refactor(docker): added base url override in docker (#320) 2025-06-03 22:04:15 +00:00
Corentin Thomasset
60059c895c feat(invitations): add invitations management page (#319) 2025-06-03 22:13:21 +02:00
Corentin Thomasset
6e22a93dff feat(locales): add fr translations for document activity logging (#318) 2025-05-30 13:58:00 +02:00
Corentin Thomasset
79c1d3206b feat(documents): added document activity logging (#317) 2025-05-30 13:45:25 +02:00
Corentin Thomasset
48a953a584 refactor(client): migrated tanstack createQuery and createMutation to useQuery and useMutation (#316) 2025-05-28 21:51:30 +02:00
Corentin Thomasset
fdb90fa164 feat(tags): add error handling for existing tags (#315) 2025-05-27 21:09:46 +02:00
Corentin Thomasset
e9a205c0a3 feat(documents): added document renaming (#314) 2025-05-27 20:11:05 +02:00
Corentin Thomasset
278db63fc8 chore(deps): updated some dependencies version (#313) 2025-05-27 13:46:43 +02:00
Corentin Thomasset
e5ef40f36c chore(version): added missing changeset for password reset fix (#312) 2025-05-26 20:30:24 +00:00
Corentin Thomasset
27c9e39422 fix(auth): fix deprecated better-auth database id generation conf (#311) 2025-05-26 20:27:32 +00:00
Corentin Thomasset
91d2e236d0 fix(auth): corrected password reset navigation guard (#310) 2025-05-26 22:19:33 +02:00
Corentin Thomasset
d4f72e889a refactor(client): hide manage subscription section (#309) 2025-05-26 21:42:19 +02:00
Corentin Thomasset
759a3ff713 feat(i18n): extracted hard coded text for i18n (#308) 2025-05-26 01:14:43 +02:00
Corentin Thomasset
34862991fb chore(cf): added security headers in docs and papra-client (#307) 2025-05-25 12:05:38 +00:00
Corentin Thomasset
f0876fdc63 feat(server): added smtp client support for emailing (#306) 2025-05-25 11:47:12 +02:00
Corentin Thomasset
cb38d66485 refactor(emails): restructure emails service to support multiple drivers (#304) 2025-05-25 01:26:28 +02:00
Corentin Thomasset
c28af1407f chore(release): update versions (#303)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-24 19:58:09 +02:00
Corentin Thomasset
b62ddf2bc4 chore(docker): add EMAILS_DRY_RUN environment variable to Dockerfiles (#302) 2025-05-24 17:55:59 +00:00
Corentin Thomasset
fa7909c62d chore(release): add 'actions' permission to changeset workflow (#301) 2025-05-24 17:38:19 +00:00
Corentin Thomasset
1996b51b4d chore(release): update versions (#292)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-24 18:17:41 +02:00
Corentin Thomasset
734027f00c feat(docs): updated feature list statuses (#300) 2025-05-24 18:08:32 +02:00
Corentin Thomasset
557cde940c feat(organizations): added member role update functionality (#299) 2025-05-24 17:13:32 +02:00
Corentin Thomasset
26a83052bd fix(intake-emails): enhance disabled intake-email state (#298) 2025-05-24 12:27:09 +00:00
Corentin Thomasset
5aac3f7ba6 fix(demo): added missing routes in demo (#297) 2025-05-24 12:16:05 +00:00
Corentin Thomasset
0ddc2340f0 fix(locales): update registration page description (#296) 2025-05-24 11:24:44 +00:00
Corentin Thomasset
438a31171c feat(auth): added support for custom oauth2 providers (#295) 2025-05-24 03:12:39 +02:00
Corentin Thomasset
53bf93f128 feat(doc): added a papra docker compose generator (#293) 2025-05-23 21:24:08 +00:00
Corentin Thomasset
b400b3f18d feat(database): ensure local database directory en boot (#294) 2025-05-23 22:21:33 +02:00
Corentin Thomasset
0627ec25a4 feat(organizations): add permission check for invitation (#291) 2025-05-21 23:06:43 +02:00
Corentin Thomasset
72e5a9a4de feat(invitations): added organizations invitations and multi-user (#289) 2025-05-21 21:53:56 +02:00
Corentin Thomasset
268ac8e358 chore(release): update Docker release workflow to use version input parameter (#286) 2025-05-14 13:11:19 +02:00
Corentin Thomasset
249b3bcfd2 chore(release): update versions (#285)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-13 22:44:37 +02:00
Corentin Thomasset
d7838b5d57 chore(release): remove commitMode from release job configuration (#284) 2025-05-13 20:14:08 +00:00
Corentin Thomasset
f170ddd817 chore(release): use PAT for release PR creation (#283) 2025-05-13 20:05:57 +00:00
Corentin Thomasset
4f53c70854 chore(release): update permissions for release job (#281) 2025-05-13 16:44:47 +00:00
Corentin Thomasset
85fa5c4342 chore(version): added changeset for versioning (#280) 2025-05-13 13:48:55 +02:00
Corentin Thomasset
c5d984a3a0 refactor(docker): build transitive dependencies (#277) 2025-05-08 20:47:57 +02:00
Corentin Thomasset
565bd8d7fd feat(webhooks): added webhook management and logic (#276) 2025-05-08 18:52:11 +02:00
Corentin Thomasset
9b72aa886c feat(cli): added cli documentation (#275) 2025-05-02 23:30:33 +02:00
Corentin Thomasset
7410455093 feat(cli): setup base cli (#274) 2025-05-02 00:20:57 +02:00
riskpoint-per
dd8f194fd0 feat(server): add azure blob storage support (#261)
* add azure blob storage support

* set stream to nodejs.readable

* Update apps/papra-server/src/modules/documents/storage/drivers/az-blob/az-blob.storage-driver.ts

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>

* fix lock file

* bugfixes

* fix lint issues

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2025-04-28 09:34:28 +02:00
Corentin Thomasset
803c39cbc8 chore(packages): added api sdk package (#270) 2025-04-27 22:08:59 +02:00
Joshua Anderson
096331a4ee feat(server): add support for b2 object storage type (#232)
* feat(b2): add support for b2 object storage type

* feat(b2): fix order of tsconfig entries

* feat(b2): fix accidental responseType change

* fix(b2): remove unnecessary try-catches

* refactor(b2): use error factories
2025-04-27 21:35:29 +02:00
Corentin Thomasset
59ba9465f6 docs(features): marked tagging rules and folder ingestion as available features (#268) 2025-04-27 13:57:11 +00:00
Corentin Thomasset
a1056702af feat(docs): fixed broken links with auto check (#267) 2025-04-27 13:20:34 +00:00
Corentin Thomasset
fd44897bca fix(documents): hard delete file in storage driver (#266) 2025-04-27 14:52:05 +02:00
Corentin Thomasset
332d836d11 fix(ingestion-folders): added schema validation coercion in config (#265) 2025-04-27 12:11:05 +00:00
Corentin Thomasset
f613198cbd refactor(storage): replace generic error messages with specific file not found errors (#264) 2025-04-27 13:52:47 +02:00
Corentin Thomasset
80491a5a58 chore(deps): update eslint and and eslint config (#260) 2025-04-27 13:43:17 +02:00
Corentin Thomasset
605e21a410 chore(deps): updated pnpm to 10.9.0 in all package.json files (#258) 2025-04-25 13:15:32 +00:00
Corentin Thomasset
dec589b6ed fix(documents): remove incorrect default tab value (#259) 2025-04-25 13:08:39 +00:00
Corentin Thomasset
c0bd6e2ae4 refactor(i18n): flattened keys directly in yaml (#255)
* refactor(i18n): flattened keys directly in yaml

* Update apps/papra-client/src/locales/fr.yml

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-04-24 21:17:37 +00:00
Corentin Thomasset
6287aaa973 feat(i18n): auto generate i18n type in dev mode (#254) 2025-04-24 20:03:21 +00:00
Corentin Thomasset
cc2edc59b0 feat(server): added api-keys (#248) 2025-04-24 21:13:56 +02:00
Corentin Thomasset
9cba84e38b refactor(client, services): updated API responses to use AsDto and date coercion (#250) 2025-04-22 22:35:48 +02:00
Corentin Thomasset
5fe401778d feat(documents): enhance document page with alert for content extraction details (#249) 2025-04-22 19:26:48 +00:00
Joshua Anderson
38aa1ea7f1 feat(documents): add document searchable content view and edit (#230)
* feat(documents): add tab for viewing content

* feat(documents): allow editing document content

* fix(demo): add content to demo document api

* refactor(documents): fix api structure for updating documents

* refactor(documents): return updated document after change

* refactor(documents): use correct api validation

* refactor(documents): move update to repository

* refactor(documents): limit height of content view

* refactor(documents): use new validation schemas
2025-04-22 21:14:57 +02:00
Corentin Thomasset
ab98c1b255 refactor(validation): mutualized resources ids for route validation (#241) 2025-04-19 16:27:51 +02:00
Corentin Thomasset
265f06f8b7 docs(CONTRIBUTING): updated guidelines for PRs (#240) 2025-04-18 21:47:59 +00:00
Corentin Thomasset
a787b7915c chore(github): added CODEOWNERS file (#239) 2025-04-18 21:40:37 +00:00
Joshua Anderson
0ba6a09923 fix(documents): fix padding when tags input wraps (#227)
* fix(documents): fix padding when tags input wraps

* fix(documents): use padding for y not just b
2025-04-18 22:58:50 +02:00
Joshua Anderson
6880bfd41c feat(documents): wrap txt document preview (#231)
* feat(documents): wrap txt document preview

* Update apps/papra-client/src/modules/documents/components/document-preview.component.tsx

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2025-04-18 22:43:48 +02:00
Corentin Thomasset
21a2c95e56 fix(tags): exclude deleted documents from tags docs count (#234) 2025-04-18 08:43:32 +00:00
Joshua Anderson
19e2083a71 feat(documents): add create tag button on document page (#220)
* feat(documents): fix tag selection input width

* feat(documents): add create tag button on document page

also added i18n just for the tag button, to be used for future localizing
2025-04-17 23:09:47 +02:00
Corentin Thomasset
e6b2d9fb2d refactor(documents): add functions to retrieve document name and extension (#217) 2025-04-16 23:07:54 +02:00
Corentin Thomasset
5140a64c40 chore: release v0.3.0 2025-04-16 20:00:09 +02:00
Corentin Thomasset
9ddb7d545d feat(ingestion): added folder ingestion support (#215) 2025-04-16 00:46:04 +02:00
Corentin Thomasset
2a73551ca4 feat(documents): restore document in trash when same file is uploaded (#213) 2025-04-11 22:08:55 +02:00
Corentin Thomasset
7be56455b0 feat(documents): implement document upload context with status (#212) 2025-04-11 18:41:38 +02:00
Corentin Thomasset
1085bf079c feat(documents): delete documents from the trash (#211) 2025-04-10 20:24:47 +00:00
Corentin Thomasset
b13986e1e3 refactor(config): remove support for wildcard '*' in trustedOrigins (#210) 2025-04-10 18:56:18 +00:00
Corentin Thomasset
d4462f942b refactor(auth, i18n): extracted hard coded text for i18n (#205)
* refactor(auth, i18n): extracted hard coded text for i18n

* Update apps/papra-client/src/modules/auth/pages/register.page.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-04-10 20:55:26 +02:00
Corentin Thomasset
2f2ad90fd3 feat(documents): made file upload limit disableable (#209) 2025-04-10 18:47:22 +00:00
Corentin Thomasset
2bbb68aa17 feat(tagging-rules): added documents auto tagging rules (#200) 2025-04-09 23:51:23 +02:00
Corentin Thomasset
2b2827cdb3 feat(demo): added Discord support link in demo popup (#203)
* feat(demo): added Discord support link in demo popup

* Update apps/papra-client/src/modules/demo/demo.provider.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-04-07 20:34:19 +00:00
Corentin Thomasset
4b4621e4d0 chore(issues): added issue template configuration with Discord community link (#202) 2025-04-06 20:57:23 +00:00
Corentin Thomasset
fd0f79feb0 docs(docker): improved docker and docker-compose instructions for Papra deployment (#201) 2025-04-06 19:25:02 +00:00
Corentin Thomasset
b9c2448805 feat(docs): implement text wrapping for documentation in .env configuration display (#199) 2025-04-06 07:37:50 +00:00
Corentin Thomasset
542225fabc feat(docs): add full .env configuration display in self-hosting guide (#198) 2025-04-06 09:19:07 +02:00
Corentin Thomasset
f37c7dd8f7 chore: release v0.0.4 2025-01-22 15:08:50 +01:00
Corentin Thomasset
a7fbf21a9f docs(readme): added images in supported file formats 2025-01-22 14:03:22 +01:00
Corentin Thomasset
f97e5f863e feat(extractors): add image extractor 2025-01-22 13:59:19 +01:00
Corentin Thomasset
8dcd6bc5ed feat(extractors): improved tests 2025-01-22 13:18:54 +01:00
Corentin Thomasset
87cb325369 fix(extractor): corrected name of text extractor 2025-01-22 13:11:39 +01:00
Corentin Thomasset
e1743954d2 chore: release v0.0.3 2025-01-22 11:40:18 +01:00
Corentin Thomasset
44b5b9fd5a refactor(interface): normalized api 2025-01-22 11:39:54 +01:00
Corentin Thomasset
68c5a3e2b7 refactor(npm): auto format package.json 2025-01-22 11:39:12 +01:00
Corentin Thomasset
684138c3fd chore(npm): added keywords in package.json 2025-01-22 04:30:55 +01:00
Corentin Thomasset
0aa3241712 chore: release v0.0.2 2025-01-22 04:29:02 +01:00
Corentin Thomasset
ad6358195e chore(cd): added actions for npm release 2025-01-22 04:27:44 +01:00
Corentin Thomasset
0e99669206 docs(readme): update README to include usage in Papra project 2025-01-22 04:18:34 +01:00
Corentin Thomasset
a91d98fb44 chore(setup): first commit 2025-01-22 04:16:54 +01:00
502 changed files with 44857 additions and 4124 deletions

8
.changeset/README.md Normal file
View File

@@ -0,0 +1,8 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)

19
.changeset/config.json Normal file
View File

@@ -0,0 +1,19 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
"changelog": [
"@changesets/changelog-github",
{ "repo": "papra-hq/papra"}
],
"commit": false,
"fixed": [
["@papra/app-client", "@papra/app-server"]
],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": [],
"privatePackages": {
"tag": true,
"version": true
}
}

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: 💬 Discord Community
url: https://papra.app/discord
about: Join the Papra Discord community to get help, share your feedback, and stay updated on the project.

View File

@@ -38,11 +38,12 @@ jobs:
- name: Run unit test
run: pnpm test
- name: Build the app
run: pnpm build
# Ensure locales types are up to date
# Ensure locales types are up to date, must be run before building the app
- name: Check locales types
run: |
pnpm script:generate-i18n-types
git diff --exit-code -- src/modules/i18n/locales.types.ts > /dev/null || (echo "Locales types are outdated, please run 'pnpm script:generate-i18n-types' and commit the changes." && exit 1)
git diff --exit-code -- src/modules/i18n/locales.types.ts > /dev/null || (echo "Locales types are outdated, please run 'pnpm script:generate-i18n-types' and commit the changes." && exit 1)
- name: Build the app
run: pnpm build

View File

@@ -26,7 +26,9 @@ jobs:
cache: 'pnpm'
- name: Install dependencies
run: pnpm i --frozen-lockfile
run: |
pnpm i --frozen-lockfile
pnpm --filter "@papra/app-server^..." build
- name: Run linters
run: pnpm lint

View File

@@ -0,0 +1,41 @@
name: CI - Api SDK
on:
pull_request:
push:
branches:
- main
jobs:
ci-packages-api-sdk:
name: CI - Api SDK
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/api-sdk
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm i
- name: Run linters
run: pnpm lint
- name: Type check
run: pnpm typecheck
# - name: Run unit test
# run: pnpm test
- name: Build the app
run: pnpm build

44
.github/workflows/ci-packages-cli.yaml vendored Normal file
View File

@@ -0,0 +1,44 @@
name: CI - CLI
on:
pull_request:
push:
branches:
- main
jobs:
ci-packages-cli:
name: CI - CLI
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/cli
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm i
- name: Build related packages
run: cd ../api-sdk && pnpm build
- name: Run linters
run: pnpm lint
- name: Type check
run: pnpm typecheck
# - name: Run unit test
# run: pnpm test
- name: Build the app
run: pnpm build

View File

@@ -0,0 +1,41 @@
name: CI - Lecture
on:
pull_request:
push:
branches:
- main
jobs:
ci-packages-lecture:
name: CI - Lecture
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/lecture
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm i
- name: Run linters
run: pnpm lint
- name: Type check
run: pnpm typecheck
- name: Run unit test
run: pnpm test
- name: Build the app
run: pnpm build

View File

@@ -0,0 +1,41 @@
name: CI - Webhooks
on:
pull_request:
push:
branches:
- main
jobs:
ci-packages-webhooks:
name: CI - Webhooks
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/webhooks
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm i
- name: Run linters
run: pnpm lint
- name: Type check
run: pnpm typecheck
- name: Run unit test
run: pnpm test
- name: Build the app
run: pnpm build

View File

@@ -1,9 +1,12 @@
name: Release new versions
on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
version:
description: 'Version to release'
required: true
type: string
permissions:
contents: read
@@ -14,14 +17,6 @@ jobs:
name: Release Docker images
runs-on: ubuntu-latest
steps:
- name: Get release version from tag
if: ${{ github.event_name == 'push' }}
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
- name: Get release version from input
if: ${{ github.event_name == 'workflow_dispatch' }}
run: echo "RELEASE_VERSION=${{ github.event.inputs.release_version }}" >> $GITHUB_ENV
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -53,9 +48,9 @@ jobs:
push: true
tags: |
corentinth/papra:latest-root
corentinth/papra:${{ env.RELEASE_VERSION }}-root
corentinth/papra:${{ inputs.version }}-root
ghcr.io/papra-hq/papra:latest-root
ghcr.io/papra-hq/papra:${{ env.RELEASE_VERSION }}-root
ghcr.io/papra-hq/papra:${{ inputs.version }}-root
- name: Build and push rootless Docker image
uses: docker/build-push-action@v6
@@ -67,7 +62,7 @@ jobs:
tags: |
corentinth/papra:latest
corentinth/papra:latest-rootless
corentinth/papra:${{ env.RELEASE_VERSION }}-rootless
corentinth/papra:${{ inputs.version }}-rootless
ghcr.io/papra-hq/papra:latest
ghcr.io/papra-hq/papra:latest-rootless
ghcr.io/papra-hq/papra:${{ env.RELEASE_VERSION }}-rootless
ghcr.io/papra-hq/papra:${{ inputs.version }}-rootless

53
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,53 @@
name: Release
on:
push:
branches:
- main
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
release:
name: Release
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
id-token: write
actions: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm i
- name: Create Release Pull Request
id: changesets
uses: changesets/action@v1
with:
# Note: pnpm install after versioning is necessary to refresh lockfile
version: pnpm run version
publish: pnpm exec changeset publish
commit: "chore(release): update versions"
title: "chore(release): update versions"
env:
GITHUB_TOKEN: ${{ secrets.CHANGESET_GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Trigger Docker build
if: steps.changesets.outputs.published == 'true' && contains(fromJson(steps.changesets.outputs.publishedPackages).*.name, '@papra/app-server')
run: |
VERSION=$(echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[] | select(.name=="@papra/app-server") | .version')
echo "VERSION: $VERSION"
gh workflow run release-docker.yaml -f version="$VERSION"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

1
.gitignore vendored
View File

@@ -37,5 +37,6 @@ cache
*.sqlite
local-documents
ingestion
.cursorrules
*.traineddata

1
CODEOWNERS Normal file
View File

@@ -0,0 +1 @@
* @papra-hq/papra-maintainers

View File

@@ -22,11 +22,11 @@ We follow a **GitHub Flow** model where all PRs should target the `main` branch,
- Each PR should be small and atomic. Please avoid solving multiple unrelated issues in a single PR.
- Ensure that the **CI is green** before submitting. Some of the following checks are automatically run for each package: linting, type checking, testing, and building.
- PRs without a corresponding issue are welcome.
- If your PR fixes an issue, please reference the issue number in the PR description.
- If your PR adds a new feature, please include tests and update the documentation if necessary.
- Be prepared to address feedback and iterate on your PR.
- Resolving merge conflicts is part of the PR author's responsibility.
- Draft PRs are welcome to get feedback early on your work but only when requested, they'll not be reviewed.
### Branching
@@ -60,7 +60,10 @@ pnpm script:generate-i18n-types
```
- This command will update the file [`locales.types.ts`](./apps/papra-client/src/modules/i18n/locale.types.ts) with the new/removed keys.
- Will developing you can use `pnpm script:generate-i18n-types:watch` to automatically update the types when you touch the [`en.yml`](./apps/papra-client/src/locales/en.yml) file.
- When developing in papra-client (using `pnpm dev`), **the i18n types definition will automatically update** when you touch the [`en.yml`](./apps/papra-client/src/locales/en.yml) file, so no need to run the command above.
> [!TIP]
> You can use the command `pnpm script:sync-i18n-key-order` to sync the order of the keys in the i18n files, it'll also add the missing keys as comments.
## Development Setup
@@ -81,7 +84,15 @@ We recommend running the app locally for development. Follow these steps:
pnpm install
```
3. Start the development server for the backend:
3. Build the monorepo packages:
As the apps rely on internal packages, you need to build them first.
```bash
pnpm build:packages
```
4. Start the development server for the backend:
```bash
cd apps/papra-server
@@ -91,7 +102,7 @@ We recommend running the app locally for development. Follow these steps:
pnpm dev
```
4. Start the frontend:
5. Start the frontend:
```bash
cd apps/papra-client
@@ -99,7 +110,7 @@ We recommend running the app locally for development. Follow these steps:
pnpm dev
```
5. Open your browser and navigate to `http://localhost:3000`.
6. Open your browser and navigate to `http://localhost:3000`.
### Testing

View File

@@ -39,12 +39,9 @@ A live demo of the platform is available at [demo.papra.app](https://demo.papra.
## Project Status
Papra is currently in **beta**. The core functionality is stable and usable, but you may encounter occasional bugs or limitations. The project is actively developed, with new features being added regularly.
Papra is under active development, the core functionalities are stable and usable. With lots of features and improvements added regularly.
- ✅ Core document management features are stable
- ✅ Self-hosting is fully supported
- 🚧 Some advanced features are still in development
- 📝 Feedback and bug reports are highly appreciated
Feedback and bug reports are highly appreciated to help us improve the platform.
## Features
@@ -59,15 +56,21 @@ Papra is currently in **beta**. The core functionality is stable and usable, but
- **Tags**: Organize your documents with tags.
- **Email ingestion**: Send/forward emails to a generated address to automatically import documents.
- **Content extraction**: Automatically extract text from images or scanned documents for search.
- *In progress:* **i18n**: Support for multiple languages.
- *Coming soon:* **Tagging Rules**: Automatically tag documents based on custom rules.
- *Coming soon:* **Folder ingestion**: Automatically import documents from a folder.
- *Coming soon:* **SDK and API**: Build your own applications on top of Papra.
- *Coming soon:* **CLI**: Manage your documents from the command line.
- **Tagging Rules**: Automatically tag documents based on custom rules.
- **Folder ingestion**: Automatically import documents from a folder.
- **CLI**: Manage your documents from the command line.
- **API, SDK and webhooks**: Build your own applications on top of Papra.
- **i18n**: Support for multiple languages.
- *Coming soon:* **Document sharing**: Share documents with others.
- *Coming soon:* **Document requests**: Generate upload links for people to add documents.
- *Coming maybe one day:* **Mobile app**: Access and upload documents on the go.
- *Coming maybe one day:* **Desktop app**: Access and upload documents from your computer.
- *Coming maybe one day:* **Browser extension**: Upload documents from your browser.
- *Coming maybe one day:* **AI**: Use AI to help you manage or tag your documents.
## Sponsors
Papra is a free and open-source project, but it is not free to run and develop. If you want to support the project, you can become a sponsor on [GitHub Sponsors](https://github.com/sponsors/corentinth) or [Buy me a coffee](https://buymeacoffee.com/cthmsst). If you are a company, you can also contact me to discuss a sponsorship.
## Self-hosting

55
apps/docs/CHANGELOG.md Normal file
View File

@@ -0,0 +1,55 @@
# @papra/docs
## 0.5.2
### Patch Changes
- [#405](https://github.com/papra-hq/papra/pull/405) [`3401cfb`](https://github.com/papra-hq/papra/commit/3401cfbfdc7e280d2f0f3166ceddcbf55486f574) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Introduce APP_BASE_URL to mutualize server and client base url
- [#379](https://github.com/papra-hq/papra/pull/379) [`6cedc30`](https://github.com/papra-hq/papra/commit/6cedc30716e320946f79a0a9fd8d3b26e834f4db) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Updated dependencies
- [#390](https://github.com/papra-hq/papra/pull/390) [`42bc3c6`](https://github.com/papra-hq/papra/commit/42bc3c669840eb778d251dcfb0dd96b45bf6e277) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added API endpoints documentation
- [#402](https://github.com/papra-hq/papra/pull/402) [`1d23f40`](https://github.com/papra-hq/papra/commit/1d23f4089479387d5b87dbcf6d3819f5ee14d580) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix invalid domain in json schema urls
## 0.5.1
### Patch Changes
- [#357](https://github.com/papra-hq/papra/pull/357) [`585c53c`](https://github.com/papra-hq/papra/commit/585c53cd9d0d7dbd517dbb1adddfd9e7b70f9fe5) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added a /llms.txt on main website
## 0.5.0
### Minor Changes
- [#337](https://github.com/papra-hq/papra/pull/337) [`1c574b8`](https://github.com/papra-hq/papra/commit/1c574b8305eb7bde4f1b75ac38a610ca0120a613) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added troubleshooting page
### Patch Changes
- [#337](https://github.com/papra-hq/papra/pull/337) [`1c574b8`](https://github.com/papra-hq/papra/commit/1c574b8305eb7bde4f1b75ac38a610ca0120a613) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added `docker compose up` command in dc generator
## 0.4.2
### Patch Changes
- [#322](https://github.com/papra-hq/papra/pull/322) [`f54b8e1`](https://github.com/papra-hq/papra/commit/f54b8e162acd6cfe92241aaa649847fc03ca5852) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Auto computes urls from the provided port
## 0.4.1
### Patch Changes
- [#320](https://github.com/papra-hq/papra/pull/320) [`8ccdb74`](https://github.com/papra-hq/papra/commit/8ccdb748349a3cacf38f032fd4d3beebce202487) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added base url configuration in docker compose generator
## 0.4.0
### Minor Changes
- [#295](https://github.com/papra-hq/papra/pull/295) [`438a311`](https://github.com/papra-hq/papra/commit/438a31171c606138c4b7fa299fdd58dcbeaaf298) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added support for custom oauth2 providers
- [#293](https://github.com/papra-hq/papra/pull/293) [`53bf93f`](https://github.com/papra-hq/papra/commit/53bf93f128b54ad1d3553e18680c87ab23155f8d) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added a papra docker-compose.yml generator
## 0.3.1
### Patch Changes
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix broken lint and added auto link check

View File

@@ -1,8 +1,11 @@
import { env } from 'node:process';
import starlight from '@astrojs/starlight';
import { defineConfig } from 'astro/config';
import starlightLinksValidator from 'starlight-links-validator';
import starlightThemeRapide from 'starlight-theme-rapide';
import UnoCSS from 'unocss/astro';
import { sidebar } from './src/content/navigation';
import posthogRawScript from './src/scripts/posthog.script.js?raw';
const posthogApiKey = env.POSTHOG_API_KEY;
@@ -15,19 +18,20 @@ const posthogScript = posthogRawScript.replace('[POSTHOG-API-KEY]', posthogApiKe
export default defineConfig({
site: 'https://docs.papra.app',
integrations: [
UnoCSS(),
starlight({
plugins: [starlightThemeRapide()],
plugins: [starlightThemeRapide(), starlightLinksValidator({ exclude: ['http://localhost:1221'] })],
title: 'Papra Docs',
logo: {
dark: './src/assets/logo-dark.svg',
light: './src/assets/logo-light.svg',
alt: 'Papra Logo',
},
social: {
github: 'https://github.com/papra-hq/papra',
blueSky: 'https://bsky.app/profile/papra.app',
discord: 'https://papra.app/discord',
},
social: [
{ href: 'https://github.com/papra-hq/papra', icon: 'github', label: 'GitHub' },
{ href: 'https://bsky.app/profile/papra.app', icon: 'blueSky', label: 'BlueSky' },
{ href: 'https://papra.app/discord', icon: 'discord', label: 'Discord' },
],
expressiveCode: {
themes: ['vitesse-black', 'vitesse-light'],
},
@@ -37,7 +41,7 @@ export default defineConfig({
sidebar,
favicon: '/favicon.svg',
head: [
// Add ICO favicon fallback for Safari.
// Add ICO favicon fallback for Safari.
{
tag: 'link',
attrs: {

1382
apps/docs/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,9 @@
{
"name": "@papra/docs",
"type": "module",
"version": "0.2.1",
"packageManager": "pnpm@9.15.4",
"version": "0.5.2",
"private": true,
"packageManager": "pnpm@10.12.3",
"description": "Papra documentation website",
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
"license": "AGPL-3.0-or-later",
@@ -17,10 +18,16 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@astrojs/starlight": "^0.31.0",
"astro": "^5.1.5",
"@astrojs/solid-js": "^5.1.0",
"@astrojs/starlight": "^0.34.3",
"astro": "^5.8.0",
"sharp": "^0.32.5",
"starlight-theme-rapide": "^0.3.0",
"shiki": "^3.4.2",
"starlight-links-validator": "^0.16.0",
"starlight-theme-rapide": "^0.5.0",
"tailwind-merge": "^2.6.0",
"unocss-preset-animations": "^1.2.1",
"yaml": "^2.8.0",
"zod-to-json-schema": "^3.24.5"
},
"devDependencies": {
@@ -33,6 +40,7 @@
"figue": "^2.2.2",
"lodash-es": "^4.17.21",
"marked": "^15.0.6",
"typescript": "^5.7.3"
"typescript": "^5.7.3",
"unocss": "0.65.0-beta.2"
}
}

View File

@@ -0,0 +1,3 @@
/*
X-Frame-Options: DENY
X-Content-Type-Options: nosniff

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -1,4 +1,38 @@
:root[data-theme='dark'] {
--background: 240 4% 10%;
--foreground: 0 0% 98%;
--card: 240 4% 8%;
--card-foreground: 0 0% 98%;
--popover: 240 4% 8%;
--popover-foreground: 0 0% 98%;
--primary: 77 100% 74%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--warning: 31 98% 50%;
--warning-foreground: 0 0% 98%;
--border: 345 4% 17%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
--background-color: #0c0d0f!important;
--accent-color: #fff!important;
--foreground-color: #9ea3a2!important;
@@ -55,4 +89,8 @@
.site-title img {
width: 1.8rem !important;
}
}
pre.shiki {
border-radius: 0.5rem!important;
}

View File

@@ -1,5 +1,7 @@
import type { ConfigDefinition, ConfigDefinitionElement } from 'figue';
import { isArray, isEmpty, isNil } from 'lodash-es';
import { marked } from 'marked';
import { configDefinition } from '../../papra-server/src/modules/config/config';
function walk(configDefinition: ConfigDefinition, path: string[] = []): (ConfigDefinitionElement & { path: string[] })[] {
@@ -33,16 +35,19 @@ const rows = configDetails
const rawDocumentation = formatDoc(doc);
// The client baseUrl default value is overridden in the Dockerfiles
const defaultOverride = path.join('.') === 'client.baseUrl' ? 'http://localhost:1221' : undefined;
return {
path,
env,
documentation: rawDocumentation,
defaultValue: isEmptyDefaultValue ? undefined : defaultValue,
defaultValue: defaultOverride ?? (isEmptyDefaultValue ? undefined : defaultValue),
};
});
const mdSections = rows.map(({ documentation, env, path, defaultValue }) => `
### ${env}
### ${env}
${documentation}
- Path: \`${path.join('.')}\`
@@ -52,4 +57,48 @@ ${documentation}
`.trim()).join('\n\n---\n\n');
export { mdSections };
function wrapText(text: string, maxLength = 75) {
const words = text.split(' ');
const lines: string[] = [];
let currentLine = '';
words.forEach((word) => {
if ((currentLine + word).length + 1 <= maxLength) {
currentLine += (currentLine ? ' ' : '') + word;
} else {
lines.push(currentLine);
currentLine = word;
}
});
if (currentLine) {
lines.push(currentLine);
}
return lines.map(line => `# ${line}`);
}
const fullDotEnv = rows.map(({ env, defaultValue, documentation }) => {
const isEmptyDefaultValue = isNil(defaultValue) || (isArray(defaultValue) && isEmpty(defaultValue)) || defaultValue === '';
return [
...wrapText(documentation),
`# ${env}=${isEmptyDefaultValue ? '' : defaultValue}`,
].join('\n');
}).join('\n\n');
// Dirty hack to add the same anchors to the headings as the ones generated by Starlight
const renderer = new marked.Renderer();
renderer.heading = function ({ text, depth }) {
const slug = text.toLowerCase().replace(/\W+/g, '-');
return `
<div class="sl-heading-wrapper level-h${depth}">
<h${depth} id="${slug}">${text}</h${depth}>
<a class="sl-anchor-link" href="#${slug}"><span aria-hidden="true" class="sl-anchor-icon"><svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentcolor" d="m12.11 15.39-3.88 3.88a2.52 2.52 0 0 1-3.5 0 2.47 2.47 0 0 1 0-3.5l3.88-3.88a1 1 0 0 0-1.42-1.42l-3.88 3.89a4.48 4.48 0 0 0 6.33 6.33l3.89-3.88a1 1 0 1 0-1.42-1.42Zm8.58-12.08a4.49 4.49 0 0 0-6.33 0l-3.89 3.88a1 1 0 0 0 1.42 1.42l3.88-3.88a2.52 2.52 0 0 1 3.5 0 2.47 2.47 0 0 1 0 3.5l-3.88 3.88a1 1 0 1 0 1.42 1.42l3.88-3.89a4.49 4.49 0 0 0 0-6.33ZM8.83 15.17a1 1 0 0 0 1.1.22 1 1 0 0 0 .32-.22l4.92-4.92a1 1 0 0 0-1.42-1.42l-4.92 4.92a1 1 0 0 0 0 1.42Z"></path></svg></span><span class="sr-only">Section titled “Configuration files”</span></a>
</div>
`.trim().replace(/\n/g, '');
};
const sectionsHtml = marked.parse(mdSections, { renderer });
export { fullDotEnv, mdSections, sectionsHtml };

View File

@@ -1,55 +0,0 @@
---
title: Installing Papra using Docker
description: Self-host Papra using Docker.
slug: self-hosting/using-docker
---
Papra can be easily installed and run using Docker. This method is recommended for users who want a quick and straightforward way to deploy their own instance of Papra with minimal setup.
- Single lightweight image
- Only one container to manage
- Available for all platforms (arm64, arm/v7, x86_64)
- Root and Rootless image variants
## Prerequisites
Before you begin, ensure that you have Docker installed on your system. You can download and install Docker from the official [Docker website](https://www.docker.com/get-started).
Verify your installation:
```bash frame="none"
docker --version
```
## Installation
You can run Papra using the following command:
```bash frame="none"
docker run -d --name papra --restart unless-stopped -p 1221:1221 ghcr.io/papra-hq/papra
```
It will automatically download the latest image and start the container. The application will be available at [http://localhost:1221](http://localhost:1221).
## Root and Rootless installation
Papra can be installed in two different ways:
- **Rootless** (recommended): This method does not require root privileges to run. The images are suffixed with `-rootless` like `corentinth/papra:latest-rootless` or `corentinth/papra:1.0.0-rootless` and the default `:latest` tag points to the latest rootless image.
- **Root**: This is the default installation method. It requires root privileges to run. The images are suffixed with `-root` like `corentinth/papra:latest-root` or `corentinth/papra:1.0.0-root`.
## Image Sources
Papra Docker images are available on both **Docker Hub** and **GitHub Container Registry** (GHCR). You can choose the source that best suits your needs.
```bash frame="none"
# Using Docker Hub
docker pull corentinth/papra:latest
docker pull corentinth/papra:latest-rootless
docker pull corentinth/papra:latest-root
# Using GitHub Container Registry
docker pull ghcr.io/papra-hq/papra:latest
docker pull ghcr.io/papra-hq/papra:latest-rootless
docker pull ghcr.io/papra-hq/papra:latest-root
```

View File

@@ -0,0 +1,111 @@
---
title: Installing Papra using Docker
description: Self-host Papra using Docker.
slug: self-hosting/using-docker
---
import { Steps } from '@astrojs/starlight/components';
Papra provides optimized Docker images for streamlined deployment. This method is recommended for users seeking a production-ready setup with minimal maintenance overhead.
- **Simplified management**: Single container handles all components
- **Lightweight**: Optimized image sizes across architectures
- **Cross-platform support**: Compatible with `arm64`, `arm/v7`, and `x86_64` systems
- **Security options**: Supports both rootless (recommended) and rootful configurations
## Prerequisites
Ensure Docker is installed on your host system. Official installation guides are available at:
[docker.com/get-started](https://www.docker.com/get-started)
Verify Docker installation with:
```bash
docker --version
```
## Quick Deployment
Launch Papra with default configuration using:
```bash
docker run -d \
--name papra \
--restart unless-stopped \
-p 1221:1221 \
ghcr.io/papra-hq/papra:latest
```
This command will:
1. Pull the latest rootless image from GitHub Container Registry
2. Expose the web interface on [http://localhost:1221](http://localhost:1221)
3. Configure automatic restarts for service continuity
## Image Variants
Choose between two security models based on your requirements:
- **Rootless**: Tagged as `latest`, `latest-rootless` or `<version>-rootless` (like `0.2.1-rootless`). Recommended for most users.
- **Root**: Tagged as `latest-root` or `<version>-root` (like `0.2.1-root`). Only use if you need to run Papra as the root user.
The `:latest` tag always references the latest rootless build.
## Persistent Data Configuration
For production deployments, mount host directories to preserve application data between container updates.
<Steps>
1. Create Storage Directories
Create a directory for Papra data `./papra-data`, with `./papra-data/db` and `./papra-data/documents` subdirectories:
```bash
mkdir -p ./papra-data/{db,documents}
```
2. Launch Container with Volume Binding
```bash
docker run -d \
--name papra \
--restart unless-stopped \
-p 1221:1221 \
-v $(pwd)/papra-data:/app/app-data \
--user $(id -u):$(id -g) \
ghcr.io/papra-hq/papra:latest
```
This configuration:
- Maintains data integrity across container lifecycle events
- Enforces proper file ownership without manual permission adjustments
- Stores both database files and document assets persistently
</Steps>
## Image Registries
Papra images are distributed through multiple channels:
**Primary Source (GHCR):**
```bash
docker pull ghcr.io/papra-hq/papra:latest
docker pull ghcr.io/papra-hq/papra:latest-rootless
docker pull ghcr.io/papra-hq/papra:latest-root
```
**Community Mirror (Docker Hub):**
```bash
docker pull corentinth/papra:latest
docker pull corentinth/papra:latest-rootless
docker pull corentinth/papra:latest-root
```
## Updating Papra
Regularly pull updated images and recreate containers to receive security patches and feature updates.
```bash
docker pull ghcr.io/papra-hq/papra:latest
# Or
docker pull corentinth/papra:latest
```

View File

@@ -1,22 +1,40 @@
---
title: Using Docker Compose
slug: self-hosting/using-docker-compose
description: Self-host Papra using Docker Compose.
---
import { Steps } from '@astrojs/starlight/components';
Docker Compose makes it easy to deploy Papra on your server or local machine. Follow these simple steps to get your instance up and running quickly.
This guide covers how to deploy Papra using Docker Compose, ideal for users who prefer declarative configurations or plan to integrate Papra into a broader service stack.
Using Docker Compose provides:
- A single, versioned configuration file
- Easy integration with volumes, networks, and service dependencies
- Simplified updates and re-deployments
This method supports both `rootless` and `rootful` Papra images, please refer to the [Docker](/self-hosting/using-docker) guide for more information about the difference between the two. The following example uses the recommended `rootless` setup.
## Prerequisites
Ensure Docker and Docker Compose are installed on your host system. Official installation guides are available at: [docker.com/get-started](https://www.docker.com/get-started)
Verify Docker installation with:
```bash
docker --version
docker compose version
```
<Steps>
1. Prepare your environment
1. Initialize Project Structure
Make sure you have [Docker](https://www.docker.com/get-started) and [Docker Compose](https://docs.docker.com/compose/install/) installed on your system.
Create working directory and persistent storage subdirectories:
Verify your installation:
```bash frame="none"
docker --version
```bash
mkdir -p papra/app-data/{db,documents} && cd papra
```
2. Create Docker Compose file
@@ -26,16 +44,14 @@ Docker Compose makes it easy to deploy Papra on your server or local machine. Fo
```yaml
services:
papra:
image: corentinth/papra:latest-rootless
ports:
- '1221:1221'
volumes:
- papra-data:/app/app-data
container_name: papra
image: ghcr.io/papra-hq/papra:latest
restart: unless-stopped
volumes:
papra-data:
driver: local
ports:
- "1221:1221"
volumes:
- ./app-data:/app/app-data
user: "${UID}:${GID}"
```
3. Start Papra
@@ -43,10 +59,10 @@ Docker Compose makes it easy to deploy Papra on your server or local machine. Fo
From the directory containing your `docker-compose.yml` file, run:
```bash
docker compose up -d
UID=$(id -u) GID=$(id -g) docker compose up -d
```
This command downloads the latest Papra image, sets up the container, and starts the Papra service.
This command downloads the latest Papra image, sets up the container, and starts the Papra service. The `UID` and `GID` variables are used to set the user and group for the container, ensuring proper file ownership. If you don't want to use the `UID` and `GID` variables, you can replace the image with the rootful variant.
4. Access Papra

View File

@@ -1,16 +1,29 @@
---
title: Configuration
slug: self-hosting/configuration
description: Configure your self-hosted Papra instance.
---
import { mdSections } from '../../../config.data.ts';
import { marked } from 'marked';
import { sectionsHtml, fullDotEnv } from '../../../config.data.ts';
import { Tabs, TabItem } from '@astrojs/starlight/components';
import { Aside } from '@astrojs/starlight/components';
import { Code } from '@astrojs/starlight/components';
Configuring your self hosted Papra allows you to customize the application to better suit your environment and requirements. This guide covers the key environment variables you can set to control various aspects of the application, including port settings, security options, and storage configurations.
## Complete .env
Here is the full configuration file that you can use to configure Papra. The variables values are the default values.
<Code code={fullDotEnv} language="env" title=".env" />
## Configuration variables
Here is the complete list of configuration variables that you can use to configure Papra. You can set these variables in the `.env` file or pass them as environment variables when running the Docker container.
<Fragment set:html={sectionsHtml} />
## Configuration files
You can configure Papra using standard environment variables or use some configuration files.
@@ -41,7 +54,7 @@ Example of configuration files:
<TabItem label="papra.config.json">
```json
{
"$schema": "https://docs.papra.com/papra-config-schema.json",
"$schema": "https://docs.papra.app/papra-config-schema.json",
"server": {
"baseUrl": "https://papra.example.com"
},
@@ -60,7 +73,7 @@ Example of configuration files:
```json
{
"$schema": "https://docs.papra.com/papra-config-schema.json",
"$schema": "https://docs.papra.app/papra-config-schema.json",
// ...
}
```
@@ -71,12 +84,4 @@ Example of configuration files:
</Tabs>
You'll find the complete list of configuration variables with their environment variables equivalents and path for files in the next section.
## Configuration variables
Here is the complete list of configuration variables that you can use to configure Papra. You can set these variables in the `.env` file or pass them as environment variables when running the Docker container.
<Fragment set:html={marked.parse(mdSections)} />
Coming soon.
You'll find the complete list of configuration variables with their environment variables equivalents and path for files in the previous section.

View File

@@ -12,7 +12,7 @@ This guide will show you how to setup a Cloudflare Email worker to receive email
<Aside type="note">
Setting up a Papra Intake Emails with a Cloudflare Email worker requires bit of knowledge about how to setup a CF Email worker and how to configure your Papra instance to receive emails.
For a simpler solution, you can use the official [OwlRelay integration](/docs/guides/intake-emails-with-papra-email-intake) guide.
For a simpler solution, you can use the official [OwlRelay integration](/guides/intake-emails-with-owlrelay) guide.
</Aside>
## Prerequisites
@@ -23,7 +23,7 @@ In order to follow this guide, you need:
- a publicly accessible Papra instance
- basic development skills (git and node.js to setup the Email Worker)
If you prefer a simpler solution, you can use the official [OwlRelay integration](/docs/guides/intake-emails-with-papra-email-intake) guide.
If you prefer a simpler solution, you can use the official [OwlRelay integration](/guides/intake-emails-with-owlrelay) guide.
## How it works

View File

@@ -0,0 +1,105 @@
---
title: Setup Ingestion Folder
description: Step-by-step guide to setup an ingestion folder to automatically ingest documents into your Papra instance.
slug: guides/setup-ingestion-folder
---
import { Steps } from '@astrojs/starlight/components';
import { Aside } from '@astrojs/starlight/components';
import { FileTree } from '@astrojs/starlight/components';
The ingestion folder is a special folder that is watched by Papra for new files. When a new file is added to the ingestion folder, Papra will automatically import it.
## Multi-Organization Structure
Papra supports multiple organizations within a single instance, each requiring a dedicated ingestion folder. The ingestion system uses a hierarchical structure where:
<FileTree>
- ingestion-folder
- org_abc123
- document.pdf
- report.docx
- org_def456
- file.txt
- foo.txt # Ignored as it's not in an organization
</FileTree>
This allows you to have a single instance of Papra watching multiple organizations' ingestion folders.
<Aside>
Files and folders that are within the `ingestion-root-folder` but not within an organization folder are ignored.
</Aside>
## Setup
Add the following to your `docker-compose.yml` file:
```yaml title="docker-compose.yml" ins={9,12}
services:
papra:
container_name: papra
image: ghcr.io/papra-hq/papra:latest
restart: unless-stopped
ports:
- "1221:1221"
environment:
- INGESTION_FOLDER_IS_ENABLED=true
volumes:
- ./app-data:/app/app-data
- <your-ingestion-folder>:/app/ingestion
user: "${UID}:${GID}"
```
Then add files to a folder named with the organization id (available in Papra URL, e.g. `https://papra.example.com/organizations/<organization-id>`, the format is `org_<random>`).
```bash
mkdir -p <your-ingestion-folder>/<org_id>
touch <your-ingestion-folder>/<org_id>/hello.txt
```
## Post-processing
Once a file has been ingested in your Papra organization, you can configure what happens to it by setting the `INGESTION_FOLDER_POST_PROCESSING_STRATEGY` environment variable. There are two strategies:
- `delete`: The file is deleted from the ingestion folder (default strategy)
- `move`: The file is moved to the `INGESTION_FOLDER_POST_PROCESSING_MOVE_FOLDER_PATH` folder (default: `./ingestion-done`)
Note that the `INGESTION_FOLDER_POST_PROCESSING_MOVE_FOLDER_PATH` path is relative to the organization ingestion folder.
So with `INGESTION_FOLDER_POST_PROCESSING_MOVE_FOLDER_PATH=ingestion-done`, the file `<ingestion-folder>/<org_id>/file.pdf` will be moved to `<ingestion-folder>/<org_id>/ingestion-done/file.pdf` once ingested.
## Safeguards
To avoid accidental data loss, if for some reason the ingestion fails, the file is moved to the `INGESTION_FOLDER_ERROR_FOLDER_PATH` folder (default: `./ingestion-error`).
<Aside>
As for the `INGESTION_FOLDER_POST_PROCESSING_MOVE_FOLDER_PATH`, the `INGESTION_FOLDER_ERROR_FOLDER_PATH` path is relative to the organization ingestion folder.
</Aside>
## Polling
By default, Papra uses native file watchers to detect changes in the ingestion folder. On some OS (like Windows), this can be flaky with Docker. To avoid this issue, you can enable polling by setting the `INGESTION_FOLDER_WATCHER_USE_POLLING` environment variable to `true`.
The default polling interval is 2 seconds, you can change it by setting the `INGESTION_FOLDER_WATCHER_POLLING_INTERVAL_MS` environment variable.
```yaml title="docker-compose.yml" ins={2-3}
environment:
- INGESTION_FOLDER_WATCHER_USE_POLLING=true
- INGESTION_FOLDER_WATCHER_POLLING_INTERVAL_MS=2000
```
## Configuration
You can find the list of all configuration options in the [configuration reference](/self-hosting/configuration), the related variables are prefixed with `INGESTION_FOLDER_`.
## Edge cases and behaviors
- The ingestion folder is watched recursively.
- Files in the ingestion folder `done` and `error` folders are ignored.
- When a file from the ingestion folder is already present (and not in the trash) in the organization, no ingestion is done, but the file is post-processed (deleted or moved) as successfully ingested.
- When a file is moved to "done" or "error" folder
- If a file with the same name and same content is present in the destination folder, the original file is deleted
- If a file with the same name but different content is present in the destination folder, the original file is moved and a timestamp is added to the filename
- Some files are ignored by default (`.DS_Store`, `Thumbs.db`, `desktop.ini`, etc.) see [ingestion-folders.constants.ts](https://github.com/papra-hq/papra/blob/main/apps/papra-server/src/modules/ingestion-folders/ingestion-folders.constants.ts) for the list of ignored files and patterns. You can change this by setting the `INGESTION_FOLDER_IGNORED_PATTERNS` environment variable.

View File

@@ -0,0 +1,181 @@
---
title: Setup Custom OAuth2 Providers
description: Step-by-step guide to setup custom OAuth2 providers for authentication in your Papra instance.
slug: guides/setup-custom-oauth2-providers
---
import { Aside } from '@astrojs/starlight/components';
import { Steps } from '@astrojs/starlight/components';
This guide will show you how to configure custom OAuth2 providers for authentication in your Papra instance.
<Aside type="note">
Papra's OAuth2 implementation is based on the [Better Auth Generic OAuth plugin](https://www.better-auth.com/docs/plugins/generic-oauth). For more detailed information about the configuration options and advanced usage, please refer to their documentation.
</Aside>
## Prerequisites
In order to follow this guide, you need:
- A custom OAuth2 provider
- An accessible Papra instance
- Basic understanding of OAuth2 flows
## Configuration
To set up custom OAuth2 providers, you'll need to configure the `AUTH_PROVIDERS_CUSTOMS` environment variable with an array of provider configurations. Here's an example:
```bash
AUTH_PROVIDERS_CUSTOMS='[
{
"providerId": "custom-oauth2",
"providerName": "Custom OAuth2",
"providerIconUrl": "https://api.iconify.design/tabler:login-2.svg",
"clientId": "your-client-id",
"clientSecret": "your-client-secret",
"type": "oidc",
"discoveryUrl": "https://your-provider.tld/.well-known/openid-configuration",
"scopes": ["openid", "profile", "email"]
}
]'
```
Each provider configuration supports the following fields:
- `providerId`: A unique identifier for the OAuth provider
- `providerName`: The display name of the provider
- `providerIconUrl`: URL of the icon to display (optional) you can use a base64 encoded image or an url to a remote image.
- `clientId`: OAuth client ID
- `clientSecret`: OAuth client secret
- `type`: Type of OAuth flow ("oauth2" or "oidc")
- `discoveryUrl`: URL to fetch OAuth 2.0 configuration (recommended for OIDC providers)
- `authorizationUrl`: URL for the authorization endpoint (required for OAuth2 if not using discoveryUrl)
- `tokenUrl`: URL for the token endpoint (required for OAuth2 if not using discoveryUrl)
- `userInfoUrl`: URL for the user info endpoint (required for OAuth2 if not using discoveryUrl)
- `scopes`: Array of OAuth scopes to request
- `redirectURI`: Custom redirect URI (optional)
- `responseType`: OAuth response type (defaults to "code")
- `prompt`: Controls the authentication experience ("select_account", "consent", "login", "none")
- `pkce`: Whether to use PKCE (Proof Key for Code Exchange)
- `accessType`: Access type for the authorization request
## Setup
<Steps>
1. **Configure your OAuth2 Provider**
First, you'll need to register your application with your OAuth2 provider. This typically involves:
- Creating a new application in your provider's dashboard
- Setting up the redirect URI (usually `https://<your-papra-instance>/api/auth/oauth2/callback/:providerId`)
- Obtaining the client ID and client secret
- Configuring the required scopes
2. **Configure Papra**
Add the `AUTH_PROVIDERS_CUSTOMS` environment variable to your Papra instance. Here are some examples:
For OIDC providers:
```json
[
{
"providerId": "custom-oauth2",
"providerName": "Custom OAuth2",
"providerIconUrl": "https://api.iconify.design/tabler:login-2.svg",
"clientId": "your-client-id",
"clientSecret": "your-client-secret",
"type": "oidc",
"discoveryUrl": "https://your-provider.tld/.well-known/openid-configuration",
"scopes": ["openid", "profile", "email"]
}
]
```
For standard OAuth2 providers:
```json
[
{
"providerId": "custom-oauth2",
"providerName": "Custom OAuth2",
"providerIconUrl": "https://api.iconify.design/tabler:login-2.svg",
"clientId": "your-client-id",
"clientSecret": "your-client-secret",
"type": "oauth2",
"authorizationUrl": "https://your-provider.tld/oauth2/authorize",
"tokenUrl": "https://your-provider.tld/oauth2/token",
"userInfoUrl": "https://your-provider.tld/oauth2/userinfo",
"scopes": ["profile", "email"]
}
]
```
<Aside type="note">
The `discoveryUrl` is recommended for OIDC providers as it automatically configures all the necessary endpoints.
For standard OAuth2 providers, you'll need to specify the endpoints manually.
</Aside>
3. **Test the Configuration**
- Restart your Papra instance to apply the changes
- Go to the login page
- You should see your custom OAuth2 providers as login options
- Try logging in with a test account
</Steps>
## Troubleshooting
### Providers Not Showing Up
If your OAuth2 providers are not showing up on the login page:
- Check that the JSON configuration in `AUTH_PROVIDERS_CUSTOMS` is valid
- Ensure all required fields are provided
- Verify that the provider IDs are unique
### Authentication Fails
If authentication fails:
- Verify that the redirect URI is correctly configured in your OAuth2 provider
- Check that the client ID and client secret are correct
- Ensure the required scopes are properly configured
- Check the Papra logs for any error messages
### OIDC Discovery Issues
If you're using OIDC and experiencing issues:
- Verify that the `discoveryUrl` is accessible
- Check that the provider supports OIDC discovery
- Ensure the provider's configuration is properly exposed through the discovery endpoint
## Security Considerations
<Aside type="caution">
Always use HTTPS for your OAuth2 endpoints and ensure your client secret is kept secure.
Consider using PKCE (Proof Key for Code Exchange) for additional security by setting `pkce: true` in your configuration.
</Aside>
## Multiple Providers
You can configure multiple custom OAuth2 providers by adding them to the array:
```json
[
{
"providerId": "custom-oauth2-1",
"providerName": "Custom OAuth2 Provider 1",
"type": "oidc",
"discoveryUrl": "https://provider1.tld/.well-known/openid-configuration",
"clientId": "client-id-1",
"clientSecret": "client-secret-1",
"scopes": ["openid", "profile", "email"]
},
{
"providerId": "custom-oauth2-2",
"providerName": "Custom OAuth2 Provider 2",
"type": "oidc",
"discoveryUrl": "https://provider2.tld/.well-known/openid-configuration",
"clientId": "client-id-2",
"clientSecret": "client-secret-2",
"scopes": ["openid", "profile", "email"]
}
]
```

View File

@@ -0,0 +1,28 @@
---
title: Troubleshooting
description: Troubleshooting guide for Papra
slug: resources/troubleshooting
---
You can find here some common issues and how to fix them. If you encounter an issue that is not listed here, please [open an issue](https://github.com/papra-hq/papra/issues/new/choose) or [join our Discord](https://papra.app/discord).
## Failed to ensure that the database directory exists
Upon starting the server or a script, you may encounter this error
```
Failed to ensure that the database directory exists, error while creating the directory
Error: EACCES: permission denied, mkdir './app-data/db'
```
Before accessing the DB sqlite file, the server will try to ensure that the database directory exists, and if it doesn't, it try will create it. But in case of insufficient permissions, it will fail.
To fix this, you can either:
- Create the directory manually `mkdir -p <your-app-data-dir>/db`
- Ensure that the directory is owned by the user running the container
- Run the server as root (not recommended)

View File

@@ -0,0 +1,89 @@
---
title: CLI Documentation
description: Learn how to use the Papra CLI to interact with your Papra instance from the command line.
slug: resources/cli
---
The Papra CLI is a command-line interface tool that helps you interact with the Papra platform from your terminal.
## Installation
For the moment, the CLI is only available as an NPM package.
```bash
# using pnpm
pnpm i -g @papra/cli
# or using npm
npm i -g @papra/cli
# or using yarn
yarn add -g @papra/cli
```
The CLI will be installed globally, so you can use it from anywhere in your system with the `papra` command.
## Configuration
Before using the CLI, you need to configure it with your API credentials.
### Initial Setup
To initialize the configuration, run:
```bash
papra config init
```
This command will prompt you for:
- **Instance URL**: Your Papra instance URL (e.g., `https://api.papra.app`)
- **API Key**: Your personal API key (can be created in your User Settings)
### Managing Configuration
You can manage your configuration using the following commands:
- `papra config list`: View your current configuration
- `papra config set api-key`: Set or update your API key
- `papra config set api-url`: Set or update your instance URL
- `papra config set default-org-id`: Set a default organization ID
### Organization IDs
Since Papra supports multiple organizations, you may need to specify the organization ID when importing documents for example. If want, you can set a default organization ID in your configuration.
```bash
papra config set default-org-id <organization-id>
papra documents import <file-path>
# or
papra documents import -o <organization-id> <file-path>
```
## Available Commands
### Importing documents
The `import` command allows you to import a document into your Papra organization.
```bash
papra documents import -o <organization-id> <file-path>
```
## Getting Help
For more information about any command, you can use the `--help` flag:
```bash
papra --help
papra config --help
papra documents --help
```
## About the CLI
The CLI is built using the [citty](https://github.com/unjs/citty) framework and the [Papra TS SDK](https://github.com/papra-hq/papra/tree/main/packages/api-sdk).

View File

@@ -0,0 +1,210 @@
---
title: API Endpoints
description: The list and details of all the API endpoints available in Papra.
slug: resources/api-endpoints
---
## Authentication
The public API uses a bearer token for authentication. You can get a token by logging to your Papra account and creating an API token.
<details>
<summary>How to create an API token</summary>
![API Token](../../../assets/api-key-creation-1.png)
![API Token](../../../assets/api-key-creation-2.png)
</details>
## Endpoints
### Create a document
**POST** `/api/organizations/:organizationId/documents`
Create a new document in the organization.
- Required API key permissions: `documents:create`
- Body (form-data)
- `file`: The file to upload.
- `ocrLanguages`: (optional) The languages to use for OCR.
- Response (JSON)
- `document`: The created document.
### List documents
**GET** `/api/organizations/:organizationId/documents`
List all documents in the organization.
- Required API key permissions: `documents:read`
- Query parameters
- `pageIndex`: (optional, default: 0) The page index to start from.
- `pageSize`: (optional, default: 100) The number of documents to return.
- `tags`: (optional) The tags IDs to filter by.
- Response (JSON)
- `documents`: The list of documents.
- `documentsCount`: The total number of documents.
### List deleted documents (trash)
**GET** `/api/organizations/:organizationId/documents/deleted`
List all deleted documents (in trash) in the organization.
- Required API key permissions: `documents:read`
- Query parameters
- `pageIndex`: (optional, default: 0) The page index to start from.
- `pageSize`: (optional, default: 100) The number of documents to return.
- Response (JSON)
- `documents`: The list of deleted documents.
- `documentsCount`: The total number of deleted documents.
### Get a document
**GET** `/api/organizations/:organizationId/documents/:documentId`
Get a document by its ID.
- Required API key permissions: `documents:read`
- Response (JSON)
- `document`: The document.
### Delete a document
**DELETE** `/api/organizations/:organizationId/documents/:documentId`
Delete a document by its ID.
- Required API key permissions: `documents:delete`
- Response: empty (204 status code)
### Get a document file
**GET** `/api/organizations/:organizationId/documents/:documentId/file`
Get a document file content by its ID.
- Required API key permissions: `documents:read`
- Response: The document file stream.
### Search documents
**GET** `/api/organizations/:organizationId/documents/search`
Search documents in the organization by name or content.
- Required API key permissions: `documents:read`
- Query parameters
- `searchQuery`: The search query.
- `pageIndex`: (optional, default: 0) The page index to start from.
- `pageSize`: (optional, default: 100) The number of documents to return.
- Response (JSON)
- `documents`: The list of documents.
### Get organization documents statistics
**GET** `/api/organizations/:organizationId/documents/statistics`
Get the statistics (number of documents and total size) of the documents in the organization.
- Required API key permissions: `documents:read`
- Response (JSON)
- `organizationStats`: The organization documents statistics.
- `documentsCount`: The total number of documents.
- `documentsSize`: The total size of the documents.
### Update a document
**PATCH** `/api/organizations/:organizationId/documents/:documentId`
Change the name or content (for search purposes) of a document.
- Required API key permissions: `documents:update`
- Body (form-data)
- `name`: (optional) The document name.
- `content`: (optional) The document content.
- Response (JSON)
- `document`: The updated document.
### Get document activity
**GET** `/api/organizations/:organizationId/documents/:documentId/activity`
Get the activity log of a document.
- Required API key permissions: `documents:read`
- Query parameters
- `pageIndex`: (optional, default: 0) The page index to start from.
- `pageSize`: (optional, default: 100) The number of documents to return.
- Response (JSON)
- `activities`: The list of activities.
### Create a tag
**POST** `/api/organizations/:organizationId/tags`
Create a new tag in the organization.
- Required API key permissions: `tags:create`
- Body (form-data)
- `name`: The tag name.
- `color`: The tag color in hex format (e.g. `#000000`).
- `description`: (optional) The tag description.
- Response (JSON)
- `tag`: The created tag.
### List tags
**GET** `/api/organizations/:organizationId/tags`
List all tags in the organization.
- Required API key permissions: `tags:read`
- Response (JSON)
- `tags`: The list of tags.
### Update a tag
**PUT** `/api/organizations/:organizationId/tags/:tagId`
Change the name, color or description of a tag.
- Required API key permissions: `tags:update`
- Body
- `name`: (optional) The tag name.
- `color`: (optional) The tag color in hex format (e.g. `#000000`).
- `description`: (optional) The tag description.
- Response (JSON)
- `tag`: The updated tag.
### Delete a tag
**DELETE** `/api/organizations/:organizationId/tags/:tagId`
Delete a tag by its ID.
- Required API key permissions: `tags:delete`
- Response: empty (204 status code)
### Add a tag to a document
**POST** `/api/organizations/:organizationId/documents/:documentId/tags`
Associate a tag to a document.
- Required API key permissions: `tags:read` and `documents:update`
- Body
- `tagId`: The tag ID.
- Response: empty (204 status code)
### Remove a tag from a document
**DELETE** `/api/organizations/:organizationId/documents/:documentId/tags/:tagId`
Remove a tag from a document.
- Required API key permissions: `tags:read` and `documents:update`
- Response: empty (204 status code)

View File

@@ -1,6 +1,6 @@
---
title: Papra documentation
description: Papra documentation.
description: Documentation for Papra, the minimalistic document archiving platform.
hero:
title: Papra Docs
tagline: Documentation for Papra, the minimalistic document archiving platform.
@@ -51,11 +51,11 @@ In today's digital world, managing countless important documents efficiently and
- **Tags**: Organize your documents with tags.
- **Email ingestion**: Send/forward emails to a generated address to automatically import documents.
- **Content extraction**: Automatically extract text from images or scanned documents for search.
- *In progress:* **i18n**: Support for multiple languages.
- *Coming soon:* **Tagging Rules**: Automatically tag documents based on custom rules.
- *Coming soon:* **Folder ingestion**: Automatically import documents from a folder.
- *Coming soon:* **SDK and API**: Build your own applications on top of Papra.
- *Coming soon:* **CLI**: Manage your documents from the command line.
- **Tagging Rules**: Automatically tag documents based on custom rules.
- **Folder ingestion**: Automatically import documents from a folder.
- **API, SDK and webhooks**: Build your own applications on top of Papra.
- **CLI**: Manage your documents from the command line.
- **i18n**: Support for multiple languages.
- *Coming soon:* **Document sharing**: Share documents with others.
- *Coming soon:* **Document requests**: Generate upload links for people to add documents.

View File

@@ -1,6 +1,6 @@
import type { StarlightUserConfig } from '@astrojs/starlight/types';
export const sidebar: StarlightUserConfig['sidebar'] = [
export const sidebar = [
{
label: 'Getting Started',
items: [
@@ -12,6 +12,7 @@ export const sidebar: StarlightUserConfig['sidebar'] = [
items: [
{ label: 'Using Docker', slug: 'self-hosting/using-docker' },
{ label: 'Using Docker Compose', slug: 'self-hosting/using-docker-compose' },
{ label: 'Docker Compose Generator', link: '/docker-compose-generator', badge: { text: 'new', variant: 'note' } },
{ label: 'Configuration', slug: 'self-hosting/configuration' },
],
},
@@ -26,11 +27,27 @@ export const sidebar: StarlightUserConfig['sidebar'] = [
label: 'Setup intake emails with CF Email Workers',
slug: 'guides/intake-emails-with-cloudflare-email-workers',
},
{
label: 'Setup Ingestion Folder',
slug: 'guides/setup-ingestion-folder',
},
{
label: 'Setup Custom OAuth2 Providers',
slug: 'guides/setup-custom-oauth2-providers',
},
],
},
{
label: 'Resources',
items: [
{
label: 'Troubleshooting',
slug: 'resources/troubleshooting',
},
{
label: 'CLI Documentation',
slug: 'resources/cli',
},
{
label: 'Security Policy',
link: 'https://github.com/papra-hq/papra/blob/main/SECURITY.md',
@@ -38,6 +55,10 @@ export const sidebar: StarlightUserConfig['sidebar'] = [
target: '_blank',
},
},
{
label: 'API Endpoints',
slug: 'resources/api-endpoints',
},
],
},
];
] satisfies StarlightUserConfig['sidebar'];

View File

@@ -0,0 +1,487 @@
---
import { codeToHtml } from 'shiki';
const images = {
GitHub: 'ghcr.io/papra-hq/papra',
DockerHub: 'corentinth/papra',
};
const defaultDockerCompose = `
services:
papra:
image: ghcr.io/papra-hq/papra:latest
container_name: papra
restart: unless-stopped
ports:
- 1221:1221
environment:
- AUTH_SECRET=change-me
- APP_BASE_URL=http://localhost:1221
volumes:
- ./app-data:/app/app-data
user: 1000:1000
`.trim();
const dcHtml = await codeToHtml(defaultDockerCompose, { theme: 'vitesse-black', lang: 'yaml' });
const defaultCommand = `mkdir -p ./app-data/{db,documents} && docker compose up -d`;
---
<h2 class="mt-8 mb-2">General settings</h2>
<div class="flex items-center gap-2 mt-1">
<label for="port" class="min-w-32">External port</label>
<input id="port" class="input-field" value="1221" type="number" min="1024" max="65535" placeholder="eg: 1221" />
</div>
<div class="flex items-center gap-2 mt-1">
<label for="app-base-url" class="min-w-32">App base URL</label>
<input id="app-base-url" class="input-field" type="text" placeholder="eg: https://papra.example.com" value="http://localhost:1221" />
</div>
<div class="flex items-center gap-2 mt-1">
<label for="source" class="min-w-32">Image source</label>
<select class="input-field mt-0" id="source">
{Object.entries(images).map(([registry, imageName]) => <option class="bg-background" value={imageName}>{`${registry} - ${imageName}`}</option>)}
</select>
</div>
<div class="flex items-center gap-2 mt-1">
<label for="service-name" class="min-w-32">Service Name</label>
<input id="service-name" class="input-field" value="papra" type="text" placeholder="eg: papra" />
</div>
<div class="flex items-center gap-2 mt-1">
<label
for="auth-secret"
class="min-w-32"
>
Auth secret
</label>
<div class="flex items-center gap-2 mt-0 w-full">
<input class="input-field font-mono" id="auth-secret" type="text" placeholder="eg: 1234567890" />
<button class="btn bg-muted" id="refresh-secret"> Refresh </button>
</div>
</div>
<div class="flex items-center gap-2 mt-1">
<label for="volume-path" class="min-w-32">Volume path</label>
<input id="volume-path" class="input-field" value="./app-data" type="text" placeholder="eg: ./app-data" />
</div>
<div class="flex items-center gap-2 mt-1">
<label for="privileged-mode" class="min-w-32">Privileged mode</label>
<div class="flex items-center gap-2 mt-0 w-full">
<select class="input-field mt-0" id="privileged-mode">
<option value="false" class="bg-background">Rootless</option>
<option value="true" class="bg-background">Root</option>
</select>
</div>
</div>
<h2 class="mt-8 mb-2">Ingestion folder</h2>
<div class="flex items-center gap-2 mt-1">
<label for="ingestion-enabled" class="min-w-32">Enable ingestion</label>
<div class="flex items-center gap-2 mt-0 w-full">
<select class="input-field mt-0" id="ingestion-enabled">
<option value="false" class="bg-background">Disabled</option>
<option value="true" class="bg-background">Enabled</option>
</select>
</div>
</div>
<div class="flex items-center gap-2 mt-1" id="ingestion-path-container" style="display: none;">
<label for="ingestion-path" class="min-w-32">Ingestion path</label>
<input id="ingestion-path" class="input-field" value="./ingestion" type="text" placeholder="eg: ./ingestion" />
</div>
<h2 class="mt-8 mb-2">Intake emails</h2>
<div class="flex items-center gap-2 mt-1">
<label for="intake-email-enabled" class="min-w-32">Enabled</label>
<div class="flex items-center gap-2 mt-0 w-full">
<select class="input-field mt-0" id="intake-email-enabled">
<option value="false" class="bg-background">Disabled</option>
<option value="true" class="bg-background">Enabled</option>
</select>
</div>
</div>
<div class="flex items-center gap-2 mt-1" id="intake-email-driver-container" style="display: none;">
<label for="intake-email-driver" class="min-w-32">Driver</label>
<div class="flex items-center gap-2 mt-0 w-full">
<select class="input-field mt-0" id="intake-email-driver">
<option value="owlrelay" class="bg-background">OwlRelay</option>
<option value="random-username" class="bg-background">Cloudflare Email Worker</option>
</select>
</div>
</div>
<div id="intake-email-owlrelay-config" style="display: none;" class="mt-1">
<div class="flex items-center gap-2 mt-1">
<label for="intake-email-owlrelay-api-key" class="min-w-32">API Key</label>
<input id="intake-email-owlrelay-api-key" class="input-field" type="text" placeholder="owrl_*****" />
</div>
<div class="flex items-center gap-2 mt-1">
<label for="intake-email-owlrelay-webhook-url" class="min-w-32">Webhook URL</label>
<input id="intake-email-owlrelay-webhook-url" class="input-field" type="text" placeholder="https://your-instance.com/api/intake-emails/ingest" value="https://localhost:1221/api/intake-emails/ingest" />
</div>
</div>
<div id="intake-email-cf-worker-config" style="display: none;" class="mt-1">
<div class="flex items-center gap-2 mt-1">
<label for="intake-email-cf-email-domain" class="min-w-32">Email domain</label>
<input id="intake-email-cf-email-domain" class="input-field" type="text" placeholder="papra.email" />
</div>
</div>
<div class="flex items-center gap-2 mt-1" id="intake-email-webhook-secret-container" style="display: none;">
<label for="intake-email-webhook-secret" class="min-w-32">Webhook secret</label>
<div class="flex items-center gap-2 mt-0 w-full">
<input class="input-field font-mono" id="intake-email-webhook-secret" type="text" placeholder="a-random-key" />
<button class="btn bg-muted" id="refresh-webhook-secret">Refresh</button>
</div>
</div>
<div id="docker-compose-output" class="mt-12" set:html={dcHtml} />
<pre id="command-output" class="bg-card p-4 rounded-md text-muted-foreground text-sm font-mono overflow-x-auto">{defaultCommand}</pre>
<div class="flex items-center gap-2 mt-4">
<button class="btn bg-muted mt-0" id="download-button">Download docker-compose.yml</button>
<button class="btn bg-muted mt-0" id="copy-button">Copy docker compose to clipboard</button>
<button class="btn bg-muted mt-0" id="copy-command-button">Copy command</button>
</div>
<script>
import { codeToHtml } from 'shiki';
import { stringify } from 'yaml';
const portInput = document.getElementById('port') as HTMLInputElement;
const sourceSelect = document.getElementById('source') as HTMLSelectElement;
const serviceNameInput = document.getElementById('service-name') as HTMLInputElement;
const authSecretInput = document.getElementById('auth-secret') as HTMLInputElement;
const appBaseUrlInput = document.getElementById('app-base-url') as HTMLInputElement;
const refreshSecretButton = document.getElementById('refresh-secret');
const copyButton = document.getElementById('copy-button');
const dockerComposeOutput = document.getElementById('docker-compose-output');
const downloadButton = document.getElementById('download-button');
const volumePathInput = document.getElementById('volume-path') as HTMLInputElement;
const privilegedModeSelect = document.getElementById('privileged-mode') as HTMLSelectElement;
const ingestionEnabledSelect = document.getElementById('ingestion-enabled') as HTMLSelectElement;
const ingestionPathInput = document.getElementById('ingestion-path') as HTMLInputElement;
const ingestionPathContainer = document.getElementById('ingestion-path-container') as HTMLDivElement;
const intakeEmailEnabledSelect = document.getElementById('intake-email-enabled') as HTMLSelectElement;
const intakeDriverSelect = document.getElementById('intake-email-driver') as HTMLSelectElement;
const owlrelayConfig = document.getElementById('intake-email-owlrelay-config') as HTMLDivElement;
const cfWorkerConfig = document.getElementById('intake-email-cf-worker-config') as HTMLDivElement;
const owlrelayApiKeyInput = document.getElementById('intake-email-owlrelay-api-key') as HTMLInputElement;
const owlrelayWebhookUrlInput = document.getElementById('intake-email-owlrelay-webhook-url') as HTMLInputElement;
const cfEmailDomainInput = document.getElementById('intake-email-cf-email-domain') as HTMLInputElement;
const webhookSecretInput = document.getElementById('intake-email-webhook-secret') as HTMLInputElement;
const refreshWebhookSecretButton = document.getElementById('refresh-webhook-secret');
const commandOutput = document.getElementById('command-output');
const copyCommandButton = document.getElementById('copy-command-button');
// Track whether the app base URL has been customized by the user
let isAppBaseUrlCustomized = false;
// Track whether the webhook URL has been customized by the user
let isWebhookUrlCustomized = false;
function getRandomString() {
const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
return Array.from({ length: 48 }, () => alphabet[Math.floor(Math.random() * alphabet.length)]).join('');
}
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text);
}
function isDefaultAppBaseUrl(url: string, port: string): boolean {
return url === `http://localhost:${port}`;
}
function generateDefaultWebhookUrl(baseUrl: string): string {
// Remove trailing slash if present
const cleanBaseUrl = baseUrl.replace(/\/$/, '');
return `${cleanBaseUrl}/api/intake-emails/ingest`;
}
function isDefaultWebhookUrl(webhookUrl: string, baseUrl: string): boolean {
return webhookUrl === generateDefaultWebhookUrl(baseUrl);
}
function refreshIsWebhookUrlCustomized() {
const currentBaseUrl = appBaseUrlInput.value.trim();
const currentWebhookUrl = owlrelayWebhookUrlInput.value.trim();
if (isDefaultWebhookUrl(currentWebhookUrl, currentBaseUrl)) {
isWebhookUrlCustomized = false;
} else {
isWebhookUrlCustomized = true;
}
}
function refreshIsAppBaseUrlCustomized() {
const currentPort = portInput.value;
const currentUrl = appBaseUrlInput.value.trim();
if (isDefaultAppBaseUrl(currentUrl, currentPort)) {
isAppBaseUrlCustomized = false;
} else {
isAppBaseUrlCustomized = true;
}
}
function updateWebhookUrlFromBaseUrl() {
if (!isWebhookUrlCustomized) {
const baseUrl = appBaseUrlInput.value.trim();
if (baseUrl) {
owlrelayWebhookUrlInput.value = generateDefaultWebhookUrl(baseUrl);
}
}
}
function updateAppBaseUrlFromPort() {
if (!isAppBaseUrlCustomized) {
const port = portInput.value;
appBaseUrlInput.value = `http://localhost:${port}`;
// Also update webhook URL when app base URL changes
updateWebhookUrlFromBaseUrl();
}
}
function handlePortChange() {
updateAppBaseUrlFromPort();
updateDockerCompose();
}
function handleAppBaseUrlChange() {
refreshIsAppBaseUrlCustomized();
updateWebhookUrlFromBaseUrl();
updateDockerCompose();
}
function handleWebhookUrlChange() {
refreshIsWebhookUrlCustomized();
updateDockerCompose();
}
function getDockerComposeYml() {
const serviceName = serviceNameInput.value;
const isRootless = privilegedModeSelect.value === 'false';
const image = sourceSelect.value;
const port = portInput.value;
const authSecret = authSecretInput.value;
const volumePath = volumePathInput.value;
const isIngestionEnabled = ingestionEnabledSelect.value === 'true';
const ingestionPath = ingestionPathInput.value;
const intakeEmailEnabled = intakeEmailEnabledSelect.value === 'true';
const intakeDriver = intakeDriverSelect.value;
const webhookSecret = webhookSecretInput.value;
const appBaseUrl = appBaseUrlInput.value.trim() || `http://localhost:${port}`;
const version = isRootless ? 'latest' : 'latest-root';
const fullImage = `${image}:${version}`;
const environment = [
`AUTH_SECRET=${authSecret}`,
`APP_BASE_URL=${appBaseUrl}`,
isIngestionEnabled && 'INGESTION_FOLDER_IS_ENABLED=true',
intakeEmailEnabled && 'INTAKE_EMAILS_IS_ENABLED=true',
intakeEmailEnabled && `INTAKE_EMAILS_DRIVER=${intakeDriver}`,
intakeEmailEnabled && `INTAKE_EMAILS_WEBHOOK_SECRET=${webhookSecret}`,
intakeEmailEnabled && intakeDriver === 'owlrelay' && owlrelayApiKeyInput.value && `OWLRELAY_API_KEY=${owlrelayApiKeyInput.value}`,
intakeEmailEnabled && intakeDriver === 'owlrelay' && owlrelayWebhookUrlInput.value && `OWLRELAY_WEBHOOK_URL=${owlrelayWebhookUrlInput.value}`,
intakeEmailEnabled && intakeDriver === 'random-username' && cfEmailDomainInput.value && `INTAKE_EMAILS_EMAIL_GENERATION_DOMAIN=${cfEmailDomainInput.value}`,
].flat().filter(Boolean);
const volumes = [
`${volumePath}:/app/app-data`,
isIngestionEnabled && `${ingestionPath}:/app/ingestion`,
].filter(Boolean);
const dc = {
services: {
[serviceName]: {
image: fullImage,
container_name: serviceName,
restart: 'unless-stopped',
ports: [`${port}:1221`],
environment,
volumes,
...(isRootless && {
user: '1000:1000',
}),
},
},
};
return stringify(dc);
}
function getStartCommand() {
const volumePath = volumePathInput.value;
const volumePathNormalized = volumePath.replace(/\/$/, '');
const volumeWithSubdirs = `${volumePathNormalized}/{db,documents}`;
const mkdirCommand = `mkdir -p ${volumeWithSubdirs}`;
const dockerCommand = 'docker compose up -d';
return `${mkdirCommand} && ${dockerCommand}`;
}
async function updateDockerCompose() {
const dockerCompose = getDockerComposeYml();
const command = getStartCommand();
const html = await codeToHtml(dockerCompose, { theme: 'vitesse-black', lang: 'yaml' });
if (dockerComposeOutput) {
dockerComposeOutput.innerHTML = html;
}
if (commandOutput) {
commandOutput.textContent = command;
}
}
function handleCopy() {
const dockerCompose = getDockerComposeYml();
copyToClipboard(dockerCompose);
if (copyButton) {
copyButton.textContent = 'Copied!';
}
setTimeout(() => {
if (copyButton) {
copyButton.textContent = 'Copy to clipboard';
}
}, 1000);
}
function handleRefreshSecret() {
authSecretInput.value = getRandomString();
updateDockerCompose();
}
function handleDownload() {
const dockerCompose = getDockerComposeYml();
const blob = new Blob([dockerCompose], { type: 'text/yaml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'docker-compose.yml';
a.click();
}
function handleIngestionEnabledChange() {
const isEnabled = ingestionEnabledSelect.value === 'true';
ingestionPathContainer.style.display = isEnabled ? 'flex' : 'none';
updateDockerCompose();
}
function handleIntakeEmailEnabledChange() {
const isEnabled = intakeEmailEnabledSelect.value === 'true';
const driverContainer = document.getElementById('intake-email-driver-container');
const webhookSecretContainer = document.getElementById('intake-email-webhook-secret-container');
if (driverContainer) {
driverContainer.style.display = isEnabled ? 'flex' : 'none';
}
if (webhookSecretContainer) {
webhookSecretContainer.style.display = isEnabled ? 'flex' : 'none';
}
if (!isEnabled) {
// Reset driver-specific configs when disabled
if (owlrelayConfig) {
owlrelayConfig.style.display = 'none';
}
if (cfWorkerConfig) {
cfWorkerConfig.style.display = 'none';
}
} else {
// Show the appropriate driver config
handleIntakeDriverChange();
}
updateDockerCompose();
}
function handleIntakeDriverChange() {
const driver = intakeDriverSelect.value;
const isEnabled = intakeEmailEnabledSelect.value === 'true';
if (!isEnabled) {
return;
}
if (owlrelayConfig) {
owlrelayConfig.style.display = driver === 'owlrelay' ? 'block' : 'none';
}
if (cfWorkerConfig) {
cfWorkerConfig.style.display = driver === 'random-username' ? 'block' : 'none';
}
updateDockerCompose();
}
function handleRefreshWebhookSecret() {
webhookSecretInput.value = getRandomString();
updateDockerCompose();
}
function handleCopyCommand() {
const command = getStartCommand();
copyToClipboard(command);
if (copyCommandButton) {
copyCommandButton.textContent = 'Copied!';
}
setTimeout(() => {
if (copyCommandButton) {
copyCommandButton.textContent = 'Copy command';
}
}, 1000);
}
// Add event listeners
portInput.addEventListener('input', handlePortChange);
sourceSelect.addEventListener('change', updateDockerCompose);
serviceNameInput.addEventListener('input', updateDockerCompose);
authSecretInput.addEventListener('input', updateDockerCompose);
appBaseUrlInput.addEventListener('input', handleAppBaseUrlChange);
refreshSecretButton?.addEventListener('click', handleRefreshSecret);
copyButton?.addEventListener('click', handleCopy);
downloadButton?.addEventListener('click', handleDownload);
volumePathInput.addEventListener('input', updateDockerCompose);
privilegedModeSelect.addEventListener('change', updateDockerCompose);
ingestionEnabledSelect.addEventListener('change', handleIngestionEnabledChange);
ingestionPathInput.addEventListener('input', updateDockerCompose);
intakeEmailEnabledSelect.addEventListener('change', handleIntakeEmailEnabledChange);
intakeDriverSelect.addEventListener('change', handleIntakeDriverChange);
owlrelayApiKeyInput.addEventListener('input', updateDockerCompose);
owlrelayWebhookUrlInput.addEventListener('input', handleWebhookUrlChange);
cfEmailDomainInput.addEventListener('input', updateDockerCompose);
webhookSecretInput.addEventListener('input', updateDockerCompose);
refreshWebhookSecretButton?.addEventListener('click', handleRefreshWebhookSecret);
copyCommandButton?.addEventListener('click', handleCopyCommand);
authSecretInput.value = getRandomString();
// Initial render
updateDockerCompose();
// Initial setup
handleIngestionEnabledChange();
handleIntakeEmailEnabledChange();
webhookSecretInput.value = getRandomString();
</script>

View File

@@ -0,0 +1,16 @@
---
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
import DockerComposeGeneratorComp from '../docker-compose-generator/dc-generator.astro';
---
<StarlightPage
frontmatter={{
title: 'Papra docker-compose.yml generator',
description: 'Generate a custom docker-compose.yml file for Papra, tailored to your needs.',
tableOfContents: false,
}}
>
<p>This tool will help you generate a custom docker-compose.yml file for Papra, tailored to your needs. You can personalize the service name, the port, the auth secret, and the source image.</p>
<p>For more configuration options, you can use the <a href="/self-hosting/configuration">configuration reference</a>.</p>
<DockerComposeGeneratorComp />
</StarlightPage>

View File

@@ -0,0 +1,28 @@
import type { APIRoute } from 'astro';
import { getCollection } from 'astro:content';
import { sidebar } from '../content/navigation';
export const GET: APIRoute = async ({ site }) => {
const docs = await getCollection('docs');
const sections = sidebar.map((section) => {
return {
label: section.label,
items: section
.items
.filter(item => item.slug !== undefined || (item.link && !item.link.startsWith('http')))
.map((item) => {
const slug = item.slug ?? item.link?.replace(/^\//, '');
return {
label: item.label,
slug,
url: new URL(slug, site).toString(),
description: docs.find(doc => (doc.id === slug || (slug === '' && doc.id === 'index')))?.data.description,
};
}),
};
});
return new Response(JSON.stringify(sections));
};

View File

@@ -1,5 +1,10 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
"include": [
".astro/types.d.ts",
"**/*"
],
"exclude": [
"dist"
]
}

97
apps/docs/uno.config.ts Normal file
View File

@@ -0,0 +1,97 @@
import {
defineConfig,
presetUno,
transformerDirectives,
transformerVariantGroup,
} from 'unocss';
import presetAnimations from 'unocss-preset-animations';
export default defineConfig({
presets: [
presetUno({
dark: {
dark: '[data-kb-theme="dark"]',
light: '[data-kb-theme="light"]',
},
prefix: '',
}),
presetAnimations(),
],
transformers: [transformerVariantGroup(), transformerDirectives()],
theme: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
animation: {
keyframes: {
'accordion-down':
'{ from { height: 0 } to { height: var(--kb-accordion-content-height) } }',
'accordion-up':
'{ from { height: var(--kb-accordion-content-height) } to { height: 0 } }',
'collapsible-down':
'{ from { height: 0 } to { height: var(--kb-collapsible-content-height) } }',
'collapsible-up':
'{ from { height: var(--kb-collapsible-content-height) } to { height: 0 } }',
'caret-blink': '{ 0%,70%,100% { opacity: 1 } 20%,50% { opacity: 0 } }',
},
timingFns: {
'accordion-down': 'ease-out',
'accordion-up': 'ease-out',
'collapsible-down': 'ease-out',
'collapsible-up': 'ease-out',
'caret-blink': 'ease-out',
},
durations: {
'accordion-down': '0.2s',
'accordion-up': '0.2s',
'collapsible-down': '0.2s',
'collapsible-up': '0.2s',
'caret-blink': '1.25s',
},
counts: {
'caret-blink': 'infinite',
},
},
},
shortcuts: {
'input-field': 'flex h-9 w-full bg-none outline-none rounded-lg border border-border border-solid bg-inherit px-3 py-1 text-sm shadow-none placeholder:text-muted-foreground focus-visible:(outline-none ring-1.5 ring-ring) disabled:(cursor-not-allowed opacity-50) transition-shadow',
'btn': 'text-sm font-medium hover:opacity-80 rounded-lg transition-all px-4 py-2 bg-none outline-none border-none cursor-pointer',
},
});

View File

@@ -0,0 +1,99 @@
# @papra/app-client
## 0.6.4
### Patch Changes
- [#377](https://github.com/papra-hq/papra/pull/377) [`205c6cf`](https://github.com/papra-hq/papra/commit/205c6cfd461fa0020a93753571f886726ddfdb57) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Improve file preview for text-like files (.env, yaml, extension-less text files,...)
- [#393](https://github.com/papra-hq/papra/pull/393) [`aad36f3`](https://github.com/papra-hq/papra/commit/aad36f325296548019148bc4e32782fe562fd95b) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix weird centering in document page for long filenames
- [#394](https://github.com/papra-hq/papra/pull/394) [`f28d824`](https://github.com/papra-hq/papra/commit/f28d8245bf385d7be3b3b8ee449c3fdc88fa375c) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added the possibility to disable login via email, to support sso-only auth
- [#405](https://github.com/papra-hq/papra/pull/405) [`3401cfb`](https://github.com/papra-hq/papra/commit/3401cfbfdc7e280d2f0f3166ceddcbf55486f574) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Introduce APP_BASE_URL to mutualize server and client base url
- [#346](https://github.com/papra-hq/papra/pull/346) [`c54a71d`](https://github.com/papra-hq/papra/commit/c54a71d2c5998abde8ec78741b8c2e561203a045) Thanks [@blstmo](https://github.com/blstmo)! - Fixes 400 error when submitting tags with uppercase hex colour codes.
- [#408](https://github.com/papra-hq/papra/pull/408) [`09e3bc5`](https://github.com/papra-hq/papra/commit/09e3bc5e151594bdbcb1f9df1b869a78e583af3f) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added Romanian (ro) translation
- [#383](https://github.com/papra-hq/papra/pull/383) [`0b276ee`](https://github.com/papra-hq/papra/commit/0b276ee0d5e936fffc1f8284c654a8ada0efbafb) Thanks [@LMArantes](https://github.com/LMArantes)! - Added Brazilian Portuguese (pt-BR) language support
- [#399](https://github.com/papra-hq/papra/pull/399) [`47b69b1`](https://github.com/papra-hq/papra/commit/47b69b15f4f711e47421fc21a3ac447824d67642) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix back to organization link in organization settings
- [#403](https://github.com/papra-hq/papra/pull/403) [`1711ef8`](https://github.com/papra-hq/papra/commit/1711ef866d0071a804484b3e163a5e2ccbcec8fd) Thanks [@Icikowski](https://github.com/Icikowski)! - Added Polish (pl) language support
- [#379](https://github.com/papra-hq/papra/pull/379) [`6cedc30`](https://github.com/papra-hq/papra/commit/6cedc30716e320946f79a0a9fd8d3b26e834f4db) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Updated dependencies
- [#411](https://github.com/papra-hq/papra/pull/411) [`2601566`](https://github.com/papra-hq/papra/commit/26015666de197827a65a5bebf376921bbfcc3ab8) Thanks [@4DRIAN0RTIZ](https://github.com/4DRIAN0RTIZ)! - Added Spanish (es) translation
- [#391](https://github.com/papra-hq/papra/pull/391) [`40a1f91`](https://github.com/papra-hq/papra/commit/40a1f91b67d92e135d13dfcd41e5fd3532c30ca5) Thanks [@itsjuoum](https://github.com/itsjuoum)! - Added European Portuguese (pt) translation
- [#378](https://github.com/papra-hq/papra/pull/378) [`f1e1b40`](https://github.com/papra-hq/papra/commit/f1e1b4037b31ff5de1fd228b8390dd4d97a8bda8) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added tag color swatches and picker
## 0.6.3
### Patch Changes
- [#357](https://github.com/papra-hq/papra/pull/357) [`585c53c`](https://github.com/papra-hq/papra/commit/585c53cd9d0d7dbd517dbb1adddfd9e7b70f9fe5) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added a /llms.txt on main website
- [#359](https://github.com/papra-hq/papra/pull/359) [`0c2cf69`](https://github.com/papra-hq/papra/commit/0c2cf698d1a9e9a3cea023920b10cfcd5d83be14) Thanks [@Mavv3006](https://github.com/Mavv3006)! - Add German translation
## 0.6.2
### Patch Changes
- [#333](https://github.com/papra-hq/papra/pull/333) [`ff830c2`](https://github.com/papra-hq/papra/commit/ff830c234a02ddb4cbc480cf77ef49b8de35fbae) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fixed version release link
## 0.6.1
## 0.6.0
### Minor Changes
- [#317](https://github.com/papra-hq/papra/pull/317) [`79c1d32`](https://github.com/papra-hq/papra/commit/79c1d3206b140cf8b3d33ef8bda6098dcf4c9c9c) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added document activity log
- [#319](https://github.com/papra-hq/papra/pull/319) [`60059c8`](https://github.com/papra-hq/papra/commit/60059c895c4860cbfda69d3c989ad00542def65b) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added pending invitation management page
### Patch Changes
- [#309](https://github.com/papra-hq/papra/pull/309) [`d4f72e8`](https://github.com/papra-hq/papra/commit/d4f72e889a4d39214de998942bc0eb88cd5cee3d) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Disable "Manage subscription" from organization setting by default
- [#308](https://github.com/papra-hq/papra/pull/308) [`759a3ff`](https://github.com/papra-hq/papra/commit/759a3ff713db8337061418b9c9b122b957479343) Thanks [@CorentinTh](https://github.com/CorentinTh)! - I18n: full support for French language
- [#312](https://github.com/papra-hq/papra/pull/312) [`e5ef40f`](https://github.com/papra-hq/papra/commit/e5ef40f36c27ea25dc8a79ef2805d673761eec2a) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fixed an issue with the reset-password page navigation guard that prevented reset
## 0.5.1
## 0.5.0
### Minor Changes
- [#295](https://github.com/papra-hq/papra/pull/295) [`438a311`](https://github.com/papra-hq/papra/commit/438a31171c606138c4b7fa299fdd58dcbeaaf298) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added support for custom oauth2 providers
- [#291](https://github.com/papra-hq/papra/pull/291) [`0627ec2`](https://github.com/papra-hq/papra/commit/0627ec25a422b7b820b08740cfc2905f9c55c00e) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added invitation system to add users to an organization
### Patch Changes
- [#296](https://github.com/papra-hq/papra/pull/296) [`0ddc234`](https://github.com/papra-hq/papra/commit/0ddc2340f092cf6fe5bf2175b55fb46db7681c36) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix register page description
## 0.4.0
### Minor Changes
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added webhook management
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added API keys support
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added document searchable content edit
### Patch Changes
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added tag creation button in document page
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Improved tag selector input wrapping
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Properly handle file names without extensions
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Wrap text in document preview
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Excluded deleted documents from doc count

View File

@@ -5,6 +5,11 @@ export default antfu({
semi: true,
},
ignores: [
// Generated file
'src/modules/i18n/locales.types.ts',
],
rules: {
// To allow export on top of files
'ts/no-use-before-define': ['error', { allowNamedExports: true, functions: false }],

View File

@@ -1,8 +1,9 @@
{
"name": "@papra/papra-app-client",
"name": "@papra/app-client",
"type": "module",
"version": "0.2.1",
"packageManager": "pnpm@9.15.4",
"version": "0.6.4",
"private": true,
"packageManager": "pnpm@10.12.3",
"description": "Papra frontend client",
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
"license": "AGPL-3.0-or-later",
@@ -26,50 +27,51 @@
"typecheck": "tsc --noEmit",
"script:get-missing-i18n-keys": "tsx src/scripts/get-missing-i18n-keys.script.ts",
"script:generate-i18n-types": "tsx src/scripts/generate-i18n-types.script.ts",
"script:generate-i18n-types:watch": "tsx watch --include src/locales/en.yml src/scripts/generate-i18n-types.script.ts"
"script:sync-i18n-key-order": "tsx src/scripts/sync-i18n-key-order.script.ts"
},
"dependencies": {
"@corentinth/chisels": "^1.0.2",
"@kobalte/core": "^0.13.7",
"@corentinth/chisels": "^1.3.1",
"@kobalte/core": "^0.13.10",
"@kobalte/utils": "^0.9.1",
"@modular-forms/solid": "^0.25.0",
"@pdfslick/solid": "^2.0.0",
"@solid-primitives/storage": "^4.2.1",
"@solidjs/router": "^0.14.3",
"@tanstack/solid-query": "^5.61.5",
"@tanstack/solid-table": "^8.20.5",
"@unocss/reset": "^0.64.0",
"@modular-forms/solid": "^0.25.1",
"@pdfslick/solid": "^2.3.0",
"@solid-primitives/storage": "^4.3.2",
"@solidjs/router": "^0.14.10",
"@tanstack/solid-query": "^5.81.2",
"@tanstack/solid-table": "^8.21.3",
"@unocss/reset": "^0.64.1",
"better-auth": "catalog:",
"class-variance-authority": "^0.7.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk-solid": "^1.1.0",
"cmdk-solid": "^1.1.2",
"date-fns": "^4.1.0",
"lodash-es": "^4.17.21",
"ofetch": "^1.4.1",
"posthog-js": "^1.231.0",
"posthog-js": "^1.255.1",
"radix3": "^1.1.2",
"solid-js": "^1.8.11",
"solid-js": "^1.9.7",
"solid-sonner": "^0.2.8",
"tailwind-merge": "^2.6.0",
"ts-pattern": "^5.5.0",
"unocss-preset-animations": "^1.1.0",
"unstorage": "^1.14.4",
"ts-pattern": "^5.7.1",
"unocss-preset-animations": "^1.2.1",
"unstorage": "^1.16.0",
"valibot": "1.0.0-beta.10"
},
"devDependencies": {
"@antfu/eslint-config": "catalog:",
"@iconify-json/tabler": "^1.1.120",
"@playwright/test": "^1.46.1",
"@iconify-json/tabler": "^1.2.19",
"@playwright/test": "^1.53.1",
"@types/lodash-es": "^4.17.12",
"@types/node": "catalog:",
"eslint": "catalog:",
"jsdom": "^25.0.0",
"tsx": "^4.19.1",
"jsdom": "^25.0.1",
"tinyglobby": "^0.2.14",
"tsx": "^4.20.3",
"typescript": "catalog:",
"unocss": "0.65.0-beta.2",
"vite": "^5.0.11",
"vite-plugin-solid": "^2.8.2",
"vite": "^5.4.19",
"vite-plugin-solid": "^2.11.7",
"vitest": "catalog:",
"yaml": "^2.7.0"
"yaml": "^2.8.0"
}
}

View File

@@ -0,0 +1,3 @@
/*
X-Frame-Options: DENY
X-Content-Type-Options: nosniff

View File

@@ -9,6 +9,7 @@ import { render, Suspense } from 'solid-js/web';
import { CommandPaletteProvider } from './modules/command-palette/command-palette.provider';
import { ConfigProvider } from './modules/config/config.provider';
import { DemoIndicator } from './modules/demo/demo.provider';
import { RenameDocumentDialogProvider } from './modules/documents/components/rename-document-button.component';
import { I18nProvider } from './modules/i18n/i18n.provider';
import { ConfirmModalProvider } from './modules/shared/confirm';
import { queryClient } from './modules/shared/query/query-client';
@@ -44,9 +45,11 @@ render(
>
<CommandPaletteProvider>
<ConfigProvider>
<div class="min-h-screen font-sans text-sm font-400">
{props.children}
</div>
<RenameDocumentDialogProvider>
<div class="min-h-screen font-sans text-sm font-400">
{props.children}
</div>
</RenameDocumentDialogProvider>
<DemoIndicator />
</ConfigProvider>

View File

@@ -0,0 +1,565 @@
# Authentication
auth.request-password-reset.title: Passwort zurücksetzen
auth.request-password-reset.description: Geben Sie Ihre E-Mail-Adresse ein, um Ihr Passwort zurückzusetzen.
auth.request-password-reset.requested: Wenn ein Konto mit dieser E-Mail-Adresse existiert, haben wir Ihnen eine E-Mail zum Zurücksetzen Ihres Passworts gesendet.
auth.request-password-reset.back-to-login: Zurück zum Login
auth.request-password-reset.form.email.label: E-Mail
auth.request-password-reset.form.email.placeholder: 'Beispiel: ada@papra.app'
auth.request-password-reset.form.email.required: Bitte geben Sie Ihre E-Mail-Adresse ein
auth.request-password-reset.form.email.invalid: Diese E-Mail-Adresse ist ungültig
auth.request-password-reset.form.submit: Passwort zurücksetzen anfordern
auth.reset-password.title: Passwort zurücksetzen
auth.reset-password.description: Geben Sie Ihr neues Passwort ein, um Ihr Passwort zurückzusetzen.
auth.reset-password.reset: Ihr Passwort wurde zurückgesetzt.
auth.reset-password.back-to-login: Zurück zum Login
auth.reset-password.form.new-password.label: Neues Passwort
auth.reset-password.form.new-password.placeholder: 'Beispiel: **********'
auth.reset-password.form.new-password.required: Bitte geben Sie Ihr neues Passwort ein
auth.reset-password.form.new-password.min-length: Das Passwort muss mindestens {{ minLength }} Zeichen lang sein
auth.reset-password.form.new-password.max-length: Das Passwort muss weniger als {{ maxLength }} Zeichen lang sein
auth.reset-password.form.submit: Passwort zurücksetzen
auth.email-provider.open: '{{ provider }} öffnen'
auth.login.title: Bei Papra anmelden
auth.login.description: Geben Sie Ihre E-Mail-Adresse ein oder verwenden Sie die soziale Anmeldung, um auf Ihr Papra-Konto zuzugreifen.
auth.login.login-with-provider: Mit {{ provider }} anmelden
auth.login.no-account: Sie haben noch kein Konto?
auth.login.register: Registrieren
auth.login.form.email.label: E-Mail
auth.login.form.email.placeholder: 'Beispiel: ada@papra.app'
auth.login.form.email.required: Bitte geben Sie Ihre E-Mail-Adresse ein
auth.login.form.email.invalid: Diese E-Mail-Adresse ist ungültig
auth.login.form.password.label: Passwort
auth.login.form.password.placeholder: Passwort festlegen
auth.login.form.password.required: Bitte geben Sie Ihr Passwort ein
auth.login.form.remember-me.label: Angemeldet bleiben
auth.login.form.forgot-password.label: Passwort vergessen?
auth.login.form.submit: Anmelden
auth.register.title: Bei Papra registrieren
auth.register.description: Erstellen Sie ein Konto, um Papra zu nutzen.
auth.register.register-with-email: Mit E-Mail registrieren
auth.register.register-with-provider: Mit {{ provider }} registrieren
auth.register.providers.google: Google
auth.register.providers.github: GitHub
auth.register.have-account: Sie haben bereits ein Konto?
auth.register.login: Anmelden
auth.register.registration-disabled.title: Registrierung ist deaktiviert
auth.register.registration-disabled.description: Die Erstellung neuer Konten ist auf dieser Papra-Instanz derzeit deaktiviert. Nur Benutzer mit bestehenden Konten können sich anmelden. Wenn Sie dies für einen Fehler halten, wenden Sie sich bitte an den Administrator dieser Instanz.
auth.register.form.email.label: E-Mail
auth.register.form.email.placeholder: 'Beispiel: ada@papra.app'
auth.register.form.email.required: Bitte geben Sie Ihre E-Mail-Adresse ein
auth.register.form.email.invalid: Diese E-Mail-Adresse ist ungültig
auth.register.form.password.label: Passwort
auth.register.form.password.placeholder: Passwort festlegen
auth.register.form.password.required: Bitte geben Sie Ihr Passwort ein
auth.register.form.password.min-length: Das Passwort muss mindestens {{ minLength }} Zeichen lang sein
auth.register.form.password.max-length: Das Passwort muss weniger als {{ maxLength }} Zeichen lang sein
auth.register.form.name.label: Name
auth.register.form.name.placeholder: 'Beispiel: Ada Lovelace'
auth.register.form.name.required: Bitte geben Sie Ihren Namen ein
auth.register.form.name.max-length: Der Name muss weniger als {{ maxLength }} Zeichen lang sein
auth.register.form.submit: Registrieren
auth.email-validation-required.title: E-Mail verifizieren
auth.email-validation-required.description: Eine Verifizierungs-E-Mail wurde an Ihre E-Mail-Adresse gesendet. Bitte verifizieren Sie Ihre E-Mail-Adresse, indem Sie auf den Link in der E-Mail klicken.
auth.legal-links.description: Indem Sie fortfahren, bestätigen Sie, dass Sie die {{ terms }} und die {{ privacy }} verstanden haben und ihnen zustimmen.
auth.legal-links.terms: Nutzungsbedingungen
auth.legal-links.privacy: Datenschutzrichtlinie
auth.no-auth-provider.title: Kein Authentifizierungsanbieter
auth.no-auth-provider.description: Es gibt keine Authentifizierungsanbieter auf dieser Papra-Instanz. Bitte kontaktieren Sie den Administrator dieser Instanz, um sie zu aktivieren.
# User settings
user.settings.title: Benutzereinstellungen
user.settings.description: Verwalten Sie hier Ihre Kontoeinstellungen.
user.settings.email.title: E-Mail-Adresse
user.settings.email.description: Ihre E-Mail-Adresse kann nicht geändert werden.
user.settings.email.label: E-Mail-Adresse
user.settings.name.title: Vollständiger Name
user.settings.name.description: Ihr vollständiger Name wird anderen Organisationsmitgliedern angezeigt.
user.settings.name.label: Vollständiger Name
user.settings.name.placeholder: Z.B. Max Mustermann
user.settings.name.update: Namen aktualisieren
user.settings.name.updated: Ihr vollständiger Name wurde aktualisiert
user.settings.logout.title: Abmelden
user.settings.logout.description: Melden Sie sich von Ihrem Konto ab. Sie können sich später wieder anmelden.
user.settings.logout.button: Abmelden
# Organizations
organizations.list.title: Ihre Organisationen
organizations.list.description: Organisationen sind eine Möglichkeit, Ihre Dokumente zu gruppieren und den Zugriff darauf zu verwalten. Sie können mehrere Organisationen erstellen und Ihre Teammitglieder zur Zusammenarbeit einladen.
organizations.list.create-new: Neue Organisation erstellen
organizations.details.no-documents.title: Keine Dokumente
organizations.details.no-documents.description: Es sind noch keine Dokumente in dieser Organisation vorhanden. Beginnen Sie mit dem Hochladen von Dokumenten.
organizations.details.upload-documents: Dokumente hochladen
organizations.details.documents-count: Dokumente insgesamt
organizations.details.total-size: Gesamtgröße
organizations.details.latest-documents: Neueste importierte Dokumente
organizations.create.title: Eine neue Organisation erstellen
organizations.create.description: Ihre Dokumente werden nach Organisation gruppiert. Sie können mehrere Organisationen erstellen, um Ihre Dokumente zu trennen, z.B. für persönliche und geschäftliche Dokumente.
organizations.create.back: Zurück
organizations.create.error.max-count-reached: Sie haben die maximale Anzahl an Organisationen erreicht, die Sie erstellen können. Wenn Sie weitere erstellen möchten, kontaktieren Sie bitte den Support.
organizations.create.form.name.label: Name der Organisation
organizations.create.form.name.placeholder: Z.B. Acme Inc.
organizations.create.form.name.required: Bitte geben Sie einen Organisationsnamen ein
organizations.create.form.submit: Organisation erstellen
organizations.create.success: Organisation erfolgreich erstellt
organizations.create-first.title: Erstellen Sie Ihre Organisation
organizations.create-first.description: Ihre Dokumente werden nach Organisation gruppiert. Sie können mehrere Organisationen erstellen, um Ihre Dokumente zu trennen, z.B. für persönliche und geschäftliche Dokumente.
organizations.create-first.default-name: Meine Organisation
organizations.create-first.user-name: Organisation von "{{ name }}"
organization.settings.title: Organisationseinstellungen
organization.settings.page.title: Organisationseinstellungen
organization.settings.page.description: Verwalten Sie hier Ihre Organisationseinstellungen.
organization.settings.name.title: Name der Organisation
organization.settings.name.update: Namen aktualisieren
organization.settings.name.placeholder: Z.B. Acme Inc.
organization.settings.name.updated: Organisationsname aktualisiert
organization.settings.subscription.title: Abonnement
organization.settings.subscription.description: Verwalten Sie Ihre Abrechnung, Rechnungen und Zahlungsmethoden.
organization.settings.subscription.manage: Abonnement verwalten
organization.settings.subscription.error: Kundenportal-URL konnte nicht abgerufen werden
organization.settings.delete.title: Organisation löschen
organization.settings.delete.description: Das Löschen dieser Organisation entfernt dauerhaft alle damit verbundenen Daten.
organization.settings.delete.confirm.title: Organisation löschen
organization.settings.delete.confirm.message: Sind Sie sicher, dass Sie diese Organisation löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden und alle mit dieser Organisation verbundenen Daten werden dauerhaft entfernt.
organization.settings.delete.confirm.confirm-button: Organisation löschen
organization.settings.delete.confirm.cancel-button: Abbrechen
organization.settings.delete.success: Organisation gelöscht
organizations.members.title: Mitglieder
organizations.members.description: Verwalten Sie Ihre Organisationsmitglieder
organizations.members.invite-member: Mitglied einladen
organizations.members.invite-member-disabled-tooltip: Nur Administratoren oder Eigentümer können Mitglieder in die Organisation einladen
organizations.members.remove-from-organization: Aus Organisation entfernen
organizations.members.role: Rolle
organizations.members.roles.owner: Eigentümer
organizations.members.roles.admin: Administrator
organizations.members.roles.member: Mitglied
organizations.members.delete.confirm.title: Mitglied entfernen
organizations.members.delete.confirm.message: Sind Sie sicher, dass Sie dieses Mitglied aus der Organisation entfernen möchten?
organizations.members.delete.confirm.confirm-button: Entfernen
organizations.members.delete.confirm.cancel-button: Abbrechen
organizations.members.delete.success: Mitglied aus Organisation entfernt
organizations.members.update-role.success: Mitgliederrolle aktualisiert
organizations.members.table.headers.name: Name
organizations.members.table.headers.email: E-Mail
organizations.members.table.headers.role: Rolle
organizations.members.table.headers.created: Erstellt
organizations.members.table.headers.actions: Aktionen
organizations.invite-member.title: Mitglied einladen
organizations.invite-member.description: Laden Sie ein Mitglied in Ihre Organisation ein
organizations.invite-member.form.email.label: E-Mail
organizations.invite-member.form.email.placeholder: 'Beispiel: ada@papra.app'
organizations.invite-member.form.email.required: Bitte geben Sie eine gültige E-Mail-Adresse ein
organizations.invite-member.form.role.label: Rolle
organizations.invite-member.form.submit: In Organisation einladen
organizations.invite-member.success.message: Mitglied eingeladen
organizations.invite-member.success.description: Die E-Mail wurde in die Organisation eingeladen.
organizations.invite-member.error.message: Mitglied konnte nicht eingeladen werden
organizations.invitations.title: Einladungen
organizations.invitations.description: Verwalten Sie Ihre Organisationseinladungen
organizations.invitations.list.cta: Mitglied einladen
organizations.invitations.list.empty.title: Keine ausstehenden Einladungen
organizations.invitations.list.empty.description: Sie wurden noch nicht zu Organisationen eingeladen.
organizations.invitations.status.pending: Ausstehend
organizations.invitations.status.accepted: Angenommen
organizations.invitations.status.rejected: Abgelehnt
organizations.invitations.status.expired: Abgelaufen
organizations.invitations.status.cancelled: Abgebrochen
organizations.invitations.resend: Einladung erneut senden
organizations.invitations.cancel.title: Einladung abbrechen
organizations.invitations.cancel.description: Sind Sie sicher, dass Sie diese Einladung abbrechen möchten?
organizations.invitations.cancel.confirm: Einladung abbrechen
organizations.invitations.cancel.cancel: Abbrechen
organizations.invitations.resend.title: Einladung erneut senden
organizations.invitations.resend.description: Sind Sie sicher, dass Sie diese Einladung erneut senden möchten? Dadurch wird eine neue E-Mail an den Empfänger gesendet.
organizations.invitations.resend.confirm: Einladung erneut senden
organizations.invitations.resend.cancel: Abbrechen
invitations.list.title: Einladungen
invitations.list.description: Verwalten Sie Ihre Organisationseinladungen
invitations.list.empty.title: Keine ausstehenden Einladungen
invitations.list.empty.description: Sie wurden noch nicht zu Organisationen eingeladen.
invitations.list.headers.organization: Organisation
invitations.list.headers.status: Status
invitations.list.headers.created: Erstellt
invitations.list.headers.actions: Aktionen
invitations.list.actions.accept: Annehmen
invitations.list.actions.reject: Ablehnen
invitations.list.actions.accept.success.message: Einladung angenommen
invitations.list.actions.accept.success.description: Die Einladung wurde angenommen.
invitations.list.actions.reject.success.message: Einladung abgelehnt
invitations.list.actions.reject.success.description: Die Einladung wurde abgelehnt.
# Documents
documents.list.title: Dokumente
documents.list.no-documents.title: Keine Dokumente
documents.list.no-documents.description: Es sind noch keine Dokumente in dieser Organisation vorhanden. Beginnen Sie mit dem Hochladen von Dokumenten.
documents.list.no-results: Keine Dokumente gefunden
documents.tabs.info: Info
documents.tabs.content: Inhalt
documents.tabs.activity: Aktivität
documents.deleted.message: Dieses Dokument wurde gelöscht und wird in {{ days }} Tagen dauerhaft entfernt.
documents.actions.download: Herunterladen
documents.actions.open-in-new-tab: In neuem Tab öffnen
documents.actions.restore: Wiederherstellen
documents.actions.delete: Löschen
documents.actions.edit: Bearbeiten
documents.actions.cancel: Abbrechen
documents.actions.save: Speichern
documents.actions.saving: Speichern...
documents.content.alert: Der Inhalt des Dokuments wird beim Hochladen automatisch aus dem Dokument extrahiert. Er wird nur für Such- und Indexierungszwecke verwendet.
documents.info.id: ID
documents.info.name: Name
documents.info.type: Typ
documents.info.size: Größe
documents.info.created-at: Erstellt am
documents.info.updated-at: Aktualisiert am
documents.info.never: Nie
documents.rename.title: Dokument umbenennen
documents.rename.form.name.label: Name
documents.rename.form.name.placeholder: 'Beispiel: Rechnung 2024'
documents.rename.form.name.required: Bitte geben Sie einen Namen für das Dokument ein
documents.rename.form.name.max-length: Der Name muss weniger als 255 Zeichen lang sein
documents.rename.form.submit: Dokument umbenennen
documents.rename.success: Dokument erfolgreich umbenannt
documents.rename.cancel: Abbrechen
import-documents.title.error: '{{ count }} Dokumente fehlgeschlagen'
import-documents.title.success: '{{ count }} Dokumente importiert'
import-documents.title.pending: '{{ count }} / {{ total }} Dokumente importiert'
import-documents.title.none: Dokumente importieren
import-documents.no-import-in-progress: Kein Dokumentimport im Gange
documents.deleted.title: Gelöschte Dokumente
documents.deleted.empty.title: Keine gelöschten Dokumente
documents.deleted.empty.description: Sie haben keine gelöschten Dokumente. Gelöschte Dokumente werden für {{ days }} Tage in den Papierkorb verschoben.
documents.deleted.retention-notice: Alle gelöschten Dokumente werden für {{ days }} Tage im Papierkorb gespeichert. Nach Ablauf dieser Frist werden die Dokumente dauerhaft gelöscht und Sie können sie nicht wiederherstellen.
documents.deleted.deleted-at: Gelöscht
documents.deleted.restoring: Wiederherstellen...
documents.deleted.deleting: Löschen...
documents.preview.unknown-file-type: Kein Vorschau verfügbar für diesen Dateityp
documents.preview.binary-file: Dies scheint eine Binärdatei zu sein und kann nicht als Text angezeigt werden
trash.delete-all.button: Alles löschen
trash.delete-all.confirm.title: Alle Dokumente dauerhaft löschen?
trash.delete-all.confirm.description: Sind Sie sicher, dass Sie alle Dokumente aus dem Papierkorb dauerhaft löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.
trash.delete-all.confirm.label: Löschen
trash.delete-all.confirm.cancel: Abbrechen
trash.delete.button: Löschen
trash.delete.confirm.title: Dokument dauerhaft löschen?
trash.delete.confirm.description: Sind Sie sicher, dass Sie dieses Dokument dauerhaft aus dem Papierkorb löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.
trash.delete.confirm.label: Löschen
trash.delete.confirm.cancel: Abbrechen
trash.deleted.success.title: Dokument gelöscht
trash.deleted.success.description: Das Dokument wurde dauerhaft gelöscht.
activity.document.created: Das Dokument wurde erstellt
activity.document.updated.single: Das Feld {{ field }} wurde aktualisiert
activity.document.updated.multiple: Die Felder {{ fields }} wurden aktualisiert
activity.document.updated: Das Dokument wurde aktualisiert
activity.document.deleted: Das Dokument wurde gelöscht
activity.document.restored: Das Dokument wurde wiederhergestellt
activity.document.tagged: Tag {{ tag }} wurde hinzugefügt
activity.document.untagged: Tag {{ tag }} wurde entfernt
activity.document.user.name: von {{ name }}
activity.load-more: Mehr laden
activity.no-more-activities: Keine weiteren Aktivitäten für dieses Dokument
# Tags
tags.no-tags.title: Noch keine Tags
tags.no-tags.description: Diese Organisation hat noch keine Tags. Tags werden zur Kategorisierung von Dokumenten verwendet. Sie können Ihren Dokumenten Tags hinzufügen, um sie leichter zu finden und zu organisieren.
tags.no-tags.create-tag: Tag erstellen
tags.title: Dokumenten-Tags
tags.description: Tags werden zur Kategorisierung von Dokumenten verwendet. Sie können Ihren Dokumenten Tags hinzufügen, um sie leichter zu finden und zu organisieren.
tags.create: Tag erstellen
tags.update: Tag aktualisieren
tags.delete: Tag löschen
tags.delete.confirm.title: Tag löschen
tags.delete.confirm.message: Sind Sie sicher, dass Sie diesen Tag löschen möchten? Das Löschen eines Tags entfernt ihn von allen Dokumenten.
tags.delete.confirm.confirm-button: Löschen
tags.delete.confirm.cancel-button: Abbrechen
tags.delete.success: Tag erfolgreich gelöscht
tags.create.success: Tag "{{ name }}" erfolgreich erstellt.
tags.update.success: Tag "{{ name }}" erfolgreich aktualisiert.
tags.form.name.label: Name
tags.form.name.placeholder: Z.B. Verträge
tags.form.name.required: Bitte geben Sie einen Tag-Namen ein
tags.form.name.max-length: Tag-Name muss weniger als 64 Zeichen lang sein
tags.form.color.label: Farbe
tags.form.color.required: Bitte geben Sie eine Farbe ein
tags.form.color.invalid: Die Hex-Farbe ist falsch formatiert.
tags.form.description.label: Beschreibung
tags.form.description.optional: (optional)
tags.form.description.placeholder: Z.B. Alle von der Firma unterzeichneten Verträge
tags.form.description.max-length: Beschreibung muss weniger als 256 Zeichen lang sein
tags.form.no-description: Keine Beschreibung
tags.table.headers.tag: Tag
tags.table.headers.description: Beschreibung
tags.table.headers.documents: Dokumente
tags.table.headers.created: Erstellt
tags.table.headers.actions: Aktionen
# Tagging rules
tagging-rules.field.name: Dokumentenname
tagging-rules.field.content: Dokumenteninhalt
tagging-rules.operator.equals: ist gleich
tagging-rules.operator.not-equals: ist nicht gleich
tagging-rules.operator.contains: enthält
tagging-rules.operator.not-contains: enthält nicht
tagging-rules.operator.starts-with: beginnt mit
tagging-rules.operator.ends-with: endet mit
tagging-rules.list.title: Tagging-Regeln
tagging-rules.list.description: Verwalten Sie die Tagging-Regeln Ihrer Organisation, um Dokumente automatisch basierend auf von Ihnen definierten Bedingungen zu taggen.
tagging-rules.list.demo-warning: 'Hinweis: Da dies eine Demo-Umgebung (ohne Server) ist, werden Tagging-Regeln nicht auf neu hinzugefügte Dokumente angewendet.'
tagging-rules.list.no-tagging-rules.title: Keine Tagging-Regeln
tagging-rules.list.no-tagging-rules.description: Erstellen Sie eine Tagging-Regel, um Ihre hinzugefügten Dokumente automatisch basierend auf von Ihnen definierten Bedingungen zu taggen.
tagging-rules.list.no-tagging-rules.create-tagging-rule: Tagging-Regel erstellen
tagging-rules.list.card.no-conditions: Keine Bedingungen
tagging-rules.list.card.one-condition: 1 Bedingung
tagging-rules.list.card.conditions: '{{ count }} Bedingungen'
tagging-rules.list.card.delete: Regel löschen
tagging-rules.list.card.edit: Regel bearbeiten
tagging-rules.create.title: Tagging-Regel erstellen
tagging-rules.create.success: Tagging-Regel erfolgreich erstellt
tagging-rules.create.error: Tagging-Regel konnte nicht erstellt werden
tagging-rules.create.submit: Regel erstellen
tagging-rules.form.name.label: Name
tagging-rules.form.name.placeholder: 'Beispiel: Rechnungen taggen'
tagging-rules.form.name.min-length: Bitte geben Sie einen Namen für die Regel ein
tagging-rules.form.name.max-length: Der Name muss weniger als 64 Zeichen lang sein
tagging-rules.form.description.label: Beschreibung
tagging-rules.form.description.placeholder: "Beispiel: Dokumente mit 'Rechnung' im Namen taggen"
tagging-rules.form.description.max-length: Die Beschreibung muss weniger als 256 Zeichen lang sein
tagging-rules.form.conditions.label: Bedingungen
tagging-rules.form.conditions.description: Definieren Sie die Bedingungen, die erfüllt sein müssen, damit die Regel angewendet wird. Alle Bedingungen müssen erfüllt sein, damit die Regel angewendet wird.
tagging-rules.form.conditions.add-condition: Bedingung hinzufügen
tagging-rules.form.conditions.no-conditions.title: Keine Bedingungen
tagging-rules.form.conditions.no-conditions.description: Sie haben dieser Regel keine Bedingungen hinzugefügt. Diese Regel wendet ihre Tags auf alle Dokumente an.
tagging-rules.form.conditions.no-conditions.confirm: Regel ohne Bedingungen anwenden
tagging-rules.form.conditions.no-conditions.cancel: Abbrechen
tagging-rules.form.conditions.value.placeholder: 'Beispiel: Rechnung'
tagging-rules.form.conditions.value.min-length: Bitte geben Sie einen Wert für die Bedingung ein
tagging-rules.form.tags.label: Tags
tagging-rules.form.tags.description: Wählen Sie die Tags aus, die auf die hinzugefügten Dokumente angewendet werden sollen, die den Bedingungen entsprechen
tagging-rules.form.tags.min-length: Es ist mindestens ein anzuwendender Tag erforderlich
tagging-rules.form.tags.add-tag: Tag erstellen
tagging-rules.form.submit: Regel erstellen
tagging-rules.update.title: Tagging-Regel aktualisieren
tagging-rules.update.error: Tagging-Regel konnte nicht aktualisiert werden
tagging-rules.update.submit: Regel aktualisieren
tagging-rules.update.cancel: Abbrechen
# Intake emails
intake-emails.title: E-Mail-Eingang
intake-emails.description: E-Mail-Eingangsadressen werden verwendet, um E-Mails automatisch in Papra aufzunehmen. Leiten Sie einfach E-Mails an die Eingangsadresse weiter und deren Anhänge werden zu den Dokumenten Ihrer Organisation hinzugefügt.
intake-emails.disabled.title: E-Mail-Eingang ist deaktiviert
intake-emails.disabled.description: E-Mail-Eingang ist auf dieser Instanz deaktiviert. Bitte kontaktieren Sie Ihren Administrator, um ihn zu aktivieren. Weitere Informationen finden Sie in der {{ documentation }}.
intake-emails.disabled.documentation: Dokumentation
intake-emails.info: Es werden nur aktivierte E-Mails aus zulässigen Ursprüngen verarbeitet. Sie können eine E-Mail-Eingangsadresse jederzeit aktivieren oder deaktivieren.
intake-emails.empty.title: Keine E-Mail-Eingänge
intake-emails.empty.description: Generieren Sie eine Eingangsadresse, um E-Mail-Anhänge einfach aufzunehmen.
intake-emails.empty.generate: E-Mail-Eingang generieren
intake-emails.count: '{{ count }} Eingangse-Mail{{ plural }} für diese Organisation'
intake-emails.new: Neue Eingangse-Mail
intake-emails.disabled-label: (Deaktiviert)
intake-emails.no-origins: Keine zulässigen E-Mail-Ursprünge
intake-emails.allowed-origins: Zulässig von {{ count }} Adresse{{ plural }}
intake-emails.actions.enable: Aktivieren
intake-emails.actions.disable: Deaktivieren
intake-emails.actions.manage-origins: Ursprungsadressen verwalten
intake-emails.actions.delete: Löschen
intake-emails.delete.confirm.title: Eingangse-Mail löschen?
intake-emails.delete.confirm.message: Sind Sie sicher, dass Sie diese Eingangse-Mail löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.
intake-emails.delete.confirm.confirm-button: Eingangse-Mail löschen
intake-emails.delete.confirm.cancel-button: Abbrechen
intake-emails.delete.success: Eingangse-Mail gelöscht
intake-emails.create.success: Eingangse-Mail erstellt
intake-emails.update.success.enabled: Eingangse-Mail aktiviert
intake-emails.update.success.disabled: Eingangse-Mail deaktiviert
intake-emails.allowed-origins.title: Zulässige Ursprünge
intake-emails.allowed-origins.description: Es werden nur E-Mails, die an {{ email }} von diesen Ursprüngen gesendet werden, verarbeitet. Wenn keine Ursprünge angegeben sind, werden alle E-Mails verworfen.
intake-emails.allowed-origins.add.label: Zulässige Ursprungs-E-Mail hinzufügen
intake-emails.allowed-origins.add.placeholder: Z.B. ada@papra.app
intake-emails.allowed-origins.add.button: Hinzufügen
intake-emails.allowed-origins.add.error.exists: Diese E-Mail ist bereits in den zulässigen Ursprüngen für diese Eingangse-Mail vorhanden
# API keys
api-keys.permissions.documents.title: Dokumente
api-keys.permissions.documents.documents:create: Dokumente erstellen
api-keys.permissions.documents.documents:read: Dokumente lesen
api-keys.permissions.documents.documents:update: Dokumente aktualisieren
api-keys.permissions.documents.documents:delete: Dokumente löschen
api-keys.permissions.tags.title: Tags
api-keys.permissions.tags.tags:create: Tags erstellen
api-keys.permissions.tags.tags:read: Tags lesen
api-keys.permissions.tags.tags:update: Tags aktualisieren
api-keys.permissions.tags.tags:delete: Tags löschen
api-keys.create.title: API-Schlüssel erstellen
api-keys.create.description: Erstellen Sie einen neuen API-Schlüssel, um auf die Papra API zuzugreifen.
api-keys.create.success: Der API-Schlüssel wurde erfolgreich erstellt.
api-keys.create.back: Zurück zu den API-Schlüsseln
api-keys.create.form.name.label: Name
api-keys.create.form.name.placeholder: 'Beispiel: Mein API-Schlüssel'
api-keys.create.form.name.required: Bitte geben Sie einen Namen für den API-Schlüssel ein
api-keys.create.form.permissions.label: Berechtigungen
api-keys.create.form.permissions.required: Bitte wählen Sie mindestens eine Berechtigung aus
api-keys.create.form.submit: API-Schlüssel erstellen
api-keys.create.created.title: API-Schlüssel erstellt
api-keys.create.created.description: Der API-Schlüssel wurde erfolgreich erstellt. Speichern Sie ihn an einem sicheren Ort, da er nicht erneut angezeigt wird.
api-keys.list.title: API-Schlüssel
api-keys.list.description: Verwalten Sie hier Ihre API-Schlüssel.
api-keys.list.create: API-Schlüssel erstellen
api-keys.list.empty.title: Keine API-Schlüssel
api-keys.list.empty.description: Erstellen Sie einen API-Schlüssel, um auf die Papra API zuzugreifen.
api-keys.list.card.last-used: Zuletzt verwendet
api-keys.list.card.never: Nie
api-keys.list.card.created: Erstellt
api-keys.delete.success: Der API-Schlüssel wurde erfolgreich gelöscht
api-keys.delete.confirm.title: API-Schlüssel löschen
api-keys.delete.confirm.message: Sind Sie sicher, dass Sie diesen API-Schlüssel löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.
api-keys.delete.confirm.confirm-button: Löschen
api-keys.delete.confirm.cancel-button: Abbrechen
# Webhooks
webhooks.list.title: Webhooks
webhooks.list.description: Verwalten Sie Ihre Organisations-Webhooks
webhooks.list.empty.title: Keine Webhooks
webhooks.list.empty.description: Erstellen Sie Ihren ersten Webhook, um Ereignisse zu empfangen
webhooks.list.create: Webhook erstellen
webhooks.list.card.last-triggered: Zuletzt ausgelöst
webhooks.list.card.never: Nie
webhooks.list.card.created: Erstellt
webhooks.create.title: Webhook erstellen
webhooks.create.description: Erstellen Sie einen neuen Webhook, um Ereignisse zu empfangen
webhooks.create.success: Webhook erfolgreich erstellt
webhooks.create.back: Zurück
webhooks.create.form.submit: Webhook erstellen
webhooks.create.form.name.label: Webhook-Name
webhooks.create.form.name.placeholder: Webhook-Namen eingeben
webhooks.create.form.name.required: Name ist erforderlich
webhooks.create.form.url.label: Webhook-URL
webhooks.create.form.url.placeholder: Webhook-URL eingeben
webhooks.create.form.url.required: URL ist erforderlich
webhooks.create.form.url.invalid: URL ist ungültig
webhooks.create.form.secret.label: Geheimnis
webhooks.create.form.secret.placeholder: Webhook-Geheimnis eingeben
webhooks.create.form.events.label: Ereignisse
webhooks.create.form.events.required: Mindestens ein Ereignis ist erforderlich
webhooks.update.title: Webhook bearbeiten
webhooks.update.description: Aktualisieren Sie Ihre Webhook-Details
webhooks.update.success: Webhook erfolgreich aktualisiert
webhooks.update.submit: Webhook aktualisieren
webhooks.update.cancel: Abbrechen
webhooks.update.form.secret.placeholder: Neues Geheimnis eingeben
webhooks.update.form.secret.placeholder-redacted: '[Geheimnis geschwärzt]'
webhooks.update.form.rotate-secret.button: Geheimnis rotieren
webhooks.delete.success: Webhook erfolgreich gelöscht
webhooks.delete.confirm.title: Webhook löschen
webhooks.delete.confirm.message: Sind Sie sicher, dass Sie diesen Webhook löschen möchten?
webhooks.delete.confirm.confirm-button: Löschen
webhooks.delete.confirm.cancel-button: Abbrechen
webhooks.events.documents.document:created.description: Dokument erstellt
webhooks.events.documents.document:deleted.description: Dokument gelöscht
# Navigation
layout.menu.home: Startseite
layout.menu.documents: Dokumente
layout.menu.tags: Tags
layout.menu.tagging-rules: Tagging-Regeln
layout.menu.deleted-documents: Gelöschte Dokumente
layout.menu.organization-settings: Einstellungen
layout.menu.api-keys: API-Schlüssel
layout.menu.settings: Einstellungen
layout.menu.account: Konto
layout.menu.general-settings: Allgemeine Einstellungen
layout.menu.intake-emails: E-Mail-Eingang
layout.menu.webhooks: Webhooks
layout.menu.members: Mitglieder
layout.menu.invitations: Einladungen
layout.theme.light: Heller Modus
layout.theme.dark: Dunkler Modus
layout.theme.system: Systemmodus
layout.search.placeholder: Suchen...
layout.menu.import-document: Dokument importieren
user-menu.account-settings: Kontoeinstellungen
user-menu.api-keys: API-Schlüssel
user-menu.invitations: Einladungen
user-menu.language: Sprache
user-menu.logout: Abmelden
# Command palette
command-palette.search.placeholder: Befehle oder Dokumente suchen
command-palette.no-results: Keine Ergebnisse gefunden
command-palette.sections.documents: Dokumente
command-palette.sections.theme: Thema
# API errors
api-errors.document.already_exists: Das Dokument existiert bereits
api-errors.document.file_too_big: Die Dokumentdatei ist zu groß
api-errors.intake_email.limit_reached: Die maximale Anzahl an Eingangse-Mails für diese Organisation wurde erreicht. Bitte aktualisieren Sie Ihren Plan, um weitere Eingangse-Mails zu erstellen.
api-errors.user.max_organization_count_reached: Sie haben die maximale Anzahl an Organisationen erreicht, die Sie erstellen können. Wenn Sie weitere erstellen möchten, kontaktieren Sie bitte den Support.
api-errors.default: Beim Verarbeiten Ihrer Anfrage ist ein Fehler aufgetreten.
api-errors.organization.invitation_already_exists: Eine Einladung für diese E-Mail existiert bereits in dieser Organisation.
api-errors.user.already_in_organization: Dieser Benutzer ist bereits in dieser Organisation.
api-errors.user.organization_invitation_limit_reached: Die maximale Anzahl an Einladungen für heute wurde erreicht. Bitte versuchen Sie es morgen erneut.
api-errors.demo.not_available: Diese Funktion ist in der Demo nicht verfügbar
api-errors.tags.already_exists: Ein Tag mit diesem Namen existiert bereits für diese Organisation
# Not found
not-found.title: 404 - Seite nicht gefunden
not-found.description: Entschuldigung, die gesuchte Seite scheint nicht zu existieren. Bitte überprüfen Sie die URL und versuchen Sie es erneut.
not-found.back-to-home: Zurück zur Startseite
# Demo
demo.popup.description: Dies ist eine Demo-Umgebung, alle Daten werden im lokalen Speicher Ihres Browsers gespeichert.
demo.popup.discord: Treten Sie dem {{ discordLink }} bei, um Support zu erhalten, Funktionen vorzuschlagen oder einfach nur zu chatten.
demo.popup.discord-link-label: Discord-Server
demo.popup.reset: Demo-Daten zurücksetzen
demo.popup.hide: Ausblenden
# Color picker
color-picker.hue: Farbton
color-picker.saturation: Sättigung
color-picker.lightness: Helligkeit
color-picker.select-color: Farbe auswählen
color-picker.select-a-color: Eine Farbe auswählen

View File

@@ -1,35 +1,565 @@
auth:
login:
title: Login to Papra
description: Enter your email or use social login to access your Papra account.
login-with-provider: Login with {{ provider }}
no-account: Don't have an account?
register: Register
email-validation-required:
title: Verify your email
description: A verification email has been sent to your email address. Please verify your email address by clicking the link in the email.
legal-links:
description: By continuing, you acknowledge that you understand and agree to the {{ terms }} and {{ privacy }}.
terms: Terms of Service
privacy: Privacy Policy
# Authentication
tags:
no-tags:
title: No tags yet
description: This organization has no tags yet. Tags are used to categorize documents. You can add tags to your documents to make them easier to find and organize.
create-tag: Create tag
auth.request-password-reset.title: Reset your password
auth.request-password-reset.description: Enter your email to reset your password.
auth.request-password-reset.requested: If an account exists for this email, we've sent you an email to reset your password.
auth.request-password-reset.back-to-login: Back to login
auth.request-password-reset.form.email.label: Email
auth.request-password-reset.form.email.placeholder: 'Example: ada@papra.app'
auth.request-password-reset.form.email.required: Please enter your email address
auth.request-password-reset.form.email.invalid: This email address is invalid
auth.request-password-reset.form.submit: Request password reset
layout:
menu:
home: Home
documents: Documents
tags: Tags
integrations: Integrations
deleted-documents: Deleted documents
organization-settings: Organization settings
auth.reset-password.title: Reset your password
auth.reset-password.description: Enter your new password to reset your password.
auth.reset-password.reset: Your password has been reset.
auth.reset-password.back-to-login: Back to login
auth.reset-password.form.new-password.label: New password
auth.reset-password.form.new-password.placeholder: 'Example: **********'
auth.reset-password.form.new-password.required: Please enter your new password
auth.reset-password.form.new-password.min-length: Password must be at least {{ minLength }} characters
auth.reset-password.form.new-password.max-length: Password must be less than {{ maxLength }} characters
auth.reset-password.form.submit: Reset password
demo:
popup:
description: This is a demo environment, all data is save to your browser local storage.
reset: Reset demo data
hide: Hide
auth.email-provider.open: Open {{ provider }}
auth.login.title: Login to Papra
auth.login.description: Enter your email or use social login to access your Papra account.
auth.login.login-with-provider: Login with {{ provider }}
auth.login.no-account: Don't have an account?
auth.login.register: Register
auth.login.form.email.label: Email
auth.login.form.email.placeholder: 'Example: ada@papra.app'
auth.login.form.email.required: Please enter your email address
auth.login.form.email.invalid: This email address is invalid
auth.login.form.password.label: Password
auth.login.form.password.placeholder: Set a password
auth.login.form.password.required: Please enter your password
auth.login.form.remember-me.label: Remember me
auth.login.form.forgot-password.label: Forgot password?
auth.login.form.submit: Login
auth.register.title: Register to Papra
auth.register.description: Create an account to start using Papra.
auth.register.register-with-email: Register with email
auth.register.register-with-provider: Register with {{ provider }}
auth.register.providers.google: Google
auth.register.providers.github: GitHub
auth.register.have-account: Already have an account?
auth.register.login: Login
auth.register.registration-disabled.title: Registration is disabled
auth.register.registration-disabled.description: The creation of new accounts is currently disabled on this instance of Papra. Only users with existing accounts can log in. If you think this is a mistake, please contact the administrator of this instance.
auth.register.form.email.label: Email
auth.register.form.email.placeholder: 'Example: ada@papra.app'
auth.register.form.email.required: Please enter your email address
auth.register.form.email.invalid: This email address is invalid
auth.register.form.password.label: Password
auth.register.form.password.placeholder: Set a password
auth.register.form.password.required: Please enter your password
auth.register.form.password.min-length: Password must be at least {{ minLength }} characters
auth.register.form.password.max-length: Password must be less than {{ maxLength }} characters
auth.register.form.name.label: Name
auth.register.form.name.placeholder: 'Example: Ada Lovelace'
auth.register.form.name.required: Please enter your name
auth.register.form.name.max-length: Name must be less than {{ maxLength }} characters
auth.register.form.submit: Register
auth.email-validation-required.title: Verify your email
auth.email-validation-required.description: A verification email has been sent to your email address. Please verify your email address by clicking the link in the email.
auth.legal-links.description: By continuing, you acknowledge that you understand and agree to the {{ terms }} and {{ privacy }}.
auth.legal-links.terms: Terms of Service
auth.legal-links.privacy: Privacy Policy
auth.no-auth-provider.title: No authentication provider
auth.no-auth-provider.description: There are no authentication providers enabled on this instance of Papra. Please contact the administrator of this instance to enable them.
# User settings
user.settings.title: User settings
user.settings.description: Manage your account settings here.
user.settings.email.title: Email address
user.settings.email.description: Your email address cannot be changed.
user.settings.email.label: Email address
user.settings.name.title: Full name
user.settings.name.description: Your full name is displayed to other organization members.
user.settings.name.label: Full name
user.settings.name.placeholder: Eg. John Doe
user.settings.name.update: Update name
user.settings.name.updated: Your full name has been updated
user.settings.logout.title: Logout
user.settings.logout.description: Logout from your account. You can login again later.
user.settings.logout.button: Logout
# Organizations
organizations.list.title: Your organizations
organizations.list.description: Organizations are a way to group your documents and manage access to them. You can create multiple organizations and invite your team members to collaborate.
organizations.list.create-new: Create new organization
organizations.details.no-documents.title: No documents
organizations.details.no-documents.description: There are no documents in this organization yet. Start by uploading some documents.
organizations.details.upload-documents: Upload documents
organizations.details.documents-count: documents in total
organizations.details.total-size: total size
organizations.details.latest-documents: Latest imported documents
organizations.create.title: Create a new organization
organizations.create.description: Your documents will be grouped by organization. You can create multiple organizations to separate your documents, for example, for personal and work documents.
organizations.create.back: Back
organizations.create.error.max-count-reached: You have reached the maximum number of organizations you can create, if you need to create more, please contact support.
organizations.create.form.name.label: Organization name
organizations.create.form.name.placeholder: Eg. Acme Inc.
organizations.create.form.name.required: Please enter an organization name
organizations.create.form.submit: Create organization
organizations.create.success: Organization created successfully
organizations.create-first.title: Create your organization
organizations.create-first.description: Your documents will be grouped by organization. You can create multiple organizations to separate your documents, for example, for personal and work documents.
organizations.create-first.default-name: My organization
organizations.create-first.user-name: "{{ name }}'s organization"
organization.settings.title: Organization Settings
organization.settings.page.title: Organization settings
organization.settings.page.description: Manage your organization settings here.
organization.settings.name.title: Organization name
organization.settings.name.update: Update name
organization.settings.name.placeholder: Eg. Acme Inc.
organization.settings.name.updated: Organization name updated
organization.settings.subscription.title: Subscription
organization.settings.subscription.description: Manage your billing, invoices and payment methods.
organization.settings.subscription.manage: Manage subscription
organization.settings.subscription.error: Failed to get customer portal URL
organization.settings.delete.title: Delete organization
organization.settings.delete.description: Deleting this organization will permanently remove all data associated with it.
organization.settings.delete.confirm.title: Delete organization
organization.settings.delete.confirm.message: Are you sure you want to delete this organization? This action cannot be undone, and all data associated with this organization will be permanently removed.
organization.settings.delete.confirm.confirm-button: Delete organization
organization.settings.delete.confirm.cancel-button: Cancel
organization.settings.delete.success: Organization deleted
organizations.members.title: Members
organizations.members.description: Manage your organization members
organizations.members.invite-member: Invite member
organizations.members.invite-member-disabled-tooltip: Only admins or owners can invite members to the organization
organizations.members.remove-from-organization: Remove from organization
organizations.members.role: Role
organizations.members.roles.owner: Owner
organizations.members.roles.admin: Admin
organizations.members.roles.member: Member
organizations.members.delete.confirm.title: Remove member
organizations.members.delete.confirm.message: Are you sure you want to remove this member from the organization?
organizations.members.delete.confirm.confirm-button: Remove
organizations.members.delete.confirm.cancel-button: Cancel
organizations.members.delete.success: Member removed from organization
organizations.members.update-role.success: Member role updated
organizations.members.table.headers.name: Name
organizations.members.table.headers.email: Email
organizations.members.table.headers.role: Role
organizations.members.table.headers.created: Created
organizations.members.table.headers.actions: Actions
organizations.invite-member.title: Invite member
organizations.invite-member.description: Invite a member to your organization
organizations.invite-member.form.email.label: Email
organizations.invite-member.form.email.placeholder: 'Example: ada@papra.app'
organizations.invite-member.form.email.required: Please enter a valid email address
organizations.invite-member.form.role.label: Role
organizations.invite-member.form.submit: Invite to organization
organizations.invite-member.success.message: Member invited
organizations.invite-member.success.description: The email has been invited to the organization.
organizations.invite-member.error.message: Failed to invite member
organizations.invitations.title: Invitations
organizations.invitations.description: Manage your organization invitations
organizations.invitations.list.cta: Invite member
organizations.invitations.list.empty.title: No pending invitations
organizations.invitations.list.empty.description: You haven't been invited to any organizations yet.
organizations.invitations.status.pending: Pending
organizations.invitations.status.accepted: Accepted
organizations.invitations.status.rejected: Rejected
organizations.invitations.status.expired: Expired
organizations.invitations.status.cancelled: Cancelled
organizations.invitations.resend: Resend invitation
organizations.invitations.cancel.title: Cancel invitation
organizations.invitations.cancel.description: Are you sure you want to cancel this invitation?
organizations.invitations.cancel.confirm: Cancel invitation
organizations.invitations.cancel.cancel: Cancel
organizations.invitations.resend.title: Resend invitation
organizations.invitations.resend.description: Are you sure you want to resend this invitation? This will send a new email to the recipient.
organizations.invitations.resend.confirm: Resend invitation
organizations.invitations.resend.cancel: Cancel
invitations.list.title: Invitations
invitations.list.description: Manage your organization invitations
invitations.list.empty.title: No pending invitations
invitations.list.empty.description: You haven't been invited to any organizations yet.
invitations.list.headers.organization: Organization
invitations.list.headers.status: Status
invitations.list.headers.created: Created
invitations.list.headers.actions: Actions
invitations.list.actions.accept: Accept
invitations.list.actions.reject: Reject
invitations.list.actions.accept.success.message: Invitation accepted
invitations.list.actions.accept.success.description: The invitation has been accepted.
invitations.list.actions.reject.success.message: Invitation rejected
invitations.list.actions.reject.success.description: The invitation has been rejected.
# Documents
documents.list.title: Documents
documents.list.no-documents.title: No documents
documents.list.no-documents.description: There are no documents in this organization yet. Start by uploading some documents.
documents.list.no-results: No documents found
documents.tabs.info: Info
documents.tabs.content: Content
documents.tabs.activity: Activity
documents.deleted.message: This document has been deleted and will be permanently removed in {{ days }} days.
documents.actions.download: Download
documents.actions.open-in-new-tab: Open in new tab
documents.actions.restore: Restore
documents.actions.delete: Delete
documents.actions.edit: Edit
documents.actions.cancel: Cancel
documents.actions.save: Save
documents.actions.saving: Saving...
documents.content.alert: The content of the document is automatically extracted from the document on upload. It is only used for search and indexing purposes.
documents.info.id: ID
documents.info.name: Name
documents.info.type: Type
documents.info.size: Size
documents.info.created-at: Created At
documents.info.updated-at: Updated At
documents.info.never: Never
documents.rename.title: Rename document
documents.rename.form.name.label: Name
documents.rename.form.name.placeholder: 'Example: Invoice 2024'
documents.rename.form.name.required: Please enter a name for the document
documents.rename.form.name.max-length: The name must be less than 255 characters
documents.rename.form.submit: Rename document
documents.rename.success: Document renamed successfully
documents.rename.cancel: Cancel
import-documents.title.error: '{{ count }} documents failed'
import-documents.title.success: '{{ count }} documents imported'
import-documents.title.pending: '{{ count }} / {{ total }} documents imported'
import-documents.title.none: Import documents
import-documents.no-import-in-progress: No document import in progress
documents.deleted.title: Deleted documents
documents.deleted.empty.title: No deleted documents
documents.deleted.empty.description: You have no deleted documents. Documents that are deleted will be moved to the trash bin for {{ days }} days.
documents.deleted.retention-notice: All deleted documents are stored in the trash bin for {{ days }} days. Passing this delay, the documents will be permanently deleted, and you will not be able to restore them.
documents.deleted.deleted-at: Deleted
documents.deleted.restoring: Restoring...
documents.deleted.deleting: Deleting...
documents.preview.unknown-file-type: No preview available for this file type
documents.preview.binary-file: This appears to be a binary file and cannot be displayed as text
trash.delete-all.button: Delete all
trash.delete-all.confirm.title: Permanently delete all documents?
trash.delete-all.confirm.description: Are you sure you want to permanently delete all documents from the trash? This action cannot be undone.
trash.delete-all.confirm.label: Delete
trash.delete-all.confirm.cancel: Cancel
trash.delete.button: Delete
trash.delete.confirm.title: Permanently delete document?
trash.delete.confirm.description: Are you sure you want to permanently delete this document from the trash? This action cannot be undone.
trash.delete.confirm.label: Delete
trash.delete.confirm.cancel: Cancel
trash.deleted.success.title: Document deleted
trash.deleted.success.description: The document has been permanently deleted.
activity.document.created: The document has been created
activity.document.updated.single: The {{ field }} has been updated
activity.document.updated.multiple: The {{ fields }} have been updated
activity.document.updated: The document has been updated
activity.document.deleted: The document has been deleted
activity.document.restored: The document has been restored
activity.document.tagged: Tag {{ tag }} has been added
activity.document.untagged: Tag {{ tag }} has been removed
activity.document.user.name: by {{ name }}
activity.load-more: Load more
activity.no-more-activities: No more activities for this document
# Tags
tags.no-tags.title: No tags yet
tags.no-tags.description: This organization has no tags yet. Tags are used to categorize documents. You can add tags to your documents to make them easier to find and organize.
tags.no-tags.create-tag: Create tag
tags.title: Documents Tags
tags.description: Tags are used to categorize documents. You can add tags to your documents to make them easier to find and organize.
tags.create: Create tag
tags.update: Update tag
tags.delete: Delete tag
tags.delete.confirm.title: Delete tag
tags.delete.confirm.message: Are you sure you want to delete this tag? Deleting a tag will remove it from all documents.
tags.delete.confirm.confirm-button: Delete
tags.delete.confirm.cancel-button: Cancel
tags.delete.success: Tag deleted successfully
tags.create.success: Tag "{{ name }}" created successfully.
tags.update.success: Tag "{{ name }}" updated successfully.
tags.form.name.label: Name
tags.form.name.placeholder: Eg. Contracts
tags.form.name.required: Please enter a tag name
tags.form.name.max-length: Tag name must be less than 64 characters
tags.form.color.label: Color
tags.form.color.required: Please enter a color
tags.form.color.invalid: The hex color is badly formatted.
tags.form.description.label: Description
tags.form.description.optional: (optional)
tags.form.description.placeholder: Eg. All the contracts signed by the company
tags.form.description.max-length: Description must be less than 256 characters
tags.form.no-description: No description
tags.table.headers.tag: Tag
tags.table.headers.description: Description
tags.table.headers.documents: Documents
tags.table.headers.created: Created
tags.table.headers.actions: Actions
# Tagging rules
tagging-rules.field.name: document name
tagging-rules.field.content: document content
tagging-rules.operator.equals: equals
tagging-rules.operator.not-equals: not equals
tagging-rules.operator.contains: contains
tagging-rules.operator.not-contains: not contains
tagging-rules.operator.starts-with: starts with
tagging-rules.operator.ends-with: ends with
tagging-rules.list.title: Tagging rules
tagging-rules.list.description: Manage your organization's tagging rules, to automatically tag documents based on conditions you define.
tagging-rules.list.demo-warning: 'Note: As this is a demo environment (with no server), tagging rules will not be applied to newly added documents.'
tagging-rules.list.no-tagging-rules.title: No tagging rules
tagging-rules.list.no-tagging-rules.description: Create a tagging rule to automatically tag your added documents based on conditions you define.
tagging-rules.list.no-tagging-rules.create-tagging-rule: Create tagging rule
tagging-rules.list.card.no-conditions: No conditions
tagging-rules.list.card.one-condition: 1 condition
tagging-rules.list.card.conditions: '{{ count }} conditions'
tagging-rules.list.card.delete: Delete rule
tagging-rules.list.card.edit: Edit rule
tagging-rules.create.title: Create tagging rule
tagging-rules.create.success: Tagging rule created successfully
tagging-rules.create.error: Failed to create tagging rule
tagging-rules.create.submit: Create rule
tagging-rules.form.name.label: Name
tagging-rules.form.name.placeholder: 'Example: Tag invoices'
tagging-rules.form.name.min-length: Please enter a name for the rule
tagging-rules.form.name.max-length: The name must be less than 64 characters
tagging-rules.form.description.label: Description
tagging-rules.form.description.placeholder: "Example: Tag documents with 'invoice' in the name"
tagging-rules.form.description.max-length: The description must be less than 256 characters
tagging-rules.form.conditions.label: Conditions
tagging-rules.form.conditions.description: Define the conditions that must be met for the rule to apply. All conditions must be met for the rule to apply.
tagging-rules.form.conditions.add-condition: Add condition
tagging-rules.form.conditions.no-conditions.title: No conditions
tagging-rules.form.conditions.no-conditions.description: You didn't add any conditions to this rule. This rule will apply its tags to all documents.
tagging-rules.form.conditions.no-conditions.confirm: Apply rule without conditions
tagging-rules.form.conditions.no-conditions.cancel: Cancel
tagging-rules.form.conditions.value.placeholder: 'Example: invoice'
tagging-rules.form.conditions.value.min-length: Please enter a value for the condition
tagging-rules.form.tags.label: Tags
tagging-rules.form.tags.description: Select the tags to apply to the added documents that match the conditions
tagging-rules.form.tags.min-length: At least one tag to apply is required
tagging-rules.form.tags.add-tag: Create tag
tagging-rules.form.submit: Create rule
tagging-rules.update.title: Update tagging rule
tagging-rules.update.error: Failed to update tagging rule
tagging-rules.update.submit: Update rule
tagging-rules.update.cancel: Cancel
# Intake emails
intake-emails.title: Intake Emails
intake-emails.description: Intake emails address are used to automatically ingest emails into Papra. Just forward emails to the intake email address and their attachments will be added to your organization's documents.
intake-emails.disabled.title: Intake Emails are disabled
intake-emails.disabled.description: Intake emails are disabled on this instance. Please contact your administrator to enable them. See the {{ documentation }} for more information.
intake-emails.disabled.documentation: documentation
intake-emails.info: Only enabled intake emails from allowed origins will be processed. You can enable or disable an intake email at any time.
intake-emails.empty.title: No intake emails
intake-emails.empty.description: Generate an intake address to easily ingest emails attachments.
intake-emails.empty.generate: Generate intake email
intake-emails.count: '{{ count }} intake email{{ plural }} for this organization'
intake-emails.new: New intake email
intake-emails.disabled-label: (Disabled)
intake-emails.no-origins: No allowed email origins
intake-emails.allowed-origins: Allowed from {{ count }} address{{ plural }}
intake-emails.actions.enable: Enable
intake-emails.actions.disable: Disable
intake-emails.actions.manage-origins: Manage origins addresses
intake-emails.actions.delete: Delete
intake-emails.delete.confirm.title: Delete intake email?
intake-emails.delete.confirm.message: Are you sure you want to delete this intake email? This action cannot be undone.
intake-emails.delete.confirm.confirm-button: Delete intake email
intake-emails.delete.confirm.cancel-button: Cancel
intake-emails.delete.success: Intake email deleted
intake-emails.create.success: Intake email created
intake-emails.update.success.enabled: Intake email enabled
intake-emails.update.success.disabled: Intake email disabled
intake-emails.allowed-origins.title: Allowed origins
intake-emails.allowed-origins.description: Only emails sent to {{ email }} from these origins will be processed. If no origins are specified, all emails will be discarded.
intake-emails.allowed-origins.add.label: Add allowed origin email
intake-emails.allowed-origins.add.placeholder: Eg. ada@papra.app
intake-emails.allowed-origins.add.button: Add
intake-emails.allowed-origins.add.error.exists: This email is already in the allowed origins for this intake email
# API keys
api-keys.permissions.documents.title: Documents
api-keys.permissions.documents.documents:create: Create documents
api-keys.permissions.documents.documents:read: Read documents
api-keys.permissions.documents.documents:update: Update documents
api-keys.permissions.documents.documents:delete: Delete documents
api-keys.permissions.tags.title: Tags
api-keys.permissions.tags.tags:create: Create tags
api-keys.permissions.tags.tags:read: Read tags
api-keys.permissions.tags.tags:update: Update tags
api-keys.permissions.tags.tags:delete: Delete tags
api-keys.create.title: Create API key
api-keys.create.description: Create a new API key to access the Papra API.
api-keys.create.success: The API key has been created successfully.
api-keys.create.back: Back to API keys
api-keys.create.form.name.label: Name
api-keys.create.form.name.placeholder: 'Example: My API key'
api-keys.create.form.name.required: Please enter a name for the API key
api-keys.create.form.permissions.label: Permissions
api-keys.create.form.permissions.required: Please select at least one permission
api-keys.create.form.submit: Create API key
api-keys.create.created.title: API key created
api-keys.create.created.description: The API key has been created successfully. Save it in a secure location as it will not be displayed again.
api-keys.list.title: API keys
api-keys.list.description: Manage your API keys here.
api-keys.list.create: Create API key
api-keys.list.empty.title: No API keys
api-keys.list.empty.description: Create an API key to access the Papra API.
api-keys.list.card.last-used: Last used
api-keys.list.card.never: Never
api-keys.list.card.created: Created
api-keys.delete.success: The API key has been deleted successfully
api-keys.delete.confirm.title: Delete API key
api-keys.delete.confirm.message: Are you sure you want to delete this API key? This action cannot be undone.
api-keys.delete.confirm.confirm-button: Delete
api-keys.delete.confirm.cancel-button: Cancel
# Webhooks
webhooks.list.title: Webhooks
webhooks.list.description: Manage your organization webhooks
webhooks.list.empty.title: No webhooks
webhooks.list.empty.description: Create your first webhook to start receiving events
webhooks.list.create: Create webhook
webhooks.list.card.last-triggered: Last triggered
webhooks.list.card.never: Never
webhooks.list.card.created: Created
webhooks.create.title: Create webhook
webhooks.create.description: Create a new webhook to receive events
webhooks.create.success: Webhook created successfully
webhooks.create.back: Back
webhooks.create.form.submit: Create webhook
webhooks.create.form.name.label: Webhook name
webhooks.create.form.name.placeholder: Enter webhook name
webhooks.create.form.name.required: Name is required
webhooks.create.form.url.label: Webhook URL
webhooks.create.form.url.placeholder: Enter webhook URL
webhooks.create.form.url.required: URL is required
webhooks.create.form.url.invalid: URL is invalid
webhooks.create.form.secret.label: Secret
webhooks.create.form.secret.placeholder: Enter webhook secret
webhooks.create.form.events.label: Events
webhooks.create.form.events.required: At least one event is required
webhooks.update.title: Edit webhook
webhooks.update.description: Update your webhook details
webhooks.update.success: Webhook updated successfully
webhooks.update.submit: Update webhook
webhooks.update.cancel: Cancel
webhooks.update.form.secret.placeholder: Enter new secret
webhooks.update.form.secret.placeholder-redacted: '[Redacted secret]'
webhooks.update.form.rotate-secret.button: Rotate secret
webhooks.delete.success: Webhook deleted successfully
webhooks.delete.confirm.title: Delete webhook
webhooks.delete.confirm.message: Are you sure you want to delete this webhook?
webhooks.delete.confirm.confirm-button: Delete
webhooks.delete.confirm.cancel-button: Cancel
webhooks.events.documents.document:created.description: Document created
webhooks.events.documents.document:deleted.description: Document deleted
# Navigation
layout.menu.home: Home
layout.menu.documents: Documents
layout.menu.tags: Tags
layout.menu.tagging-rules: Tagging rules
layout.menu.deleted-documents: Deleted documents
layout.menu.organization-settings: Settings
layout.menu.api-keys: API keys
layout.menu.settings: Settings
layout.menu.account: Account
layout.menu.general-settings: General settings
layout.menu.intake-emails: Intake emails
layout.menu.webhooks: Webhooks
layout.menu.members: Members
layout.menu.invitations: Invitations
layout.theme.light: Light mode
layout.theme.dark: Dark mode
layout.theme.system: System mode
layout.search.placeholder: Search...
layout.menu.import-document: Import a document
user-menu.account-settings: Account settings
user-menu.api-keys: API keys
user-menu.invitations: Invitations
user-menu.language: Language
user-menu.logout: Logout
# Command palette
command-palette.search.placeholder: Search commands or documents
command-palette.no-results: No results found
command-palette.sections.documents: Documents
command-palette.sections.theme: Theme
# API errors
api-errors.document.already_exists: The document already exists
api-errors.document.file_too_big: The document file is too big
api-errors.intake_email.limit_reached: The maximum number of intake emails for this organization has been reached. Please upgrade your plan to create more intake emails.
api-errors.user.max_organization_count_reached: You have reached the maximum number of organizations you can create, if you need to create more, please contact support.
api-errors.default: An error occurred while processing your request.
api-errors.organization.invitation_already_exists: An invitation for this email already exists in this organization.
api-errors.user.already_in_organization: This user is already in this organization.
api-errors.user.organization_invitation_limit_reached: The maximum number of invitations has been reached for today. Please try again tomorrow.
api-errors.demo.not_available: This feature is not available in demo
api-errors.tags.already_exists: A tag with this name already exists for this organization
# Not found
not-found.title: 404 - Not Found
not-found.description: Sorry, the page you are looking for does not seem to exist. Please check the URL and try again.
not-found.back-to-home: Go back to home
# Demo
demo.popup.description: This is a demo environment, all data is save to your browser local storage.
demo.popup.discord: Join the {{ discordLink }} to get support, propose features or just chat.
demo.popup.discord-link-label: Discord server
demo.popup.reset: Reset demo data
demo.popup.hide: Hide
# Color picker
color-picker.hue: Hue
color-picker.saturation: Saturation
color-picker.lightness: Lightness
color-picker.select-color: Select color
color-picker.select-a-color: Select a color

View File

@@ -0,0 +1,565 @@
# Authentication
auth.request-password-reset.title: Restablece tu contraseña
auth.request-password-reset.description: Ingresa tu correo electrónico para restablecer tu contraseña.
auth.request-password-reset.requested: Si existe una cuenta para este correo electrónico, te enviaremos un correo para restablecer tu contraseña.
auth.request-password-reset.back-to-login: Volver al inicio de sesión
auth.request-password-reset.form.email.label: Correo electrónico
auth.request-password-reset.form.email.placeholder: 'Ejemplo: ada@papra.app'
auth.request-password-reset.form.email.required: Por favor, ingresa tu correo electrónico
auth.request-password-reset.form.email.invalid: Esta dirección de correo electrónico no es válida
auth.request-password-reset.form.submit: Solicitar restablecimiento de contraseña
auth.reset-password.title: Restablece tu contraseña
auth.reset-password.description: Ingresa tu nueva contraseña para restablecerla.
auth.reset-password.reset: Tu contraseña ha sido restablecida.
auth.reset-password.back-to-login: Volver al inicio de sesión
auth.reset-password.form.new-password.label: Nueva contraseña
auth.reset-password.form.new-password.placeholder: 'Ejemplo: **********'
auth.reset-password.form.new-password.required: Por favor, ingresa tu nueva contraseña
auth.reset-password.form.new-password.min-length: La contraseña debe tener al menos {{ minLength }} caracteres
auth.reset-password.form.new-password.max-length: La contraseña debe tener menos de {{ maxLength }} caracteres
auth.reset-password.form.submit: Restablecer contraseña
auth.email-provider.open: Abrir {{ provider }}
auth.login.title: Inicia sesión en Papra
auth.login.description: Ingresa tu correo electrónico o usa un inicio de sesión social para acceder a tu cuenta de Papra.
auth.login.login-with-provider: Iniciar sesión con {{ provider }}
auth.login.no-account: ¿No tienes una cuenta?
auth.login.register: Registrarse
auth.login.form.email.label: Correo electrónico
auth.login.form.email.placeholder: 'Ejemplo: ada@papra.app'
auth.login.form.email.required: Por favor, ingresa tu correo electrónico
auth.login.form.email.invalid: Esta dirección de correo electrónico no es válida
auth.login.form.password.label: Contraseña
auth.login.form.password.placeholder: Establece una contraseña
auth.login.form.password.required: Por favor, ingresa tu contraseña
auth.login.form.remember-me.label: Recordarme
auth.login.form.forgot-password.label: ¿Olvidaste tu contraseña?
auth.login.form.submit: Iniciar sesión
auth.register.title: Regístrate en Papra
auth.register.description: Crea una cuenta para comenzar a usar Papra.
auth.register.register-with-email: Registrarse con correo electrónico
auth.register.register-with-provider: Registrarse con {{ provider }}
auth.register.providers.google: Google
auth.register.providers.github: GitHub
auth.register.have-account: ¿Ya tienes una cuenta?
auth.register.login: Iniciar sesión
auth.register.registration-disabled.title: El registro está deshabilitado
auth.register.registration-disabled.description: La creación de nuevas cuentas está deshabilitada actualmente en esta instancia de Papra. Solo los usuarios con cuentas existentes pueden iniciar sesión. Si crees que esto es un error, contacta al administrador de esta instancia.
auth.register.form.email.label: Correo electrónico
auth.register.form.email.placeholder: 'Ejemplo: ada@papra.app'
auth.register.form.email.required: Por favor, ingresa tu correo electrónico
auth.register.form.email.invalid: Esta dirección de correo electrónico no es válida
auth.register.form.password.label: Contraseña
auth.register.form.password.placeholder: Establece una contraseña
auth.register.form.password.required: Por favor, ingresa tu contraseña
auth.register.form.password.min-length: La contraseña debe tener al menos {{ minLength }} caracteres
auth.register.form.password.max-length: La contraseña debe tener menos de {{ maxLength }} caracteres
auth.register.form.name.label: Nombre
auth.register.form.name.placeholder: 'Ejemplo: Ada Lovelace'
auth.register.form.name.required: Por favor, ingresa tu nombre
auth.register.form.name.max-length: El nombre debe tener menos de {{ maxLength }} caracteres
auth.register.form.submit: Registrarse
auth.email-validation-required.title: Verifica tu correo electrónico
auth.email-validation-required.description: Se ha enviado un correo de verificación a tu dirección de correo electrónico. Por favor, verifica tu correo haciendo clic en el enlace del correo.
auth.legal-links.description: Al continuar, reconoces que entiendes y aceptas los {{ terms }} y la {{ privacy }}.
auth.legal-links.terms: Términos de servicio
auth.legal-links.privacy: Política de privacidad
auth.no-auth-provider.title: No hay proveedor de autenticación
auth.no-auth-provider.description: No hay proveedores de autenticación habilitados en esta instancia de Papra. Por favor, contacta al administrador de esta instancia para habilitarlos.
# User settings
user.settings.title: Configuración de usuario
user.settings.description: Administra aquí la configuración de tu cuenta.
user.settings.email.title: Dirección de correo electrónico
user.settings.email.description: Tu dirección de correo electrónico no puede ser cambiada.
user.settings.email.label: Correo electrónico
user.settings.name.title: Nombre completo
user.settings.name.description: Tu nombre completo se muestra a otros miembros de la organización.
user.settings.name.label: Nombre completo
user.settings.name.placeholder: Ej. John Doe
user.settings.name.update: Actualizar nombre
user.settings.name.updated: Tu nombre completo ha sido actualizado
user.settings.logout.title: Cerrar sesión
user.settings.logout.description: Cierra la sesión de tu cuenta. Puedes iniciar sesión nuevamente más tarde.
user.settings.logout.button: Cerrar sesión
# Organizations
organizations.list.title: Tus organizaciones
organizations.list.description: Las organizaciones son una manera de agrupar tus documentos y gestionar el acceso a ellos. Puedes crear varias organizaciones e invitar a tus compañeros para colaborar.
organizations.list.create-new: Crear nueva organización
organizations.details.no-documents.title: Sin documentos
organizations.details.no-documents.description: Aún no hay documentos en esta organización. Comienza subiendo algunos documentos.
organizations.details.upload-documents: Subir documentos
organizations.details.documents-count: documentos en total
organizations.details.total-size: tamaño total
organizations.details.latest-documents: Últimos documentos importados
organizations.create.title: Crear una nueva organización
organizations.create.description: Tus documentos se agruparán por organización. Puedes crear varias organizaciones para separar tus documentos, por ejemplo, para documentos personales y de trabajo.
organizations.create.back: Volver
organizations.create.error.max-count-reached: Has alcanzado el número máximo de organizaciones que puedes crear, si necesitas crear más, contacta al soporte.
organizations.create.form.name.label: Nombre de la organización
organizations.create.form.name.placeholder: Ej. Acme Inc.
organizations.create.form.name.required: Por favor, ingresa un nombre para la organización
organizations.create.form.submit: Crear organización
organizations.create.success: Organización creada exitosamente
organizations.create-first.title: Crea tu organización
organizations.create-first.description: Tus documentos se agruparán por organización. Puedes crear varias organizaciones para separar tus documentos, por ejemplo, para documentos personales y de trabajo.
organizations.create-first.default-name: Mi organización
organizations.create-first.user-name: Organización de {{ name }}
organization.settings.title: Configuración de la organización
organization.settings.page.title: Configuración de la organización
organization.settings.page.description: Administra la configuración de tu organización aquí.
organization.settings.name.title: Nombre de la organización
organization.settings.name.update: Actualizar nombre
organization.settings.name.placeholder: Ej. Acme Inc.
organization.settings.name.updated: Nombre de la organización actualizado
organization.settings.subscription.title: Suscripción
organization.settings.subscription.description: Administra tu facturación, facturas y métodos de pago.
organization.settings.subscription.manage: Gestionar suscripción
organization.settings.subscription.error: Error al obtener la URL del portal del cliente
organization.settings.delete.title: Eliminar organización
organization.settings.delete.description: Eliminar esta organización eliminará permanentemente todos los datos asociados a ella.
organization.settings.delete.confirm.title: Eliminar organización
organization.settings.delete.confirm.message: ¿Estás seguro de que deseas eliminar esta organización? Esta acción no se puede deshacer, y todos los datos asociados se eliminarán permanentemente.
organization.settings.delete.confirm.confirm-button: Eliminar organización
organization.settings.delete.confirm.cancel-button: Cancelar
organization.settings.delete.success: Organización eliminada
organizations.members.title: Miembros
organizations.members.description: Administra los miembros de tu organización
organizations.members.invite-member: Invitar miembro
organizations.members.invite-member-disabled-tooltip: Solo los administradores o propietarios pueden invitar miembros a la organización
organizations.members.remove-from-organization: Eliminar de la organización
organizations.members.role: Rol
organizations.members.roles.owner: Propietario
organizations.members.roles.admin: Administrador
organizations.members.roles.member: Miembro
organizations.members.delete.confirm.title: Eliminar miembro
organizations.members.delete.confirm.message: ¿Estás seguro de que deseas eliminar a este miembro de la organización?
organizations.members.delete.confirm.confirm-button: Eliminar
organizations.members.delete.confirm.cancel-button: Cancelar
organizations.members.delete.success: Miembro eliminado de la organización
organizations.members.update-role.success: Rol del miembro actualizado
organizations.members.table.headers.name: Nombre
organizations.members.table.headers.email: Correo electrónico
organizations.members.table.headers.role: Rol
organizations.members.table.headers.created: Creado
organizations.members.table.headers.actions: Acciones
organizations.invite-member.title: Invitar miembro
organizations.invite-member.description: Invita a un miembro a tu organización
organizations.invite-member.form.email.label: Correo electrónico
organizations.invite-member.form.email.placeholder: 'Ejemplo: ada@papra.app'
organizations.invite-member.form.email.required: Por favor, ingresa un correo electrónico válido
organizations.invite-member.form.role.label: Rol
organizations.invite-member.form.submit: Invitar a la organización
organizations.invite-member.success.message: Miembro invitado
organizations.invite-member.success.description: El correo ha sido invitado a la organización.
organizations.invite-member.error.message: Error al invitar al miembro
organizations.invitations.title: Invitaciones
organizations.invitations.description: Administra las invitaciones de tu organización
organizations.invitations.list.cta: Invitar miembro
organizations.invitations.list.empty.title: No hay invitaciones pendientes
organizations.invitations.list.empty.description: Aún no te han invitado a ninguna organización.
organizations.invitations.status.pending: Pendiente
organizations.invitations.status.accepted: Aceptada
organizations.invitations.status.rejected: Rechazada
organizations.invitations.status.expired: Expirada
organizations.invitations.status.cancelled: Cancelada
organizations.invitations.resend: Reenviar invitación
organizations.invitations.cancel.title: Cancelar invitación
organizations.invitations.cancel.description: ¿Estás seguro de que deseas cancelar esta invitación?
organizations.invitations.cancel.confirm: Cancelar invitación
organizations.invitations.cancel.cancel: Cancelar
organizations.invitations.resend.title: Reenviar invitación
organizations.invitations.resend.description: ¿Estás seguro de que deseas reenviar esta invitación? Esto enviará un nuevo correo al destinatario.
organizations.invitations.resend.confirm: Reenviar invitación
organizations.invitations.resend.cancel: Cancelar
invitations.list.title: Invitaciones
invitations.list.description: Administra las invitaciones de tu organización
invitations.list.empty.title: No hay invitaciones pendientes
invitations.list.empty.description: Aún no te han invitado a ninguna organización.
invitations.list.headers.organization: Organización
invitations.list.headers.status: Estado
invitations.list.headers.created: Creado
invitations.list.headers.actions: Acciones
invitations.list.actions.accept: Aceptar
invitations.list.actions.reject: Rechazar
invitations.list.actions.accept.success.message: Invitación aceptada
invitations.list.actions.accept.success.description: La invitación ha sido aceptada.
invitations.list.actions.reject.success.message: Invitación rechazada
invitations.list.actions.reject.success.description: La invitación ha sido rechazada.
# Documents
documents.list.title: Documentos
documents.list.no-documents.title: Sin documentos
documents.list.no-documents.description: Aún no hay documentos en esta organización. Comienza subiendo algunos documentos.
documents.list.no-results: No se encontraron documentos
documents.tabs.info: Información
documents.tabs.content: Contenido
documents.tabs.activity: Actividad
documents.deleted.message: Este documento ha sido eliminado y será borrado permanentemente en {{ days }} días.
documents.actions.download: Descargar
documents.actions.open-in-new-tab: Abrir en una nueva pestaña
documents.actions.restore: Restaurar
documents.actions.delete: Eliminar
documents.actions.edit: Editar
documents.actions.cancel: Cancelar
documents.actions.save: Guardar
documents.actions.saving: Guardando...
documents.content.alert: El contenido del documento se extrae automáticamente al subirlo. Solo se utiliza para búsqueda e indexación.
documents.info.id: ID
documents.info.name: Nombre
documents.info.type: Tipo
documents.info.size: Tamaño
documents.info.created-at: Creado el
documents.info.updated-at: Actualizado el
documents.info.never: Nunca
documents.rename.title: Renombrar documento
documents.rename.form.name.label: Nombre
documents.rename.form.name.placeholder: 'Ejemplo: Factura 2024'
documents.rename.form.name.required: Por favor, ingresa un nombre para el documento
documents.rename.form.name.max-length: El nombre debe tener menos de 255 caracteres
documents.rename.form.submit: Renombrar documento
documents.rename.success: Documento renombrado exitosamente
documents.rename.cancel: Cancelar
import-documents.title.error: '{{ count }} documentos fallidos'
import-documents.title.success: '{{ count }} documentos importados'
import-documents.title.pending: '{{ count }} / {{ total }} documentos importados'
import-documents.title.none: Importar documentos
import-documents.no-import-in-progress: No hay importación de documentos en curso
documents.deleted.title: Documentos eliminados
documents.deleted.empty.title: No hay documentos eliminados
documents.deleted.empty.description: No tienes documentos eliminados. Los documentos eliminados se moverán a la papelera durante {{ days }} días.
documents.deleted.retention-notice: Todos los documentos eliminados se almacenan en la papelera durante {{ days }} días. Pasado este tiempo, los documentos serán eliminados permanentemente y no podrás restaurarlos.
documents.deleted.deleted-at: Eliminado
documents.deleted.restoring: Restaurando...
documents.deleted.deleting: Eliminando...
documents.preview.unknown-file-type: No hay vista previa disponible para este tipo de archivo
documents.preview.binary-file: Este parece ser un archivo binario y no puede mostrarse como texto
trash.delete-all.button: Eliminar todo
trash.delete-all.confirm.title: ¿Eliminar permanentemente todos los documentos?
trash.delete-all.confirm.description: ¿Estás seguro de que deseas eliminar permanentemente todos los documentos de la papelera? Esta acción no se puede deshacer.
trash.delete-all.confirm.label: Eliminar
trash.delete-all.confirm.cancel: Cancelar
trash.delete.button: Eliminar
trash.delete.confirm.title: ¿Eliminar permanentemente el documento?
trash.delete.confirm.description: ¿Estás seguro de que deseas eliminar permanentemente este documento de la papelera? Esta acción no se puede deshacer.
trash.delete.confirm.label: Eliminar
trash.delete.confirm.cancel: Cancelar
trash.deleted.success.title: Documento eliminado
trash.deleted.success.description: El documento ha sido eliminado permanentemente.
activity.document.created: El documento ha sido creado
activity.document.updated.single: El campo {{ field }} ha sido actualizado
activity.document.updated.multiple: Los campos {{ fields }} han sido actualizados
activity.document.updated: El documento ha sido actualizado
activity.document.deleted: El documento ha sido eliminado
activity.document.restored: El documento ha sido restaurado
activity.document.tagged: La etiqueta {{ tag }} ha sido añadida
activity.document.untagged: La etiqueta {{ tag }} ha sido eliminada
activity.document.user.name: por {{ name }}
activity.load-more: Cargar más
activity.no-more-activities: No hay más actividades para este documento
# Tags
tags.no-tags.title: Aún no hay etiquetas
tags.no-tags.description: Esta organización no tiene etiquetas aún. Las etiquetas se utilizan para categorizar documentos. Puedes añadir etiquetas a tus documentos para que sean más fáciles de encontrar y organizar.
tags.no-tags.create-tag: Crear etiqueta
tags.title: Etiquetas de documentos
tags.description: Las etiquetas se utilizan para categorizar documentos. Puedes añadir etiquetas a tus documentos para que sean más fáciles de encontrar y organizar.
tags.create: Crear etiqueta
tags.update: Actualizar etiqueta
tags.delete: Eliminar etiqueta
tags.delete.confirm.title: Eliminar etiqueta
tags.delete.confirm.message: ¿Estás seguro de que deseas eliminar esta etiqueta? Eliminar una etiqueta la quitará de todos los documentos.
tags.delete.confirm.confirm-button: Eliminar
tags.delete.confirm.cancel-button: Cancelar
tags.delete.success: Etiqueta eliminada exitosamente
tags.create.success: Etiqueta "{{ name }}" creada exitosamente.
tags.update.success: Etiqueta "{{ name }}" actualizada exitosamente.
tags.form.name.label: Nombre
tags.form.name.placeholder: Ej. Contratos
tags.form.name.required: Por favor, ingresa un nombre para la etiqueta
tags.form.name.max-length: El nombre de la etiqueta debe tener menos de 64 caracteres
tags.form.color.label: Color
tags.form.color.required: Por favor, ingresa un color
tags.form.color.invalid: El color hexadecimal tiene un formato incorrecto.
tags.form.description.label: Descripción
tags.form.description.optional: (opcional)
tags.form.description.placeholder: Ej. Todos los contratos firmados por la empresa
tags.form.description.max-length: La descripción debe tener menos de 256 caracteres
tags.form.no-description: Sin descripción
tags.table.headers.tag: Etiqueta
tags.table.headers.description: Descripción
tags.table.headers.documents: Documentos
tags.table.headers.created: Creado
tags.table.headers.actions: Acciones
# Tagging rules
tagging-rules.field.name: nombre del documento
tagging-rules.field.content: contenido del documento
tagging-rules.operator.equals: es igual a
tagging-rules.operator.not-equals: no es igual a
tagging-rules.operator.contains: contiene
tagging-rules.operator.not-contains: no contiene
tagging-rules.operator.starts-with: comienza con
tagging-rules.operator.ends-with: termina con
tagging-rules.list.title: Reglas de etiquetado
tagging-rules.list.description: Administra las reglas de etiquetado de tu organización, para etiquetar documentos automáticamente según las condiciones que definas.
tagging-rules.list.demo-warning: 'Nota: Como este es un entorno de demostración (sin servidor), las reglas de etiquetado no se aplicarán a los nuevos documentos añadidos.'
tagging-rules.list.no-tagging-rules.title: No hay reglas de etiquetado
tagging-rules.list.no-tagging-rules.description: Crea una regla de etiquetado para etiquetar automáticamente tus documentos añadidos según las condiciones que definas.
tagging-rules.list.no-tagging-rules.create-tagging-rule: Crear regla de etiquetado
tagging-rules.list.card.no-conditions: Sin condiciones
tagging-rules.list.card.one-condition: 1 condición
tagging-rules.list.card.conditions: '{{ count }} condiciones'
tagging-rules.list.card.delete: Eliminar regla
tagging-rules.list.card.edit: Editar regla
tagging-rules.create.title: Crear regla de etiquetado
tagging-rules.create.success: Regla de etiquetado creada exitosamente
tagging-rules.create.error: Error al crear la regla de etiquetado
tagging-rules.create.submit: Crear regla
tagging-rules.form.name.label: Nombre
tagging-rules.form.name.placeholder: 'Ejemplo: Etiquetar facturas'
tagging-rules.form.name.min-length: Por favor, ingresa un nombre para la regla
tagging-rules.form.name.max-length: El nombre debe tener menos de 64 caracteres
tagging-rules.form.description.label: Descripción
tagging-rules.form.description.placeholder: "Ejemplo: Etiquetar documentos con 'factura' en el nombre"
tagging-rules.form.description.max-length: La descripción debe tener menos de 256 caracteres
tagging-rules.form.conditions.label: Condiciones
tagging-rules.form.conditions.description: Define las condiciones que deben cumplirse para que la regla se aplique. Todas las condiciones deben cumplirse.
tagging-rules.form.conditions.add-condition: Añadir condición
tagging-rules.form.conditions.no-conditions.title: Sin condiciones
tagging-rules.form.conditions.no-conditions.description: No añadiste ninguna condición a esta regla. Esta regla aplicará sus etiquetas a todos los documentos.
tagging-rules.form.conditions.no-conditions.confirm: Aplicar regla sin condiciones
tagging-rules.form.conditions.no-conditions.cancel: Cancelar
tagging-rules.form.conditions.value.placeholder: 'Ejemplo: factura'
tagging-rules.form.conditions.value.min-length: Por favor, ingresa un valor para la condición
tagging-rules.form.tags.label: Etiquetas
tagging-rules.form.tags.description: Selecciona las etiquetas a aplicar a los documentos añadidos que cumplan las condiciones
tagging-rules.form.tags.min-length: Se requiere al menos una etiqueta para aplicar
tagging-rules.form.tags.add-tag: Crear etiqueta
tagging-rules.form.submit: Crear regla
tagging-rules.update.title: Actualizar regla de etiquetado
tagging-rules.update.error: Error al actualizar la regla de etiquetado
tagging-rules.update.submit: Actualizar regla
tagging-rules.update.cancel: Cancelar
# Intake emails
intake-emails.title: Correos de ingreso
intake-emails.description: Las direcciones de correo de ingreso se usan para ingresar automáticamente correos en Papra. Solo reenvía correos a la dirección de ingreso y sus archivos adjuntos se agregarán a los documentos de tu organización.
intake-emails.disabled.title: Correos de ingreso deshabilitados
intake-emails.disabled.description: Los correos de ingreso están deshabilitados en esta instancia. Contacta a tu administrador para habilitarlos. Consulta la {{ documentation }} para más información.
intake-emails.disabled.documentation: documentación
intake-emails.info: Solo los correos de ingreso habilitados desde orígenes permitidos serán procesados. Puedes habilitar o deshabilitar un correo de ingreso en cualquier momento.
intake-emails.empty.title: Sin correos de ingreso
intake-emails.empty.description: Genera una dirección de ingreso para añadir fácilmente archivos adjuntos de correos.
intake-emails.empty.generate: Generar correo de ingreso
intake-emails.count: '{{ count }} correo{{ plural }} de ingreso para esta organización'
intake-emails.new: Nuevo correo de ingreso
intake-emails.disabled-label: (Deshabilitado)
intake-emails.no-origins: Sin orígenes de correo permitidos
intake-emails.allowed-origins: Permitido desde {{ count }} dirección{{ plural }}
intake-emails.actions.enable: Habilitar
intake-emails.actions.disable: Deshabilitar
intake-emails.actions.manage-origins: Gestionar direcciones de origen
intake-emails.actions.delete: Eliminar
intake-emails.delete.confirm.title: ¿Eliminar correo de ingreso?
intake-emails.delete.confirm.message: ¿Estás seguro de que deseas eliminar este correo de ingreso? Esta acción no se puede deshacer.
intake-emails.delete.confirm.confirm-button: Eliminar correo de ingreso
intake-emails.delete.confirm.cancel-button: Cancelar
intake-emails.delete.success: Correo de ingreso eliminado
intake-emails.create.success: Correo de ingreso creado
intake-emails.update.success.enabled: Correo de ingreso habilitado
intake-emails.update.success.disabled: Correo de ingreso deshabilitado
intake-emails.allowed-origins.title: Orígenes permitidos
intake-emails.allowed-origins.description: Solo los correos enviados a {{ email }} desde estos orígenes serán procesados. Si no se especifican orígenes, todos los correos serán descartados.
intake-emails.allowed-origins.add.label: Añadir dirección de correo permitida
intake-emails.allowed-origins.add.placeholder: Ej. ada@papra.app
intake-emails.allowed-origins.add.button: Añadir
intake-emails.allowed-origins.add.error.exists: Este correo ya está en los orígenes permitidos para este correo de ingreso
# API keys
api-keys.permissions.documents.title: Documentos
api-keys.permissions.documents.documents:create: Crear documentos
api-keys.permissions.documents.documents:read: Leer documentos
api-keys.permissions.documents.documents:update: Actualizar documentos
api-keys.permissions.documents.documents:delete: Eliminar documentos
api-keys.permissions.tags.title: Etiquetas
api-keys.permissions.tags.tags:create: Crear etiquetas
api-keys.permissions.tags.tags:read: Leer etiquetas
api-keys.permissions.tags.tags:update: Actualizar etiquetas
api-keys.permissions.tags.tags:delete: Eliminar etiquetas
api-keys.create.title: Crear clave API
api-keys.create.description: Crea una nueva clave API para acceder a la API de Papra.
api-keys.create.success: La clave API ha sido creada exitosamente.
api-keys.create.back: Volver a claves API
api-keys.create.form.name.label: Nombre
api-keys.create.form.name.placeholder: 'Ejemplo: Mi clave API'
api-keys.create.form.name.required: Por favor, ingresa un nombre para la clave API
api-keys.create.form.permissions.label: Permisos
api-keys.create.form.permissions.required: Por favor, selecciona al menos un permiso
api-keys.create.form.submit: Crear clave API
api-keys.create.created.title: Clave API creada
api-keys.create.created.description: La clave API ha sido creada exitosamente. Guárdala en un lugar seguro ya que no se mostrará nuevamente.
api-keys.list.title: Claves API
api-keys.list.description: Administra tus claves API aquí.
api-keys.list.create: Crear clave API
api-keys.list.empty.title: Sin claves API
api-keys.list.empty.description: Crea una clave API para acceder a la API de Papra.
api-keys.list.card.last-used: Último uso
api-keys.list.card.never: Nunca
api-keys.list.card.created: Creado
api-keys.delete.success: La clave API ha sido eliminada exitosamente
api-keys.delete.confirm.title: Eliminar clave API
api-keys.delete.confirm.message: ¿Estás seguro de que deseas eliminar esta clave API? Esta acción no se puede deshacer.
api-keys.delete.confirm.confirm-button: Eliminar
api-keys.delete.confirm.cancel-button: Cancelar
# Webhooks
webhooks.list.title: Webhooks
webhooks.list.description: Administra los webhooks de tu organización
webhooks.list.empty.title: Sin webhooks
webhooks.list.empty.description: Crea tu primer webhook para empezar a recibir eventos
webhooks.list.create: Crear webhook
webhooks.list.card.last-triggered: Última activación
webhooks.list.card.never: Nunca
webhooks.list.card.created: Creado
webhooks.create.title: Crear webhook
webhooks.create.description: Crea un nuevo webhook para recibir eventos
webhooks.create.success: Webhook creado exitosamente
webhooks.create.back: Volver
webhooks.create.form.submit: Crear webhook
webhooks.create.form.name.label: Nombre del webhook
webhooks.create.form.name.placeholder: Ingresa el nombre del webhook
webhooks.create.form.name.required: El nombre es obligatorio
webhooks.create.form.url.label: URL del webhook
webhooks.create.form.url.placeholder: Ingresa la URL del webhook
webhooks.create.form.url.required: La URL es obligatoria
webhooks.create.form.url.invalid: La URL no es válida
webhooks.create.form.secret.label: Secreto
webhooks.create.form.secret.placeholder: Ingresa el secreto del webhook
webhooks.create.form.events.label: Eventos
webhooks.create.form.events.required: Se requiere al menos un evento
webhooks.update.title: Editar webhook
webhooks.update.description: Actualiza los detalles de tu webhook
webhooks.update.success: Webhook actualizado exitosamente
webhooks.update.submit: Actualizar webhook
webhooks.update.cancel: Cancelar
webhooks.update.form.secret.placeholder: Ingresa un nuevo secreto
webhooks.update.form.secret.placeholder-redacted: '[Secreto oculto]'
webhooks.update.form.rotate-secret.button: Rotar secreto
webhooks.delete.success: Webhook eliminado exitosamente
webhooks.delete.confirm.title: Eliminar webhook
webhooks.delete.confirm.message: ¿Estás seguro de que deseas eliminar este webhook?
webhooks.delete.confirm.confirm-button: Eliminar
webhooks.delete.confirm.cancel-button: Cancelar
webhooks.events.documents.document:created.description: Documento creado
webhooks.events.documents.document:deleted.description: Documento eliminado
# Navigation
layout.menu.home: Inicio
layout.menu.documents: Documentos
layout.menu.tags: Etiquetas
layout.menu.tagging-rules: Reglas de etiquetado
layout.menu.deleted-documents: Documentos eliminados
layout.menu.organization-settings: Configuración
layout.menu.api-keys: Claves API
layout.menu.settings: Ajustes
layout.menu.account: Cuenta
layout.menu.general-settings: Ajustes generales
layout.menu.intake-emails: Correos de ingreso
layout.menu.webhooks: Webhooks
layout.menu.members: Miembros
layout.menu.invitations: Invitaciones
layout.theme.light: Modo claro
layout.theme.dark: Modo oscuro
layout.theme.system: Modo del sistema
layout.search.placeholder: Buscar...
layout.menu.import-document: Importar un documento
user-menu.account-settings: Ajustes de cuenta
user-menu.api-keys: Claves API
user-menu.invitations: Invitaciones
user-menu.language: Idioma
user-menu.logout: Cerrar sesión
# Command palette
command-palette.search.placeholder: Buscar comandos o documentos
command-palette.no-results: No se encontraron resultados
command-palette.sections.documents: Documentos
command-palette.sections.theme: Tema
# API errors
api-errors.document.already_exists: El documento ya existe
api-errors.document.file_too_big: El archivo del documento es demasiado grande
api-errors.intake_email.limit_reached: Se ha alcanzado el número máximo de correos de ingreso para esta organización. Por favor, mejora tu plan para crear más correos de ingreso.
api-errors.user.max_organization_count_reached: Has alcanzado el número máximo de organizaciones que puedes crear, si necesitas crear más, contacta al soporte.
api-errors.default: Ocurrió un error al procesar tu solicitud.
api-errors.organization.invitation_already_exists: Ya existe una invitación para este correo electrónico en esta organización.
api-errors.user.already_in_organization: Este usuario ya está en esta organización.
api-errors.user.organization_invitation_limit_reached: Se ha alcanzado el número máximo de invitaciones para hoy. Por favor, inténtalo de nuevo mañana.
api-errors.demo.not_available: Esta función no está disponible en la demostración
api-errors.tags.already_exists: Ya existe una etiqueta con este nombre en esta organización
# Not found
not-found.title: 404 - No encontrado
not-found.description: Lo sentimos, la página que buscas no parece existir. Por favor, verifica la URL e inténtalo de nuevo.
not-found.back-to-home: Volver al inicio
# Demo
demo.popup.description: Este es un entorno de demostración, todos los datos se guardan en el almacenamiento local de tu navegador.
demo.popup.discord: Únete a {{ discordLink }} para obtener soporte, proponer funciones o simplemente chatear.
demo.popup.discord-link-label: Servidor de Discord
demo.popup.reset: Restablecer datos de la demo
demo.popup.hide: Ocultar
# Color picker
color-picker.hue: Matiz
color-picker.saturation: Saturación
color-picker.lightness: Luminosidad
color-picker.select-color: Seleccionar color
color-picker.select-a-color: Selecciona un color

View File

@@ -1,35 +1,565 @@
auth:
login:
title: Connexion à Papra
description: Entrez votre adresse e-mail ou utilisez un service tiers pour accéder à votre compte Papra.
login-with-provider: Connexion via {{ provider }}
no-account: Vous n'avez pas de compte ?
register: S'inscrire
email-validation-required:
title: Vérifiez votre adresse e-mail
description: Un e-mail de vérification a été envoyé à votre adresse. Veuillez vérifier votre adresse en cliquant sur le lien dans l'e-mail.
legal-links:
description: En continuant, vous reconnaissez que vous comprenez et acceptez les {{ terms }} et la {{ privacy }}.
terms: Conditions d'utilisation
privacy: Politique de confidentialité
# Authentication
tags:
no-tags:
title: Aucun tag pour le moment
description: Cette organisation n'a pas encore de tags. Les tags sont utilisés pour catégoriser les documents. Vous pouvez ajouter des tags à vos documents pour les rendre plus faciles à trouver et à organiser.
create-tag: Créer un tag
auth.request-password-reset.title: Réinitialiser votre mot de passe
auth.request-password-reset.description: Entrez votre email pour réinitialiser votre mot de passe.
auth.request-password-reset.requested: Si un compte existe pour cet email, nous vous avons envoyé un email pour réinitialiser votre mot de passe.
auth.request-password-reset.back-to-login: Retour à la connexion
auth.request-password-reset.form.email.label: Email
auth.request-password-reset.form.email.placeholder: 'Exemple: ada@papra.app'
auth.request-password-reset.form.email.required: Veuillez entrer votre adresse email
auth.request-password-reset.form.email.invalid: Cette adresse email est invalide
auth.request-password-reset.form.submit: Réinitialiser le mot de passe
layout:
menu:
home: Accueil
documents: Documents
tags: Tags
integrations: Intégrations
deleted-documents: Documents supprimés
organization-settings: Paramètres de l'organisation
auth.reset-password.title: Réinitialiser votre mot de passe
auth.reset-password.description: Entrez votre nouveau mot de passe pour réinitialiser votre mot de passe.
auth.reset-password.reset: Votre mot de passe a été réinitialisé.
auth.reset-password.back-to-login: Retour à la connexion
auth.reset-password.form.new-password.label: Nouveau mot de passe
auth.reset-password.form.new-password.placeholder: 'Exemple: **********'
auth.reset-password.form.new-password.required: Veuillez entrer votre nouveau mot de passe
auth.reset-password.form.new-password.min-length: Le mot de passe doit contenir au moins {{ minLength }} caractères
auth.reset-password.form.new-password.max-length: Le mot de passe doit contenir moins de {{ maxLength }} caractères
auth.reset-password.form.submit: Réinitialiser le mot de passe
demo:
popup:
description: Ceci est un environnement de démo, toutes les données sont enregistrées dans le local storage de votre navigateur.
reset: Réinitialiser la démo
hide: Masquer
auth.email-provider.open: Ouvrir {{ provider }}
auth.login.title: Connexion à Papra
auth.login.description: Entrez votre email ou utilisez une connexion sociale pour accéder à votre compte Papra.
auth.login.login-with-provider: Connexion avec {{ provider }}
auth.login.no-account: Je n'ai pas de compte
auth.login.register: S'inscrire
auth.login.form.email.label: Email
auth.login.form.email.placeholder: 'Exemple: ada@papra.app'
auth.login.form.email.required: Veuillez entrer votre adresse email
auth.login.form.email.invalid: Cette adresse email est invalide
auth.login.form.password.label: Mot de passe
auth.login.form.password.placeholder: Définir un mot de passe
auth.login.form.password.required: Veuillez entrer votre mot de passe
auth.login.form.remember-me.label: Se souvenir de moi
auth.login.form.forgot-password.label: Mot de passe oublié ?
auth.login.form.submit: Connexion
auth.register.title: S'inscrire à Papra
auth.register.description: Créez un compte pour commencer à utiliser Papra.
auth.register.register-with-email: S'inscrire avec email
auth.register.register-with-provider: S'inscrire avec {{ provider }}
auth.register.providers.google: Google
auth.register.providers.github: GitHub
auth.register.have-account: Je possède déjà un compte
auth.register.login: Connexion
auth.register.registration-disabled.title: Inscription désactivée
auth.register.registration-disabled.description: La création de nouveaux comptes est actuellement désactivée sur cette instance de Papra. Seuls les utilisateurs avec un compte existant peuvent se connecter. Si vous pensez que c'est une erreur, veuillez contacter l'administrateur de cette instance.
auth.register.form.email.label: Email
auth.register.form.email.placeholder: 'Exemple: ada@papra.app'
auth.register.form.email.required: Veuillez entrer votre adresse email
auth.register.form.email.invalid: Cette adresse email est invalide
auth.register.form.password.label: Mot de passe
auth.register.form.password.placeholder: Définir un mot de passe
auth.register.form.password.required: Veuillez entrer votre mot de passe
auth.register.form.password.min-length: Le mot de passe doit contenir au moins {{ minLength }} caractères
auth.register.form.password.max-length: Le mot de passe doit contenir moins de {{ maxLength }} caractères
auth.register.form.name.label: Nom
auth.register.form.name.placeholder: 'Exemple: Ada Lovelace'
auth.register.form.name.required: Veuillez entrer votre nom
auth.register.form.name.max-length: Le nom doit contenir moins de {{ maxLength }} caractères
auth.register.form.submit: S'inscrire
auth.email-validation-required.title: Vérifier votre email
auth.email-validation-required.description: Un email de vérification a été envoyé à votre adresse email. Veuillez vérifier votre adresse email en cliquant sur le lien dans l'email.
auth.legal-links.description: En continuant, vous reconnaissez que vous comprenez et acceptez les {{ terms }} et {{ privacy }}.
auth.legal-links.terms: Conditions d'utilisation
auth.legal-links.privacy: Politique de confidentialité
auth.no-auth-provider.title: Aucun fournisseur d'authentification
auth.no-auth-provider.description: Il n'y a pas de fournisseurs d'authentification activés sur cette instance de Papra. Veuillez contacter l'administrateur de cette instance pour les activer.
# User settings
user.settings.title: Paramètres de l'utilisateur
user.settings.description: Gérez vos paramètres de compte ici.
user.settings.email.title: Adresse email
user.settings.email.description: Votre adresse email ne peut pas être modifiée.
user.settings.email.label: Adresse email
user.settings.name.title: Nom complet
user.settings.name.description: Votre nom complet est affiché aux autres membres de l'organisation.
user.settings.name.label: Nom complet
user.settings.name.placeholder: 'Exemple: John Doe'
user.settings.name.update: Mettre à jour le nom
user.settings.name.updated: Votre nom complet a été mis à jour
user.settings.logout.title: Déconnexion
user.settings.logout.description: Déconnectez-vous de votre compte. Vous pouvez vous reconnecter plus tard.
user.settings.logout.button: Déconnexion
# Organizations
organizations.list.title: Vos organisations
organizations.list.description: Les organisations sont un moyen de grouper vos documents et de gérer l'accès à eux. Vous pouvez créer plusieurs organisations et inviter vos membres de l'équipe à collaborer.
organizations.list.create-new: Créer une nouvelle organisation
organizations.details.no-documents.title: Aucun document
organizations.details.no-documents.description: Il n'y a pas de documents dans cette organisation. Commencez par télécharger des documents.
organizations.details.upload-documents: Télécharger des documents
organizations.details.documents-count: documents en total
organizations.details.total-size: taille totale
organizations.details.latest-documents: Derniers documents importés
organizations.create.title: Créer une nouvelle organisation
organizations.create.description: Vos documents seront regroupés par organisation. Vous pouvez créer plusieurs organisations pour séparer vos documents, par exemple, pour les documents personnels et professionnels.
organizations.create.back: Retour
organizations.create.error.max-count-reached: Vous avez atteint le nombre maximum d'organisations que vous pouvez créer, si vous avez besoin de créer plus, veuillez contacter le support.
organizations.create.form.name.label: Nom de l'organisation
organizations.create.form.name.placeholder: 'Exemple: Acme Inc.'
organizations.create.form.name.required: Veuillez entrer un nom pour l'organisation
organizations.create.form.submit: Créer l'organisation
organizations.create.success: Organisation créée avec succès
organizations.create-first.title: Créer votre organisation
organizations.create-first.description: Vos documents seront regroupés par organisation. Vous pouvez créer plusieurs organisations pour séparer vos documents, par exemple, pour les documents personnels et professionnels.
organizations.create-first.default-name: Mon organisation
organizations.create-first.user-name: "{{ name }}'s organisation"
organization.settings.title: Paramètres de l'organisation
organization.settings.page.title: Paramètres de l'organisation
organization.settings.page.description: Gérez les paramètres de votre organisation ici.
organization.settings.name.title: Nom de l'organisation
organization.settings.name.update: Modifier le nom
organization.settings.name.placeholder: 'Exemple: Acme Inc.'
organization.settings.name.updated: Nom de l'organisation mis à jour
organization.settings.subscription.title: Subscription
organization.settings.subscription.description: Gérez votre facturation, vos factures et vos méthodes de paiement.
organization.settings.subscription.manage: Gérer la souscription
organization.settings.subscription.error: Échec de la récupération de l'URL du portail client
organization.settings.delete.title: Supprimer l'organisation
organization.settings.delete.description: Supprimer cette organisation supprimera définitivement toutes les données associées à elle.
organization.settings.delete.confirm.title: Supprimer l'organisation
organization.settings.delete.confirm.message: Êtes-vous sûr de vouloir supprimer cette organisation ? Cette action est irréversible, et toutes les données associées à cette organisation seront supprimées définitivement.
organization.settings.delete.confirm.confirm-button: Supprimer l'organisation
organization.settings.delete.confirm.cancel-button: Annuler
organization.settings.delete.success: Organisation supprimée
organizations.members.title: Membres
organizations.members.description: Gérez les membres de votre organisation.
organizations.members.invite-member: Inviter un membre
organizations.members.invite-member-disabled-tooltip: Seuls les administrateurs ou les propriétaires peuvent inviter des membres à l'organisation
organizations.members.remove-from-organization: Retirer de l'organisation
organizations.members.role: Rôle
organizations.members.roles.owner: Propriétaire
organizations.members.roles.admin: Admin
organizations.members.roles.member: Membre
organizations.members.delete.confirm.title: Retirer un membre
organizations.members.delete.confirm.message: Êtes-vous sûr de vouloir retirer ce membre de l'organisation ?
organizations.members.delete.confirm.confirm-button: Retirer
organizations.members.delete.confirm.cancel-button: Annuler
organizations.members.delete.success: Membre retiré de l'organisation
organizations.members.update-role.success: Rôle du membre mis à jour
organizations.members.table.headers.name: Nom
organizations.members.table.headers.email: Email
organizations.members.table.headers.role: Rôle
# organizations.members.table.headers.created: Created
organizations.members.table.headers.actions: Actions
organizations.invite-member.title: Inviter un membre
organizations.invite-member.description: Invite un membre à votre organisation
organizations.invite-member.form.email.label: Email
organizations.invite-member.form.email.placeholder: 'Exemple: ada@papra.app'
organizations.invite-member.form.email.required: Veuillez entrer une adresse email valide
organizations.invite-member.form.role.label: Rôle
organizations.invite-member.form.submit: Inviter à l'organisation
organizations.invite-member.success.message: Membre invité
organizations.invite-member.success.description: L'email a été invité à l'organisation.
organizations.invite-member.error.message: Échec de l'invitation du membre
organizations.invitations.title: Invitations
organizations.invitations.description: Gérez les invitations de votre organisation.
organizations.invitations.list.cta: Inviter un membre
organizations.invitations.list.empty.title: Aucune invitation en attente
organizations.invitations.list.empty.description: Vous n'avez pas été invité à aucune organisation.
organizations.invitations.status.pending: En attente
organizations.invitations.status.accepted: Accepté
organizations.invitations.status.rejected: Refusé
organizations.invitations.status.expired: Expiré
organizations.invitations.status.cancelled: Annulé
organizations.invitations.resend: Renvoyer l'invitation
organizations.invitations.cancel.title: Annuler l'invitation
organizations.invitations.cancel.description: Êtes-vous sûr de vouloir annuler cette invitation ?
organizations.invitations.cancel.confirm: Annuler l'invitation
organizations.invitations.cancel.cancel: Annuler
organizations.invitations.resend.title: Renvoyer l'invitation
organizations.invitations.resend.description: Êtes-vous sûr de vouloir renvoyer cette invitation ? Cela enverra un nouvel email à l'invité.
organizations.invitations.resend.confirm: Renvoyer l'invitation
organizations.invitations.resend.cancel: Annuler
invitations.list.title: Invitations
invitations.list.description: Gérez les invitations de votre organisation.
invitations.list.empty.title: Aucune invitation en attente
invitations.list.empty.description: Vous n'avez pas été invité à aucune organisation.
invitations.list.headers.organization: Organisation
# invitations.list.headers.status: Status
invitations.list.headers.created: Créé
invitations.list.headers.actions: Actions
invitations.list.actions.accept: Accepter
invitations.list.actions.reject: Refuser
invitations.list.actions.accept.success.message: Invitation acceptée
invitations.list.actions.accept.success.description: L'invitation a été acceptée.
invitations.list.actions.reject.success.message: Invitation refusée
invitations.list.actions.reject.success.description: L'invitation a été refusée.
# Documents
documents.list.title: Documents
documents.list.no-documents.title: Aucun document
documents.list.no-documents.description: Il n'y a pas de documents dans cette organisation. Commencez par télécharger des documents.
documents.list.no-results: Aucun document trouvé
documents.tabs.info: Info
documents.tabs.content: Contenu
documents.tabs.activity: Activité
documents.deleted.message: Ce document a été supprimé et sera supprimé définitivement dans {{ days }} jours.
documents.actions.download: Télécharger
documents.actions.open-in-new-tab: Ouvrir dans un nouvel onglet
documents.actions.restore: Restaurer
documents.actions.delete: Supprimer
documents.actions.edit: Modifier
documents.actions.cancel: Annuler
documents.actions.save: Enregistrer
documents.actions.saving: Enregistrement...
documents.content.alert: Le contenu du document est automatiquement extrait du document lors de l'import. Il est uniquement utilisé pour la recherche et l'indexation.
documents.info.id: ID
documents.info.name: Nom
documents.info.type: Type
documents.info.size: Taille
documents.info.created-at: Créé le
documents.info.updated-at: Mis à jour le
documents.info.never: Jamais
documents.rename.title: Renommer le document
documents.rename.form.name.label: Nom
documents.rename.form.name.placeholder: 'Exemple: Facture 2024'
documents.rename.form.name.required: Veuillez entrer un nom pour le document
documents.rename.form.name.max-length: Le nom doit contenir moins de 255 caractères
documents.rename.form.submit: Renommer
documents.rename.success: Document renommé avec succès
documents.rename.cancel: Annuler
import-documents.title.error: '{{ count }} documents ont échoué'
import-documents.title.success: '{{ count }} documents ont été importés'
import-documents.title.pending: '{{ count }} / {{ total }} documents importés'
import-documents.title.none: Importer des documents
import-documents.no-import-in-progress: Aucune importation de documents en cours
documents.deleted.title: Documents supprimés
documents.deleted.empty.title: Aucun document supprimé
documents.deleted.empty.description: Vous n'avez pas de documents supprimés. Les documents supprimés seront déplacés dans la corbeille pour {{ days }} jours.
documents.deleted.retention-notice: Tous les documents supprimés sont stockés dans la corbeille pour {{ days }} jours. Passé ce délai, les documents seront supprimés définitivement, et vous ne pourrez plus les restaurer.
documents.deleted.deleted-at: Supprimé
documents.deleted.restoring: Restauration...
documents.deleted.deleting: Suppression...
documents.preview.unknown-file-type: Aucun aperçu disponible pour ce type de fichier
documents.preview.binary-file: Cela semble être un fichier binaire et ne peut pas être affiché en texte
trash.delete-all.button: Supprimer tous les documents
trash.delete-all.confirm.title: Supprimer définitivement tous les documents ?
trash.delete-all.confirm.description: Êtes-vous sûr de vouloir supprimer définitivement tous les documents de la corbeille ? Cette action est irréversible.
trash.delete-all.confirm.label: Supprimer
trash.delete-all.confirm.cancel: Annuler
trash.delete.button: Supprimer
trash.delete.confirm.title: Supprimer définitivement le document ?
trash.delete.confirm.description: Êtes-vous sûr de vouloir supprimer définitivement ce document de la corbeille ? Cette action est irréversible.
trash.delete.confirm.label: Supprimer
trash.delete.confirm.cancel: Annuler
trash.deleted.success.title: Document supprimé
trash.deleted.success.description: Le document a été supprimé définitivement.
activity.document.created: Le document a été créé
activity.document.updated.single: Le {{ field }} a été mis à jour
activity.document.updated.multiple: Les {{ fields }} ont été mis à jour
activity.document.updated: Le document a été mis à jour
activity.document.deleted: Le document a été supprimé
activity.document.restored: Le document a été restauré
activity.document.tagged: Le tag {{ tag }} a été ajouté
activity.document.untagged: Le tag {{ tag }} a été supprimé
activity.document.user.name: par {{ name }}
activity.load-more: Charger plus
activity.no-more-activities: Aucune activité pour ce document
# Tags
tags.no-tags.title: Aucun tag
tags.no-tags.description: Cette organisation n'a pas de tags. Les tags sont utilisés pour catégoriser les documents. Vous pouvez ajouter des tags à vos documents pour les rendre plus faciles à trouver et à organiser.
tags.no-tags.create-tag: Créer un tag
tags.title: Tags de documents
tags.description: Les tags sont utilisés pour catégoriser les documents. Vous pouvez ajouter des tags à vos documents pour les rendre plus faciles à trouver et à organiser.
tags.create: Créer un tag
tags.update: Mettre à jour un tag
tags.delete: Supprimer un tag
tags.delete.confirm.title: Supprimer un tag
tags.delete.confirm.message: Êtes-vous sûr de vouloir supprimer ce tag ? Supprimer un tag supprimera toutes les règles de catégorisation qui l'utilisent.
tags.delete.confirm.confirm-button: Supprimer
tags.delete.confirm.cancel-button: Annuler
tags.delete.success: Tag supprimé avec succès
tags.create.success: Tag "{{ name }}" créé avec succès.
tags.update.success: Tag "{{ name }}" mis à jour avec succès.
tags.form.name.label: Nom
tags.form.name.placeholder: 'Exemple: Contrats'
tags.form.name.required: Veuillez entrer un nom pour le tag
tags.form.name.max-length: Le nom du tag doit contenir moins de 64 caractères
tags.form.color.label: Couleur
tags.form.color.required: Veuillez entrer une couleur
tags.form.color.invalid: La couleur hexadécimale est mal formatée.
tags.form.description.label: Description
tags.form.description.optional: (optionnel)
tags.form.description.placeholder: "Exemple: Tous les contrats signés par l'entreprise"
tags.form.description.max-length: La description doit contenir moins de 256 caractères
tags.form.no-description: Aucune description
tags.table.headers.tag: Tag
tags.table.headers.description: Description
tags.table.headers.documents: Documents
tags.table.headers.created: Date de création
tags.table.headers.actions: Actions
# Tagging rules
tagging-rules.field.name: nom du document
tagging-rules.field.content: contenu du document
tagging-rules.operator.equals: égal à
tagging-rules.operator.not-equals: différent de
tagging-rules.operator.contains: contient
tagging-rules.operator.not-contains: ne contient pas
tagging-rules.operator.starts-with: commence par
tagging-rules.operator.ends-with: finit par
tagging-rules.list.title: Règles de catégorisation
tagging-rules.list.description: Gérez vos règles de catégorisation, pour catégoriser automatiquement les documents en fonction de conditions que vous définissez.
tagging-rules.list.demo-warning: 'Note: Cette instance est une démo, les règles de catégorisation ne seront pas appliquées aux documents ajoutés.'
tagging-rules.list.no-tagging-rules.title: Aucune règle de catégorisation
tagging-rules.list.no-tagging-rules.description: Créez une règle de catégorisation pour catégoriser automatiquement vos documents en fonction de conditions que vous définissez.
tagging-rules.list.no-tagging-rules.create-tagging-rule: Créer une règle de catégorisation
tagging-rules.list.card.no-conditions: Aucune condition
tagging-rules.list.card.one-condition: 1 condition
tagging-rules.list.card.conditions: '{{ count }} conditions'
tagging-rules.list.card.delete: Supprimer la règle
tagging-rules.list.card.edit: Modifier la règle
tagging-rules.create.title: Créer une règle de catégorisation
tagging-rules.create.success: Règle de catégorisation créée avec succès
tagging-rules.create.error: Échec de la création de la règle de catégorisation
tagging-rules.create.submit: Créer la règle
tagging-rules.form.name.label: Nom
tagging-rules.form.name.placeholder: 'Exemple: Catégoriser les factures'
tagging-rules.form.name.min-length: Veuillez entrer un nom pour la règle
tagging-rules.form.name.max-length: Le nom doit contenir moins de 64 caractères
tagging-rules.form.description.label: Description
tagging-rules.form.description.placeholder: "Exemple: Catégoriser les documents avec 'facture' dans le nom"
tagging-rules.form.description.max-length: La description doit contenir moins de 256 caractères
tagging-rules.form.conditions.label: Conditions
tagging-rules.form.conditions.description: Définissez les conditions que doivent remplir la règle pour qu'elle s'applique. Toutes les conditions doivent être remplies pour que la règle s'applique.
tagging-rules.form.conditions.add-condition: Ajouter une condition
tagging-rules.form.conditions.no-conditions.title: Aucune condition
tagging-rules.form.conditions.no-conditions.description: Vous n'avez pas ajouté de conditions à cette règle. Cette règle appliquera ses tags à tous les documents.
tagging-rules.form.conditions.no-conditions.confirm: Appliquer la règle sans conditions
tagging-rules.form.conditions.no-conditions.cancel: Annuler
tagging-rules.form.conditions.value.placeholder: 'Exemple: facture'
tagging-rules.form.conditions.value.min-length: Veuillez entrer une valeur pour la condition
tagging-rules.form.tags.label: Tags
tagging-rules.form.tags.description: Sélectionnez les tags à appliquer aux documents ajoutés qui correspondent aux conditions
tagging-rules.form.tags.min-length: Au moins un tag à appliquer est requis
tagging-rules.form.tags.add-tag: Créer un tag
tagging-rules.form.submit: Créer la règle
tagging-rules.update.title: Mettre à jour la règle de catégorisation
tagging-rules.update.error: Échec de la mise à jour de la règle de catégorisation
tagging-rules.update.submit: Mettre à jour la règle
tagging-rules.update.cancel: Annuler
# Intake emails
intake-emails.title: Adresses de réception
intake-emails.description: Les adresses de réception sont utilisées pour ingérer automatiquement les emails dans Papra. Il suffit de les envoyer à l'adresse de réception et leurs pièces jointes seront ajoutées à vos documents.
intake-emails.disabled.title: Les adresses de réception sont désactivées
intake-emails.disabled.description: Les adresses de réception sont désactivées sur cette instance. Veuillez contacter votre administrateur pour les activer. Voir la {{ documentation }} pour plus d'informations.
intake-emails.disabled.documentation: documentation
intake-emails.info: Seules les adresses de réception activées depuis les origines autorisées seront traitées. Vous pouvez activer ou désactiver une adresse de réception à tout moment.
intake-emails.empty.title: Aucune adresse de réception
intake-emails.empty.description: Générez une adresse de réception pour ingérer facilement les pièces jointes des emails.
intake-emails.empty.generate: Générer une adresse de réception
intake-emails.count: '{{ count }} intake email{{ plural }} for this organization'
intake-emails.new: Nouvelle adresse de réception
intake-emails.disabled-label: (Désactivé)
intake-emails.no-origins: Aucune adresse de réception autorisée
intake-emails.allowed-origins: Autorisées depuis {{ count }} adresse{{ plural }}
intake-emails.actions.enable: Activer
intake-emails.actions.disable: Désactiver
intake-emails.actions.manage-origins: Gérer les adresses d'origine
intake-emails.actions.delete: Supprimer
intake-emails.delete.confirm.title: Supprimer l'adresse de réception ?
intake-emails.delete.confirm.message: Êtes-vous sûr de vouloir supprimer cette adresse de réception ? Cette action est irréversible.
intake-emails.delete.confirm.confirm-button: Supprimer l'adresse de réception
intake-emails.delete.confirm.cancel-button: Annuler
intake-emails.delete.success: Adresse de réception supprimée
intake-emails.create.success: Adresse de réception créée
intake-emails.update.success.enabled: Adresse de réception activée
intake-emails.update.success.disabled: Adresse de réception désactivée
intake-emails.allowed-origins.title: Adresses d'origine autorisées
intake-emails.allowed-origins.description: Seuls les emails envoyés à {{ email }} depuis ces adresses d'origine seront traités. Si aucune adresse d'origine n'est spécifiée, tous les emails seront rejetés.
intake-emails.allowed-origins.add.label: Ajouter une adresse d'origine autorisée
intake-emails.allowed-origins.add.placeholder: 'Exemple: ada@papra.app'
intake-emails.allowed-origins.add.button: Ajouter
intake-emails.allowed-origins.add.error.exists: Cette adresse email est déjà dans les adresses d'origine autorisées pour cette adresse de réception
# API keys
api-keys.permissions.documents.title: Documents
api-keys.permissions.documents.documents:create: Créer des documents
api-keys.permissions.documents.documents:read: Lire des documents
api-keys.permissions.documents.documents:update: Mettre à jour des documents
api-keys.permissions.documents.documents:delete: Supprimer des documents
api-keys.permissions.tags.title: Tags
api-keys.permissions.tags.tags:create: Créer des tags
api-keys.permissions.tags.tags:read: Lire des tags
api-keys.permissions.tags.tags:update: Mettre à jour des tags
api-keys.permissions.tags.tags:delete: Supprimer des tags
api-keys.create.title: Créer une clé API
api-keys.create.description: Créer une nouvelle clé API pour accéder à l'API de Papra.
api-keys.create.success: La clé API a été créée avec succès.
api-keys.create.back: Retour aux clés API
api-keys.create.form.name.label: Nom
api-keys.create.form.name.placeholder: 'Exemple: Ma clé API'
api-keys.create.form.name.required: Veuillez entrer un nom pour la clé API
api-keys.create.form.permissions.label: Permissions
api-keys.create.form.permissions.required: Veuillez sélectionner au moins une permission
api-keys.create.form.submit: Créer la clé API
api-keys.create.created.title: Clé API créée
api-keys.create.created.description: La clé API a été créée avec succès. Enregistrez-la dans un endroit sûr car elle ne sera plus affichée.
api-keys.list.title: Clés API
api-keys.list.description: Gérez vos clés API ici.
api-keys.list.create: Créer une clé API
api-keys.list.empty.title: Aucune clé API
api-keys.list.empty.description: Créez une clé API pour accéder à l'API de Papra.
api-keys.list.card.last-used: Dernière utilisation
api-keys.list.card.never: Jamais
api-keys.list.card.created: Créée
api-keys.delete.success: La clé API a été supprimée avec succès
api-keys.delete.confirm.title: Supprimer la clé API
api-keys.delete.confirm.message: Êtes-vous sûr de vouloir supprimer cette clé API ? Cette action est irréversible.
api-keys.delete.confirm.confirm-button: Supprimer
api-keys.delete.confirm.cancel-button: Annuler
# Webhooks
webhooks.list.title: Webhooks
webhooks.list.description: Gérez vos webhooks ici.
webhooks.list.empty.title: Aucun webhook
webhooks.list.empty.description: Créez votre premier webhook pour commencer à recevoir des événements.
webhooks.list.create: Créer un webhook
webhooks.list.card.last-triggered: Dernière invocation
webhooks.list.card.never: Jamais
webhooks.list.card.created: Créée
webhooks.create.title: Créer un webhook
webhooks.create.description: Créez un webhook pour recevoir des événements lorsque des documents sont ajoutés à votre organisation.
webhooks.create.success: Le webhook a été créé avec succès.
webhooks.create.back: Retour aux webhooks
webhooks.create.form.submit: Créer le webhook
webhooks.create.form.name.label: Nom du webhook
webhooks.create.form.name.placeholder: Entrez le nom du webhook
webhooks.create.form.name.required: Le nom est requis
webhooks.create.form.url.label: URL du webhook
webhooks.create.form.url.placeholder: Entrez l'URL du webhook
webhooks.create.form.url.required: L'URL est requise
webhooks.create.form.url.invalid: L'URL est invalide
webhooks.create.form.secret.label: Secret
webhooks.create.form.secret.placeholder: Entrez le secret du webhook
webhooks.create.form.events.label: Événements
webhooks.create.form.events.required: Au moins un événement est requis
webhooks.update.title: Modifier le webhook
webhooks.update.description: Mettez à jour les détails de votre webhook
webhooks.update.success: Le webhook a été mis à jour avec succès
webhooks.update.submit: Mettre à jour le webhook
webhooks.update.cancel: Annuler
webhooks.update.form.secret.placeholder: Entrez un nouveau secret
webhooks.update.form.secret.placeholder-redacted: '[Secret masqué]'
webhooks.update.form.rotate-secret.button: Rotation du secret
webhooks.delete.success: Le webhook a été supprimé avec succès
webhooks.delete.confirm.title: Supprimer le webhook
webhooks.delete.confirm.message: Êtes-vous sûr de vouloir supprimer ce webhook ? Cette action est irréversible.
webhooks.delete.confirm.confirm-button: Supprimer
webhooks.delete.confirm.cancel-button: Annuler
webhooks.events.documents.document:created.description: Document créé
webhooks.events.documents.document:deleted.description: Document supprimé
# Navigation
layout.menu.home: Accueil
layout.menu.documents: Documents
layout.menu.tags: Tags
layout.menu.tagging-rules: Règles de catégorisation
layout.menu.deleted-documents: Documents supprimés
layout.menu.organization-settings: Paramètres
layout.menu.api-keys: API keys
layout.menu.settings: Paramètres
layout.menu.account: Compte
layout.menu.general-settings: Paramètres généraux
layout.menu.intake-emails: Adresses de réception
layout.menu.webhooks: Webhooks
layout.menu.members: Membres
layout.menu.invitations: Invitations
layout.theme.light: Mode clair
layout.theme.dark: Mode sombre
layout.theme.system: Mode système
layout.search.placeholder: Rechercher...
layout.menu.import-document: Importer un document
user-menu.account-settings: Paramètres du compte
user-menu.api-keys: Clés d'API
user-menu.invitations: Invitations
user-menu.language: Langue
user-menu.logout: Déconnexion
# Command palette
command-palette.search.placeholder: Rechercher des commandes ou des documents
command-palette.no-results: Aucun résultat trouvé
command-palette.sections.documents: Documents
command-palette.sections.theme: Thème
# API errors
api-errors.document.already_exists: Le document existe déjà
api-errors.document.file_too_big: Le fichier du document est trop grand
api-errors.intake_email.limit_reached: Le nombre maximum d'emails de réception pour cette organisation a été atteint. Veuillez mettre à niveau votre plan pour créer plus d'emails de réception.
api-errors.user.max_organization_count_reached: Vous avez atteint le nombre maximum d'organisations que vous pouvez créer, si vous avez besoin de créer plus, veuillez contacter le support.
api-errors.default: Une erreur est survenue lors du traitement de votre requête.
api-errors.organization.invitation_already_exists: Une invitation pour cet email existe déjà dans cette organisation.
api-errors.user.already_in_organization: Cet utilisateur est déjà dans cette organisation.
api-errors.user.organization_invitation_limit_reached: Le nombre maximum d'invitations a été atteint pour aujourd'hui. Veuillez réessayer demain.
api-errors.demo.not_available: Cette fonctionnalité n'est pas disponible dans la démo
api-errors.tags.already_exists: Un tag avec ce nom existe déjà pour cette organisation
# Not found
not-found.title: 404 - Not Found
not-found.description: Désolé, la page que vous cherchez n'existe pas. Veuillez vérifier l'URL et réessayer.
not-found.back-to-home: Retour à l'accueil
# Demo
demo.popup.description: Cette instance est une démo, toutes les données sont sauvegardées dans le stockage local de votre navigateur.
demo.popup.discord: Rejoignez le {{ discordLink }} pour obtenir de l'aide, proposer des fonctionnalités ou simplement discuter.
demo.popup.discord-link-label: Serveur Discord
demo.popup.reset: Réinitialiser la démo
demo.popup.hide: Masquer
# Color picker
color-picker.hue: Teinte
color-picker.saturation: Saturation
color-picker.lightness: Luminosité
color-picker.select-color: Sélectionner la couleur
color-picker.select-a-color: Sélectionner une couleur

View File

@@ -0,0 +1,565 @@
# Authentication
auth.request-password-reset.title: Zresetuj swoje hasło
auth.request-password-reset.description: Wprowadź swój adres e-mail, aby zresetować hasło.
auth.request-password-reset.requested: Jeśli istnieje konto powiązane z tym adresem e-mail, otrzymasz wiadomość umożliwiającą zresetowanie hasła.
auth.request-password-reset.back-to-login: Wróć do logowania
auth.request-password-reset.form.email.label: E-mail
auth.request-password-reset.form.email.placeholder: 'Przykład: ada@papra.app'
auth.request-password-reset.form.email.required: Wprowadź swój adres e-mail
auth.request-password-reset.form.email.invalid: Ten adres e-mail jest nieprawidłowy
auth.request-password-reset.form.submit: Poproś o zresetowanie hasła
auth.reset-password.title: Zresetuj swoje hasło
auth.reset-password.description: Wprowadź nowe hasło, aby zresetować dotychczasowe.
auth.reset-password.reset: Twoje hasło zostało zresetowane.
auth.reset-password.back-to-login: Wróć do logowania
auth.reset-password.form.new-password.label: Nowe hasło
auth.reset-password.form.new-password.placeholder: 'Przykład: **********'
auth.reset-password.form.new-password.required: Wprowadź nowe hasło
auth.reset-password.form.new-password.min-length: Hasło musi mieć co najmniej {{ minLength }} znaków
auth.reset-password.form.new-password.max-length: Hasło musi mieć mniej niż {{ maxLength }} znaków
auth.reset-password.form.submit: Zresetuj hasło
auth.email-provider.open: Otwórz {{ provider }}
auth.login.title: Zaloguj się do Papra
auth.login.description: Wprowadź swój adres e-mail lub skorzystaj z logowania federacyjnego, aby uzyskać dostęp do swojego konta Papra.
auth.login.login-with-provider: Zaloguj się za pomocą {{ provider }}
auth.login.no-account: Nie masz konta?
auth.login.register: Zarejestruj się
auth.login.form.email.label: E-mail
auth.login.form.email.placeholder: 'Przykład: ada@papra.app'
auth.login.form.email.required: Wprowadź swój adres e-mail
auth.login.form.email.invalid: Ten adres e-mail jest nieprawidłowy
auth.login.form.password.label: Hasło
auth.login.form.password.placeholder: Ustaw hasło
auth.login.form.password.required: Wprowadź swoje hasło
auth.login.form.remember-me.label: Zapamiętaj mnie
auth.login.form.forgot-password.label: Zapomniałeś hasła?
auth.login.form.submit: Zaloguj się
auth.register.title: Zarejestruj się w Papra
auth.register.description: Utwórz konto, aby zacząć korzystać z Papra.
auth.register.register-with-email: Zarejestruj się przez e-mail
auth.register.register-with-provider: Zarejestruj się przez {{ provider }}
auth.register.providers.google: Google
auth.register.providers.github: GitHub
auth.register.have-account: Masz już konto?
auth.register.login: Zaloguj się
auth.register.registration-disabled.title: Rejestracja jest wyłączona
auth.register.registration-disabled.description: Tworzenie nowych kont na tej instancji Papra jest obecnie wyłączone. Tylko użytkownicy z istniejącymi kontami mogą się zalogować. Jeśli uważasz, że to błąd, skontaktuj się z administratorem tej instancji.
auth.register.form.email.label: E-mail
auth.register.form.email.placeholder: 'Przykład: ada@papra.app'
auth.register.form.email.required: Wprowadź swój adres e-mail
auth.register.form.email.invalid: Ten adres e-mail jest nieprawidłowy
auth.register.form.password.label: Hasło
auth.register.form.password.placeholder: Ustaw hasło
auth.register.form.password.required: Wprowadź swoje hasło
auth.register.form.password.min-length: Hasło musi mieć co najmniej {{ minLength }} znaków
auth.register.form.password.max-length: Hasło musi mieć mniej niż {{ maxLength }} znaków
auth.register.form.name.label: Imię i nazwisko
auth.register.form.name.placeholder: 'Przykład: Ada Lovelace'
auth.register.form.name.required: Wprowadź swoje imię i nazwisko
auth.register.form.name.max-length: Imię i nazwisko musi mieć mniej niż {{ maxLength }} znaków
auth.register.form.submit: Zarejestruj się
auth.email-validation-required.title: Zweryfikuj swój adres e-mail
auth.email-validation-required.description: Wiadomość weryfikacyjna została wysłana na Twój adres e-mail. Zweryfikuj swój adres e-mail, klikając link w wiadomości.
auth.legal-links.description: Kontynuując, potwierdzasz, że rozumiesz i zgadzasz się na {{ terms }} oraz {{ privacy }}.
auth.legal-links.terms: Warunki korzystania z usługi
auth.legal-links.privacy: Polityka prywatności
auth.no-auth-provider.title: Brak dostawcy uwierzytelniania
auth.no-auth-provider.description: Na tej instancji Papra nie ma włączonych dostawców uwierzytelniania. Skontaktuj się z administratorem tej instancji, aby je włączyć.
# User settings
user.settings.title: Ustawienia użytkownika
user.settings.description: Zarządzaj ustawieniami swojego konta.
user.settings.email.title: Adres e-mail
user.settings.email.description: Twój adres e-mail nie może być zmieniony.
user.settings.email.label: Adres e-mail
user.settings.name.title: Imię i nazwisko
user.settings.name.description: Twoje imię i nazwisko jest wyświetlane innym członkom organizacji.
user.settings.name.label: Imię i nazwisko
user.settings.name.placeholder: 'Przykład: Jan Kowalski'
user.settings.name.update: Zaktualizuj imię i nazwisko
user.settings.name.updated: Twoje imię i nazwisko zostało zaktualizowane
user.settings.logout.title: Wyloguj się
user.settings.logout.description: Wyloguj się ze swojego konta. Możesz zalogować się ponownie później.
user.settings.logout.button: Wyloguj się
# Organizations
organizations.list.title: Twoje organizacje
organizations.list.description: Organizacje to sposób grupowania dokumentów i zarządzania dostępem do nich. Możesz tworzyć wiele organizacji i zapraszać członków zespołu do współpracy.
organizations.list.create-new: Utwórz nową organizację
organizations.details.no-documents.title: Brak dokumentów
organizations.details.no-documents.description: W tej organizacji nie ma jeszcze żadnych dokumentów. Zacznij od przesłania kilku dokumentów.
organizations.details.upload-documents: Prześlij dokumenty
organizations.details.documents-count: dokumentów w sumie
organizations.details.total-size: całkowity rozmiar
organizations.details.latest-documents: Najnowsze zaimportowane dokumenty
organizations.create.title: Utwórz nową organizację
organizations.create.description: Twoje dokumenty będą grupowane według organizacji. Możesz tworzyć wiele organizacji, aby oddzielić swoje dokumenty, na przykład dokumenty osobiste i służbowe.
organizations.create.back: Wstecz
organizations.create.error.max-count-reached: Osiągnąłeś maksymalną liczbę organizacji, które możesz utworzyć. Jeśli potrzebujesz utworzyć więcej, skontaktuj się z pomocą techniczną.
organizations.create.form.name.label: Nazwa organizacji
organizations.create.form.name.placeholder: 'Przykład: Acme Inc.'
organizations.create.form.name.required: Wprowadź nazwę organizacji
organizations.create.form.submit: Utwórz organizację
organizations.create.success: Organizacja została pomyślnie utworzona
organizations.create-first.title: Utwórz swoją organizację
organizations.create-first.description: Twoje dokumenty będą grupowane według organizacji. Możesz tworzyć wiele organizacji, aby oddzielić swoje dokumenty, na przykład dokumenty osobiste i służbowe.
organizations.create-first.default-name: Moja organizacja
organizations.create-first.user-name: 'Organizacja użytkownika {{ name }}'
organization.settings.title: Ustawienia organizacji
organization.settings.page.title: Ustawienia organizacji
organization.settings.page.description: Zarządzaj ustawieniami swojej organizacji.
organization.settings.name.title: Nazwa organizacji
organization.settings.name.update: Zaktualizuj nazwę
organization.settings.name.placeholder: 'Przykład: Acme Inc.'
organization.settings.name.updated: Nazwa organizacji została zaktualizowana
organization.settings.subscription.title: Subskrypcja
organization.settings.subscription.description: Zarządzaj swoim rozliczeniem, fakturami i metodami płatności.
organization.settings.subscription.manage: Zarządzaj subskrypcją
organization.settings.subscription.error: Nie udało się uzyskać adresu URL portalu klienta
organization.settings.delete.title: Usuń organizację
organization.settings.delete.description: Usunięcie tej organizacji spowoduje trwałe usunięcie wszystkich danych z nią związanych.
organization.settings.delete.confirm.title: Usuń organizację
organization.settings.delete.confirm.message: Czy na pewno chcesz usunąć tę organizację? Ta operacja jest nieodwracalna, a wszystkie dane związane z tą organizacją zostaną trwale usunięte.
organization.settings.delete.confirm.confirm-button: Usuń organizację
organization.settings.delete.confirm.cancel-button: Anuluj
organization.settings.delete.success: Organizacja została usunięta
organizations.members.title: Członkowie
organizations.members.description: Zarządzaj członkami swojej organizacji
organizations.members.invite-member: Zaproś członka
organizations.members.invite-member-disabled-tooltip: Tylko administratorzy lub właściciele mogą zapraszać członków do organizacji
organizations.members.remove-from-organization: Usuń z organizacji
organizations.members.role: Rola
organizations.members.roles.owner: Właściciel
organizations.members.roles.admin: Administrator
organizations.members.roles.member: Członek
organizations.members.delete.confirm.title: Usuń członka
organizations.members.delete.confirm.message: Czy na pewno chcesz usunąć tego członka z organizacji?
organizations.members.delete.confirm.confirm-button: Usuń
organizations.members.delete.confirm.cancel-button: Anuluj
organizations.members.delete.success: Członek został usunięty z organizacji
organizations.members.update-role.success: Rola członka została zaktualizowana
organizations.members.table.headers.name: Imię i nazwisko
organizations.members.table.headers.email: E-mail
organizations.members.table.headers.role: Rola
organizations.members.table.headers.created: Utworzono
organizations.members.table.headers.actions: Akcje
organizations.invite-member.title: Zaproś członka
organizations.invite-member.description: Zaproś członka do swojej organizacji
organizations.invite-member.form.email.label: E-mail
organizations.invite-member.form.email.placeholder: 'Przykład: ada@papra.app'
organizations.invite-member.form.email.required: Wprowadź poprawny adres e-mail
organizations.invite-member.form.role.label: Rola
organizations.invite-member.form.submit: Zaproś do organizacji
organizations.invite-member.success.message: Członek zaproszony
organizations.invite-member.success.description: E-mail został zaproszony do organizacji.
organizations.invite-member.error.message: Nie udało się zaprosić członka
organizations.invitations.title: Zaproszenia
organizations.invitations.description: Zarządzaj zaproszeniami do swojej organizacji
organizations.invitations.list.cta: Zaproś członka
organizations.invitations.list.empty.title: Brak oczekujących zaproszeń
organizations.invitations.list.empty.description: Nie zostałeś zaproszony do żadnej organizacji.
organizations.invitations.status.pending: Oczekujące
organizations.invitations.status.accepted: Zaakceptowane
organizations.invitations.status.rejected: Odrzucone
organizations.invitations.status.expired: Wygasłe
organizations.invitations.status.cancelled: Anulowane
organizations.invitations.resend: Wyślij zaproszenie ponownie
organizations.invitations.cancel.title: Anuluj zaproszenie
organizations.invitations.cancel.description: Czy na pewno chcesz anulować to zaproszenie?
organizations.invitations.cancel.confirm: Anuluj zaproszenie
organizations.invitations.cancel.cancel: Anuluj
organizations.invitations.resend.title: Wyślij zaproszenie ponownie
organizations.invitations.resend.description: Czy na pewno chcesz wysłać ponownie to zaproszenie? To spowoduje wysłanie nowego e-maila do odbiorcy.
organizations.invitations.resend.confirm: Wyślij zaproszenie ponownie
organizations.invitations.resend.cancel: Anuluj
invitations.list.title: Zaproszenia
invitations.list.description: Zarządzaj zaproszeniami do swojej organizacji
invitations.list.empty.title: Brak oczekujących zaproszeń
invitations.list.empty.description: Nie zostałeś zaproszony do żadnej organizacji.
invitations.list.headers.organization: Organizacja
invitations.list.headers.status: Status
invitations.list.headers.created: Utworzono
invitations.list.headers.actions: Akcje
invitations.list.actions.accept: Zaakceptuj
invitations.list.actions.reject: Odrzuć
invitations.list.actions.accept.success.message: Zaproszenie zaakceptowane
invitations.list.actions.accept.success.description: Zaproszenie zostało zaakceptowane.
invitations.list.actions.reject.success.message: Zaproszenie odrzucone
invitations.list.actions.reject.success.description: Zaproszenie zostało odrzucone.
# Documents
documents.list.title: Dokumenty
documents.list.no-documents.title: Brak dokumentów
documents.list.no-documents.description: W tej organizacji nie ma jeszcze żadnych dokumentów. Zacznij od przesłania kilku dokumentów.
documents.list.no-results: Nie znaleziono dokumentów
documents.tabs.info: Informacje
documents.tabs.content: Treść
documents.tabs.activity: Aktywność
documents.deleted.message: Ten dokument został usunięty i zostanie trwale usunięty za {{ days }} dni.
documents.actions.download: Pobierz
documents.actions.open-in-new-tab: Otwórz w nowej karcie
documents.actions.restore: Przywróć
documents.actions.delete: Usuń
documents.actions.edit: Edytuj
documents.actions.cancel: Anuluj
documents.actions.save: Zapisz
documents.actions.saving: Zapisywanie...
documents.content.alert: Zawartość dokumentu jest automatycznie wyodrębniana z dokumentu podczas przesyłania. Jest używana tylko do wyszukiwania i indeksowania.
documents.info.id: ID
documents.info.name: Nazwa
documents.info.type: Typ
documents.info.size: Rozmiar
documents.info.created-at: Utworzono
documents.info.updated-at: Zaktualizowano
documents.info.never: Nigdy
documents.rename.title: Zmień nazwę dokumentu
documents.rename.form.name.label: Nazwa
documents.rename.form.name.placeholder: 'Przykład: Faktura 2024'
documents.rename.form.name.required: Proszę wprowadzić nazwę dokumentu
documents.rename.form.name.max-length: Nazwa musi mieć mniej niż 255 znaków
documents.rename.form.submit: Zmień nazwę dokumentu
documents.rename.success: Nazwa dokumentu została pomyślnie zmieniona
documents.rename.cancel: Anuluj
import-documents.title.error: '{{ count }} dokumentów nie powiodły się'
import-documents.title.success: '{{ count }} dokumentów zaimportowane'
import-documents.title.pending: '{{ count }} / {{ total }} dokumentów zaimportowanych'
import-documents.title.none: Importuj dokumenty
import-documents.no-import-in-progress: Brak importu dokumentów w toku
documents.deleted.title: Usunięte dokumenty
documents.deleted.empty.title: Brak usuniętych dokumentów
documents.deleted.empty.description: Nie masz żadnych usuniętych dokumentów. Dokumenty, które są usuwane, zostaną przeniesione do kosza na {{ days }} dni.
documents.deleted.retention-notice: Wszystkie usunięte dokumenty są przechowywane w koszu przez {{ days }} dni. Po upływie tego terminu dokumenty zostaną trwale usunięte, a Ty nie będziesz mógł ich przywrócić.
documents.deleted.deleted-at: Usunięto
documents.deleted.restoring: Przywracanie...
documents.deleted.deleting: Usuwanie...
documents.preview.unknown-file-type: Brak podglądu dla tego typu pliku
documents.preview.binary-file: To wydaje się być plikiem binarnym i nie może być wyświetlane jako tekst
trash.delete-all.button: Usuń wszystkie
trash.delete-all.confirm.title: Trwale usunąć wszystkie dokumenty?
trash.delete-all.confirm.description: Czy na pewno chcesz trwale usunąć wszystkie dokumenty z kosza? Ta akcja nie może być cofnięta.
trash.delete-all.confirm.label: Usuń
trash.delete-all.confirm.cancel: Anuluj
trash.delete.button: Usuń
trash.delete.confirm.title: Trwale usunąć dokument?
trash.delete.confirm.description: Czy na pewno chcesz trwale usunąć ten dokument z kosza? Ta akcja nie może być cofnięta.
trash.delete.confirm.label: Usuń
trash.delete.confirm.cancel: Anuluj
trash.deleted.success.title: Dokument usunięty
trash.deleted.success.description: Dokument został trwale usunięty.
activity.document.created: Dokument został utworzony
activity.document.updated.single: Pole {{ field }} zostało zaktualizowane
activity.document.updated.multiple: Pola {{ fields }} zostały zaktualizowane
activity.document.updated: Dokument został zaktualizowany
activity.document.deleted: Dokument został usunięty
activity.document.restored: Dokument został przywrócony
activity.document.tagged: Tag {{ tag }} został dodany
activity.document.untagged: Tag {{ tag }} został usunięty
activity.document.user.name: od {{ name }}
activity.load-more: Załaduj więcej
activity.no-more-activities: Brak dalszych działań dla tego dokumentu
# Tags
tags.no-tags.title: Brak tagów
tags.no-tags.description: Ta organizacja nie ma jeszcze tagów. Tagi służą do kategoryzowania dokumentów. Możesz dodać tagi do swoich dokumentów, aby ułatwić ich wyszukiwanie i organizację.
tags.no-tags.create-tag: Utwórz tag
tags.title: Tagi dokumentów
tags.description: Tagi służą do kategoryzowania dokumentów. Możesz dodać tagi do swoich dokumentów, aby ułatwić ich wyszukiwanie i organizację.
tags.create: Utwórz tag
tags.update: Zaktualizuj tag
tags.delete: Usuń tag
tags.delete.confirm.title: Usuń tag
tags.delete.confirm.message: Czy na pewno chcesz usunąć ten tag? Usunięcie tagu spowoduje jego usunięcie ze wszystkich dokumentów.
tags.delete.confirm.confirm-button: Usuń
tags.delete.confirm.cancel-button: Anuluj
tags.delete.success: Tag został pomyślnie usunięty
tags.create.success: Tag "{{ name }}" został pomyślnie utworzony.
tags.update.success: Tag "{{ name }}" został pomyślnie zaktualizowany.
tags.form.name.label: Nazwa
tags.form.name.placeholder: 'Przykład: Umowy'
tags.form.name.required: Proszę wprowadzić nazwę tagu
tags.form.name.max-length: Nazwa tagu musi mieć mniej niż 64 znaki
tags.form.color.label: Kolor
tags.form.color.required: Proszę wprowadzić kolor
tags.form.color.invalid: Kolor hex jest źle sformatowany.
tags.form.description.label: Opis
tags.form.description.optional: (opcjonalnie)
tags.form.description.placeholder: 'Przykład: Wszystkie umowy podpisane przez firmę'
tags.form.description.max-length: Opis musi mieć mniej niż 256 znaków
tags.form.no-description: Brak opisu
tags.table.headers.tag: Tag
tags.table.headers.description: Opis
tags.table.headers.documents: Dokumenty
tags.table.headers.created: Utworzono
tags.table.headers.actions: Akcje
# Tagging rules
tagging-rules.field.name: nazwa dokumentu
tagging-rules.field.content: treść dokumentu
tagging-rules.operator.equals: równa się
tagging-rules.operator.not-equals: nie równa się
tagging-rules.operator.contains: zawiera
tagging-rules.operator.not-contains: nie zawiera
tagging-rules.operator.starts-with: zaczyna się od
tagging-rules.operator.ends-with: kończy się na
tagging-rules.list.title: Reguły tagowania
tagging-rules.list.description: Zarządzaj regułami tagowania w swojej organizacji, aby automatycznie tagować dokumenty na podstawie zdefiniowanych przez siebie warunków.
tagging-rules.list.demo-warning: 'Uwaga: Ponieważ jest to środowisko demonstracyjne (bez serwera), reguły tagowania nie będą stosowane do nowo dodanych dokumentów.'
tagging-rules.list.no-tagging-rules.title: Brak reguł tagowania
tagging-rules.list.no-tagging-rules.description: Utwórz regułę tagowania, aby automatycznie tagować dodane dokumenty na podstawie zdefiniowanych przez siebie warunków.
tagging-rules.list.no-tagging-rules.create-tagging-rule: Utwórz regułę tagowania
tagging-rules.list.card.no-conditions: Brak warunków
tagging-rules.list.card.one-condition: 1 warunek
tagging-rules.list.card.conditions: '{{ count }} warunków'
tagging-rules.list.card.delete: Usuń regułę
tagging-rules.list.card.edit: Edytuj regułę
tagging-rules.create.title: Utwórz regułę tagowania
tagging-rules.create.success: Reguła tagowania została pomyślnie utworzona
tagging-rules.create.error: Nie udało się utworzyć reguły tagowania
tagging-rules.create.submit: Utwórz regułę
tagging-rules.form.name.label: Nazwa
tagging-rules.form.name.placeholder: 'Przykład: Taguj faktury'
tagging-rules.form.name.min-length: Proszę wprowadzić nazwę reguły
tagging-rules.form.name.max-length: Nazwa musi mieć mniej niż 64 znaki
tagging-rules.form.description.label: Opis
tagging-rules.form.description.placeholder: "Przykład: Oznacz dokumenty ze słowem 'faktura' w nazwie"
tagging-rules.form.description.max-length: Opis musi mieć mniej niż 256 znaków
tagging-rules.form.conditions.label: Warunki
tagging-rules.form.conditions.description: Zdefiniuj warunki, które muszą być spełnione, aby reguła mogła zostać zastosowana. Wszystkie warunki muszą być spełnione, aby reguła mogła zostać zastosowana.
tagging-rules.form.conditions.add-condition: Dodaj warunek
tagging-rules.form.conditions.no-conditions.title: Brak warunków
tagging-rules.form.conditions.no-conditions.description: Nie dodałeś żadnych warunków do tej reguły. Ta reguła zastosuje swoje tagi do wszystkich dokumentów.
tagging-rules.form.conditions.no-conditions.confirm: Zastosuj regułę bez warunków
tagging-rules.form.conditions.no-conditions.cancel: Anuluj
tagging-rules.form.conditions.value.placeholder: 'Przykład: faktura'
tagging-rules.form.conditions.value.min-length: Proszę wprowadzić wartość dla warunku
tagging-rules.form.tags.label: Tagi
tagging-rules.form.tags.description: Wybierz tagi do zastosowania do dodanych dokumentów, które spełniają warunki
tagging-rules.form.tags.min-length: Co najmniej jeden tag do zastosowania jest wymagany
tagging-rules.form.tags.add-tag: Utwórz tag
tagging-rules.form.submit: Utwórz regułę
tagging-rules.update.title: Zaktualizuj regułę tagowania
tagging-rules.update.error: Nie udało się zaktualizować reguły tagowania
tagging-rules.update.submit: Zaktualizuj regułę
tagging-rules.update.cancel: Anuluj
# Intake emails
intake-emails.title: Adresy przyjęć
intake-emails.description: Adresy przyjęć służą do automatycznego przyjmowania wiadomości e-mail do Papra. Wystarczy przekazać wiadomości e-mail na adres e-mail do przyjmowania, a ich załączniki zostaną dodane do dokumentów Twojej organizacji.
intake-emails.disabled.title: Adresy przyjęć są wyłączone
intake-emails.disabled.description: Adresy przyjęć są wyłączone na tej instancji. Skontaktuj się z administratorem, aby je włączyć. Zobacz {{ documentation }} w celu uzyskania dodatkowych informacji.
intake-emails.disabled.documentation: dokumentację
intake-emails.info: Tylko włączone adresy przyjęć z dozwolonych źródeł będą przetwarzane. Możesz w dowolnym momencie włączyć lub wyłączyć adres e-mail do przyjęć.
intake-emails.empty.title: Brak adresów przyjęć
intake-emails.empty.description: Wygeneruj adres przyjęć, aby łatwo przyjmować załączniki e-mail.
intake-emails.empty.generate: Wygeneruj adres e-mail do przyjęć
intake-emails.count: '{{ count }} adres/ów e-mail do przyjęć dla tej organizacji'
intake-emails.new: Nowy adres e-mail do przyjęć
intake-emails.disabled-label: (Wyłączone)
intake-emails.no-origins: Brak dozwolonych źródeł e-mail
intake-emails.allowed-origins: Dozwolone z {{ count }} adresu/ów
intake-emails.actions.enable: Włącz
intake-emails.actions.disable: Wyłącz
intake-emails.actions.manage-origins: Zarządzaj dozwolonymi źródłami
intake-emails.actions.delete: Usuń
intake-emails.delete.confirm.title: Usuąć adres e-mail do przyjęć?
intake-emails.delete.confirm.message: Czy na pewno chcesz usunąć ten adres e-mail do przyjęć? Ta akcja jest nieodwracalna.
intake-emails.delete.confirm.confirm-button: Usuń adres przyjęć
intake-emails.delete.confirm.cancel-button: Anuluj
intake-emails.delete.success: Adres przyjęć usunięty
intake-emails.create.success: Adres przyjęć utworzony
intake-emails.update.success.enabled: Adres przyjęć włączony
intake-emails.update.success.disabled: Adres przyjęć wyłączony
intake-emails.allowed-origins.title: Dozwolone źródła
intake-emails.allowed-origins.description: Tylko e-maile wysłane na {{ email }} z tych źródeł będą przetwarzane. Jeśli nie określono źródeł, wszystkie e-maile zostaną odrzucone.
intake-emails.allowed-origins.add.label: Dodaj dozwolony adres e-mail
intake-emails.allowed-origins.add.placeholder: 'Przykład: ada@papra.app'
intake-emails.allowed-origins.add.button: Dodaj
intake-emails.allowed-origins.add.error.exists: Ten adres e-mail jest już w dozwolonych źródłach dla tego adresu e-mail do przyjęć.
# API keys
api-keys.permissions.documents.title: Dokumenty
api-keys.permissions.documents.documents:create: Tworzenie dokumentów
api-keys.permissions.documents.documents:read: Odczyt dokumentów
api-keys.permissions.documents.documents:update: Aktualizacja dokumentów
api-keys.permissions.documents.documents:delete: Usuwanie dokumentów
api-keys.permissions.tags.title: Tag
api-keys.permissions.tags.tags:create: Tworzenie tagów
api-keys.permissions.tags.tags:read: Odczyt tagów
api-keys.permissions.tags.tags:update: Aktualizacja tagów
api-keys.permissions.tags.tags:delete: Usuwanie tagów
api-keys.create.title: Tworzenie klucza API
api-keys.create.description: Utwórz nowy klucz API, aby uzyskać dostęp do API Papra.
api-keys.create.success: Klucz API został utworzony pomyślnie.
api-keys.create.back: Wróć do kluczy API
api-keys.create.form.name.label: Nazwa
api-keys.create.form.name.placeholder: 'Przykład: Mój klucz API'
api-keys.create.form.name.required: Proszę wprowadzić nazwę dla klucza API
api-keys.create.form.permissions.label: Uprawnienia
api-keys.create.form.permissions.required: Proszę wybrać co najmniej jedno uprawnienie
api-keys.create.form.submit: Utwórz klucz API
api-keys.create.created.title: Klucz API utworzony
api-keys.create.created.description: Klucz API został utworzony pomyślnie. Zapisz go w bezpiecznym miejscu, ponieważ nie będzie wyświetlony ponownie.
api-keys.list.title: Klucze API
api-keys.list.description: Zarządzaj swoimi kluczami API tutaj.
api-keys.list.create: Utwórz klucz API
api-keys.list.empty.title: Brak kluczy API
api-keys.list.empty.description: Utwórz klucz API, aby uzyskać dostęp do API Papra.
api-keys.list.card.last-used: Ostatnie użycie
api-keys.list.card.never: Nigdy
api-keys.list.card.created: Utworzono
api-keys.delete.success: Klucz API został usunięty pomyślnie
api-keys.delete.confirm.title: Usuń klucz API
api-keys.delete.confirm.message: Czy na pewno chcesz usunąć ten klucz API? Ta akcja jest nieodwracalna.
api-keys.delete.confirm.confirm-button: Usuń
api-keys.delete.confirm.cancel-button: Anuluj
# Webhooks
webhooks.list.title: Webhooki
webhooks.list.description: Zarządzaj webhookami swojej organizacji
webhooks.list.empty.title: Brak webhooków
webhooks.list.empty.description: Utwórz pierwszy webhook, aby rozpocząć odbieranie zdarzeń
webhooks.list.create: Utwórz webhook
webhooks.list.card.last-triggered: Ostatnie wywołanie
webhooks.list.card.never: Nigdy
webhooks.list.card.created: Utworzono
webhooks.create.title: Utwórz webhook
webhooks.create.description: Utwórz nowy webhook, aby odbierać zdarzenia
webhooks.create.success: Webhook został utworzony pomyślnie
webhooks.create.back: Wróć
webhooks.create.form.submit: Utwórz webhook
webhooks.create.form.name.label: Nazwa webhooka
webhooks.create.form.name.placeholder: Wprowadź nazwę webhooka
webhooks.create.form.name.required: Nazwa jest wymagana
webhooks.create.form.url.label: URL webhooka
webhooks.create.form.url.placeholder: Wprowadź URL webhooka
webhooks.create.form.url.required: URL jest wymagany
webhooks.create.form.url.invalid: URL jest nieprawidłowy
webhooks.create.form.secret.label: Sekret
webhooks.create.form.secret.placeholder: Wprowadź sekret webhooka
webhooks.create.form.events.label: Zdarzenia
webhooks.create.form.events.required: Co najmniej jedno zdarzenie jest wymagane
webhooks.update.title: Edytuj webhook
webhooks.update.description: Zaktualizuj szczegóły webhooka
webhooks.update.success: Webhook został zaktualizowany pomyślnie
webhooks.update.submit: Zaktualizuj webhook
webhooks.update.cancel: Anuluj
webhooks.update.form.secret.placeholder: Wprowadź nowy sekret
webhooks.update.form.secret.placeholder-redacted: '[Zredagowany sekret]'
webhooks.update.form.rotate-secret.button: Wygeneruj nowy sekret
webhooks.delete.success: Webhook został usunięty pomyślnie
webhooks.delete.confirm.title: Usuń webhook
webhooks.delete.confirm.message: Czy na pewno chcesz usunąć ten webhook?
webhooks.delete.confirm.confirm-button: Usuń
webhooks.delete.confirm.cancel-button: Anuluj
webhooks.events.documents.document:created.description: Utworzono dokument
webhooks.events.documents.document:deleted.description: Usunięto dokument
# Navigation
layout.menu.home: Strona główna
layout.menu.documents: Dokumenty
layout.menu.tags: Tagi
layout.menu.tagging-rules: Zasady tagowania
layout.menu.deleted-documents: Usunięte dokumenty
layout.menu.organization-settings: Ustawienia
layout.menu.api-keys: Klucze API
layout.menu.settings: Ustawienia
layout.menu.account: Konto
layout.menu.general-settings: Ustawienia ogólne
layout.menu.intake-emails: Adresy przyjęć
layout.menu.webhooks: Webhooki
layout.menu.members: Członkowie
layout.menu.invitations: Zaproszenia
layout.theme.light: Tryb jasny
layout.theme.dark: Tryb ciemny
layout.theme.system: Tryb systemowy
layout.search.placeholder: Szukaj...
layout.menu.import-document: Importuj dokument
user-menu.account-settings: Ustawienia konta
user-menu.api-keys: Klucze API
user-menu.invitations: Zaproszenia
user-menu.language: Język
user-menu.logout: Wyloguj
# Command palette
command-palette.search.placeholder: Szukaj poleceń lub dokumentów
command-palette.no-results: Nie znaleziono wyników
command-palette.sections.documents: Dokumenty
command-palette.sections.theme: Motyw
# API errors
api-errors.document.already_exists: Dokument już istnieje
api-errors.document.file_too_big: Plik dokumentu jest zbyt duży
api-errors.intake_email.limit_reached: Osiągnięto maksymalną liczbę adresów e-mail do przyjęć dla tej organizacji. Aby utworzyć więcej adresów e-mail do przyjęć, zaktualizuj swój plan.
api-errors.user.max_organization_count_reached: Osiągnięto maksymalną liczbę organizacji, które możesz utworzyć. Jeśli potrzebujesz utworzyć więcej, skontaktuj się z pomocą techniczną.
api-errors.default: Wystąpił błąd podczas przetwarzania żądania.
api-errors.organization.invitation_already_exists: Zaproszenie dla tego adresu e-mail już istnieje w tej organizacji.
api-errors.user.already_in_organization: Ten użytkownik należy już do tej organizacji.
api-errors.user.organization_invitation_limit_reached: Osiągnięto maksymalną liczbę zaproszeń na dzisiaj. Spróbuj ponownie jutro.
api-errors.demo.not_available: Ta funkcja nie jest dostępna w wersji demo
api-errors.tags.already_exists: Tag o tej nazwie już istnieje w tej organizacji
# Not found
not-found.title: 404 - Nie znaleziono
not-found.description: Przepraszamy, strona, której szukasz wydaje się nie istnieć. Sprawdź URL i spróbuj ponownie.
not-found.back-to-home: Wróć do strony głównej
# Demo
demo.popup.description: To jest środowisko demonstracyjne, wszystkie dane są zapisywane w lokalnej pamięci przeglądarki.
demo.popup.discord: Dołącz do {{ discordLink }}, aby uzyskać wsparcie, zaproponować funkcje lub po prostu porozmawiać.
demo.popup.discord-link-label: Serwer Discord
demo.popup.reset: Zresetuj dane demonstracyjne
demo.popup.hide: Ukryj
# Color picker
color-picker.hue: Odcień
color-picker.saturation: Nasycenie
color-picker.lightness: Jasność
color-picker.select-color: Wybierz kolor
color-picker.select-a-color: Wybierz kolor

View File

@@ -0,0 +1,565 @@
# Authentication
auth.request-password-reset.title: Redefina sua senha
auth.request-password-reset.description: Insira seu e-mail para redefinir sua senha.
auth.request-password-reset.requested: Se uma conta com este e-mail existe, enviamos uma mensagem para redefinir sua senha.
auth.request-password-reset.back-to-login: Voltar para o login
auth.request-password-reset.form.email.label: E-mail
auth.request-password-reset.form.email.placeholder: 'Exemplo: cesar@papra.app'
auth.request-password-reset.form.email.required: Por favor, insira seu endereço de e-mail
auth.request-password-reset.form.email.invalid: Este endereço de e-mail é inválido
auth.request-password-reset.form.submit: Solicitar redefinição de senha
auth.reset-password.title: Redefina sua senha
auth.reset-password.description: Insira sua nova senha para redefinir sua senha.
auth.reset-password.reset: Sua senha foi redefinida.
auth.reset-password.back-to-login: Voltar para o login
auth.reset-password.form.new-password.label: Nova senha
auth.reset-password.form.new-password.placeholder: 'Exemplo: **********'
auth.reset-password.form.new-password.required: Por favor, insira sua nova senha
auth.reset-password.form.new-password.min-length: A senha deve ter pelo menos {{ minLength }} caracteres
auth.reset-password.form.new-password.max-length: A senha deve ter menos de {{ maxLength }} caracteres
auth.reset-password.form.submit: Redefinir senha
auth.email-provider.open: Abrir {{ provider }}
auth.login.title: Acessar o Papra
auth.login.description: Insira seu e-mail ou use um login de rede social para acessar sua conta no Papra.
auth.login.login-with-provider: Entrar com {{ provider }}
auth.login.no-account: Não tem uma conta?
auth.login.register: Cadastre-se
auth.login.form.email.label: E-mail
auth.login.form.email.placeholder: 'Exemplo: cesar@papra.app'
auth.login.form.email.required: Por favor, insira seu endereço de e-mail
auth.login.form.email.invalid: Este endereço de e-mail é inválido
auth.login.form.password.label: Senha
auth.login.form.password.placeholder: Defina uma senha
auth.login.form.password.required: Por favor, insira sua senha
auth.login.form.remember-me.label: Lembrar de mim
auth.login.form.forgot-password.label: Esqueceu a senha?
auth.login.form.submit: Entrar
auth.register.title: Cadastre-se no Papra
auth.register.description: Crie uma conta para começar a usar o Papra.
auth.register.register-with-email: Cadastrar com e-mail
auth.register.register-with-provider: Cadastrar com {{ provider }}
auth.register.providers.google: Google
auth.register.providers.github: GitHub
auth.register.have-account: Já tem uma conta?
auth.register.login: Entrar
auth.register.registration-disabled.title: Cadastro desativado
auth.register.registration-disabled.description: A criação de novas contas está desativada nesta instância do Papra. Somente usuários com contas existentes podem acessar. Se você acha que isso é um engano, entre em contato com o administrador desta instância.
auth.register.form.email.label: E-mail
auth.register.form.email.placeholder: 'Exemplo: cesar@papra.app'
auth.register.form.email.required: Por favor, insira seu endereço de e-mail
auth.register.form.email.invalid: Este endereço de e-mail é inválido
auth.register.form.password.label: Senha
auth.register.form.password.placeholder: Defina uma senha
auth.register.form.password.required: Por favor, insira sua senha
auth.register.form.password.min-length: A senha deve ter pelo menos {{ minLength }} caracteres
auth.register.form.password.max-length: A senha deve ter menos de {{ maxLength }} caracteres
auth.register.form.name.label: Nome
auth.register.form.name.placeholder: 'Exemplo: César Lattes'
auth.register.form.name.required: Por favor, insira seu nome
auth.register.form.name.max-length: O nome deve ter menos de {{ maxLength }} caracteres
auth.register.form.submit: Cadastrar
auth.email-validation-required.title: Verifique seu e-mail
auth.email-validation-required.description: Um e-mail de verificação foi enviado para seu endereço. Por favor, verifique seu e-mail clicando no link enviado.
auth.legal-links.description: Ao continuar, você reconhece que leu e concorda com os {{ terms }} e a {{ privacy }}.
auth.legal-links.terms: Termos de Serviço
auth.legal-links.privacy: Política de Privacidade
auth.no-auth-provider.title: Nenhum provedor de autenticação
auth.no-auth-provider.description: Não há provedores de autenticação habilitados nesta instância do Papra. Por favor, entre em contato com o administrador desta instância para habilitá-los.
# User settings
user.settings.title: Configurações do usuário
user.settings.description: Gerencie as configurações da sua conta aqui.
user.settings.email.title: Endereço de e-mail
user.settings.email.description: Seu endereço de e-mail não pode ser alterado.
user.settings.email.label: Endereço de e-mail
user.settings.name.title: Nome completo
user.settings.name.description: Seu nome completo é exibido para outros membros da organização.
user.settings.name.label: Nome completo
user.settings.name.placeholder: 'Ex: João da Silva'
user.settings.name.update: Atualizar nome
user.settings.name.updated: Seu nome completo foi atualizado
user.settings.logout.title: Sair
user.settings.logout.description: Encerre a sessão da sua conta. Você poderá acessá-la novamente mais tarde.
user.settings.logout.button: Sair
# Organizations
organizations.list.title: Suas organizações
organizations.list.description: Organizações são uma forma de agrupar seus documentos e gerenciar o acesso a eles. Você pode criar várias organizações e convidar membros da sua equipe para colaborar.
organizations.list.create-new: Criar nova organização
organizations.details.no-documents.title: Nenhum documento
organizations.details.no-documents.description: Ainda não há documentos nesta organização. Comece enviando documentos.
organizations.details.upload-documents: Enviar documentos
organizations.details.documents-count: documentos no total
organizations.details.total-size: tamanho total
organizations.details.latest-documents: Documentos importados recentemente
organizations.create.title: Criar uma nova organização
organizations.create.description: Seus documentos serão agrupados por organização. Você pode criar várias organizações para separar, por exemplo, documentos pessoais e profissionais.
organizations.create.back: Voltar
organizations.create.error.max-count-reached: Você atingiu o número máximo de organizações que pode criar. Se precisar criar mais, entre em contato com o suporte.
organizations.create.form.name.label: Nome da organização
organizations.create.form.name.placeholder: 'Ex: Empresa Ltda.'
organizations.create.form.name.required: Por favor, insira um nome para a organização
organizations.create.form.submit: Criar organização
organizations.create.success: Organização criada com sucesso
organizations.create-first.title: Crie sua organização
organizations.create-first.description: Seus documentos serão agrupados por organização. Você pode criar várias organizações para separar, por exemplo, documentos pessoais e profissionais.
organizations.create-first.default-name: Minha organização
organizations.create-first.user-name: Organização de {{ name }}
organization.settings.title: Configurações da Organização
organization.settings.page.title: Configurações da organização
organization.settings.page.description: Gerencie aqui as configurações da sua organização.
organization.settings.name.title: Nome da organização
organization.settings.name.update: Atualizar nome
organization.settings.name.placeholder: 'Ex: Empresa Ltda.'
organization.settings.name.updated: Nome da organização atualizado
organization.settings.subscription.title: Assinatura
organization.settings.subscription.description: Gerencie sua cobrança, faturas e formas de pagamento.
organization.settings.subscription.manage: Gerenciar assinatura
organization.settings.subscription.error: Falha ao obter o link do portal do cliente
organization.settings.delete.title: Excluir organização
organization.settings.delete.description: A exclusão desta organização removerá permanentemente todos seus dados associados.
organization.settings.delete.confirm.title: Excluir organização
organization.settings.delete.confirm.message: Tem certeza de que deseja excluir esta organização? Esta ação não pode ser desfeita e todos os dados associados serão permanentemente removidos.
organization.settings.delete.confirm.confirm-button: Excluir organização
organization.settings.delete.confirm.cancel-button: Cancelar
organization.settings.delete.success: Organização excluída
organizations.members.title: Membros
organizations.members.description: Gerencie os membros da sua organização
organizations.members.invite-member: Convidar membro
organizations.members.invite-member-disabled-tooltip: Apenas administradores ou proprietários podem convidar membros para a organização
organizations.members.remove-from-organization: Remover da organização
organizations.members.role: Função
organizations.members.roles.owner: Proprietário
organizations.members.roles.admin: Administrador
organizations.members.roles.member: Membro
organizations.members.delete.confirm.title: Remover membro
organizations.members.delete.confirm.message: Tem certeza de que deseja remover este membro da organização?
organizations.members.delete.confirm.confirm-button: Remover
organizations.members.delete.confirm.cancel-button: Cancelar
organizations.members.delete.success: Membro removido da organização
organizations.members.update-role.success: Função do membro atualizada
organizations.members.table.headers.name: Nome
organizations.members.table.headers.email: E-mail
organizations.members.table.headers.role: Função
organizations.members.table.headers.created: Criado em
organizations.members.table.headers.actions: Ações
organizations.invite-member.title: Convidar membro
organizations.invite-member.description: Convide um membro para a sua organização
organizations.invite-member.form.email.label: E-mail
organizations.invite-member.form.email.placeholder: 'Exemplo: cesar@papra.app'
organizations.invite-member.form.email.required: Por favor, insira um endereço de e-mail válido
organizations.invite-member.form.role.label: Função
organizations.invite-member.form.submit: Convidar para a organização
organizations.invite-member.success.message: Membro convidado
organizations.invite-member.success.description: O e-mail foi convidado para a organização.
organizations.invite-member.error.message: Falha ao convidar o membro
organizations.invitations.title: Convites
organizations.invitations.description: Gerencie os convites da sua organização
organizations.invitations.list.cta: Convidar membro
organizations.invitations.list.empty.title: Nenhum convite pendente
organizations.invitations.list.empty.description: Você ainda não foi convidado para nenhuma organização.
organizations.invitations.status.pending: Pendente
organizations.invitations.status.accepted: Aceito
organizations.invitations.status.rejected: Rejeitado
organizations.invitations.status.expired: Expirado
organizations.invitations.status.cancelled: Cancelado
organizations.invitations.resend: Reenviar convite
organizations.invitations.cancel.title: Cancelar convite
organizations.invitations.cancel.description: Tem certeza de que deseja cancelar este convite?
organizations.invitations.cancel.confirm: Cancelar convite
organizations.invitations.cancel.cancel: Cancelar
organizations.invitations.resend.title: Reenviar convite
organizations.invitations.resend.description: Tem certeza de que deseja reenviar este convite? Um novo e-mail será enviado ao destinatário.
organizations.invitations.resend.confirm: Reenviar convite
organizations.invitations.resend.cancel: Cancelar
invitations.list.title: Convites
invitations.list.description: Gerencie os convites da sua organização
invitations.list.empty.title: Nenhum convite pendente
invitations.list.empty.description: Você ainda não foi convidado para nenhuma organização.
invitations.list.headers.organization: Organização
invitations.list.headers.status: Status
invitations.list.headers.created: Criado em
invitations.list.headers.actions: Ações
invitations.list.actions.accept: Aceitar
invitations.list.actions.reject: Rejeitar
invitations.list.actions.accept.success.message: Convite aceito
invitations.list.actions.accept.success.description: O convite foi aceito.
invitations.list.actions.reject.success.message: Convite rejeitado
invitations.list.actions.reject.success.description: O convite foi rejeitado.
# Documents
documents.list.title: Documentos
documents.list.no-documents.title: Nenhum documento
documents.list.no-documents.description: Ainda não há documentos nesta organização. Comece enviando documentos.
documents.list.no-results: Nenhum documento encontrado
documents.tabs.info: Informações
documents.tabs.content: Conteúdo
documents.tabs.activity: Atividades
documents.deleted.message: Este documento foi excluído e será deletado permanentemente em {{ days }} dias.
documents.actions.download: Baixar
documents.actions.open-in-new-tab: Abrir em nova aba
documents.actions.restore: Restaurar
documents.actions.delete: Excluir
documents.actions.edit: Editar
documents.actions.cancel: Cancelar
documents.actions.save: Salvar
documents.actions.saving: Salvando...
documents.content.alert: O conteúdo do documento é extraído automaticamente durante o envio e será utilizado apenas para fins de busca e indexação.
documents.info.id: ID
documents.info.name: Nome
documents.info.type: Tipo
documents.info.size: Tamanho
documents.info.created-at: Criado em
documents.info.updated-at: Atualizado em
documents.info.never: Nunca
documents.rename.title: Renomear documento
documents.rename.form.name.label: Nome
documents.rename.form.name.placeholder: 'Exemplo: Fatura 2024'
documents.rename.form.name.required: Por favor, insira um nome para o documento
documents.rename.form.name.max-length: O nome deve ter menos de 255 caracteres
documents.rename.form.submit: Renomear documento
documents.rename.success: Documento renomeado com sucesso
documents.rename.cancel: Cancelar
import-documents.title.error: '{{ count }} documentos falharam'
import-documents.title.success: '{{ count }} documentos importados'
import-documents.title.pending: '{{ count }} / {{ total }} documentos importados'
import-documents.title.none: Importar documentos
import-documents.no-import-in-progress: Nenhuma importação de documentos em andamento
documents.deleted.title: Documentos excluídos
documents.deleted.empty.title: Nenhum documento excluído
documents.deleted.empty.description: Você não tem documentos excluídos. Documentos excluídos serão movidos para a lixeira por {{ days }} dias.
documents.deleted.retention-notice: Todos os documentos excluídos são armazenados na lixeira por {{ days }} dias. Após esse período, os documentos serão excluídos permanentemente e não será possível restaurá-los.
documents.deleted.deleted-at: Excluído em
documents.deleted.restoring: Restaurando...
documents.deleted.deleting: Excluindo...
documents.preview.unknown-file-type: Pré-visualização não disponível para este tipo de arquivo
documents.preview.binary-file: Arquivos binários não podem ser exibidos como texto
trash.delete-all.button: Excluir tudo
trash.delete-all.confirm.title: Excluir todos os documentos permanentemente?
trash.delete-all.confirm.description: Tem certeza de que deseja excluir permanentemente todos os documentos da lixeira? Esta ação não poderá ser desfeita.
trash.delete-all.confirm.label: Excluir
trash.delete-all.confirm.cancel: Cancelar
trash.delete.button: Excluir
trash.delete.confirm.title: Excluir documento permanentemente?
trash.delete.confirm.description: Tem certeza de que deseja excluir permanentemente este documento da lixeira? Esta ação não poderá ser desfeita.
trash.delete.confirm.label: Excluir
trash.delete.confirm.cancel: Cancelar
trash.deleted.success.title: Documento excluído
trash.deleted.success.description: O documento foi excluído permanentemente.
activity.document.created: O documento foi criado
activity.document.updated.single: O {{ field }} foi atualizado
activity.document.updated.multiple: Os {{ fields }} foram atualizados
activity.document.updated: O documento foi atualizado
activity.document.deleted: O documento foi excluído
activity.document.restored: O documento foi restaurado
activity.document.tagged: A tag {{ tag }} foi adicionada
activity.document.untagged: A tag {{ tag }} foi removida
activity.document.user.name: por {{ name }}
activity.load-more: Carregar mais
activity.no-more-activities: Não há mais atividades para este documento
# Tags
tags.no-tags.title: Nenhuma tag
tags.no-tags.description: Esta organização ainda não possui tags. As tags são usadas para categorizar documentos. Você pode adicioná-las aos seus documentos para facilitar a busca e a organização.
tags.no-tags.create-tag: Criar tag
tags.title: Tags de documentos
tags.description: As tags são usadas para categorizar documentos. Você pode adicioná-las aos seus documentos para facilitar a busca e a organização.
tags.create: Criar tag
tags.update: Atualizar tag
tags.delete: Excluir tag
tags.delete.confirm.title: Excluir tag
tags.delete.confirm.message: Tem certeza de que deseja excluir esta tag? A exclusão de uma tag a removerá de todos os documentos.
tags.delete.confirm.confirm-button: Excluir
tags.delete.confirm.cancel-button: Cancelar
tags.delete.success: Tag excluída com sucesso
tags.create.success: Tag "{{ name }}" criada com sucesso.
tags.update.success: Tag "{{ name }}" atualizada com sucesso.
tags.form.name.label: Nome
tags.form.name.placeholder: 'Ex: Contratos'
tags.form.name.required: Por favor, insira um nome para a tag
tags.form.name.max-length: O nome da tag deve ter menos de 64 caracteres
tags.form.color.label: Cor
tags.form.color.required: Por favor, insira uma cor
tags.form.color.invalid: Código hexadecimal formatado incorretamente.
tags.form.description.label: Descrição
tags.form.description.optional: (opcional)
tags.form.description.placeholder: 'Ex: Todos os contratos assinados pela empresa'
tags.form.description.max-length: A descrição deve ter menos de 256 caracteres
tags.form.no-description: Sem descrição
tags.table.headers.tag: Tag
tags.table.headers.description: Descrição
tags.table.headers.documents: Documentos
tags.table.headers.created: Criado em
tags.table.headers.actions: Ações
# Tagging rules
tagging-rules.field.name: nome do documento
tagging-rules.field.content: conteúdo do documento
tagging-rules.operator.equals: é igual a
tagging-rules.operator.not-equals: é diferente de
tagging-rules.operator.contains: contém
tagging-rules.operator.not-contains: não contém
tagging-rules.operator.starts-with: começa com
tagging-rules.operator.ends-with: termina com
tagging-rules.list.title: Regras de marcação
tagging-rules.list.description: Gerencie as regras de marcação da sua organização para aplicar tags automaticamente a documentos com base em condições definidas por você.
tagging-rules.list.demo-warning: 'Nota: Como este é um ambiente de demonstração (sem servidor), as regras de marcação não serão aplicadas a novos documentos adicionados.'
tagging-rules.list.no-tagging-rules.title: Nenhuma regra de marcação
tagging-rules.list.no-tagging-rules.description: Crie uma regra de marcação para aplicar tags automaticamente aos documentos adicionados, com base em condições definidas por você.
tagging-rules.list.no-tagging-rules.create-tagging-rule: Criar regra de marcação
tagging-rules.list.card.no-conditions: Nenhuma condição
tagging-rules.list.card.one-condition: 1 condição
tagging-rules.list.card.conditions: '{{ count }} condições'
tagging-rules.list.card.delete: Excluir regra
tagging-rules.list.card.edit: Editar regra
tagging-rules.create.title: Criar regra de marcação
tagging-rules.create.success: Regra de marcação criada com sucesso
tagging-rules.create.error: Falha ao criar a regra de marcação
tagging-rules.create.submit: Criar regra
tagging-rules.form.name.label: Nome
tagging-rules.form.name.placeholder: 'Exemplo: Marcar faturas'
tagging-rules.form.name.min-length: Por favor, insira um nome para a regra
tagging-rules.form.name.max-length: O nome deve ter menos de 64 caracteres
tagging-rules.form.description.label: Descrição
tagging-rules.form.description.placeholder: "Exemplo: Marcar documentos com 'fatura' no nome"
tagging-rules.form.description.max-length: A descrição deve ter menos de 256 caracteres
tagging-rules.form.conditions.label: Condições
tagging-rules.form.conditions.description: Defina as condições que devem ser atendidas para que a regra seja aplicada. Todas as condições devem ser atendidas.
tagging-rules.form.conditions.add-condition: Adicionar condição
tagging-rules.form.conditions.no-conditions.title: Nenhuma condição
tagging-rules.form.conditions.no-conditions.description: Você não adicionou nenhuma condição a esta regra. Ela será aplicada a todos os documentos.
tagging-rules.form.conditions.no-conditions.confirm: Aplicar regra sem condições
tagging-rules.form.conditions.no-conditions.cancel: Cancelar
tagging-rules.form.conditions.value.placeholder: 'Exemplo: fatura'
tagging-rules.form.conditions.value.min-length: Por favor, insira um valor para a condição
tagging-rules.form.tags.label: Tags
tagging-rules.form.tags.description: Selecione as tags que serão aplicadas aos documentos adicionados que correspondam às condições
tagging-rules.form.tags.min-length: Ao menos uma tag para aplicar é necessária
tagging-rules.form.tags.add-tag: Criar tag
tagging-rules.form.submit: Criar regra
tagging-rules.update.title: Atualizar regra de marcação
tagging-rules.update.error: Falha ao atualizar a regra de marcação
tagging-rules.update.submit: Atualizar regra
tagging-rules.update.cancel: Cancelar
# Intake emails
intake-emails.title: E-mails de entrada
intake-emails.description: Os endereços de e-mail de entrada são usados para importar automaticamente e-mails para o Papra. Basta encaminhar e-mails para o endereço de entrada e os anexos serão adicionados aos documentos da sua organização.
intake-emails.disabled.title: E-mails de entrada desativados
intake-emails.disabled.description: Os e-mails de entrada estão desativados nesta instância. Por favor, entre em contato com o administrador para ativá-los. Consulte a {{ documentation }} para mais informações.
intake-emails.disabled.documentation: documentação
intake-emails.info: Apenas e-mails de entrada habilitados e provenientes de origens permitidas serão processados. Você pode ativar ou desativar um e-mail de entrada a qualquer momento.
intake-emails.empty.title: Nenhum e-mail de entrada
intake-emails.empty.description: Gere um endereço de entrada para importar facilmente anexos de e-mails.
intake-emails.empty.generate: Gerar e-mail de entrada
intake-emails.count: '{{ count }} e-mail{{ plural }} de entrada para esta organização'
intake-emails.new: Novo e-mail de entrada
intake-emails.disabled-label: (Desativado)
intake-emails.no-origins: Nenhuma origem de e-mail permitida
intake-emails.allowed-origins: Permitido de {{ count }} endereço{{ plural }}
intake-emails.actions.enable: Ativar
intake-emails.actions.disable: Desativar
intake-emails.actions.manage-origins: Gerenciar endereços de origem
intake-emails.actions.delete: Excluir
intake-emails.delete.confirm.title: Excluir e-mail de entrada?
intake-emails.delete.confirm.message: Tem certeza de que deseja excluir este e-mail de entrada? Esta ação não poderá ser desfeita.
intake-emails.delete.confirm.confirm-button: Excluir e-mail de entrada
intake-emails.delete.confirm.cancel-button: Cancelar
intake-emails.delete.success: E-mail de entrada excluído
intake-emails.create.success: E-mail de entrada criado
intake-emails.update.success.enabled: E-mail de entrada ativado
intake-emails.update.success.disabled: E-mail de entrada desativado
intake-emails.allowed-origins.title: Origens permitidas
intake-emails.allowed-origins.description: Apenas e-mails enviados para {{ email }} a partir dessas origens serão processados. Se nenhuma origem for especificada, todos os e-mails serão descartados.
intake-emails.allowed-origins.add.label: Adicionar e-mail de origem permitida
intake-emails.allowed-origins.add.placeholder: 'Ex: ada@papra.app'
intake-emails.allowed-origins.add.button: Adicionar
intake-emails.allowed-origins.add.error.exists: Este e-mail já está nas origens permitidas para este e-mail de entrada
# API keys
api-keys.permissions.documents.title: Documentos
api-keys.permissions.documents.documents:create: Criar documentos
api-keys.permissions.documents.documents:read: Ler documentos
api-keys.permissions.documents.documents:update: Atualizar documentos
api-keys.permissions.documents.documents:delete: Excluir documentos
api-keys.permissions.tags.title: Tags
api-keys.permissions.tags.tags:create: Criar tags
api-keys.permissions.tags.tags:read: Ler tags
api-keys.permissions.tags.tags:update: Atualizar tags
api-keys.permissions.tags.tags:delete: Excluir tags
api-keys.create.title: Criar chave de API
api-keys.create.description: Crie uma nova chave de API para acessar a API do Papra.
api-keys.create.success: A chave de API foi criada com sucesso.
api-keys.create.back: Voltar para as chaves de API
api-keys.create.form.name.label: Nome
api-keys.create.form.name.placeholder: 'Exemplo: Minha chave de API'
api-keys.create.form.name.required: Por favor, insira um nome para a chave de API
api-keys.create.form.permissions.label: Permissões
api-keys.create.form.permissions.required: Por favor, selecione ao menos uma permissão
api-keys.create.form.submit: Criar chave de API
api-keys.create.created.title: Chave de API criada
api-keys.create.created.description: A chave de API foi criada com sucesso. Salve-a em um local seguro, pois ela não será exibida novamente.
api-keys.list.title: Chaves de API
api-keys.list.description: Gerencie suas chaves de API aqui.
api-keys.list.create: Criar chave de API
api-keys.list.empty.title: Nenhuma chave de API
api-keys.list.empty.description: Crie uma chave de API para acessar a API do Papra.
api-keys.list.card.last-used: Último uso
api-keys.list.card.never: Nunca
api-keys.list.card.created: Criada em
api-keys.delete.success: A chave de API foi excluída com sucesso
api-keys.delete.confirm.title: Excluir chave de API
api-keys.delete.confirm.message: Tem certeza de que deseja excluir esta chave de API? Esta ação não poderá ser desfeita.
api-keys.delete.confirm.confirm-button: Excluir
api-keys.delete.confirm.cancel-button: Cancelar
# Webhooks
webhooks.list.title: Webhooks
webhooks.list.description: Gerencie os webhooks da sua organização
webhooks.list.empty.title: Nenhum webhook
webhooks.list.empty.description: Crie seu primeiro webhook para começar a receber eventos
webhooks.list.create: Criar webhook
webhooks.list.card.last-triggered: Última ativação
webhooks.list.card.never: Nunca
webhooks.list.card.created: Criado em
webhooks.create.title: Criar webhook
webhooks.create.description: Crie um novo webhook para receber eventos
webhooks.create.success: Webhook criado com sucesso
webhooks.create.back: Voltar
webhooks.create.form.submit: Criar webhook
webhooks.create.form.name.label: Nome do webhook
webhooks.create.form.name.placeholder: Insira o nome do webhook
webhooks.create.form.name.required: O nome é obrigatório
webhooks.create.form.url.label: URL do Webhook
webhooks.create.form.url.placeholder: Insira a URL do webhook
webhooks.create.form.url.required: A URL é obrigatória
webhooks.create.form.url.invalid: URL inválida
webhooks.create.form.secret.label: Segredo
webhooks.create.form.secret.placeholder: Insira o segredo do webhook
webhooks.create.form.events.label: Eventos
webhooks.create.form.events.required: Adicione pelo menos um evento
webhooks.update.title: Editar webhook
webhooks.update.description: Atualize os detalhes do seu webhook
webhooks.update.success: Webhook atualizado com sucesso
webhooks.update.submit: Atualizar webhook
webhooks.update.cancel: Cancelar
webhooks.update.form.secret.placeholder: Insira um novo segredo
webhooks.update.form.secret.placeholder-redacted: '[Segredo ocultado]'
webhooks.update.form.rotate-secret.button: Rotacionar segredo
webhooks.delete.success: Webhook excluído com sucesso
webhooks.delete.confirm.title: Excluir webhook
webhooks.delete.confirm.message: Tem certeza de que deseja excluir este webhook?
webhooks.delete.confirm.confirm-button: Excluir
webhooks.delete.confirm.cancel-button: Cancelar
webhooks.events.documents.document:created.description: Documento criado
webhooks.events.documents.document:deleted.description: Documento excluído
# Navigation
layout.menu.home: Início
layout.menu.documents: Documentos
layout.menu.tags: Tags
layout.menu.tagging-rules: Regras de marcação
layout.menu.deleted-documents: Documentos excluídos
layout.menu.organization-settings: Configurações
layout.menu.api-keys: Chaves de API
layout.menu.settings: Configurações
layout.menu.account: Conta
layout.menu.general-settings: Configurações gerais
layout.menu.intake-emails: E-mails de entrada
layout.menu.webhooks: Webhooks
layout.menu.members: Membros
layout.menu.invitations: Convites
layout.theme.light: Tema claro
layout.theme.dark: Tema escuro
layout.theme.system: Tema do sistema
layout.search.placeholder: Buscar...
layout.menu.import-document: Importar um documento
user-menu.account-settings: Configurações da conta
user-menu.api-keys: Chaves de API
user-menu.invitations: Convites
user-menu.language: Idioma
user-menu.logout: Sair
# Command palette
command-palette.search.placeholder: Buscar comandos ou documentos
command-palette.no-results: Nenhum resultado encontrado
command-palette.sections.documents: Documentos
command-palette.sections.theme: Tema
# API errors
api-errors.document.already_exists: O documento já existe
api-errors.document.file_too_big: O arquivo do documento é muito grande
api-errors.intake_email.limit_reached: O número máximo de e-mails de entrada para esta organização foi atingido. Faça um upgrade no seu plano para criar mais e-mails de entrada.
api-errors.user.max_organization_count_reached: Você atingiu o número máximo de organizações que pode criar. Se precisar criar mais, entre em contato com o suporte.
api-errors.default: Ocorreu um erro ao processar sua solicitação.
api-errors.organization.invitation_already_exists: Já existe um convite para este e-mail nesta organização.
api-errors.user.already_in_organization: Este usuário já faz parte desta organização.
api-errors.user.organization_invitation_limit_reached: O número máximo de convites por hoje foi atingido. Por favor, tente novamente amanhã.
api-errors.demo.not_available: Este recurso não está disponível em ambiente de demonstração
api-errors.tags.already_exists: Já existe uma tag com este nome nesta organização
# Not found
not-found.title: 404 - Página não encontrada
not-found.description: Desculpe, a página que você está procurando não existe. Verifique o URL e tente novamente.
not-found.back-to-home: Voltar para a página inicial
# Demo
demo.popup.description: Este é um ambiente de demonstração; todos os dados são salvos no armazenamento local do seu navegador.
demo.popup.discord: Entre no {{ discordLink }} para obter suporte, sugerir funcionalidades ou apenas conversar.
demo.popup.discord-link-label: Comunidade do Discord
demo.popup.reset: Redefinir dados da demonstração
demo.popup.hide: Ocultar
# Color picker
color-picker.hue: Matiz
color-picker.saturation: Saturação
color-picker.lightness: Brilho
color-picker.select-color: Selecionar cor
color-picker.select-a-color: Selecione uma cor

View File

@@ -0,0 +1,562 @@
# Authentication
auth.request-password-reset.title: Redefinir a sua palavra-passe
auth.request-password-reset.description: Introduza o seu e-mail para redefinir a palavra-passe.
auth.request-password-reset.requested: Se existir uma conta para este e-mail, enviámos-lhe um e-mail para redefinir a palavra-passe.
auth.request-password-reset.back-to-login: Voltar ao início de sessão
auth.request-password-reset.form.email.label: E-mail
auth.request-password-reset.form.email.placeholder: 'Exemplo: joao@papra.app'
auth.request-password-reset.form.email.required: Por favor, introduza o seu endereço de e-mail
auth.request-password-reset.form.email.invalid: Este endereço de e-mail é inválido
auth.request-password-reset.form.submit: Solicitar redefinição de palavra-passe
auth.reset-password.title: Redefinir a sua palavra-passe
auth.reset-password.description: Introduza a sua nova palavra-passe para redefinir a palavra-passe.
auth.reset-password.reset: A sua palavra-passe foi redefinida.
auth.reset-password.back-to-login: Voltar ao início de sessão
auth.reset-password.form.new-password.label: Nova palavra-passe
auth.reset-password.form.new-password.placeholder: 'Exemplo: **********'
auth.reset-password.form.new-password.required: Por favor, introduza a sua nova palavra-passe
auth.reset-password.form.new-password.min-length: A palavra-passe deve ter pelo menos {{ minLength }} caracteres
auth.reset-password.form.new-password.max-length: A palavra-passe deve ter menos de {{ maxLength }} caracteres
auth.reset-password.form.submit: Redefinir palavra-passe
auth.email-provider.open: Abrir {{ provider }}
auth.login.title: Iniciar sessão no Papra
auth.login.description: Introduza o seu e-mail ou use o início de sessão social para aceder à sua conta Papra.
auth.login.login-with-provider: Iniciar sessão com {{ provider }}
auth.login.no-account: Não tem uma conta?
auth.login.register: Registar
auth.login.form.email.label: E-mail
auth.login.form.email.placeholder: 'Exemplo: joao@papra.app'
auth.login.form.email.required: Por favor, introduza o seu endereço de e-mail
auth.login.form.email.invalid: Este endereço de e-mail é inválido
auth.login.form.password.label: Palavra-passe
auth.login.form.password.placeholder: Definir uma palavra-passe
auth.login.form.password.required: Por favor, introduza a sua palavra-passe
auth.login.form.remember-me.label: Lembrar-me
auth.login.form.forgot-password.label: Esqueceu-se da palavra-passe?
auth.login.form.submit: Iniciar sessão
auth.register.title: Registar no Papra
auth.register.description: Crie uma conta para começar a usar o Papra.
auth.register.register-with-email: Registar com e-mail
auth.register.register-with-provider: Registar com {{ provider }}
auth.register.providers.google: Google
auth.register.providers.github: GitHub
auth.register.have-account: Já tem uma conta?
auth.register.login: Iniciar sessão
auth.register.registration-disabled.title: O registo está desativado
auth.register.registration-disabled.description: A criação de novas contas está atualmente desativada nesta instância do Papra. Apenas utilizadores com contas existentes podem iniciar sessão. Se acha que isto é um erro, contacte o administrador desta instância.
auth.register.form.email.label: E-mail
auth.register.form.email.placeholder: 'Exemplo: joao@papra.app'
auth.register.form.email.required: Por favor, introduza o seu endereço de e-mail
auth.register.form.email.invalid: Este endereço de e-mail é inválido
auth.register.form.password.label: Palavra-passe
auth.register.form.password.placeholder: Definir uma palavra-passe
auth.register.form.password.required: Por favor, introduza a sua palavra-passe
auth.register.form.password.min-length: A palavra-passe deve ter pelo menos {{ minLength }} caracteres
auth.register.form.password.max-length: A palavra-passe deve ter menos de {{ maxLength }} caracteres
auth.register.form.name.label: Nome
auth.register.form.name.placeholder: 'Exemplo: Ada Lovelace'
auth.register.form.name.required: Por favor, introduza o seu nome
auth.register.form.name.max-length: O nome deve ter menos de {{ maxLength }} caracteres
auth.register.form.submit: Registar
auth.email-validation-required.title: Verifique o seu e-mail
auth.email-validation-required.description: Foi enviado um e-mail de verificação para o seu endereço de e-mail. Por favor, verifique o seu endereço de e-mail clicando na ligação no e-mail.
auth.legal-links.description: Ao continuar, reconhece que compreende e concorda com os {{ terms }} e a {{ privacy }}.
auth.legal-links.terms: Termos de Serviço
auth.legal-links.privacy: Política de Privacidade
# User settings
user.settings.title: Definições do utilizador
user.settings.description: Gira as definições da sua conta aqui.
user.settings.email.title: Endereço de e-mail
user.settings.email.description: O seu endereço de e-mail não pode ser alterado.
user.settings.email.label: Endereço de e-mail
user.settings.name.title: Nome completo
user.settings.name.description: O seu nome completo é exibido a outros membros da organização.
user.settings.name.label: Nome completo
user.settings.name.placeholder: Ex. João Silva
user.settings.name.update: Atualizar nome
user.settings.name.updated: O seu nome completo foi atualizado
user.settings.logout.title: Terminar sessão
user.settings.logout.description: Terminar sessão da sua conta. Pode iniciar sessão novamente mais tarde.
user.settings.logout.button: Terminar sessão
# Organizations
organizations.list.title: As suas organizações
organizations.list.description: As organizações são uma forma de agrupar os seus documentos e gerir o acesso aos mesmos. Pode criar várias organizações e convidar os membros da sua equipa para colaborar.
organizations.list.create-new: Criar nova organização
organizations.details.no-documents.title: Sem documentos
organizations.details.no-documents.description: Não há documentos nesta organização ainda. Comece por carregar alguns documentos.
organizations.details.upload-documents: Carregar documentos
organizations.details.documents-count: documentos no total
organizations.details.total-size: tamanho total
organizations.details.latest-documents: Últimos documentos importados
organizations.create.title: Criar uma nova organização
organizations.create.description: Os seus documentos serão agrupados por organização. Pode criar várias organizações para separar os seus documentos, por exemplo, para documentos pessoais e de trabalho.
organizations.create.back: Voltar
organizations.create.error.max-count-reached: Atingiu o número máximo de organizações que pode criar, se precisar de criar mais, contacte o suporte.
organizations.create.form.name.label: Nome da organização
organizations.create.form.name.placeholder: Ex. Acme Inc.
organizations.create.form.name.required: Por favor, introduza um nome para a organização
organizations.create.form.submit: Criar organização
organizations.create.success: Organização criada com sucesso
organizations.create-first.title: Criar a sua organização
organizations.create-first.description: Os seus documentos serão agrupados por organização. Pode criar várias organizações para separar os seus documentos, por exemplo, para documentos pessoais e de trabalho.
organizations.create-first.default-name: A minha organização
organizations.create-first.user-name: 'Organização de {{ name }}'
organization.settings.title: Definições da Organização
organization.settings.page.title: Definições da organização
organization.settings.page.description: Gira as definições da sua organização aqui.
organization.settings.name.title: Nome da organização
organization.settings.name.update: Atualizar nome
organization.settings.name.placeholder: Ex. Acme Inc.
organization.settings.name.updated: Nome da organização atualizado
organization.settings.subscription.title: Subscrição
organization.settings.subscription.description: Gira a sua faturação, faturas e métodos de pagamento.
organization.settings.subscription.manage: Gerir subscrição
organization.settings.subscription.error: Falha ao obter URL do portal do cliente
organization.settings.delete.title: Eliminar organização
organization.settings.delete.description: Eliminar esta organização removerá permanentemente todos os dados associados à mesma.
organization.settings.delete.confirm.title: Eliminar organização
organization.settings.delete.confirm.message: Tem a certeza de que quer eliminar esta organização? Esta ação não pode ser desfeita e todos os dados associados a esta organização serão permanentemente removidos.
organization.settings.delete.confirm.confirm-button: Eliminar organização
organization.settings.delete.confirm.cancel-button: Cancelar
organization.settings.delete.success: Organização eliminada
organizations.members.title: Membros
organizations.members.description: Gira os membros da sua organização
organizations.members.invite-member: Convidar membro
organizations.members.invite-member-disabled-tooltip: Apenas administradores ou proprietários podem convidar membros para a organização
organizations.members.remove-from-organization: Remover da organização
organizations.members.role: Função
organizations.members.roles.owner: Proprietário
organizations.members.roles.admin: Administrador
organizations.members.roles.member: Membro
organizations.members.delete.confirm.title: Remover membro
organizations.members.delete.confirm.message: Tem a certeza de que quer remover este membro da organização?
organizations.members.delete.confirm.confirm-button: Remover
organizations.members.delete.confirm.cancel-button: Cancelar
organizations.members.delete.success: Membro removido da organização
organizations.members.update-role.success: Função do membro atualizada
organizations.members.table.headers.name: Nome
organizations.members.table.headers.email: E-mail
organizations.members.table.headers.role: Função
organizations.members.table.headers.created: Criado
organizations.members.table.headers.actions: Ações
organizations.invite-member.title: Convidar membro
organizations.invite-member.description: Convide um membro para a sua organização
organizations.invite-member.form.email.label: E-mail
organizations.invite-member.form.email.placeholder: 'Exemplo: joao@papra.app'
organizations.invite-member.form.email.required: Por favor, introduza um endereço de e-mail válido
organizations.invite-member.form.role.label: Função
organizations.invite-member.form.submit: Convidar para a organização
organizations.invite-member.success.message: Membro convidado
organizations.invite-member.success.description: O e-mail foi convidado para a organização.
organizations.invite-member.error.message: Falha ao convidar membro
organizations.invitations.title: Convites
organizations.invitations.description: Gira os convites da sua organização
organizations.invitations.list.cta: Convidar membro
organizations.invitations.list.empty.title: Sem convites pendentes
organizations.invitations.list.empty.description: Ainda não foi convidado para nenhuma organização.
organizations.invitations.status.pending: Pendente
organizations.invitations.status.accepted: Aceite
organizations.invitations.status.rejected: Rejeitado
organizations.invitations.status.expired: Expirado
organizations.invitations.status.cancelled: Cancelado
organizations.invitations.resend: Reenviar convite
organizations.invitations.cancel.title: Cancelar convite
organizations.invitations.cancel.description: Tem a certeza de que quer cancelar este convite?
organizations.invitations.cancel.confirm: Cancelar convite
organizations.invitations.cancel.cancel: Cancelar
organizations.invitations.resend.title: Reenviar convite
organizations.invitations.resend.description: Tem a certeza de que quer reenviar este convite? Isto enviará um novo e-mail ao destinatário.
organizations.invitations.resend.confirm: Reenviar convite
organizations.invitations.resend.cancel: Cancelar
invitations.list.title: Convites
invitations.list.description: Gira os convites da sua organização
invitations.list.empty.title: Sem convites pendentes
invitations.list.empty.description: Ainda não foi convidado para nenhuma organização.
invitations.list.headers.organization: Organização
invitations.list.headers.status: Estado
invitations.list.headers.created: Criado
invitations.list.headers.actions: Ações
invitations.list.actions.accept: Aceitar
invitations.list.actions.reject: Rejeitar
invitations.list.actions.accept.success.message: Convite aceite
invitations.list.actions.accept.success.description: O convite foi aceite.
invitations.list.actions.reject.success.message: Convite rejeitado
invitations.list.actions.reject.success.description: O convite foi rejeitado.
# Documents
documents.list.title: Documentos
documents.list.no-documents.title: Sem documentos
documents.list.no-documents.description: Não há documentos nesta organização ainda. Comece por carregar alguns documentos.
documents.list.no-results: Nenhum documento encontrado
documents.tabs.info: Informação
documents.tabs.content: Conteúdo
documents.tabs.activity: Atividade
documents.deleted.message: Este documento foi eliminado e será permanentemente removido em {{ days }} dias.
documents.actions.download: Descarregar
documents.actions.open-in-new-tab: Abrir em novo separador
documents.actions.restore: Restaurar
documents.actions.delete: Eliminar
documents.actions.edit: Editar
documents.actions.cancel: Cancelar
documents.actions.save: Guardar
documents.actions.saving: A guardar...
documents.content.alert: O conteúdo do documento é automaticamente extraído do documento no carregamento. É usado apenas para fins de pesquisa e indexação.
documents.info.id: ID
documents.info.name: Nome
documents.info.type: Tipo
documents.info.size: Tamanho
documents.info.created-at: Criado em
documents.info.updated-at: Atualizado em
documents.info.never: Nunca
documents.rename.title: Renomear documento
documents.rename.form.name.label: Nome
documents.rename.form.name.placeholder: 'Exemplo: Fatura 2024'
documents.rename.form.name.required: Por favor, introduza um nome para o documento
documents.rename.form.name.max-length: O nome deve ter menos de 255 caracteres
documents.rename.form.submit: Renomear documento
documents.rename.success: Documento renomeado com sucesso
documents.rename.cancel: Cancelar
import-documents.title.error: '{{ count }} documentos falharam'
import-documents.title.success: '{{ count }} documentos importados'
import-documents.title.pending: '{{ count }} / {{ total }} documentos importados'
import-documents.title.none: Importar documentos
import-documents.no-import-in-progress: Nenhuma importação de documento em progresso
documents.deleted.title: Documentos eliminados
documents.deleted.empty.title: Sem documentos eliminados
documents.deleted.empty.description: Não tem documentos eliminados. Os documentos que são eliminados serão movidos para a reciclagem por {{ days }} dias.
documents.deleted.retention-notice: Todos os documentos eliminados são armazenados na reciclagem por {{ days }} dias. Passando este prazo, os documentos serão permanentemente eliminados e não poderá restaurá-los.
documents.deleted.deleted-at: Eliminado
documents.deleted.restoring: A restaurar...
documents.deleted.deleting: A eliminar...
documents.preview.unknown-file-type: Não há pré-visualização disponível para este tipo de ficheiro
documents.preview.binary-file: Este parece ser um ficheiro binário e não pode ser exibido como texto
trash.delete-all.button: Eliminar tudo
trash.delete-all.confirm.title: Eliminar permanentemente todos os documentos?
trash.delete-all.confirm.description: Tem a certeza de que quer eliminar permanentemente todos os documentos da reciclagem? Esta ação não pode ser desfeita.
trash.delete-all.confirm.label: Eliminar
trash.delete-all.confirm.cancel: Cancelar
trash.delete.button: Eliminar
trash.delete.confirm.title: Eliminar documento permanentemente?
trash.delete.confirm.description: Tem a certeza de que quer eliminar permanentemente este documento da reciclagem? Esta ação não pode ser desfeita.
trash.delete.confirm.label: Eliminar
trash.delete.confirm.cancel: Cancelar
trash.deleted.success.title: Documento eliminado
trash.deleted.success.description: O documento foi eliminado permanentemente.
activity.document.created: O documento foi criado
activity.document.updated.single: O {{ field }} foi atualizado
activity.document.updated.multiple: Os {{ fields }} foram atualizados
activity.document.updated: O documento foi atualizado
activity.document.deleted: O documento foi eliminado
activity.document.restored: O documento foi restaurado
activity.document.tagged: A etiqueta {{ tag }} foi adicionada
activity.document.untagged: A etiqueta {{ tag }} foi removida
activity.document.user.name: por {{ name }}
activity.load-more: Carregar mais
activity.no-more-activities: Não há mais atividades para este documento
# Tags
tags.no-tags.title: Ainda sem etiquetas
tags.no-tags.description: Esta organização ainda não tem etiquetas. As etiquetas são usadas para categorizar documentos. Pode adicionar etiquetas aos seus documentos para os tornar mais fáceis de encontrar e organizar.
tags.no-tags.create-tag: Criar etiqueta
tags.title: Etiquetas de Documentos
tags.description: As etiquetas são usadas para categorizar documentos. Pode adicionar etiquetas aos seus documentos para os tornar mais fáceis de encontrar e organizar.
tags.create: Criar etiqueta
tags.update: Atualizar etiqueta
tags.delete: Eliminar etiqueta
tags.delete.confirm.title: Eliminar etiqueta
tags.delete.confirm.message: Tem a certeza de que quer eliminar esta etiqueta? Eliminar uma etiqueta irá removê-la de todos os documentos.
tags.delete.confirm.confirm-button: Eliminar
tags.delete.confirm.cancel-button: Cancelar
tags.delete.success: Etiqueta eliminada com sucesso
tags.create.success: Etiqueta "{{ name }}" criada com sucesso.
tags.update.success: Etiqueta "{{ name }}" atualizada com sucesso.
tags.form.name.label: Nome
tags.form.name.placeholder: Ex. Contratos
tags.form.name.required: Por favor, introduza um nome para a etiqueta
tags.form.name.max-length: O nome da etiqueta deve ter menos de 64 caracteres
tags.form.color.label: Cor
tags.form.color.required: Por favor, introduza uma cor
tags.form.color.invalid: A cor hexadecimal está mal formatada.
tags.form.description.label: Descrição
tags.form.description.optional: (opcional)
tags.form.description.placeholder: Ex. Todos os contratos assinados pela empresa
tags.form.description.max-length: A descrição deve ter menos de 256 caracteres
tags.form.no-description: Sem descrição
tags.table.headers.tag: Etiqueta
tags.table.headers.description: Descrição
tags.table.headers.documents: Documentos
tags.table.headers.created: Criado
tags.table.headers.actions: Ações
# Tagging rules
tagging-rules.field.name: nome do documento
tagging-rules.field.content: conteúdo do documento
tagging-rules.operator.equals: igual a
tagging-rules.operator.not-equals: não igual a
tagging-rules.operator.contains: contém
tagging-rules.operator.not-contains: não contém
tagging-rules.operator.starts-with: começa com
tagging-rules.operator.ends-with: termina com
tagging-rules.list.title: Regras de etiquetagem
tagging-rules.list.description: Gira as regras de etiquetagem da sua organização, para etiquetar automaticamente documentos com base em condições que define.
tagging-rules.list.demo-warning: 'Nota: Como este é um ambiente de demonstração (sem servidor), as regras de etiquetagem não serão aplicadas a documentos recém-adicionados.'
tagging-rules.list.no-tagging-rules.title: Sem regras de etiquetagem
tagging-rules.list.no-tagging-rules.description: Crie uma regra de etiquetagem para etiquetar automaticamente os seus documentos adicionados com base em condições que define.
tagging-rules.list.no-tagging-rules.create-tagging-rule: Criar regra de etiquetagem
tagging-rules.list.card.no-conditions: Sem condições
tagging-rules.list.card.one-condition: 1 condição
tagging-rules.list.card.conditions: '{{ count }} condições'
tagging-rules.list.card.delete: Eliminar regra
tagging-rules.list.card.edit: Editar regra
tagging-rules.create.title: Criar regra de etiquetagem
tagging-rules.create.success: Regra de etiquetagem criada com sucesso
tagging-rules.create.error: Falha ao criar regra de etiquetagem
tagging-rules.create.submit: Criar regra
tagging-rules.form.name.label: Nome
tagging-rules.form.name.placeholder: 'Exemplo: Etiquetar faturas'
tagging-rules.form.name.min-length: Por favor, introduza um nome para a regra
tagging-rules.form.name.max-length: O nome deve ter menos de 64 caracteres
tagging-rules.form.description.label: Descrição
tagging-rules.form.description.placeholder: "Exemplo: Etiquetar documentos com 'fatura' no nome"
tagging-rules.form.description.max-length: A descrição deve ter menos de 256 caracteres
tagging-rules.form.conditions.label: Condições
tagging-rules.form.conditions.description: Defina as condições que devem ser cumpridas para a regra se aplicar. Todas as condições devem ser cumpridas para a regra se aplicar.
tagging-rules.form.conditions.add-condition: Adicionar condição
tagging-rules.form.conditions.no-conditions.title: Sem condições
tagging-rules.form.conditions.no-conditions.description: Não adicionou nenhuma condição a esta regra. Esta regra aplicará as suas etiquetas a todos os documentos.
tagging-rules.form.conditions.no-conditions.confirm: Aplicar regra sem condições
tagging-rules.form.conditions.no-conditions.cancel: Cancelar
tagging-rules.form.conditions.value.placeholder: 'Exemplo: fatura'
tagging-rules.form.conditions.value.min-length: Por favor, introduza um valor para a condição
tagging-rules.form.tags.label: Etiquetas
tagging-rules.form.tags.description: Selecione as etiquetas a aplicar aos documentos adicionados que correspondem às condições
tagging-rules.form.tags.min-length: É necessária pelo menos uma etiqueta para aplicar
tagging-rules.form.tags.add-tag: Criar etiqueta
tagging-rules.form.submit: Criar regra
tagging-rules.update.title: Atualizar regra de etiquetagem
tagging-rules.update.error: Falha ao atualizar regra de etiquetagem
tagging-rules.update.submit: Atualizar regra
tagging-rules.update.cancel: Cancelar
# Intake emails
intake-emails.title: E-mails de Receção
intake-emails.description: Os endereços de e-mail de receção são usados para ingerir automaticamente e-mails no Papra. Basta reencaminhar e-mails para o endereço de e-mail de receção e os seus anexos serão adicionados aos documentos da sua organização.
intake-emails.disabled.title: Os E-mails de Receção estão desativados
intake-emails.disabled.description: Os e-mails de receção estão desativados nesta instância. Contacte o seu administrador para os ativar. Consulte a {{ documentation }} para mais informações.
intake-emails.disabled.documentation: documentação
intake-emails.info: Apenas e-mails de receção ativados de origens permitidas serão processados. Pode ativar ou desativar um e-mail de receção a qualquer momento.
intake-emails.empty.title: Sem e-mails de receção
intake-emails.empty.description: Gere um endereço de receção para ingerir facilmente anexos de e-mails.
intake-emails.empty.generate: Gerar e-mail de receção
intake-emails.count: '{{ count }} e-mail{{ plural }} de receção para esta organização'
intake-emails.new: Novo e-mail de receção
intake-emails.disabled-label: (Desativado)
intake-emails.no-origins: Sem origens de e-mail permitidas
intake-emails.allowed-origins: Permitido de {{ count }} endereço{{ plural }}
intake-emails.actions.enable: Ativar
intake-emails.actions.disable: Desativar
intake-emails.actions.manage-origins: Gerir endereços de origem
intake-emails.actions.delete: Eliminar
intake-emails.delete.confirm.title: Eliminar e-mail de receção?
intake-emails.delete.confirm.message: Tem a certeza de que quer eliminar este e-mail de receção? Esta ação não pode ser desfeita.
intake-emails.delete.confirm.confirm-button: Eliminar e-mail de receção
intake-emails.delete.confirm.cancel-button: Cancelar
intake-emails.delete.success: E-mail de receção eliminado
intake-emails.create.success: E-mail de receção criado
intake-emails.update.success.enabled: E-mail de receção ativado
intake-emails.update.success.disabled: E-mail de receção desativado
intake-emails.allowed-origins.title: Origens permitidas
intake-emails.allowed-origins.description: Apenas e-mails enviados para {{ email }} destas origens serão processados. Se nenhuma origem for especificada, todos os e-mails serão descartados.
intake-emails.allowed-origins.add.label: Adicionar e-mail de origem permitida
intake-emails.allowed-origins.add.placeholder: Ex. joao@papra.app
intake-emails.allowed-origins.add.button: Adicionar
intake-emails.allowed-origins.add.error.exists: Este e-mail já está nas origens permitidas para este e-mail de receção
# API keys
api-keys.permissions.documents.title: Documentos
api-keys.permissions.documents.documents:create: Criar documentos
api-keys.permissions.documents.documents:read: Ler documentos
api-keys.permissions.documents.documents:update: Atualizar documentos
api-keys.permissions.documents.documents:delete: Eliminar documentos
api-keys.permissions.tags.title: Etiquetas
api-keys.permissions.tags.tags:create: Criar etiquetas
api-keys.permissions.tags.tags:read: Ler etiquetas
api-keys.permissions.tags.tags:update: Atualizar etiquetas
api-keys.permissions.tags.tags:delete: Eliminar etiquetas
api-keys.create.title: Criar chave API
api-keys.create.description: Crie uma nova chave API para aceder à API do Papra.
api-keys.create.success: A chave API foi criada com sucesso.
api-keys.create.back: Voltar às chaves API
api-keys.create.form.name.label: Nome
api-keys.create.form.name.placeholder: 'Exemplo: A minha chave API'
api-keys.create.form.name.required: Por favor, introduza um nome para a chave API
api-keys.create.form.permissions.label: Permissões
api-keys.create.form.permissions.required: Por favor, selecione pelo menos uma permissão
api-keys.create.form.submit: Criar chave API
api-keys.create.created.title: Chave API criada
api-keys.create.created.description: A chave API foi criada com sucesso. Guarde-a num local seguro pois não será exibida novamente.
api-keys.list.title: Chaves API
api-keys.list.description: Gira as suas chaves API aqui.
api-keys.list.create: Criar chave API
api-keys.list.empty.title: Sem chaves API
api-keys.list.empty.description: Crie uma chave API para aceder à API do Papra.
api-keys.list.card.last-used: Última utilização
api-keys.list.card.never: Nunca
api-keys.list.card.created: Criado
api-keys.delete.success: A chave API foi eliminada com sucesso
api-keys.delete.confirm.title: Eliminar chave API
api-keys.delete.confirm.message: Tem a certeza de que quer eliminar esta chave API? Esta ação não pode ser desfeita.
api-keys.delete.confirm.confirm-button: Eliminar
api-keys.delete.confirm.cancel-button: Cancelar
# Webhooks
webhooks.list.title: Webhooks
webhooks.list.description: Gira os webhooks da sua organização
webhooks.list.empty.title: Nenhum webhook
webhooks.list.empty.description: Crie o seu primeiro webhook para começar a receber eventos
webhooks.list.create: Criar webhook
webhooks.list.card.last-triggered: Última ativação
webhooks.list.card.never: Nunca
webhooks.list.card.created: Criado em
webhooks.create.title: Criar webhook
webhooks.create.description: Crie um novo webhook para receber eventos
webhooks.create.success: Webhook criado com sucesso
webhooks.create.back: Voltar
webhooks.create.form.submit: Criar webhook
webhooks.create.form.name.label: Nome do webhook
webhooks.create.form.name.placeholder: Insira o nome do webhook
webhooks.create.form.name.required: O nome é obrigatório
webhooks.create.form.url.label: URL do Webhook
webhooks.create.form.url.placeholder: Insira o URL do webhook
webhooks.create.form.url.required: O URL é obrigatória
webhooks.create.form.url.invalid: URL inválido
webhooks.create.form.secret.label: Segredo
webhooks.create.form.secret.placeholder: Insira o segredo do webhook
webhooks.create.form.events.label: Eventos
webhooks.create.form.events.required: Adicione pelo menos um evento
webhooks.update.title: Editar webhook
webhooks.update.description: Atualize os detalhes do seu webhook
webhooks.update.success: Webhook atualizado com sucesso
webhooks.update.submit: Atualizar webhook
webhooks.update.cancel: Cancelar
webhooks.update.form.secret.placeholder: Insira um novo segredo
webhooks.update.form.secret.placeholder-redacted: '[Segredo ocultado]'
webhooks.update.form.rotate-secret.button: Rotacionar segredo
webhooks.delete.success: Webhook eliminado com sucesso
webhooks.delete.confirm.title: Eliminar webhook
webhooks.delete.confirm.message: Tem a certeza de que deseja eliminar este webhook?
webhooks.delete.confirm.confirm-button: Eliminar
webhooks.delete.confirm.cancel-button: Cancelar
webhooks.events.documents.document:created.description: Documento criado
webhooks.events.documents.document:deleted.description: Documento eliminado
# Navigation
layout.menu.home: Início
layout.menu.documents: Documentos
layout.menu.tags: Tags
layout.menu.tagging-rules: Regras de etiquetagem
layout.menu.deleted-documents: Documentos eliminados
layout.menu.organization-settings: Definições
layout.menu.api-keys: Chaves API
layout.menu.settings: Definições
layout.menu.account: Conta
layout.menu.general-settings: Definições gerais
layout.menu.intake-emails: E-mails de entrada
layout.menu.webhooks: Webhooks
layout.menu.members: Membros
layout.menu.invitations: Convites
layout.theme.light: Tema claro
layout.theme.dark: Tema escuro
layout.theme.system: Tema do sistema
layout.search.placeholder: Procurar...
layout.menu.import-document: Importar um documento
user-menu.account-settings: Definições da conta
user-menu.api-keys: Chaves API
user-menu.invitations: Convites
user-menu.language: Linguagem
user-menu.logout: Sair
# Command palette
command-palette.search.placeholder: Procurar comandos ou documentos
command-palette.no-results: Nenhum resultado encontrado
command-palette.sections.documents: Documentos
command-palette.sections.theme: Tema
# API errors
api-errors.document.already_exists: O documento já existe
api-errors.document.file_too_big: O arquivo do documento é muito grande
api-errors.intake_email.limit_reached: O número máximo de e-mails de entrada para esta organização foi atingido. Faça um upgrade no seu plano para criar mais e-mails de entrada.
api-errors.user.max_organization_count_reached: Atingiu o número máximo de organizações que pode criar. Se precisar de criar mais, entre em contato com o suporte.
api-errors.default: Ocorreu um erro ao processar a solicitação.
api-errors.organization.invitation_already_exists: Já existe um convite para este e-mail nesta organização.
api-errors.user.already_in_organization: Este utilizadpr já faz parte desta organização.
api-errors.user.organization_invitation_limit_reached: O número máximo de convites por hoje foi atingido. Por favor, tente novamente amanhã.
api-errors.demo.not_available: Este recurso não está disponível em ambiente de demonstração
api-errors.tags.already_exists: Já existe uma etiqueta com este nome nesta organização
# Not found
not-found.title: 404 - Página não encontrada
not-found.description: Desculpe, a página que procura não existe. Verifique o URL e tente novamente.
not-found.back-to-home: Voltar para a página inicial
# Demo
demo.popup.description: Este é um ambiente de demonstração; todos os dados são guardadis no armazenamento local do navegador.
demo.popup.discord: Entre no {{ discordLink }} para obter suporte, sugerir funcionalidades ou apenas conversar.
demo.popup.discord-link-label: Comunidade do Discord
demo.popup.reset: Redefinir dados da demonstração
demo.popup.hide: Ocultar
# Color picker
color-picker.hue: Matiz
color-picker.saturation: Saturação
color-picker.lightness: Brilho
color-picker.select-color: Selecionar cor
color-picker.select-a-color: Selecione uma cor

View File

@@ -0,0 +1,565 @@
# Authentication
auth.request-password-reset.title: Reseteaza parola
auth.request-password-reset.description: Introduceti adresa de email pentru a reseta parola.
auth.request-password-reset.requested: Daca exista un cont pentru acest email, am trimis un email cu linkul de resetare.
auth.request-password-reset.back-to-login: Inapoi la login
auth.request-password-reset.form.email.label: Email
auth.request-password-reset.form.email.placeholder: 'Exemplu: popescu@papra.app'
auth.request-password-reset.form.email.required: Introduceti adresa de email
auth.request-password-reset.form.email.invalid: Adresa email este invalida
auth.request-password-reset.form.submit: Trimite cererea de resetare a parolei
auth.reset-password.title: Reseteaza parola
auth.reset-password.description: Introdu o parola noua pentru a o reseta pe cea veche.
auth.reset-password.reset: Parola ta a fost resetata cu success.
auth.reset-password.back-to-login: Inapoi la login
auth.reset-password.form.new-password.label: Parola noua
auth.reset-password.form.new-password.placeholder: 'Exemplu: **********'
auth.reset-password.form.new-password.required: Introdu noua parola
auth.reset-password.form.new-password.min-length: Parola trebuie sa fie de minim {{ minLength }} de caractere
auth.reset-password.form.new-password.max-length: Parola trebuie sa fie de maxim {{ maxLength }} de caractere
auth.reset-password.form.submit: Reseteaza parola
auth.email-provider.open: Deschide {{ provider }}
auth.login.title: Inregistreaza-te pe Papra
auth.login.description: Introdu email ul pentru a accesa papra.
auth.login.login-with-provider: Inregistreaza-te cu {{ provider }}
auth.login.no-account: Nu ai un cont?
auth.login.register: Logheaza-te
auth.login.form.email.label: email
auth.login.form.email.placeholder: 'Exemplu: popescu@papra.app'
auth.login.form.email.required: Introduceti adresa de email
auth.login.form.email.invalid: Adresa email este invalida
auth.login.form.password.label: Parola
auth.login.form.password.placeholder: Seteaza o parola noua
auth.login.form.password.required: Introduceti parola noua
auth.login.form.remember-me.label: Nu ma uita
auth.login.form.forgot-password.label: Ai uitat parola?
auth.login.form.submit: Logheaza-te
auth.register.title: Inregistreaza-te pe Papra
auth.register.description: Introdu email ul pentru a accesa papra.
auth.register.register-with-email: Inregistreaza-te cu email
auth.register.register-with-provider: Inregistreaza-te cu {{ provider }}
auth.register.providers.google: Google
auth.register.providers.github: GitHub
auth.register.have-account: Ai deja un cont?
auth.register.login: Logheaza-te
auth.register.registration-disabled.title: Inregistrarea este dezactivata
auth.register.registration-disabled.description: Crearea de conturi noi este momentan dezactivata pe aceasta instanta de Papra. Doar utilizatorii cu conturi existente pot logheaza. Daca crezi ca e o greseala, contacteaza administratorul acestei instante.
auth.register.form.email.label: Email
auth.register.form.email.placeholder: 'Exemplu: popescu@papra.app'
auth.register.form.email.required: Introduceti adresa de email
auth.register.form.email.invalid: Adresa email este invalida
auth.register.form.password.label: Parola
auth.register.form.password.placeholder: Seteaza parola
auth.register.form.password.required: Te rugam sa introduci parola
auth.register.form.password.min-length: Parola trebuie sa fie de minim {{ minLength }} de caractere
auth.register.form.password.max-length: Parola trebuie sa fie de minim {{ maxLength }} de caractere
auth.register.form.name.label: Nume
auth.register.form.name.placeholder: 'Exemplu: Andrei Popescu'
auth.register.form.name.required: Introduce-ti numele
auth.register.form.name.max-length: Numele trebuie sa fie de minim {{ maxLength }} de caractere
auth.register.form.submit: Inregistreaza-te
auth.email-validation-required.title: Verifica-ti email-ul
auth.email-validation-required.description: A fost trimis un email de verificare la adresa de email introdusa. Verificati email-ul dumneavoastra si click pe link-ul din email.
auth.legal-links.description: Continuand, confirmati ca intelegeti si sunteti de acord cu {{ terms }} si {{ privacy }}.
auth.legal-links.terms: Termenii si conditiile
auth.legal-links.privacy: Politica de confidențialitate
auth.no-auth-provider.title: Niciun provider de autentificare nu este adaugat
auth.no-auth-provider.description: Nu exista nicio metoda de autentificare configurata. Contactati administratorul acestei instante pentru a adauga o metoda de autentificare.
# User settings
user.settings.title: Setarile tale
user.settings.description: Configureaza-ti setarile tale.
user.settings.email.title: Adresa email
user.settings.email.description: Adresa ta de email nu poate fi schimbata.
user.settings.email.label: Adresa email
user.settings.name.title: Numele dvs.
user.settings.name.description: Numele dvs. va fi distribuit cu persoanele din organizatia dvs.
user.settings.name.label: Numele dvs.
user.settings.name.placeholder: Ex. Andrei Popescu
user.settings.name.update: Schimba-ti numele
user.settings.name.updated: Numele tau s-a schimbat
user.settings.logout.title: Iesi din cont
user.settings.logout.description: Iesi din cont
user.settings.logout.button: Iesi din cont
# Organizations
organizations.list.title: Organizatiile dvs.
organizations.list.description: Organizatiile sunt o modalitate de a grupa documentele si de a le gestiona accesul la acestea. Poti crea multiple organizatii si invita membrii echipei tale sa colabora.
organizations.list.create-new: Creeaza o noua organizatie
organizations.details.no-documents.title: Niciun document
organizations.details.no-documents.description: Nu sunt documente in aceasta organizatie. Incepe prin uploadarea unei documente.
organizations.details.upload-documents: Incarca documente
organizations.details.documents-count: documente in total
organizations.details.total-size: marime totala
organizations.details.latest-documents: Ultimele documente incarcate
organizations.create.title: Creeaza o noua organizatie
organizations.create.description: Documentele dvs. sunt grupate pe organizatie. Puteti crea mai multe organizatii pentru documente diferite, de exemplu, pentru uz personal si profesional.
organizations.create.back: Inapoi
organizations.create.error.max-count-reached: Ai ajuns la numarul maxim de organizatii pe care le poti crea, daca ai nevoie de mai multe, contacteaza support ul.
organizations.create.form.name.label: Numle organizatiei
organizations.create.form.name.placeholder: Ex. Acme SRL.
organizations.create.form.name.required: Introdu numele organizatiei
organizations.create.form.submit: Creeaza organizatia
organizations.create.success: Organizatia a fost creata cu success
organizations.create-first.title: Creeaza organizatia
organizations.create-first.description: Documentele dvs. sunt grupate pe organizatie. Puteti crea mai multe organizatii pentru documente diferite, de exemplu, pentru uz personal si profesional.
organizations.create-first.default-name: Organizatia mea
organizations.create-first.user-name: 'Organizatia {{ name }}'
organization.settings.title: Setarile organizatiei
organization.settings.page.title: Setarile organizatiei
organization.settings.page.description: Gestioneaza setarile organizatiei tale aici.
organization.settings.name.title: Numele organizatiei
organization.settings.name.update: Actualizeaza numele
organization.settings.name.placeholder: Ex. Acme SRL.
organization.settings.name.updated: Numele organizatiei a fost actualizat
organization.settings.subscription.title: Subscriptie
organization.settings.subscription.description: Gestioneaza facturile, facturi si metodele de plata.
organization.settings.subscription.manage: Gestioneaza-ti subscriptia
organization.settings.subscription.error: Eroare la obtinerea URL-ului portalului client
organization.settings.delete.title: Sterge organizatie
organization.settings.delete.description: Stergerea acestei organizatii va elimina permanent toate datele asociate cu aceasta.
organization.settings.delete.confirm.title: Sterge organizatie
organization.settings.delete.confirm.message: Esti sigur ca vrei sa stergi aceasta organizatie? Aceasta operatie nu poate fi anulata si toate datele asociate cu aceasta vor fi eliminate permanent.
organization.settings.delete.confirm.confirm-button: Sterge organizatie
organization.settings.delete.confirm.cancel-button: Anuleaza
organization.settings.delete.success: Organizatie stearsa cu success
organizations.members.title: Membri
organizations.members.description: Gestioneaza membrii organizatiei tale
organizations.members.invite-member: Invita membru
organizations.members.invite-member-disabled-tooltip: Doar adminii sau proprietarii pot invita membrii la organizatie
organizations.members.remove-from-organization: Elimina din organizatie
organizations.members.role: Rol
organizations.members.roles.owner: Proprietar
organizations.members.roles.admin: Admin
organizations.members.roles.member: membru
organizations.members.delete.confirm.title: Eliminati membrul
organizations.members.delete.confirm.message: Esti sigur ca vrei sa stergi acest membru din organizatie?
organizations.members.delete.confirm.confirm-button: Elimina
organizations.members.delete.confirm.cancel-button: Anuleaza
organizations.members.delete.success: membru sters cu succes
organizations.members.update-role.success: Rolul membrului a fost actualizat
organizations.members.table.headers.name: Nume
organizations.members.table.headers.email: Email
organizations.members.table.headers.role: Rol
organizations.members.table.headers.created: Creat
organizations.members.table.headers.actions: Actiuni
organizations.invite-member.title: Invita membru
organizations.invite-member.description: Invita un membru la organizatie
organizations.invite-member.form.email.label: Email
organizations.invite-member.form.email.placeholder: 'Exemplu: ada@papra.app'
organizations.invite-member.form.email.required: Introduceti o adresa de email valida
organizations.invite-member.form.role.label: Rol
organizations.invite-member.form.submit: Invita membru
organizations.invite-member.success.message: membru invitat
organizations.invite-member.success.description: Adresa de email a fost invitata la organizatie.
organizations.invite-member.error.message: Eroare la invitatia membrului
organizations.invitations.title: Invitatii
organizations.invitations.description: Gestioneaza invitatii la organizatie
organizations.invitations.list.cta: Invita membru
organizations.invitations.list.empty.title: Niciun invitat
organizations.invitations.list.empty.description: Nu ai fost invitat la nicio organizatie inca.
organizations.invitations.status.pending: In asteptare
organizations.invitations.status.accepted: Acceptat
organizations.invitations.status.rejected: Refuzat
organizations.invitations.status.expired: Expirat
organizations.invitations.status.cancelled: Anulat
organizations.invitations.resend: Retrimite invitatia
organizations.invitations.cancel.title: Anuleaza invitatia
organizations.invitations.cancel.description: Esti sigur ca vrei sa anulezi aceasta invitatie?
organizations.invitations.cancel.confirm: Anuleaza invitatia
organizations.invitations.cancel.cancel: Anuleaza
organizations.invitations.resend.title: Retrimite invitatia
organizations.invitations.resend.description: Esti sigur ca vrei sa retrimiteti aceasta invitatie? Acest lucru va trimite un nou email destinatarului.
organizations.invitations.resend.confirm: Retrimite invitatia
organizations.invitations.resend.cancel: Anuleaza
invitations.list.title: Invitatii
invitations.list.description: Gestioneaza invitatii la organizatie
invitations.list.empty.title: Niciun invitat
invitations.list.empty.description: Nu ai fost invitat la nicio organizatie inca.
invitations.list.headers.organization: Organizatie
invitations.list.headers.status: Status
invitations.list.headers.created: Creat la
invitations.list.headers.actions: Actiuni
invitations.list.actions.accept: Accepta
invitations.list.actions.reject: Refuza
invitations.list.actions.accept.success.message: Invitatie acceptata
invitations.list.actions.accept.success.description: Invitatie a fost acceptata.
invitations.list.actions.reject.success.message: Invitatie refuzata
invitations.list.actions.reject.success.description: Invitatie a fost refuzata.
# Documents
documents.list.title: Documente
documents.list.no-documents.title: Niciun document
documents.list.no-documents.description: Nu exista documente in aceasta organizatie inca. Incepe prin a incarca cateva documente.
documents.list.no-results: Niciun document gasit
documents.tabs.info: Info
documents.tabs.content: Continut
documents.tabs.activity: Activitate
documents.deleted.message: Acest document a fost sters si va fi eliminat permanent in {{ days }} zile.
documents.actions.download: Descarca
documents.actions.open-in-new-tab: Deschide in fila noua
documents.actions.restore: Restaureaza
documents.actions.delete: Sterge
documents.actions.edit: Editeaza
documents.actions.cancel: Anuleaza
documents.actions.save: Salveaza
documents.actions.saving: Se salveaza...
documents.content.alert: Continutul documentului este extras automat din document la incarcare. Este folosit doar pentru cautare si indexare.
documents.info.id: ID
documents.info.name: Nume
documents.info.type: Tip
documents.info.size: Dimensiune
documents.info.created-at: Creat la
documents.info.updated-at: Actualizat la
documents.info.never: Niciodata
documents.rename.title: Redenumeste documentul
documents.rename.form.name.label: Nume
documents.rename.form.name.placeholder: 'Exemplu: Factura 2024'
documents.rename.form.name.required: Va rugam sa introduceti un nume pentru document
documents.rename.form.name.max-length: Numele trebuie sa aiba mai putin de 255 de caractere
documents.rename.form.submit: Redenumeste documentul
documents.rename.success: Document redenumit cu succes
documents.rename.cancel: Anuleaza
import-documents.title.error: '{{ count }} documente au esuat'
import-documents.title.success: '{{ count }} documente importate'
import-documents.title.pending: '{{ count }} / {{ total }} documente importate'
import-documents.title.none: Importa documente
import-documents.no-import-in-progress: Niciun import de documente in curs
documents.deleted.title: Documente sterse
documents.deleted.empty.title: Niciun document sters
documents.deleted.empty.description: Nu aveti documente sterse. Documentele care sunt sterse vor fi mutate in cosul de gunoi pentru {{ days }} zile.
documents.deleted.retention-notice: Toate documentele sterse sunt stocate in cosul de gunoi pentru {{ days }} zile. Dupa acest interval, documentele vor fi sterse permanent si nu le veti putea restaura.
documents.deleted.deleted-at: Sterse la
documents.deleted.restoring: Se restaureaza...
documents.deleted.deleting: Se sterge...
documents.preview.unknown-file-type: Nicio previzualizare disponibila pentru acest tip de fisier
documents.preview.binary-file: Acesta pare a fi un fisier binar si nu poate fi afisat ca text
trash.delete-all.button: Sterge tot
trash.delete-all.confirm.title: Stergeti permanent toate documentele?
trash.delete-all.confirm.description: Sunteti sigur ca doriti sa stergeti permanent toate documentele din cosul de gunoi? Aceasta actiune nu poate fi anulata.
trash.delete-all.confirm.label: Sterge
trash.delete-all.confirm.cancel: Anuleaza
trash.delete.button: Sterge
trash.delete.confirm.title: Stergeti permanent documentul?
trash.delete.confirm.description: Sunteti sigur ca doriti sa stergeti permanent acest document din cosul de gunoi? Aceasta actiune nu poate fi anulata.
trash.delete.confirm.label: Sterge
trash.delete.confirm.cancel: Anuleaza
trash.deleted.success.title: Document sters
trash.deleted.success.description: Documentul a fost sters permanent.
activity.document.created: Documentul a fost creat
activity.document.updated.single: Campul {{ field }} a fost actualizat
activity.document.updated.multiple: Campurile {{ fields }} au fost actualizate
activity.document.updated: Documentul a fost actualizat
activity.document.deleted: Documentul a fost sters
activity.document.restored: Documentul a fost restaurat
activity.document.tagged: Eticheta {{ tag }} a fost adaugata
activity.document.untagged: Eticheta {{ tag }} a fost eliminata
activity.document.user.name: de {{ name }}
activity.load-more: Incarca mai mult
activity.no-more-activities: Nu mai sunt activitati pentru acest document
# Tags
tags.no-tags.title: Inca nu exista etichete
tags.no-tags.description: Aceasta organizatie nu are inca etichete. Etichetele sunt folosite pentru a clasifica documentele. Puteti adauga etichete la documentele dvs. pentru a le gasi si organiza mai usor.
tags.no-tags.create-tag: Creeaza eticheta
tags.title: Etichete documente
tags.description: Etichetele sunt folosite pentru a clasifica documentele. Puteti adauga etichete la documentele dvs. pentru a le gasi si organiza mai usor.
tags.create: Creeaza eticheta
tags.update: Actualizeaza eticheta
tags.delete: Sterge eticheta
tags.delete.confirm.title: Sterge eticheta
tags.delete.confirm.message: Esti sigur ca vrei sa stergi aceasta eticheta? Stergerea unei etichete o va elimina din toate documentele.
tags.delete.confirm.confirm-button: Sterge
tags.delete.confirm.cancel-button: Anuleaza
tags.delete.success: Eticheta a fost stearsa cu succes
tags.create.success: Eticheta "{{ name }}" a fost creata cu succes.
tags.update.success: Eticheta "{{ name }}" a fost actualizata cu succes.
tags.form.name.label: Nume
tags.form.name.placeholder: Ex. Contracte
tags.form.name.required: Va rugam sa introduceti un nume de eticheta
tags.form.name.max-length: Numele etichetei trebuie sa aiba mai putin de 64 de caractere
tags.form.color.label: Culoare
tags.form.color.required: Va rugam sa introduceti o culoare
tags.form.color.invalid: Culoarea hex este formatata gresit.
tags.form.description.label: Descriere
tags.form.description.optional: (optional)
tags.form.description.placeholder: Ex. Toate contractele semnate de companie
tags.form.description.max-length: Descrierea trebuie sa aiba mai putin de 256 de caractere
tags.form.no-description: Nicio descriere
tags.table.headers.tag: Eticheta
tags.table.headers.description: Descriere
tags.table.headers.documents: Documente
tags.table.headers.created: Creat la
tags.table.headers.actions: Actiuni
# Tagging rules
tagging-rules.field.name: nume document
tagging-rules.field.content: continut document
tagging-rules.operator.equals: este egal cu
tagging-rules.operator.not-equals: nu este egal cu
tagging-rules.operator.contains: contine
tagging-rules.operator.not-contains: nu contine
tagging-rules.operator.starts-with: incepe cu
tagging-rules.operator.ends-with: se termina cu
tagging-rules.list.title: Reguli de etichetare
tagging-rules.list.description: Gestioneaza regulile de etichetare ale organizatiei tale, pentru a eticheta automat documentele pe baza conditiilor pe care le definesti.
tagging-rules.list.demo-warning: 'Nota: Deoarece acesta este un mediu demonstrativ (fara server), regulile de etichetare nu vor fi aplicate documentelor nou adaugate.'
tagging-rules.list.no-tagging-rules.title: Nicio regula de etichetare
tagging-rules.list.no-tagging-rules.description: Creati o regula de etichetare pentru a eticheta automat documentele adaugate pe baza conditiilor pe care le definiti.
tagging-rules.list.no-tagging-rules.create-tagging-rule: Creeaza regula de etichetare
tagging-rules.list.card.no-conditions: Nicio conditie
tagging-rules.list.card.one-condition: 1 conditie
tagging-rules.list.card.conditions: '{{ count }} conditii'
tagging-rules.list.card.delete: Sterge regula
tagging-rules.list.card.edit: Editeaza regula
tagging-rules.create.title: Creeaza regula de etichetare
tagging-rules.create.success: Regula de etichetare a fost creata cu succes
tagging-rules.create.error: Nu s-a putut crea regula de etichetare
tagging-rules.create.submit: Creeaza regula
tagging-rules.form.name.label: Nume
tagging-rules.form.name.placeholder: 'Exemplu: Eticheteaza facturile'
tagging-rules.form.name.min-length: Va rugam sa introduceti un nume pentru regula
tagging-rules.form.name.max-length: Numele trebuie sa aiba mai putin de 64 de caractere
tagging-rules.form.description.label: Descriere
tagging-rules.form.description.placeholder: "Exemplu: Eticheteaza documentele cu 'factura' in nume"
tagging-rules.form.description.max-length: Descrierea trebuie sa aiba mai putin de 256 de caractere
tagging-rules.form.conditions.label: Conditii
tagging-rules.form.conditions.description: Definiti conditiile care trebuie indeplinite pentru ca regula sa se aplice. Toate conditiile trebuie indeplinite pentru ca regula sa se aplice.
tagging-rules.form.conditions.add-condition: Adauga conditie
tagging-rules.form.conditions.no-conditions.title: Nicio conditie
tagging-rules.form.conditions.no-conditions.description: Nu ati adaugat nicio conditie acestei reguli. Aceasta regula va aplica etichetele sale tuturor documentelor.
tagging-rules.form.conditions.no-conditions.confirm: Aplica regula fara conditii
tagging-rules.form.conditions.no-conditions.cancel: Anuleaza
tagging-rules.form.conditions.value.placeholder: 'Exemplu: factura'
tagging-rules.form.conditions.value.min-length: Va rugam sa introduceti o valoare pentru conditie
tagging-rules.form.tags.label: Etichete
tagging-rules.form.tags.description: Selecteaza etichetele de aplicat documentelor adaugate care corespund conditiilor
tagging-rules.form.tags.min-length: Este necesara cel putin o eticheta de aplicat
tagging-rules.form.tags.add-tag: Creeaza eticheta
tagging-rules.form.submit: Creeaza regula
tagging-rules.update.title: Actualizeaza regula de etichetare
tagging-rules.update.error: Nu s-a putut actualiza regula de etichetare
tagging-rules.update.submit: Actualizeaza regula
tagging-rules.update.cancel: Anuleaza
# Intake emails
intake-emails.title: Email-uri de preluare
intake-emails.description: Adresele de email de preluare sunt folosite pentru a introduce automat email-uri in Papra. Doar trimiteti email-uri catre adresa de email de preluare, iar atasamentele lor vor fi adaugate la documentele organizatiei dvs.
intake-emails.disabled.title: Email-urile de preluare sunt dezactivate
intake-emails.disabled.description: Email-urile de preluare sunt dezactivate pe aceasta instanta. Va rugam sa contactati administratorul pentru a le activa. Consultati {{ documentation }} pentru mai multe informatii.
intake-emails.disabled.documentation: documentatie
intake-emails.info: Doar email-urile de preluare activate de la origini permise vor fi procesate. Puteti activa sau dezactiva un email de preluare oricand.
intake-emails.empty.title: Niciun email de preluare
intake-emails.empty.description: Generati o adresa de preluare pentru a ingera cu usurinta atasamentele de email.
intake-emails.empty.generate: Genereaza email de preluare
intake-emails.count: '{{ count }} email{{ plural }} de preluare pentru aceasta organizatie'
intake-emails.new: Email nou de preluare
intake-emails.disabled-label: (Dezactivat)
intake-emails.no-origins: Nicio origine de email permisa
intake-emails.allowed-origins: Permis de la {{ count }} adrese{{ plural }}
intake-emails.actions.enable: Activeaza
intake-emails.actions.disable: Dezactiveaza
intake-emails.actions.manage-origins: Gestioneaza adresele de origine
intake-emails.actions.delete: Sterge
intake-emails.delete.confirm.title: Sterge email-ul de preluare?
intake-emails.delete.confirm.message: Esti sigur ca vrei sa stergi acest email de preluare? Aceasta actiune nu poate fi anulata.
intake-emails.delete.confirm.confirm-button: Sterge email-ul de preluare
intake-emails.delete.confirm.cancel-button: Anuleaza
intake-emails.delete.success: Email de preluare sters
intake-emails.create.success: Email de preluare creat
intake-emails.update.success.enabled: Email de preluare activat
intake-emails.update.success.disabled: Email de preluare dezactivat
intake-emails.allowed-origins.title: Origini permise
intake-emails.allowed-origins.description: Doar email-urile trimise la {{ email }} de la aceste origini vor fi procesate. Daca nu sunt specificate origini, toate email-urile vor fi ignorate.
intake-emails.allowed-origins.add.label: Adauga adresa de email de origine permisa
intake-emails.allowed-origins.add.placeholder: Ex. ada@papra.app
intake-emails.allowed-origins.add.button: Adauga
intake-emails.allowed-origins.add.error.exists: Acest email este deja in originile permise pentru acest email de preluare
# API keys
api-keys.permissions.documents.title: Documente
api-keys.permissions.documents.documents:create: Creaza documente
api-keys.permissions.documents.documents:read: Citeste documente
api-keys.permissions.documents.documents:update: Actualizeaza documente
api-keys.permissions.documents.documents:delete: Sterge documente
api-keys.permissions.tags.title: Etichete
api-keys.permissions.tags.tags:create: Creaza etichete
api-keys.permissions.tags.tags:read: Citeste etichete
api-keys.permissions.tags.tags:update: Actualizeaza etichete
api-keys.permissions.tags.tags:delete: Sterge etichete
api-keys.create.title: Creeaza cheie API
api-keys.create.description: Creeaza o noua cheie API pentru a accesa API-ul Papra.
api-keys.create.success: Cheia API a fost creata cu succes.
api-keys.create.back: Inapoi la cheile API
api-keys.create.form.name.label: Nume
api-keys.create.form.name.placeholder: 'Exemplu: Cheia mea API'
api-keys.create.form.name.required: Va rugam sa introduceti un nume pentru cheia API
api-keys.create.form.permissions.label: Permisiuni
api-keys.create.form.permissions.required: Va rugam sa selectati cel putin o permisiune
api-keys.create.form.submit: Creeaza cheie API
api-keys.create.created.title: Cheie API creata
api-keys.create.created.description: Cheia API a fost creata cu succes. Salvati-o intr-o locatie sigura, deoarece nu va mai fi afisata.
api-keys.list.title: Chei API
api-keys.list.description: Gestioneaza-ti cheile API aici.
api-keys.list.create: Creeaza cheie API
api-keys.list.empty.title: Nicio cheie API
api-keys.list.empty.description: Creeaza o cheie API pentru a accesa API-ul Papra.
api-keys.list.card.last-used: Ultima utilizare
api-keys.list.card.never: Niciodata
api-keys.list.card.created: Creat la
api-keys.delete.success: Cheia API a fost stearsa cu succes
api-keys.delete.confirm.title: Sterge cheia API
api-keys.delete.confirm.message: Esti sigur ca vrei sa stergi aceasta cheie API? Aceasta actiune nu poate fi anulata.
api-keys.delete.confirm.confirm-button: Sterge
api-keys.delete.confirm.cancel-button: Anuleaza
# Webhooks
webhooks.list.title: Webhook-uri
webhooks.list.description: Gestioneaza webhook-urile organizatiei tale
webhooks.list.empty.title: Niciun webhook
webhooks.list.empty.description: Creeaza primul tau webhook pentru a incepe sa primesti evenimente
webhooks.list.create: Creeaza webhook
webhooks.list.card.last-triggered: Ultima declansare
webhooks.list.card.never: Niciodata
webhooks.list.card.created: Creat la
webhooks.create.title: Creeaza webhook
webhooks.create.description: Creeaza un nou webhook pentru a primi evenimente
webhooks.create.success: Webhook creat cu succes
webhooks.create.back: Inapoi
webhooks.create.form.submit: Creeaza webhook
webhooks.create.form.name.label: Nume webhook
webhooks.create.form.name.placeholder: Introdu numele webhook-ului
webhooks.create.form.name.required: Numele este obligatoriu
webhooks.create.form.url.label: URL webhook
webhooks.create.form.url.placeholder: Introdu URL-ul webhook-ului
webhooks.create.form.url.required: URL-ul este obligatoriu
webhooks.create.form.url.invalid: URL-ul este invalid
webhooks.create.form.secret.label: Secret
webhooks.create.form.secret.placeholder: Introdu secretul webhook-ului
webhooks.create.form.events.label: Evenimente
webhooks.create.form.events.required: Este necesar cel putin un eveniment
webhooks.update.title: Editeaza webhook
webhooks.update.description: Actualizeaza detaliile webhook-ului tau
webhooks.update.success: Webhook actualizat cu succes
webhooks.update.submit: Actualizeaza webhook
webhooks.update.cancel: Anuleaza
webhooks.update.form.secret.placeholder: Introdu un nou secret
webhooks.update.form.secret.placeholder-redacted: '[Secret redactat]'
webhooks.update.form.rotate-secret.button: Roteste secretul
webhooks.delete.success: Webhook sters cu succes
webhooks.delete.confirm.title: Sterge webhook
webhooks.delete.confirm.message: Esti sigur ca vrei sa stergi acest webhook?
webhooks.delete.confirm.confirm-button: Sterge
webhooks.delete.confirm.cancel-button: Anuleaza
webhooks.events.documents.document:created.description: Document creat
webhooks.events.documents.document:deleted.description: Document sters
# Navigation
layout.menu.home: Acasa
layout.menu.documents: Documente
layout.menu.tags: Etichete
layout.menu.tagging-rules: Reguli de etichetare
layout.menu.deleted-documents: Documente sterse
layout.menu.organization-settings: Setari organizatie
layout.menu.api-keys: Chei API
layout.menu.settings: Setari
layout.menu.account: Cont
layout.menu.general-settings: Setari generale
layout.menu.intake-emails: Email-uri de preluare
layout.menu.webhooks: Webhook-uri
layout.menu.members: Membri
layout.menu.invitations: Invitatii
layout.theme.light: Mod luminos
layout.theme.dark: Mod intunecat
layout.theme.system: Mod sistem
layout.search.placeholder: Cauta...
layout.menu.import-document: Importa un document
user-menu.account-settings: Setari cont
user-menu.api-keys: Chei API
user-menu.invitations: Invitatii
user-menu.language: Limba
user-menu.logout: Deconectare
# Command palette
command-palette.search.placeholder: Cauta comenzi sau documente
command-palette.no-results: Niciun rezultat gasit
command-palette.sections.documents: Documente
command-palette.sections.theme: Tema
# API errors
api-errors.document.already_exists: Documentul exista deja
api-errors.document.file_too_big: Fisierul documentului este prea mare
api-errors.intake_email.limit_reached: Numarul maxim de email-uri de preluare pentru aceasta organizatie a fost atins. Va rugam sa va imbunatatiti planul pentru a crea mai multe email-uri de preluare.
api-errors.user.max_organization_count_reached: Ai atins numarul maxim de organizatii pe care le poti crea, daca ai nevoie sa creezi mai multe, te rugam sa contactezi suportul.
api-errors.default: A aparut o eroare la procesarea cererii tale.
api-errors.organization.invitation_already_exists: O invitatie pentru acest email exista deja in aceasta organizatie.
api-errors.user.already_in_organization: Acest utilizator este deja in aceasta organizatie.
api-errors.user.organization_invitation_limit_reached: Numarul maxim de invitatii a fost atins pentru astazi. Va rugam sa incercati din nou maine.
api-errors.demo.not_available: Aceasta functie nu este disponibila in demo
api-errors.tags.already_exists: O eticheta cu acest nume exista deja pentru aceasta organizatie
# Not found
not-found.title: 404 - Nu a fost gasit
not-found.description: Ne pare rau, pagina pe care o cautati nu pare sa existe. Va rugam sa verificati URL-ul si sa incercati din nou.
not-found.back-to-home: Inapoi la pagina principala
# Demo
demo.popup.description: Acesta este un mediu demonstrativ, toate datele sunt salvate in stocarea locala a browserului dumneavoastra.
demo.popup.discord: Alaturati-va {{ discordLink }} pentru a obtine suport, a propune functionalitati sau doar pentru a discuta.
demo.popup.discord-link-label: server Discord
demo.popup.reset: Reseteaza datele demo
demo.popup.hide: Ascunde
# Color picker
color-picker.hue: Nuanta
color-picker.saturation: Saturatie
color-picker.lightness: Luminozitate
color-picker.select-color: Selecteaza culoarea
color-picker.select-a-color: Selecteaza o culoare

View File

@@ -0,0 +1,28 @@
// export const API_KEY_PERMISSIONS = {
// documents: {
// create: 'documents:create',
// },
// } as const;
export const API_KEY_PERMISSIONS = [
{
section: 'documents',
permissions: [
'documents:create',
'documents:read',
'documents:update',
'documents:delete',
],
},
{
section: 'tags',
permissions: [
'tags:create',
'tags:read',
'tags:update',
'tags:delete',
],
},
] as const;
export const API_KEY_PERMISSIONS_LIST = API_KEY_PERMISSIONS.flatMap(permission => permission.permissions);

View File

@@ -0,0 +1,56 @@
import type { ApiKey } from './api-keys.types';
import { apiClient } from '../shared/http/api-client';
import { coerceDates } from '../shared/http/http-client.models';
export async function createApiKey({
name,
permissions,
organizationIds,
allOrganizations,
expiresAt,
}: {
name: string;
permissions: string[];
organizationIds: string[];
allOrganizations: boolean;
expiresAt?: Date;
}) {
const { apiKey, token } = await apiClient<{
apiKey: ApiKey;
token: string;
}>({
path: '/api/api-keys',
method: 'POST',
body: {
name,
permissions,
organizationIds,
allOrganizations,
expiresAt,
},
});
return {
apiKey: coerceDates(apiKey),
token,
};
}
export async function fetchApiKeys() {
const { apiKeys } = await apiClient<{
apiKeys: ApiKey[];
}>({
path: '/api/api-keys',
});
return {
apiKeys: apiKeys.map(coerceDates),
};
}
export async function deleteApiKey({ apiKeyId }: { apiKeyId: string }) {
await apiClient({
path: `/api/api-keys/${apiKeyId}`,
method: 'DELETE',
});
}

View File

@@ -0,0 +1,12 @@
export type ApiKey = {
id: string;
name: string;
permissions: string[];
organizationIds: string[];
allOrganizations: boolean;
expiresAt?: Date;
prefix: string;
lastUsedAt?: Date;
createdAt: Date;
updatedAt: Date;
};

View File

@@ -0,0 +1,75 @@
import type { Component } from 'solid-js';
import type { LocaleKeys } from '@/modules/i18n/locales.types';
import { createSignal, For } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { Checkbox, CheckboxControl, CheckboxLabel } from '@/modules/ui/components/checkbox';
import { API_KEY_PERMISSIONS } from '../api-keys.constants';
export const ApiKeyPermissionsPicker: Component<{ permissions: string[]; onChange: (permissions: string[]) => void }> = (props) => {
const [permissions, setPermissions] = createSignal<string[]>(props.permissions);
const { t } = useI18n();
const getPermissionsSections = () => {
return API_KEY_PERMISSIONS.map(section => ({
...section,
title: t(`api-keys.permissions.${section.section}.title`),
permissions: section.permissions.map((permission) => {
const [prefix, suffix] = permission.split(':');
return {
name: permission,
prefix,
suffix,
description: t(`api-keys.permissions.${section.section}.${permission}` as LocaleKeys),
};
}),
}));
};
const isPermissionSelected = (permission: string) => {
return permissions().includes(permission);
};
const togglePermission = (permission: string) => {
setPermissions((prev) => {
if (prev.includes(permission)) {
return prev.filter(p => p !== permission);
}
return [...prev, permission];
});
props.onChange(permissions());
};
return (
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<For each={getPermissionsSections()}>
{section => (
<div>
<p class="text-muted-foreground text-xs">{section.title}</p>
<div class="pl-4 flex flex-col gap-4 mt-4">
<For each={section.permissions}>
{permission => (
<Checkbox
class="flex items-center gap-2"
checked={isPermissionSelected(permission.name)}
onChange={() => togglePermission(permission.name)}
>
<CheckboxControl />
<div class="flex flex-col gap-1">
<CheckboxLabel class="text-sm leading-none">
{permission.description}
</CheckboxLabel>
</div>
</Checkbox>
)}
</For>
</div>
</div>
)}
</For>
</div>
);
};

View File

@@ -0,0 +1,140 @@
import type { Component } from 'solid-js';
import type { ApiKey } from '../api-keys.types';
import { A } from '@solidjs/router';
import { useMutation, useQuery } from '@tanstack/solid-query';
import { format } from 'date-fns';
import { For, Match, Show, Suspense, Switch } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { useConfirmModal } from '@/modules/shared/confirm';
import { queryClient } from '@/modules/shared/query/query-client';
import { Button } from '@/modules/ui/components/button';
import { EmptyState } from '@/modules/ui/components/empty';
import { createToast } from '@/modules/ui/components/sonner';
import { deleteApiKey, fetchApiKeys } from '../api-keys.services';
export const ApiKeyCard: Component<{ apiKey: ApiKey }> = ({ apiKey }) => {
const { t } = useI18n();
const { confirm } = useConfirmModal();
const deleteApiKeyMutation = useMutation(() => ({
mutationFn: deleteApiKey,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['api-keys'] });
createToast({
message: t('api-keys.delete.success'),
});
},
}));
const handleDelete = async () => {
const confirmed = await confirm({
title: t('api-keys.delete.confirm.title'),
message: t('api-keys.delete.confirm.message'),
confirmButton: {
text: t('api-keys.delete.confirm.confirm-button'),
variant: 'destructive',
},
cancelButton: {
text: t('api-keys.delete.confirm.cancel-button'),
},
});
if (!confirmed) {
return;
}
deleteApiKeyMutation.mutate({ apiKeyId: apiKey.id });
};
return (
<div class="bg-card rounded-lg border p-4 flex items-center gap-4">
<div class="rounded-lg bg-muted p-2">
<div class="i-tabler-key text-muted-foreground size-5 text-primary" />
</div>
<div class="flex-1">
<h2 class="text-sm font-medium leading-tight">{apiKey.name}</h2>
<p class="text-muted-foreground text-xs font-mono">{`${apiKey.prefix}...`}</p>
</div>
<div>
<p class="text-muted-foreground text-xs">
{t('api-keys.list.card.last-used')}
{' '}
{apiKey.lastUsedAt ? format(apiKey.lastUsedAt, 'MMM d, yyyy') : t('api-keys.list.card.never')}
</p>
<p class="text-muted-foreground text-xs">
{t('api-keys.list.card.created')}
{' '}
{format(apiKey.createdAt, 'MMM d, yyyy')}
</p>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
isLoading={deleteApiKeyMutation.isPending}
onClick={handleDelete}
>
<div class="i-tabler-trash text-muted-foreground size-4" />
</Button>
</div>
</div>
);
};
export const ApiKeysPage: Component = () => {
const { t } = useI18n();
const query = useQuery(() => ({
queryKey: ['api-keys'],
queryFn: () => fetchApiKeys(),
}));
return (
<div class="p-6 mt-12 pb-32 mx-auto max-w-xl w-full">
<div class="border-b pb-4 flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold mb-1">{t('api-keys.list.title')}</h1>
<p class="text-muted-foreground">{t('api-keys.list.description')}</p>
</div>
<div>
<Show when={query.data?.apiKeys?.length}>
<Button as={A} href="/api-keys/create" class="gap-2">
<div class="i-tabler-plus size-4" />
{t('api-keys.list.create')}
</Button>
</Show>
</div>
</div>
<Suspense>
<Switch>
<Match when={query.data?.apiKeys?.length === 0}>
<EmptyState
title={t('api-keys.list.empty.title')}
description={t('api-keys.list.empty.description')}
icon="i-tabler-key"
cta={(
<Button as={A} href="/api-keys/create" class="gap-2">
<div class="i-tabler-plus size-4" />
{t('api-keys.list.create')}
</Button>
)}
/>
</Match>
<Match when={query.data?.apiKeys?.length}>
<div class="mt-6 flex flex-col gap-2">
<For each={query.data?.apiKeys}>
{apiKey => (
<ApiKeyCard apiKey={apiKey} />
)}
</For>
</div>
</Match>
</Switch>
</Suspense>
</div>
);
};

View File

@@ -0,0 +1,118 @@
import type { Component } from 'solid-js';
import { setValue } from '@modular-forms/solid';
import { A } from '@solidjs/router';
import { createSignal, Show } from 'solid-js';
import * as v from 'valibot';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createForm } from '@/modules/shared/form/form';
import { queryClient } from '@/modules/shared/query/query-client';
import { CopyButton } from '@/modules/shared/utils/copy';
import { Button } from '@/modules/ui/components/button';
import { createToast } from '@/modules/ui/components/sonner';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { API_KEY_PERMISSIONS_LIST } from '../api-keys.constants';
import { createApiKey } from '../api-keys.services';
import { ApiKeyPermissionsPicker } from '../components/api-key-permissions-picker.component';
export const CreateApiKeyPage: Component = () => {
const { t } = useI18n();
const [getToken, setToken] = createSignal<string | null>(null);
const { form, Form, Field } = createForm({
onSubmit: async ({ name, permissions }) => {
const { token } = await createApiKey({
name,
permissions,
organizationIds: [],
allOrganizations: false,
});
await queryClient.invalidateQueries({ queryKey: ['api-keys'] });
setToken(token);
createToast({
type: 'success',
message: t('api-keys.create.success'),
});
},
schema: v.object({
name: v.pipe(
v.string(),
v.nonEmpty(t('api-keys.create.form.name.required')),
),
permissions: v.pipe(
v.array(v.picklist(API_KEY_PERMISSIONS_LIST as string[])),
v.nonEmpty(t('api-keys.create.form.permissions.required')),
),
}),
initialValues: {
name: '',
permissions: API_KEY_PERMISSIONS_LIST,
},
});
return (
<div class="p-6 mt-12 pb-32 mx-auto max-w-xl w-full">
<div class="border-b pb-4 mb-6">
<h1 class="text-2xl font-bold">{t('api-keys.create.title')}</h1>
<p class="text-sm text-muted-foreground">{t('api-keys.create.description')}</p>
</div>
<Show when={getToken()}>
<div class="bg-card border p-6 rounded-md mt-6">
<h2 class="text-lg font-semibold mb-2">{t('api-keys.create.created.title')}</h2>
<p class="text-sm text-muted-foreground mb-4">{t('api-keys.create.created.description')}</p>
<TextFieldRoot class="flex items-center gap-2 space-y-0">
<TextField type="text" placeholder={t('api-keys.create.form.name.placeholder')} value={getToken() ?? ''} />
<CopyButton text={getToken() ?? ''} />
</TextFieldRoot>
</div>
<div class="flex justify-end mt-6">
<Button type="button" variant="secondary" as={A} href="/api-keys">
{t('api-keys.create.back')}
</Button>
</div>
</Show>
<Show when={!getToken()}>
<Form>
<Field name="name">
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col mb-6">
<TextFieldLabel for="name">{t('api-keys.create.form.name.label')}</TextFieldLabel>
<TextField type="text" id="name" placeholder={t('api-keys.create.form.name.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</TextFieldRoot>
)}
</Field>
<Field name="permissions" type="string[]">
{field => (
<div>
<p class="text-sm font-bold">{t('api-keys.create.form.permissions.label')}</p>
<div class="p-6 pb-8 border rounded-md mt-2">
<ApiKeyPermissionsPicker permissions={field.value ?? []} onChange={permissions => setValue(form, 'permissions', permissions)} />
</div>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</div>
)}
</Field>
<div class="flex justify-end mt-6">
<Button type="submit" isLoading={form.submitting}>
{t('api-keys.create.form.submit')}
</Button>
</div>
</Form>
</Show>
</div>
);
};

View File

@@ -1,4 +1,5 @@
import type { Config } from '../config/config';
import type { SsoProviderConfig } from './auth.types';
import { get } from 'lodash-es';
import { ssoProviders } from './auth.constants';
@@ -8,8 +9,15 @@ export function isAuthErrorWithCode({ error, code }: { error: unknown; code: str
export const isEmailVerificationRequiredError = ({ error }: { error: unknown }) => isAuthErrorWithCode({ error, code: 'EMAIL_NOT_VERIFIED' });
export function getEnabledSsoProviderConfigs({ config }: { config: Config }) {
const enabledSsoProviders = ssoProviders.filter(({ key }) => get(config, `auth.providers.${key}.isEnabled`));
export function getEnabledSsoProviderConfigs({ config }: { config: Config }): SsoProviderConfig[] {
const enabledSsoProviders: SsoProviderConfig[] = [
...ssoProviders.filter(({ key }) => get(config, `auth.providers.${key}.isEnabled`)),
...config.auth.providers.customs.map(({ providerId, providerName, providerIconUrl }) => ({
key: providerId,
name: providerName,
icon: providerIconUrl ?? 'i-tabler-login-2',
})),
];
return enabledSsoProviders;
}

View File

@@ -1,5 +1,8 @@
import { createAuthClient as createBetterAuthClient } from 'better-auth/solid';
import type { Config } from '../config/config';
import type { SsoProviderConfig } from './auth.types';
import { genericOAuthClient } from 'better-auth/client/plugins';
import { createAuthClient as createBetterAuthClient } from 'better-auth/solid';
import { buildTimeConfig } from '../config/config';
import { trackingServices } from '../tracking/tracking.services';
import { createDemoAuthClient } from './auth.demo.services';
@@ -7,6 +10,9 @@ import { createDemoAuthClient } from './auth.demo.services';
export function createAuthClient() {
const client = createBetterAuthClient({
baseURL: buildTimeConfig.baseApiUrl,
plugins: [
genericOAuthClient(),
],
});
return {
@@ -38,3 +44,17 @@ export const {
} = buildTimeConfig.isDemoMode
? createDemoAuthClient()
: createAuthClient();
export async function authWithProvider({ provider, config }: { provider: SsoProviderConfig; config: Config }) {
const isCustomProvider = config.auth.providers.customs.some(({ providerId }) => providerId === provider.key);
if (isCustomProvider) {
signIn.oauth2({
providerId: provider.key,
callbackURL: config.baseUrl,
});
return;
}
await signIn.social({ provider: provider.key as 'github' | 'google', callbackURL: config.baseUrl });
}

View File

@@ -1,3 +1,4 @@
import type { ssoProviders } from './auth.constants';
export type SsoProviderKey = (typeof ssoProviders)[number]['key'];
export type SsoProviderKey = (typeof ssoProviders)[number]['key'] | string & {};
export type SsoProviderConfig = { key: SsoProviderKey; name: string; icon: string };

View File

@@ -1,9 +1,9 @@
import type { Component } from 'solid-js';
import { A } from '@solidjs/router';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createVitrineUrl } from '@/modules/shared/utils/urls';
import { Button } from '@/modules/ui/components/button';
import { A } from '@solidjs/router';
export const AuthLegalLinks: Component = () => {
const { config } = useConfig();

View File

@@ -0,0 +1,17 @@
import type { Component } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
export const NoAuthProviderWarning: Component = () => {
const { t } = useI18n();
return (
<div class="flex items-center justify-center h-full p-6 sm:pb-32">
<div class="max-w-sm w-full">
<h1 class="text-lg font-bold">{t('auth.no-auth-provider.title')}</h1>
<p class="text-muted-foreground mt-1 mb-4">
{t('auth.no-auth-provider.description')}
</p>
</div>
</div>
);
};

View File

@@ -1,6 +1,8 @@
import type { Component, ComponentProps } from 'solid-js';
import { splitProps } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { cn } from '@/modules/shared/style/cn';
import { Button } from '@/modules/ui/components/button';
import { type Component, type ComponentProps, splitProps } from 'solid-js';
const providers = [
{
@@ -246,6 +248,7 @@ export function getEmailProvider({ email }: { email?: string }) {
export const OpenEmailProvider: Component<{ email?: string } & ComponentProps<typeof Button>> = (props) => {
const [local, rest] = splitProps(props, ['email', 'class']);
const { t } = useI18n();
const { provider } = getEmailProvider({ email: local.email });
@@ -256,9 +259,7 @@ export const OpenEmailProvider: Component<{ email?: string } & ComponentProps<ty
return (
<Button as="a" href={provider.url} target="_blank" rel="noopener noreferrer" class={cn('w-full', local.class)} {...rest}>
<div class="i-tabler-external-link mr-2 size-4" />
Open
{' '}
{provider.name}
{t('auth.email-provider.open', { provider: provider.name })}
</Button>
);
};

View File

@@ -1,19 +1,33 @@
import type { Component } from 'solid-js';
import { createSignal, Match, Switch } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { Button } from '@/modules/ui/components/button';
import { createSignal } from 'solid-js';
export const SsoProviderButton: Component<{ name: string; icon: string; onClick: () => Promise<void>; label: string }> = (props) => {
export const SsoProviderButton: Component<{ name: string; icon?: string; onClick: () => Promise<void>; label: string }> = (props) => {
const [getIsLoading, setIsLoading] = createSignal(false);
const navigateToProvider = async () => {
const onClick = async () => {
setIsLoading(true);
await props.onClick();
};
return (
<Button variant="secondary" class="block w-full flex items-center justify-center" onClick={navigateToProvider} disabled={getIsLoading()}>
<span class={cn(`mr-2 size-4.5 inline-block`, getIsLoading() ? 'i-tabler-loader-2 animate-spin' : props.icon)} />
<Button variant="secondary" class="block w-full flex items-center justify-center gap-2" onClick={onClick} disabled={getIsLoading()}>
<Switch>
<Match when={getIsLoading()}>
<span class="i-tabler-loader-2 animate-spin" />
</Match>
<Match when={props.icon?.startsWith('i-')}>
<span class={cn(`size-4.5`, props.icon)} />
</Match>
<Match when={props.icon}>
<img src={props.icon} alt={props.name} class="size-4.5" />
</Match>
</Switch>
{props.label}
</Button>
);

View File

@@ -1,5 +1,6 @@
import type { Component } from 'solid-js';
import { Navigate } from '@solidjs/router';
import { type Component, Suspense } from 'solid-js';
import { Suspense } from 'solid-js';
import { Dynamic } from 'solid-js/web';
import { match } from 'ts-pattern';
import { useSession } from '../auth.services';

View File

@@ -1,4 +1,8 @@
import type { SsoProviderKey } from '../auth.types';
import type { Component } from 'solid-js';
import type { SsoProviderConfig } from '../auth.types';
import { A, useNavigate } from '@solidjs/router';
import { createSignal, For, Show } from 'solid-js';
import * as v from 'valibot';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createForm } from '@/modules/shared/form/form';
@@ -6,18 +10,17 @@ import { Button } from '@/modules/ui/components/button';
import { Checkbox, CheckboxControl, CheckboxLabel } from '@/modules/ui/components/checkbox';
import { Separator } from '@/modules/ui/components/separator';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { A, useNavigate } from '@solidjs/router';
import { type Component, createSignal, For, Show } from 'solid-js';
import * as v from 'valibot';
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
import { getEnabledSsoProviderConfigs, isEmailVerificationRequiredError } from '../auth.models';
import { signIn } from '../auth.services';
import { authWithProvider, signIn } from '../auth.services';
import { AuthLegalLinks } from '../components/legal-links.component';
import { NoAuthProviderWarning } from '../components/no-auth-provider';
import { SsoProviderButton } from '../components/sso-provider-button.component';
export const EmailLoginForm: Component = () => {
const navigate = useNavigate();
const { config } = useConfig();
const { t } = useI18n();
const { form, Form, Field } = createForm({
onSubmit: async ({ email, password, rememberMe }) => {
@@ -35,12 +38,12 @@ export const EmailLoginForm: Component = () => {
email: v.pipe(
v.string(),
v.trim(),
v.nonEmpty('Please enter your email address'),
v.email('This is not a valid email address'),
v.nonEmpty(t('auth.login.form.email.required')),
v.email(t('auth.login.form.email.invalid')),
),
password: v.pipe(
v.string('Password is required'),
v.nonEmpty('Please enter your password'),
v.string(t('auth.login.form.password.required')),
v.nonEmpty(t('auth.login.form.password.required')),
),
rememberMe: v.boolean(),
}),
@@ -54,8 +57,8 @@ export const EmailLoginForm: Component = () => {
<Field name="email">
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col gap-1 mb-4">
<TextFieldLabel for="email">Email</TextFieldLabel>
<TextField type="email" id="email" placeholder="Eg. ada@papra.app" {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
<TextFieldLabel for="email">{t('auth.login.form.email.label')}</TextFieldLabel>
<TextField type="email" id="email" placeholder={t('auth.login.form.email.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</TextFieldRoot>
)}
@@ -64,9 +67,9 @@ export const EmailLoginForm: Component = () => {
<Field name="password">
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col gap-1 mb-4">
<TextFieldLabel for="password">Password</TextFieldLabel>
<TextFieldLabel for="password">{t('auth.login.form.password.label')}</TextFieldLabel>
<TextField type="password" id="password" placeholder="Your password" {...inputProps} value={field.value} aria-invalid={Boolean(field.error)} />
<TextField type="password" id="password" placeholder={t('auth.login.form.password.placeholder')} {...inputProps} value={field.value} aria-invalid={Boolean(field.error)} />
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</TextFieldRoot>
)}
@@ -78,18 +81,20 @@ export const EmailLoginForm: Component = () => {
<Checkbox class="flex items-center gap-2" defaultChecked={field.value}>
<CheckboxControl inputProps={inputProps} />
<CheckboxLabel class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
Remember me
{t('auth.login.form.remember-me.label')}
</CheckboxLabel>
</Checkbox>
)}
</Field>
<Button variant="link" as={A} class="inline p-0! h-auto" href="/request-password-reset">
Forgot password?
</Button>
<Show when={config.auth.isPasswordResetEnabled}>
<Button variant="link" as={A} class="inline p-0! h-auto" href="/request-password-reset">
{t('auth.login.form.forgot-password.label')}
</Button>
</Show>
</div>
<Button type="submit" class="w-full">Login</Button>
<Button type="submit" class="w-full">{t('auth.login.form.submit')}</Button>
<div class="text-red-500 text-sm mt-4">{form.response.message}</div>
@@ -101,14 +106,18 @@ export const LoginPage: Component = () => {
const { config } = useConfig();
const { t } = useI18n();
const [getShowEmailLogin, setShowEmailLogin] = createSignal(false);
const [getShowEmailLoginForm, setShowEmailLoginForm] = createSignal(false);
const loginWithProvider = async (provider: { key: SsoProviderKey }) => {
await signIn.social({ provider: provider.key, callbackURL: config.baseUrl });
const loginWithProvider = async (provider: SsoProviderConfig) => {
await authWithProvider({ provider, config });
};
const getHasSsoProviders = () => getEnabledSsoProviderConfigs({ config }).length > 0;
if (!config.auth.providers.email.isEnabled && !getHasSsoProviders()) {
return <AuthLayout><NoAuthProviderWarning /></AuthLayout>;
}
return (
<AuthLayout>
<div class="flex items-center justify-center h-full p-6 sm:pb-32">
@@ -116,17 +125,22 @@ export const LoginPage: Component = () => {
<h1 class="text-xl font-bold">{t('auth.login.title')}</h1>
<p class="text-muted-foreground mt-1 mb-4">{t('auth.login.description')}</p>
{getShowEmailLogin() || !getHasSsoProviders()
? <EmailLoginForm />
: (
<Button onClick={() => setShowEmailLogin(true)} class="w-full">
<div class="i-tabler-mail mr-2 size-4.5" />
{t('auth.login.login-with-provider', { provider: 'Email' })}
</Button>
)}
<Show when={config.auth.providers.email.isEnabled}>
{getShowEmailLoginForm() || !getHasSsoProviders()
? <EmailLoginForm />
: (
<Button onClick={() => setShowEmailLoginForm(true)} class="w-full">
<div class="i-tabler-mail mr-2 size-4.5" />
{t('auth.login.login-with-provider', { provider: 'Email' })}
</Button>
)}
</Show>
<Show when={config.auth.providers.email.isEnabled && getHasSsoProviders()}>
<Separator class="my-4" />
</Show>
<Show when={getHasSsoProviders()}>
<Separator class="my-4" />
<div class="flex flex-col gap-2">
<For each={getEnabledSsoProviderConfigs({ config })}>

View File

@@ -1,22 +1,25 @@
import type { ssoProviders } from '../auth.constants';
import type { Component } from 'solid-js';
import type { SsoProviderConfig } from '../auth.types';
import { A, useNavigate } from '@solidjs/router';
import { createSignal, For, Show } from 'solid-js';
import * as v from 'valibot';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createForm } from '@/modules/shared/form/form';
import { Button } from '@/modules/ui/components/button';
import { Separator } from '@/modules/ui/components/separator';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { A, useNavigate } from '@solidjs/router';
import { type Component, createSignal, For, Show } from 'solid-js';
import * as v from 'valibot';
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
import { getEnabledSsoProviderConfigs } from '../auth.models';
import { signIn, signUp } from '../auth.services';
import { authWithProvider, signUp } from '../auth.services';
import { AuthLegalLinks } from '../components/legal-links.component';
import { NoAuthProviderWarning } from '../components/no-auth-provider';
import { SsoProviderButton } from '../components/sso-provider-button.component';
export const EmailRegisterForm: Component = () => {
const { config } = useConfig();
const navigate = useNavigate();
const { t } = useI18n();
const { form, Form, Field } = createForm({
onSubmit: async ({ email, password, name }) => {
const { error } = await signUp.email({
@@ -41,19 +44,19 @@ export const EmailRegisterForm: Component = () => {
email: v.pipe(
v.string(),
v.trim(),
v.nonEmpty('Please enter an email address'),
v.email('This is not a valid email address'),
v.nonEmpty(t('auth.register.form.email.required')),
v.email(t('auth.register.form.email.invalid')),
),
password: v.pipe(
v.string('Password is required'),
v.nonEmpty('Please enter a password'),
v.minLength(8, 'Password must be at least 8 characters'),
v.maxLength(128, 'Password must be at most 128 characters'),
v.string(),
v.nonEmpty(t('auth.register.form.password.required')),
v.minLength(8, t('auth.register.form.password.min-length', { minLength: 8 })),
v.maxLength(128, t('auth.register.form.password.max-length', { maxLength: 128 })),
),
name: v.pipe(
v.string('Name is required'),
v.nonEmpty('Please enter a name'),
v.maxLength(64, 'Name must be at most 64 characters'),
v.string(t('auth.register.form.name.label')),
v.nonEmpty(t('auth.register.form.name.required')),
v.maxLength(64, t('auth.register.form.name.max-length', { maxLength: 64 })),
),
}),
});
@@ -63,8 +66,8 @@ export const EmailRegisterForm: Component = () => {
<Field name="email">
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col gap-1 mb-4">
<TextFieldLabel for="email">Email</TextFieldLabel>
<TextField type="email" id="email" placeholder="Eg. ada@papra.app" {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
<TextFieldLabel for="email">{t('auth.register.form.email.label')}</TextFieldLabel>
<TextField type="email" id="email" placeholder={t('auth.register.form.email.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</TextFieldRoot>
)}
@@ -73,8 +76,8 @@ export const EmailRegisterForm: Component = () => {
<Field name="name">
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col gap-1 mb-4">
<TextFieldLabel for="name">Your full name</TextFieldLabel>
<TextField type="text" id="name" placeholder="Eg. Ada Lovelace" {...inputProps} value={field.value} aria-invalid={Boolean(field.error)} />
<TextFieldLabel for="name">{t('auth.register.form.name.label')}</TextFieldLabel>
<TextField type="text" id="name" placeholder={t('auth.register.form.name.placeholder')} {...inputProps} value={field.value} aria-invalid={Boolean(field.error)} />
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</TextFieldRoot>
)}
@@ -83,15 +86,15 @@ export const EmailRegisterForm: Component = () => {
<Field name="password">
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col gap-1 mb-4">
<TextFieldLabel for="password">Password</TextFieldLabel>
<TextFieldLabel for="password">{t('auth.register.form.password.label')}</TextFieldLabel>
<TextField type="password" id="password" placeholder="Your password" {...inputProps} value={field.value} aria-invalid={Boolean(field.error)} />
<TextField type="password" id="password" placeholder={t('auth.register.form.password.placeholder')} {...inputProps} value={field.value} aria-invalid={Boolean(field.error)} />
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</TextFieldRoot>
)}
</Field>
<Button type="submit" class="w-full">Register</Button>
<Button type="submit" class="w-full">{t('auth.register.form.submit')}</Button>
<div class="text-red-500 text-sm mt-4">{form.response.message}</div>
@@ -101,6 +104,7 @@ export const EmailRegisterForm: Component = () => {
export const RegisterPage: Component = () => {
const { config } = useConfig();
const { t } = useI18n();
if (!config.auth.isRegistrationEnabled) {
return (
@@ -108,17 +112,17 @@ export const RegisterPage: Component = () => {
<div class="flex items-center justify-center h-full p-6 sm:pb-32">
<div class="max-w-sm w-full">
<h1 class="text-xl font-bold">
Registration is disabled
{t('auth.register.registration-disabled.title')}
</h1>
<p class="text-muted-foreground mt-1 mb-4">
The creation of new accounts is currently disabled on this instance of Papra. Only users with existing accounts can log in. If you think this is a mistake, please contact the administrator of this instance.
{t('auth.register.registration-disabled.description')}
</p>
<p class="text-muted-foreground mt-4">
Already have an account?
{t('auth.register.have-account')}
{' '}
<Button variant="link" as={A} class="inline px-0" href="/login">
Login
{t('auth.register.login')}
</Button>
</p>
@@ -130,49 +134,63 @@ export const RegisterPage: Component = () => {
const [getShowEmailRegister, setShowEmailRegister] = createSignal(false);
const registerWithProvider = async (provider: typeof ssoProviders[number]) => {
await signIn.social({ provider: provider.key });
const registerWithProvider = async (provider: SsoProviderConfig) => {
await authWithProvider({ provider, config });
};
const getHasSsoProviders = () => getEnabledSsoProviderConfigs({ config }).length > 0;
if (!config.auth.providers.email.isEnabled && !getHasSsoProviders()) {
return <AuthLayout><NoAuthProviderWarning /></AuthLayout>;
}
return (
<AuthLayout>
<div class="flex items-center justify-center h-full p-6 sm:pb-32">
<div class="max-w-sm w-full">
<h1 class="text-xl font-bold">
Register to Papra
{t('auth.register.title')}
</h1>
<p class="text-muted-foreground mt-1 mb-4">
Enter your email or use social login to create your Papra account.
{t('auth.register.description')}
</p>
{getShowEmailRegister() || !getHasSsoProviders()
? <EmailRegisterForm />
: (
<Button onClick={() => setShowEmailRegister(true)} class="w-full">
<div class="i-tabler-mail mr-2 size-4.5" />
Register with email
</Button>
)}
<Show when={config.auth.providers.email.isEnabled}>
{getShowEmailRegister() || !getHasSsoProviders()
? <EmailRegisterForm />
: (
<Button onClick={() => setShowEmailRegister(true)} class="w-full">
<div class="i-tabler-mail mr-2 size-4.5" />
{t('auth.register.register-with-email')}
</Button>
)}
</Show>
<Show when={config.auth.providers.email.isEnabled && getHasSsoProviders()}>
<Separator class="my-4" />
</Show>
<Show when={getHasSsoProviders()}>
<Separator class="my-4" />
<div class="flex flex-col gap-2">
<For each={getEnabledSsoProviderConfigs({ config })}>
{provider => (
<SsoProviderButton name={provider.name} icon={provider.icon} onClick={() => registerWithProvider(provider)} label={`Register with ${provider.name}`} />
<SsoProviderButton
name={provider.name}
icon={provider.icon}
onClick={() => registerWithProvider(provider)}
label={t('auth.register.register-with-provider', { provider: provider.name })}
/>
)}
</For>
</div>
</Show>
<p class="text-muted-foreground mt-4">
Already have an account?
{t('auth.register.have-account')}
{' '}
<Button variant="link" as={A} class="inline px-0" href="/login">
Login
{t('auth.register.login')}
</Button>
</p>

View File

@@ -1,24 +1,28 @@
import type { Component } from 'solid-js';
import { buildUrl } from '@corentinth/chisels';
import { A, useNavigate } from '@solidjs/router';
import { createSignal, onMount } from 'solid-js';
import * as v from 'valibot';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createForm } from '@/modules/shared/form/form';
import { Button } from '@/modules/ui/components/button';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { buildUrl } from '@corentinth/chisels';
import { A, useNavigate } from '@solidjs/router';
import { type Component, createSignal, onMount } from 'solid-js';
import * as v from 'valibot';
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
import { forgetPassword } from '../auth.services';
import { OpenEmailProvider } from '../components/open-email-provider.component';
export const ResetPasswordForm: Component<{ onSubmit: (args: { email: string }) => Promise<void> }> = (props) => {
const { t } = useI18n();
const { form, Form, Field } = createForm({
onSubmit: props.onSubmit,
schema: v.object({
email: v.pipe(
v.string(),
v.trim(),
v.nonEmpty('Please enter your email address'),
v.email('This is not a valid email address'),
v.nonEmpty(t('auth.request-password-reset.form.email.required')),
v.email(t('auth.request-password-reset.form.email.invalid')),
),
}),
});
@@ -28,15 +32,15 @@ export const ResetPasswordForm: Component<{ onSubmit: (args: { email: string })
<Field name="email">
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col gap-1 mb-4">
<TextFieldLabel for="email">Email</TextFieldLabel>
<TextField type="email" id="email" placeholder="Eg. ada@papra.app" {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
<TextFieldLabel for="email">{t('auth.request-password-reset.form.email.label')}</TextFieldLabel>
<TextField type="email" id="email" placeholder={t('auth.request-password-reset.form.email.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</TextFieldRoot>
)}
</Field>
<Button type="submit" class="w-full">
Request password reset
{t('auth.request-password-reset.form.submit')}
</Button>
<div class="text-red-500 text-sm mt-2">{form.response.message}</div>
@@ -49,11 +53,12 @@ export const RequestPasswordResetPage: Component = () => {
const [getHasPasswordResetBeenRequested, setHasPasswordResetBeenRequested] = createSignal(false);
const [getEmail, setEmail] = createSignal<string | undefined>(undefined);
const { t } = useI18n();
const { config } = useConfig();
const navigate = useNavigate();
onMount(() => {
if (config.auth.isPasswordResetEnabled) {
if (!config.auth.isPasswordResetEnabled || !config.auth.providers.email.isEnabled) {
navigate('/login');
}
});
@@ -80,14 +85,14 @@ export const RequestPasswordResetPage: Component = () => {
<div class="flex items-center justify-center p-6 sm:pb-32">
<div class="max-w-sm w-full">
<h1 class="text-xl font-bold">
Reset your password
{t('auth.request-password-reset.title')}
</h1>
{getHasPasswordResetBeenRequested()
? (
<>
<div class="text-muted-foreground mt-1 mb-4">
If an account exists for this email, we've sent you an email to reset your password.
{t('auth.request-password-reset.requested')}
</div>
<OpenEmailProvider email={getEmail()} variant="secondary" class="w-full mb-4" />
@@ -96,7 +101,7 @@ export const RequestPasswordResetPage: Component = () => {
: (
<>
<p class="text-muted-foreground mt-1 mb-4">
Enter your email to reset your password.
{t('auth.request-password-reset.description')}
</p>
<ResetPasswordForm onSubmit={onPasswordResetRequested} />
@@ -105,7 +110,7 @@ export const RequestPasswordResetPage: Component = () => {
<Button as={A} href="/login" class="w-full" variant={getHasPasswordResetBeenRequested() ? 'default' : 'ghost'}>
<div class="i-tabler-arrow-left mr-2 size-4" />
Back to login
{t('auth.request-password-reset.back-to-login')}
</Button>
</div>
</div>

View File

@@ -1,23 +1,26 @@
import type { Component } from 'solid-js';
import { A, Navigate, useNavigate, useSearchParams } from '@solidjs/router';
import { createSignal, onMount } from 'solid-js';
import * as v from 'valibot';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createForm } from '@/modules/shared/form/form';
import { Button } from '@/modules/ui/components/button';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { A, Navigate, useNavigate, useSearchParams } from '@solidjs/router';
import { type Component, createSignal } from 'solid-js';
import { onMount } from 'solid-js';
import * as v from 'valibot';
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
import { resetPassword } from '../auth.services';
export const ResetPasswordForm: Component<{ onSubmit: (args: { newPassword: string }) => Promise<void> }> = (props) => {
const { t } = useI18n();
const { form, Form, Field } = createForm({
onSubmit: props.onSubmit,
schema: v.object({
newPassword: v.pipe(
v.string(),
v.nonEmpty('Please enter your new password'),
v.minLength(8, 'Password must be at least 8 characters long'),
v.maxLength(128, 'Password must be at most 128 characters long'),
v.nonEmpty(t('auth.reset-password.form.new-password.required')),
v.minLength(8, t('auth.reset-password.form.new-password.min-length', { minLength: 8 })),
v.maxLength(128, t('auth.reset-password.form.new-password.max-length', { maxLength: 128 })),
),
}),
});
@@ -27,15 +30,15 @@ export const ResetPasswordForm: Component<{ onSubmit: (args: { newPassword: stri
<Field name="newPassword">
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col gap-1 mb-4">
<TextFieldLabel for="newPassword">New password</TextFieldLabel>
<TextField type="password" id="newPassword" placeholder="Your new password" {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
<TextFieldLabel for="newPassword">{t('auth.reset-password.form.new-password.label')}</TextFieldLabel>
<TextField type="password" id="newPassword" placeholder={t('auth.reset-password.form.new-password.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</TextFieldRoot>
)}
</Field>
<Button type="submit" class="w-full">
Reset password
{t('auth.reset-password.form.submit')}
</Button>
<div class="text-red-500 text-sm mt-2">{form.response.message}</div>
@@ -49,6 +52,8 @@ export const ResetPasswordPage: Component = () => {
const [searchParams] = useSearchParams();
const token = searchParams.token;
const { t } = useI18n();
if (!token || typeof token !== 'string') {
return <Navigate href="/login" />;
}
@@ -57,7 +62,7 @@ export const ResetPasswordPage: Component = () => {
const navigate = useNavigate();
onMount(() => {
if (config.auth.isPasswordResetEnabled) {
if (!config.auth.isPasswordResetEnabled || !config.auth.providers.email.isEnabled) {
navigate('/login');
}
});
@@ -80,18 +85,18 @@ export const ResetPasswordPage: Component = () => {
<div class="flex items-center justify-center p-6 sm:pb-32">
<div class="max-w-sm w-full">
<h1 class="text-xl font-bold">
Reset your password
{t('auth.reset-password.title')}
</h1>
{getHasPasswordBeenReset()
? (
<>
<div class="text-muted-foreground mt-1 mb-4">
Your password has been reset.
{t('auth.reset-password.reset')}
</div>
<Button as={A} href="/login" class="w-full">
Go to login
{t('auth.reset-password.back-to-login')}
<div class="i-tabler-login-2 ml-2 size-4" />
</Button>
</>
@@ -99,7 +104,7 @@ export const ResetPasswordPage: Component = () => {
: (
<>
<p class="text-muted-foreground mt-1 mb-4">
Enter your new password.
{t('auth.reset-password.description')}
</p>
<ResetPasswordForm onSubmit={onPasswordResetRequested} />

View File

@@ -5,6 +5,7 @@ import { debounce } from 'lodash-es';
import { createContext, createEffect, createSignal, For, on, onCleanup, onMount, Show, useContext } from 'solid-js';
import { getDocumentIcon } from '../documents/document.models';
import { searchDocuments } from '../documents/documents.services';
import { useI18n } from '../i18n/i18n.provider';
import { cn } from '../shared/style/cn';
import { useThemeStore } from '../theme/theme.store';
import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandLoading } from '../ui/components/command';
@@ -29,9 +30,11 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
const [getIsCommandPaletteOpen, setIsCommandPaletteOpen] = createSignal(false);
const [getMatchingDocuments, setMatchingDocuments] = createSignal<Document[]>([]);
const [getSearchQuery, setSearchQuery] = createSignal('');
const params = useParams();
const [getIsLoading, setIsLoading] = createSignal(false);
const params = useParams();
const { t } = useI18n();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
@@ -82,7 +85,7 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
options: { label: string; icon: string; action: () => void; forceMatch?: boolean }[];
}[] => [
{
label: 'Documents',
label: t('command-palette.sections.documents'),
forceMatch: true,
options: getMatchingDocuments().map(document => ({
label: document.name,
@@ -92,20 +95,20 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
})),
},
{
label: `Theme`,
label: t('command-palette.sections.theme'),
options: [
{
label: 'Switch to light mode',
label: t('layout.theme.light'),
icon: 'i-tabler-sun',
action: () => setColorMode({ mode: 'light' }),
},
{
label: 'Switch to dark mode',
label: t('layout.theme.dark'),
icon: 'i-tabler-moon',
action: () => setColorMode({ mode: 'dark' }),
},
{
label: 'Switch to system',
label: t('layout.theme.system'),
icon: 'i-tabler-device-laptop',
action: () => setColorMode({ mode: 'system' }),
},
@@ -132,7 +135,7 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
onOpenChange={setIsCommandPaletteOpen}
>
<CommandInput onValueChange={setSearchQuery} placeholder="Search commands or documents" />
<CommandInput onValueChange={setSearchQuery} placeholder={t('command-palette.search.placeholder')} />
<CommandList>
<Show when={getIsLoading()}>
<CommandLoading>
@@ -142,7 +145,7 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
<Show when={!getIsLoading()}>
<Show when={getMatchingDocuments().length === 0}>
<CommandEmpty>
No results found.
{t('command-palette.no-results')}
</CommandEmpty>
</Show>

View File

@@ -1,11 +1,12 @@
import type { ParentComponent } from 'solid-js';
import { createQuery } from '@tanstack/solid-query';
import type { Config, RuntimePublicConfig } from './config';
import { useQuery } from '@tanstack/solid-query';
import { merge } from 'lodash-es';
import { createContext, Match, Switch, useContext } from 'solid-js';
import { Button } from '../ui/components/button';
import { EmptyState } from '../ui/components/empty';
import { createToast } from '../ui/components/sonner';
import { buildTimeConfig, type Config, type RuntimePublicConfig } from './config';
import { buildTimeConfig } from './config';
import { fetchPublicConfig } from './config.services';
const ConfigContext = createContext<{
@@ -23,7 +24,7 @@ export function useConfig() {
}
export const ConfigProvider: ParentComponent = (props) => {
const query = createQuery(() => ({
const query = useQuery(() => ({
queryKey: ['config'],
queryFn: fetchPublicConfig,
}));

View File

@@ -5,7 +5,7 @@ const asString = <T extends string | undefined>(value: string | undefined, defau
const asNumber = <T extends number | undefined>(value: string | undefined, defaultValue?: T): T extends undefined ? number | undefined : number => (value === undefined ? defaultValue : Number(value)) as T extends undefined ? number | undefined : number;
export const buildTimeConfig = {
papraVersion: asString(import.meta.env.VITE_PAPRA_VERSION),
papraVersion: asString(import.meta.env.VITE_PAPRA_VERSION, '0.0.0'),
baseUrl: asString(import.meta.env.VITE_BASE_URL, window.location.origin),
baseApiUrl: asString(import.meta.env.VITE_BASE_API_URL, window.location.origin),
vitrineBaseUrl: asString(import.meta.env.VITE_VITRINE_BASE_URL, 'http://localhost:3000/'),
@@ -16,8 +16,14 @@ export const buildTimeConfig = {
isEmailVerificationRequired: asBoolean(import.meta.env.VITE_AUTH_IS_EMAIL_VERIFICATION_REQUIRED, true),
showLegalLinksOnAuthPage: asBoolean(import.meta.env.VITE_AUTH_SHOW_LEGAL_LINKS_ON_AUTH_PAGE, false),
providers: {
email: { isEnabled: asBoolean(import.meta.env.VITE_AUTH_PROVIDERS_EMAIL_IS_ENABLED, true) },
github: { isEnabled: asBoolean(import.meta.env.VITE_AUTH_PROVIDERS_GITHUB_IS_ENABLED, false) },
google: { isEnabled: asBoolean(import.meta.env.VITE_AUTH_PROVIDERS_GOOGLE_IS_ENABLED, false) },
customs: [] as {
providerId: string;
providerName: string;
providerIconUrl: string;
}[],
},
},
documents: {
@@ -30,8 +36,8 @@ export const buildTimeConfig = {
},
intakeEmails: {
isEnabled: asBoolean(import.meta.env.VITE_INTAKE_EMAILS_IS_ENABLED, false),
emailGenerationDomain: asString(import.meta.env.VITE_INTAKE_EMAILS_EMAIL_GENERATION_DOMAIN),
},
isSubscriptionsEnabled: asBoolean(import.meta.env.VITE_IS_SUBSCRIPTIONS_ENABLED, false),
} as const;
export type Config = typeof buildTimeConfig;

View File

@@ -2,8 +2,8 @@
import type { HttpClientOptions, ResponseType } from '../shared/http/http-client';
import { joinUrlPaths } from '@corentinth/chisels';
type ExtractRouteParams<Path extends string> =
Path extends `${infer _Start}:${infer Param}/${infer Rest}`
type ExtractRouteParams<Path extends string>
= Path extends `${infer _Start}:${infer Param}/${infer Rest}`
? { [k in Param | keyof ExtractRouteParams<`/${Rest}`>]: string }
: Path extends `${infer _Start}:${infer Param}`
? { [k in Param]: string }

View File

@@ -1,10 +1,31 @@
import type { ApiKey } from '../api-keys/api-keys.types';
import type { Webhook } from '../webhooks/webhooks.types';
import { get } from 'lodash-es';
import { FetchError } from 'ofetch';
import { createRouter } from 'radix3';
import { defineHandler } from './demo-api-mock.models';
import { documentFileStorage, documentStorage, organizationStorage, tagDocumentStorage, tagStorage } from './demo.storage';
import {
apiKeyStorage,
documentFileStorage,
documentStorage,
organizationStorage,
tagDocumentStorage,
taggingRuleStorage,
tagStorage,
webhooksStorage,
} from './demo.storage';
import { findMany, getValues } from './demo.storage.models';
const corpus = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
function randomString({ length = 10 }: { length?: number } = {}) {
return Array.from({ length }, () => corpus[Math.floor(Math.random() * corpus.length)]).join('');
}
function createId({ prefix }: { prefix: string }) {
return `${prefix}_${randomString({ length: 24 })}`;
}
function assert(condition: unknown, { message = 'Error', status }: { message?: string; status?: number } = {}): asserts condition {
if (!condition) {
throw Object.assign(new FetchError(message), { status });
@@ -114,12 +135,13 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
assert(file, { status: 400 });
const document = {
id: `doc_${Math.random().toString(36).slice(2)}`,
id: createId({ prefix: 'doc' }),
organizationId,
name: file.name,
originalName: file.name,
originalSize: file.size,
mimeType: file.type,
content: '',
createdAt: new Date(),
updatedAt: new Date(),
tags: [],
@@ -130,6 +152,9 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
await documentFileStorage.setItem(key, await serializeFile(file));
await documentStorage.setItem(key, document);
// Simulate a slow response
await new Promise(resolve => setTimeout(resolve, 500));
return { document };
},
}),
@@ -168,7 +193,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
const {
pageIndex = 0,
pageSize = 5,
searchQuery = '',
searchQuery: rawSearchQuery = '',
} = query ?? {};
const organization = organizationStorage.getItem(organizationId);
@@ -176,7 +201,9 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
const documents = await findMany(documentStorage, document => document?.organizationId === organizationId);
const filteredDocuments = documents.filter(document => document?.name.includes(searchQuery) && !document?.deletedAt);
const searchQuery = rawSearchQuery.trim().toLowerCase();
const filteredDocuments = documents.filter(document => document?.name.toLowerCase().includes(searchQuery) && !document?.deletedAt);
return {
documents: filteredDocuments.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize),
@@ -307,7 +334,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
assert(organization, { status: 403 });
const tag = {
id: `tag_${Math.random().toString(36).slice(2)}`,
id: createId({ prefix: 'tag' }),
organizationId,
name: get(body, 'name'),
color: get(body, 'color'),
@@ -369,7 +396,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
assert(tagId, { status: 400 });
const tagDocument = {
id: `tagDoc_${Math.random().toString(36).slice(2)}`,
id: createId({ prefix: 'tagDoc' }),
tagId,
documentId,
createdAt: new Date(),
@@ -408,7 +435,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
method: 'POST',
handler: async ({ body }) => {
const organization = {
id: `org_${Math.random().toString(36).slice(2)}`,
id: createId({ prefix: 'org' }),
name: get(body, 'name'),
createdAt: new Date(),
updatedAt: new Date(),
@@ -457,6 +484,255 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/tagging-rules',
method: 'GET',
handler: async ({ params: { organizationId } }) => {
const taggingRules = await findMany(taggingRuleStorage, taggingRule => taggingRule.organizationId === organizationId);
return { taggingRules };
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/tagging-rules',
method: 'POST',
handler: async ({ params: { organizationId }, body }) => {
const taggingRule = {
id: createId({ prefix: 'tr' }),
organizationId,
name: get(body, 'name'),
description: get(body, 'description'),
conditions: get(body, 'conditions'),
actions: get(body, 'tagIds').map((tagId: string) => ({ tagId })),
createdAt: new Date(),
updatedAt: new Date(),
};
await taggingRuleStorage.setItem(taggingRule.id, taggingRule);
return { taggingRule };
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/tagging-rules/:taggingRuleId',
method: 'GET',
handler: async ({ params: { taggingRuleId } }) => {
const taggingRule = await taggingRuleStorage.getItem(taggingRuleId);
assert(taggingRule, { status: 404 });
return { taggingRule };
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/tagging-rules/:taggingRuleId',
method: 'DELETE',
handler: async ({ params: { taggingRuleId } }) => {
await taggingRuleStorage.removeItem(taggingRuleId);
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/tagging-rules/:taggingRuleId',
method: 'PUT',
handler: async ({ params: { taggingRuleId }, body }) => {
const taggingRule = await taggingRuleStorage.getItem(taggingRuleId);
assert(taggingRule, { status: 404 });
await taggingRuleStorage.setItem(taggingRuleId, Object.assign(taggingRule, body, { updatedAt: new Date() }));
return { taggingRule };
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/documents/trash',
method: 'DELETE',
handler: async ({ params: { organizationId } }) => {
const documents = await findMany(documentStorage, document => document.organizationId === organizationId && Boolean(document.deletedAt));
await Promise.all(documents.map(document => documentStorage.removeItem(`${organizationId}:${document.id}`)));
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/documents/trash/:documentId',
method: 'DELETE',
handler: async ({ params: { organizationId, documentId } }) => {
const key = `${organizationId}:${documentId}`;
await documentStorage.removeItem(key);
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/members',
method: 'GET',
handler: async ({ params: { organizationId } }) => {
return {
members: [{
id: 'mem_1',
user: {
id: 'usr_1',
email: 'jane.doe@papra.app',
name: 'Jane Doe',
},
role: 'owner',
organizationId,
}],
};
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/members/invitations',
method: 'POST',
handler: async () => {
throw Object.assign(new FetchError('Not available in demo'), {
status: 501,
data: {
error: {
message: 'This feature is not available in demo',
code: 'demo.not_available',
},
},
});
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/members/me',
method: 'GET',
handler: async ({ params: { organizationId } }) => {
return {
member: {
id: 'mem_1',
role: 'owner',
organizationId,
},
};
},
}),
...defineHandler({
path: '/api/api-keys',
method: 'GET',
handler: async () => {
const apiKeys = await getValues(apiKeyStorage);
return { apiKeys };
},
}),
...defineHandler({
path: '/api/api-keys',
method: 'POST',
handler: async ({ body }) => {
const token = `ppapi_${randomString({ length: 64 })}`;
const apiKey = {
id: createId({ prefix: 'apiKey' }),
name: get(body, 'name'),
permissions: get(body, 'permissions'),
organizationIds: get(body, 'organizationIds'),
allOrganizations: get(body, 'allOrganizations'),
expiresAt: get(body, 'expiresAt'),
createdAt: new Date(),
updatedAt: new Date(),
prefix: token.slice(0, 11),
} as ApiKey;
await apiKeyStorage.setItem(apiKey.id, apiKey);
return { apiKey, token };
},
}),
...defineHandler({
path: '/api/api-keys/:apiKeyId',
method: 'DELETE',
handler: async ({ params: { apiKeyId } }) => {
await apiKeyStorage.removeItem(apiKeyId);
},
}),
...defineHandler({
path: '/api/invitations/count',
method: 'GET',
handler: async () => ({ pendingInvitationsCount: 0 }),
}),
...defineHandler({
path: '/api/invitations',
method: 'GET',
handler: async () => ({ invitations: [] }),
}),
...defineHandler({
path: '/api/organizations/:organizationId/webhooks',
method: 'GET',
handler: async ({ params: { organizationId } }) => {
const webhooks = await findMany(webhooksStorage, webhook => webhook.organizationId === organizationId);
return { webhooks };
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/webhooks',
method: 'POST',
handler: async ({ params: { organizationId }, body }) => {
const webhook: Webhook = {
id: createId({ prefix: 'webhook' }),
organizationId,
name: get(body, 'name'),
url: get(body, 'url'),
enabled: true,
events: get(body, 'events'),
createdAt: new Date(),
updatedAt: new Date(),
};
await webhooksStorage.setItem(webhook.id, webhook);
return { webhook };
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/webhooks/:webhookId',
method: 'GET',
handler: async ({ params: { webhookId } }) => {
const webhook = await webhooksStorage.getItem(webhookId);
return { webhook };
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/webhooks/:webhookId',
method: 'DELETE',
handler: async ({ params: { webhookId } }) => {
await webhooksStorage.removeItem(webhookId);
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/webhooks/:webhookId',
method: 'PUT',
handler: async ({ params: { webhookId }, body }) => {
const webhook = await webhooksStorage.getItem(webhookId);
assert(webhook, { status: 404 });
await webhooksStorage.setItem(webhookId, Object.assign(webhook, body, { updatedAt: new Date() }));
return { webhook };
},
}),
};
export const router = createRouter({ routes: inMemoryApiMock, strictTrailingSlash: false });

View File

@@ -4,7 +4,7 @@ import type { HttpClientOptions, ResponseType } from '../shared/http/http-client
import { joinUrlPaths } from '@corentinth/chisels';
import { router } from './demo-api-mock';
export async function demoHttpClient<A, R extends ResponseType = 'json'>(options: HttpClientOptions<R>): Promise<MappedResponseType< R, A>> {
export async function demoHttpClient<A, R extends ResponseType = 'json'>(options: HttpClientOptions<R>): Promise<MappedResponseType<R, A>> {
const path = `/${joinUrlPaths(options.method ?? 'GET', options.url)}`;
const matchedRoute = router.lookup(path);

View File

@@ -1,5 +1,6 @@
import { useNavigate } from '@solidjs/router';
import { type Component, createSignal } from 'solid-js';
import type { Component } from 'solid-js';
import { A, useNavigate } from '@solidjs/router';
import { createSignal } from 'solid-js';
import { Portal } from 'solid-js/web';
import { buildTimeConfig } from '../config/config';
import { useI18n } from '../i18n/i18n.provider';
@@ -9,7 +10,7 @@ import { clearDemoStorage } from './demo.storage';
export const DemoIndicator: Component = () => {
const [getIsMinified, setIsMinified] = createSignal(false);
const navigate = useNavigate();
const { t } = useI18n();
const { t, te } = useI18n();
const clearDemo = async () => {
await clearDemoStorage();
@@ -33,6 +34,9 @@ export const DemoIndicator: Component = () => {
<p class="text-sm">
{t('demo.popup.description')}
</p>
<p class="text-sm mt-2">
{te('demo.popup.discord', { discordLink: <A href="https://papra.app/discord" target="_blank" rel="noopener noreferrer" class="underline font-bold">{t('demo.popup.discord-link-label')}</A> })}
</p>
<div class="flex justify-end mt-4 gap-2">
<Button variant="secondary" onClick={clearDemo} size="sm" class="text-primary shadow-none">
{t('demo.popup.reset')}

View File

@@ -8,7 +8,7 @@ export async function getValues<T extends StorageValue>(storage: Storage<T>): Pr
return values;
}
export async function findOne<T extends StorageValue>(storage: Storage<T>, predicate: (value: T) => boolean): Promise< T | null> {
export async function findOne<T extends StorageValue>(storage: Storage<T>, predicate: (value: T) => boolean): Promise<T | null> {
const values = await getValues(storage);
const found = values.find(predicate);

View File

@@ -1,6 +1,9 @@
import type { ApiKey } from '../api-keys/api-keys.types';
import type { Document } from '../documents/documents.types';
import type { Organization } from '../organizations/organizations.types';
import type { TaggingRule } from '../tagging-rules/tagging-rules.types';
import type { Tag } from '../tags/tags.types';
import type { Webhook } from '../webhooks/webhooks.types';
import { createStorage, prefixStorage } from 'unstorage';
import localStorageDriver from 'unstorage/drivers/localstorage';
import { trackingServices } from '../tracking/tracking.services';
@@ -14,6 +17,9 @@ export const documentStorage = prefixStorage<Document>(storage, 'documents');
export const documentFileStorage = prefixStorage(storage, 'documentFiles');
export const tagStorage = prefixStorage<Omit<Tag, 'documentsCount'>>(storage, 'tags');
export const tagDocumentStorage = prefixStorage<{ documentId: string; tagId: string; id: string }>(storage, 'tagDocuments');
export const taggingRuleStorage = prefixStorage<TaggingRule>(storage, 'taggingRules');
export const apiKeyStorage = prefixStorage<ApiKey>(storage, 'apiKeys');
export const webhooksStorage = prefixStorage<Webhook>(storage, 'webhooks');
export async function clearDemoStorage() {
await storage.clear();

View File

@@ -0,0 +1,208 @@
import type { ParentComponent } from 'solid-js';
import type { Document } from '../documents.types';
import { safely } from '@corentinth/chisels';
import { A } from '@solidjs/router';
import { throttle } from 'lodash-es';
import { createContext, createSignal, For, Match, Show, Switch, useContext } from 'solid-js';
import { Portal } from 'solid-js/web';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { promptUploadFiles } from '@/modules/shared/files/upload';
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
import { cn } from '@/modules/shared/style/cn';
import { Button } from '@/modules/ui/components/button';
import { invalidateOrganizationDocumentsQuery } from '../documents.composables';
import { uploadDocument } from '../documents.services';
const DocumentUploadContext = createContext<{
uploadDocuments: (args: { files: File[]; organizationId: string }) => Promise<void>;
}>();
export function useDocumentUpload({ getOrganizationId }: { getOrganizationId: () => string }) {
const context = useContext(DocumentUploadContext);
if (!context) {
throw new Error('DocumentUploadContext not found');
}
const { uploadDocuments } = context;
return {
uploadDocuments: async ({ files }: { files: File[] }) => uploadDocuments({ files, organizationId: getOrganizationId() }),
promptImport: async () => {
const { files } = await promptUploadFiles();
await uploadDocuments({ files, organizationId: getOrganizationId() });
},
};
}
type TaskSuccess = {
file: File;
status: 'success';
document: Document;
};
type TaskError = {
file: File;
status: 'error';
error: Error;
};
type Task = TaskSuccess | TaskError | {
file: File;
status: 'pending' | 'uploading';
};
export const DocumentUploadProvider: ParentComponent = (props) => {
const throttledInvalidateOrganizationDocumentsQuery = throttle(invalidateOrganizationDocumentsQuery, 500);
const { getErrorMessage } = useI18nApiErrors();
const { t } = useI18n();
const [getState, setState] = createSignal<'open' | 'closed' | 'collapsed'>('closed');
const [getTasks, setTasks] = createSignal<Task[]>([]);
const updateTaskStatus = (args: { file: File; status: 'success'; document: Document } | { file: File; status: 'error'; error: Error } | { file: File; status: 'pending' | 'uploading' }) => {
setTasks(tasks => tasks.map(task => task.file === args.file ? { ...task, ...args } : task));
};
const uploadDocuments = async ({ files, organizationId }: { files: File[]; organizationId: string }) => {
setTasks(tasks => [...tasks, ...files.map(file => ({ file, status: 'pending' } as const))]);
setState('open');
await Promise.all(files.map(async (file) => {
updateTaskStatus({ file, status: 'uploading' });
const [result, error] = await safely(uploadDocument({ file, organizationId }));
if (error) {
updateTaskStatus({ file, status: 'error', error });
} else {
const { document } = result;
updateTaskStatus({ file, status: 'success', document });
}
await throttledInvalidateOrganizationDocumentsQuery({ organizationId });
}));
};
const getTitle = () => {
if (getTasks().length === 0) {
return t('import-documents.title.none');
}
const successCount = getTasks().filter(task => task.status === 'success').length;
const errorCount = getTasks().filter(task => task.status === 'error').length;
const totalCount = getTasks().length;
if (errorCount > 0) {
return t('import-documents.title.error', { count: errorCount });
}
if (successCount === totalCount) {
return t('import-documents.title.success', { count: successCount });
}
return t('import-documents.title.pending', { count: successCount, total: totalCount });
};
const close = () => {
setState('closed');
setTasks([]);
};
return (
<DocumentUploadContext.Provider value={{ uploadDocuments }}>
{props.children}
<Portal>
<Show when={getState() !== 'closed'}>
<div class="fixed bottom-0 right-0 sm:right-20px w-full sm:w-400px bg-card border-l border-t border-r sm:rounded-t-xl shadow-lg">
<div class="flex items-center gap-1 pl-6 pr-4 py-3 border-b">
<h2 class="text-base font-bold flex-1">{getTitle()}</h2>
<Button variant="ghost" size="icon" onClick={() => setState(state => state === 'open' ? 'collapsed' : 'open')}>
<div class={cn('i-tabler-chevron-down size-5 transition-transform', getState() === 'collapsed' && 'rotate-180')} />
</Button>
<Button variant="ghost" size="icon" onClick={close}>
<div class="i-tabler-x size-5"></div>
</Button>
</div>
<Show when={getState() === 'open'}>
<div class="flex flex-col overflow-y-auto h-[450px] pb-4">
<For each={getTasks()}>
{task => (
<Switch>
<Match when={task.status === 'success'}>
<A
href={`/organizations/${(task as TaskSuccess).document.organizationId}/documents/${(task as TaskSuccess).document.id}`}
class="text-sm truncate min-w-0 flex items-center gap-4 min-h-48px group hover:bg-muted/50 transition-colors px-6 border-b border-border/80"
>
<div class="flex-1 truncate">
{task.file.name}
</div>
<div class="flex-none">
<div class="i-tabler-circle-check text-primary size-5.5 group-hover:hidden"></div>
<div class="i-tabler-arrow-right text-muted-foreground size-5.5 hidden group-hover:block"></div>
</div>
</A>
</Match>
<Match when={task.status === 'error'}>
<div class="text-sm truncate min-w-0 flex items-center gap-4 min-h-48px px-6 border-b border-border/80">
<div class="flex-1 truncate">
<div class="flex-1 truncate">{task.file.name}</div>
<div class="text-xs text-muted-foreground truncate text-red-500">
{getErrorMessage({ error: (task as TaskError).error })}
</div>
</div>
<div class="flex-none">
<div class="i-tabler-circle-x text-red-500 size-5.5"></div>
</div>
</div>
</Match>
<Match when={['pending', 'uploading'].includes(task.status)}>
<div class="text-sm truncate min-w-0 flex items-center gap-4 min-h-48px px-6 border-b border-border/80">
<div class="flex-1 truncate">
{task.file.name}
</div>
<div class="flex-none">
<div class="i-tabler-loader-2 animate-spin text-muted-foreground size-5.5"></div>
</div>
</div>
</Match>
</Switch>
)}
</For>
<Show when={getTasks().length === 0}>
<div class="flex flex-col items-center justify-center gap-2 h-full mb-10">
<div class="flex flex-col items-center justify-center gap-2 ">
<div class="i-tabler-file-import size-10 text-muted-foreground"></div>
</div>
<div class="text-sm text-muted-foreground text-center mt-2">
{t('import-documents.no-import-in-progress')}
</div>
</div>
</Show>
</div>
</Show>
</div>
</Show>
</Portal>
</DocumentUploadContext.Provider>
);
};

View File

@@ -1,13 +1,15 @@
import type { DropdownMenuSubTriggerProps } from '@kobalte/core/dropdown-menu';
import type { Component } from 'solid-js';
import type { Document } from '../documents.types';
import { A } from '@solidjs/router';
import { Button } from '@/modules/ui/components/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/modules/ui/components/dropdown-menu';
import { A } from '@solidjs/router';
import { useDeleteDocument } from '../documents.composables';
import { useRenameDocumentDialog } from './rename-document-button.component';
export const DocumentManagementDropdown: Component<{ document: Document }> = (props) => {
const { deleteDocument } = useDeleteDocument();
const { openRenameDialog } = useRenameDocumentDialog();
const deleteDoc = () => deleteDocument({
documentId: props.document.id,
@@ -16,6 +18,7 @@ export const DocumentManagementDropdown: Component<{ document: Document }> = (pr
});
return (
<DropdownMenu>
<DropdownMenuTrigger
as={(props: DropdownMenuSubTriggerProps) => (
@@ -34,6 +37,18 @@ export const DocumentManagementDropdown: Component<{ document: Document }> = (pr
<span>Document details</span>
</DropdownMenuItem>
<DropdownMenuItem
class="cursor-pointer"
onClick={() => openRenameDialog({
documentId: props.document.id,
organizationId: props.document.organizationId,
documentName: props.document.name,
})}
>
<div class="i-tabler-pencil size-4 mr-2"></div>
<span>Rename document</span>
</DropdownMenuItem>
<DropdownMenuItem
class="cursor-pointer text-red"
onClick={() => deleteDoc()}
@@ -43,5 +58,6 @@ export const DocumentManagementDropdown: Component<{ document: Document }> = (pr
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@@ -1,13 +1,15 @@
import type { Component } from 'solid-js';
import type { Document } from '../documents.types';
import { useQuery } from '@tanstack/solid-query';
import { createResource, Match, Suspense, Switch } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { Card } from '@/modules/ui/components/card';
import { createQuery } from '@tanstack/solid-query';
import { type Component, createResource, Match, Suspense, Switch } from 'solid-js';
import { fetchDocumentFile } from '../documents.services';
import { PdfViewer } from './pdf-viewer.component';
const imageMimeType = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
const pdfMimeType = ['application/pdf'];
const txtLikeMimeType = ['text/plain', 'text/markdown', 'text/csv', 'text/html'];
const txtLikeMimeType = ['application/x-yaml', 'application/json', 'application/xml'];
function blobToString(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
@@ -18,13 +20,90 @@ function blobToString(blob: Blob): Promise<string> {
});
}
/**
* TODO: IA generated code, add some tests
* Detects if a blob can be safely displayed as text by checking for valid UTF-8 encoding
* and common text patterns (low ratio of control characters, presence of readable text)
*/
async function isBlobTextSafe(blob: Blob): Promise<boolean> {
try {
const text = await blobToString(blob);
// Check if the text contains mostly printable characters
const totalChars = text.length;
if (totalChars === 0) {
return true;
} // Empty files are considered text-safe
// Count control characters (excluding common whitespace and newlines)
// Use a simpler approach to avoid linter issues with Unicode escapes
let controlCharCount = 0;
for (let i = 0; i < text.length; i++) {
const charCode = text.charCodeAt(i);
// Check for control characters (0-31, 127-159) excluding common whitespace
if ((charCode >= 0 && charCode <= 31 && ![9, 10, 13, 12, 11].includes(charCode))
|| (charCode >= 127 && charCode <= 159)) {
controlCharCount++;
}
}
// If more than 10% of characters are control characters, it's likely binary
const controlCharRatio = controlCharCount / totalChars;
if (controlCharRatio > 0.1) {
return false;
}
// Check for common binary file signatures in the first few bytes
const arrayBuffer = await blob.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
// Common binary file signatures to check
const binarySignatures = [
[0xFF, 0xD8, 0xFF], // JPEG
[0x89, 0x50, 0x4E, 0x47], // PNG
[0x47, 0x49, 0x46], // GIF
[0x25, 0x50, 0x44, 0x46], // PDF
[0x50, 0x4B, 0x03, 0x04], // ZIP/DOCX/XLSX
[0x7F, 0x45, 0x4C, 0x46], // ELF executable
[0x4D, 0x5A], // Windows executable
];
for (const signature of binarySignatures) {
if (uint8Array.length >= signature.length) {
const matches = signature.every((byte, index) => uint8Array[index] === byte);
if (matches) {
return false;
}
}
}
// Check if the text contains mostly ASCII printable characters
let asciiPrintableCount = 0;
for (let i = 0; i < text.length; i++) {
const charCode = text.charCodeAt(i);
// ASCII printable characters (32-126) excluding common whitespace
if (charCode >= 32 && charCode <= 126 && ![9, 10, 13, 12, 11].includes(charCode)) {
asciiPrintableCount++;
}
}
const asciiRatio = asciiPrintableCount / totalChars;
// If less than 70% are ASCII printable, it's likely binary
return asciiRatio > 0.7;
} catch {
// If we can't read as text, it's definitely not text-safe
return false;
}
}
const TextFromBlob: Component<{ blob: Blob }> = (props) => {
const [txt] = createResource(() => blobToString(props.blob));
return (
<Card class="p-6 overflow-auto max-h-800px max-w-full text-xs">
<Suspense>
<pre>{txt()}</pre>
<pre class="break-words whitespace-pre-wrap">{txt()}</pre>
</Suspense>
</Card>
);
@@ -33,12 +112,25 @@ const TextFromBlob: Component<{ blob: Blob }> = (props) => {
export const DocumentPreview: Component<{ document: Document }> = (props) => {
const getIsImage = () => imageMimeType.includes(props.document.mimeType);
const getIsPdf = () => pdfMimeType.includes(props.document.mimeType);
const getIsTxtLike = () => txtLikeMimeType.includes(props.document.mimeType) || props.document.mimeType.startsWith('text/');
const { t } = useI18n();
const query = createQuery(() => ({
const query = useQuery(() => ({
queryKey: ['organizations', props.document.organizationId, 'documents', props.document.id, 'file'],
queryFn: () => fetchDocumentFile({ documentId: props.document.id, organizationId: props.document.organizationId }),
}));
// Create a resource to check if octet-stream blob is text-safe
const [isOctetStreamTextSafe] = createResource(
() => query.data && props.document.mimeType === 'application/octet-stream' ? query.data : null,
async (blob) => {
if (!blob) {
return false;
}
return await isBlobTextSafe(blob);
},
);
return (
<Suspense>
<Switch>
@@ -47,12 +139,30 @@ export const DocumentPreview: Component<{ document: Document }> = (props) => {
<img src={URL.createObjectURL(query.data!)} class="w-full h-full object-contain" />
</div>
</Match>
<Match when={getIsPdf() && query.data}>
<PdfViewer url={URL.createObjectURL(query.data!)} />
</Match>
<Match when={txtLikeMimeType.includes(props.document.mimeType) && query.data}>
<Match when={getIsTxtLike() && query.data}>
<TextFromBlob blob={query.data!} />
</Match>
<Match when={props.document.mimeType === 'application/octet-stream' && query.data && isOctetStreamTextSafe()}>
<TextFromBlob blob={query.data!} />
</Match>
<Match when={props.document.mimeType === 'application/octet-stream' && query.data && !isOctetStreamTextSafe()}>
<Card class="px-6 py-12 text-center text-sm text-muted-foreground">
<p>{t('documents.preview.binary-file')}</p>
</Card>
</Match>
<Match when={query.data}>
<Card class="px-6 py-12 text-center text-sm text-muted-foreground">
<p>{t('documents.preview.unknown-file-type')}</p>
</Card>
</Match>
</Switch>
</Suspense>
);

View File

@@ -1,10 +1,10 @@
import type { Component } from 'solid-js';
import { useParams } from '@solidjs/router';
import { createSignal } from 'solid-js';
import { promptUploadFiles } from '@/modules/shared/files/upload';
import { queryClient } from '@/modules/shared/query/query-client';
import { cn } from '@/modules/shared/style/cn';
import { Button } from '@/modules/ui/components/button';
import { useParams } from '@solidjs/router';
import { createSignal } from 'solid-js';
import { uploadDocument } from '../documents.services';
export const DocumentUploadArea: Component<{ organizationId?: string }> = (props) => {

View File

@@ -1,7 +1,12 @@
import type { Tag } from '@/modules/tags/tags.types';
import type { TooltipTriggerProps } from '@kobalte/core/tooltip';
import type { ColumnDef } from '@tanstack/solid-table';
import type { Accessor, Component, Setter } from 'solid-js';
import type { Document } from '../documents.types';
import type { Tag } from '@/modules/tags/tags.types';
import { formatBytes } from '@corentinth/chisels';
import { A } from '@solidjs/router';
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
import { For, Match, Show, Switch } from 'solid-js';
import { timeAgo } from '@/modules/shared/date/time-ago';
import { cn } from '@/modules/shared/style/cn';
import { TagLink } from '@/modules/tags/components/tag.component';
@@ -9,11 +14,7 @@ import { Button } from '@/modules/ui/components/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/modules/ui/components/select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/modules/ui/components/table';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/modules/ui/components/tooltip';
import { formatBytes } from '@corentinth/chisels';
import { A } from '@solidjs/router';
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
import { type Accessor, type Component, For, Match, type Setter, Show, Switch } from 'solid-js';
import { getDocumentIcon } from '../document.models';
import { getDocumentIcon, getDocumentNameExtension, getDocumentNameWithoutExtension } from '../document.models';
import { DocumentManagementDropdown } from './document-management-dropdown.component';
type Pagination = {
@@ -83,15 +84,11 @@ export const DocumentsPaginatedList: Component<{
href={`/organizations/${data.row.original.organizationId}/documents/${data.row.original.id}`}
class="font-bold truncate block hover:underline"
>
{data.row.original.name.split('.').slice(0, -1).join('.')}
{getDocumentNameWithoutExtension({ name: data.row.original.name })}
</A>
<div class="text-xs text-muted-foreground lh-tight">
{formatBytes({ bytes: data.row.original.originalSize, base: 1000 })}
{' '}
-
{' '}
{data.row.original.name.split('.').pop()?.toUpperCase()}
{[formatBytes({ bytes: data.row.original.originalSize, base: 1000 }), getDocumentNameExtension({ name: data.row.original.name })].filter(Boolean).join(' - ')}
{' '}
-
{' '}

View File

@@ -1,6 +1,6 @@
import type { Component } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { createSignal, onCleanup } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
export const GlobalDropArea: Component<{ onFilesDrop?: (args: { files: File[] }) => void }> = (props) => {
const [isDragging, setIsDragging] = createSignal(false);

View File

@@ -0,0 +1,136 @@
import type { Component, ParentComponent } from 'solid-js';
import { setValue } from '@modular-forms/solid';
import { useMutation } from '@tanstack/solid-query';
import { createContext, createEffect, createSignal, useContext } from 'solid-js';
import * as v from 'valibot';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createForm } from '@/modules/shared/form/form';
import { Button } from '@/modules/ui/components/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/modules/ui/components/dialog';
import { createToast } from '@/modules/ui/components/sonner';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { getDocumentNameExtension, getDocumentNameWithoutExtension } from '../document.models';
import { invalidateOrganizationDocumentsQuery } from '../documents.composables';
import { updateDocument } from '../documents.services';
export const RenameDocumentDialog: Component<{
documentId: string;
organizationId: string;
documentName: string;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}> = (props) => {
const { t } = useI18n();
const renameDocumentMutation = useMutation(() => ({
mutationFn: ({ name }: { name: string }) => updateDocument({ documentId: props.documentId, organizationId: props.organizationId, name }),
onSuccess: async () => {
createToast({
message: t('documents.rename.success'),
type: 'success',
});
props.setIsOpen(false);
await invalidateOrganizationDocumentsQuery({ organizationId: props.organizationId });
},
}));
const { Form, Field, form } = createForm({
schema: v.object({
name: v.pipe(
v.string(),
v.trim(),
v.maxLength(255, t('documents.rename.form.name.max-length')),
v.minLength(1, t('documents.rename.form.name.required')),
),
}),
initialValues: {
name: getDocumentNameWithoutExtension({ name: props.documentName }),
},
onSubmit: async ({ name }) => {
const extension = getDocumentNameExtension({ name: props.documentName });
const newName = extension ? `${name}.${extension}` : name;
await renameDocumentMutation.mutateAsync({ name: newName });
},
});
createEffect(() => {
setValue(form, 'name', getDocumentNameWithoutExtension({ name: props.documentName }));
});
return (
<Dialog onOpenChange={props.setIsOpen} open={props.isOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('documents.rename.title')}</DialogTitle>
</DialogHeader>
<Form>
<Field name="name">
{(field, inputProps) => (
<TextFieldRoot>
<TextFieldLabel class="sr-only" for="name">{t('documents.rename.form.name.label')}</TextFieldLabel>
<TextField {...inputProps} value={field.value} id="name" placeholder={t('documents.rename.form.name.placeholder')} />
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</TextFieldRoot>
)}
</Field>
<div class="flex justify-end mt-4 gap-2">
<Button type="button" variant="secondary" onClick={() => props.setIsOpen(false)}>
{t('documents.rename.cancel')}
</Button>
<Button type="submit">{t('documents.rename.form.submit')}</Button>
</div>
</Form>
</DialogContent>
</Dialog>
);
};
const context = createContext<{
openRenameDialog: (args: { documentId: string; organizationId: string; documentName: string }) => void;
}>();
export function useRenameDocumentDialog() {
const renameDialogContext = useContext(context);
if (!renameDialogContext) {
throw new Error('useRenameDocumentDialog must be used within a RenameDocumentDialogProvider');
}
return renameDialogContext;
}
export const RenameDocumentDialogProvider: ParentComponent = (props) => {
const [getIsRenameDialogOpen, setIsRenameDialogOpen] = createSignal(false);
const [getDocumentId, setDocumentId] = createSignal<string | undefined>(undefined);
const [getOrganizationId, setOrganizationId] = createSignal<string | undefined>(undefined);
const [getDocumentName, setDocumentName] = createSignal<string | undefined>(undefined);
return (
<context.Provider
value={{
openRenameDialog: ({ documentId, organizationId, documentName }) => {
setIsRenameDialogOpen(true);
setDocumentId(documentId);
setOrganizationId(organizationId);
setDocumentName(documentName);
},
}}
>
<RenameDocumentDialog
documentId={getDocumentId() ?? ''}
organizationId={getOrganizationId() ?? ''}
documentName={getDocumentName() ?? ''}
isOpen={getIsRenameDialogOpen()}
setIsOpen={setIsRenameDialogOpen}
/>
{props.children}
</context.Provider>
);
};

View File

@@ -1,7 +1,7 @@
import { icons as tablerIconSet } from '@iconify-json/tabler';
import { values } from 'lodash-es';
import { describe, expect, test } from 'vitest';
import { getDaysBeforePermanentDeletion, getDocumentIcon, iconByFileType } from './document.models';
import { getDaysBeforePermanentDeletion, getDocumentIcon, getDocumentNameExtension, getDocumentNameWithoutExtension, iconByFileType } from './document.models';
describe('files models', () => {
describe('iconByFileType', () => {
@@ -101,4 +101,24 @@ describe('files models', () => {
expect(daysBeforeDeletion).to.eql(undefined);
});
});
describe('getDocumentNameWithoutExtension', () => {
test('get the document name without the extension', () => {
expect(getDocumentNameWithoutExtension({ name: 'document.txt' })).to.eql('document');
expect(getDocumentNameWithoutExtension({ name: 'document' })).to.eql('document');
expect(getDocumentNameWithoutExtension({ name: '.document' })).to.eql('.document');
expect(getDocumentNameWithoutExtension({ name: '.document.txt' })).to.eql('.document');
expect(getDocumentNameWithoutExtension({ name: 'document.test.txt' })).to.eql('document.test');
});
});
describe('getDocumentNameExtension', () => {
test('get the document name extension', () => {
expect(getDocumentNameExtension({ name: 'document.txt' })).to.eql('txt');
expect(getDocumentNameExtension({ name: 'document' })).to.eql(undefined);
expect(getDocumentNameExtension({ name: '.document' })).to.eql(undefined);
expect(getDocumentNameExtension({ name: '.document.txt' })).to.eql('txt');
expect(getDocumentNameExtension({ name: 'document.test.txt' })).to.eql('txt');
});
});
});

View File

@@ -1,3 +1,4 @@
import type { DocumentActivityEvent } from './documents.types';
import { addDays, differenceInDays } from 'date-fns';
export const iconByFileType = {
@@ -49,3 +50,46 @@ export function getDaysBeforePermanentDeletion({ document, deletedDocumentsReten
return daysBeforeDeletion;
}
export function getDocumentNameWithoutExtension({ name }: { name: string }) {
const dotSplittedName = name.split('.');
const dotCount = dotSplittedName.length - 1;
if (dotCount === 0) {
return name;
}
if (dotCount === 1 && name.startsWith('.')) {
return name;
}
return dotSplittedName.slice(0, -1).join('.');
}
export function getDocumentNameExtension({ name }: { name: string }) {
const dotSplittedName = name.split('.');
const dotCount = dotSplittedName.length - 1;
if (dotCount === 0) {
return undefined;
}
if (dotCount === 1 && name.startsWith('.')) {
return undefined;
}
return dotSplittedName[dotCount];
}
export const documentActivityIcon: Record<DocumentActivityEvent, string> = {
created: 'i-tabler-file-plus',
updated: 'i-tabler-file-diff',
deleted: 'i-tabler-file-x',
restored: 'i-tabler-file-check',
tagged: 'i-tabler-tag',
untagged: 'i-tabler-tag-off',
} as const;
export function getDocumentActivityIcon({ event }: { event: DocumentActivityEvent }) {
return documentActivityIcon[event] ?? 'i-tabler-file';
}

View File

@@ -9,7 +9,7 @@ import { queryClient } from '../shared/query/query-client';
import { createToast } from '../ui/components/sonner';
import { deleteDocument, restoreDocument, uploadDocument } from './documents.services';
function invalidateOrganizationDocumentsQuery({ organizationId }: { organizationId: string }) {
export function invalidateOrganizationDocumentsQuery({ organizationId }: { organizationId: string }) {
return queryClient.invalidateQueries({
queryKey: ['organizations', organizationId],
});
@@ -77,6 +77,34 @@ export function useRestoreDocument() {
};
}
function toastUploadError({ error, file }: { error: Error; file: File }) {
if (isHttpErrorWithCode({ error, code: 'document.already_exists' })) {
createToast({
type: 'error',
message: 'Document already exists',
description: `The document ${file.name} already exists, it has not been uploaded.`,
});
return;
}
if (isHttpErrorWithCode({ error, code: 'document.file_too_big' })) {
createToast({
type: 'error',
message: 'Document too big',
description: `The document ${file.name} is too big, it has not been uploaded.`,
});
return;
}
createToast({
type: 'error',
message: 'Failed to upload document',
description: error.message,
});
}
export function useUploadDocuments({ organizationId }: { organizationId: string }) {
const uploadDocuments = async ({ files }: { files: File[] }) => {
const throttledInvalidateOrganizationDocumentsQuery = throttle(invalidateOrganizationDocumentsQuery, 500);
@@ -84,12 +112,8 @@ export function useUploadDocuments({ organizationId }: { organizationId: string
await Promise.all(files.map(async (file) => {
const [, error] = await safely(uploadDocument({ file, organizationId }));
if (isHttpErrorWithCode({ error, code: 'document.already_exists' })) {
createToast({
type: 'error',
message: 'Document already exists',
description: `The document ${file.name} already exists, it has not been uploaded.`,
});
if (error) {
toastUploadError({ error, file });
}
await throttledInvalidateOrganizationDocumentsQuery({ organizationId });

View File

@@ -0,0 +1,10 @@
export const DOCUMENT_ACTIVITY_EVENTS = {
CREATED: 'created',
UPDATED: 'updated',
DELETED: 'deleted',
RESTORED: 'restored',
TAGGED: 'tagged',
UNTAGGED: 'untagged',
} as const;
export const DOCUMENT_ACTIVITY_EVENT_LIST = Object.values(DOCUMENT_ACTIVITY_EVENTS);

View File

@@ -1,6 +1,7 @@
import type { Document } from './documents.types';
import type { AsDto } from '../shared/http/http-client.types';
import type { Document, DocumentActivity } from './documents.types';
import { apiClient } from '../shared/http/api-client';
import { getFormData } from '../shared/http/http-client.models';
import { coerceDates, getFormData } from '../shared/http/http-client.models';
export async function uploadDocument({
file,
@@ -9,18 +10,14 @@ export async function uploadDocument({
file: File;
organizationId: string;
}) {
const { document } = await apiClient<{ document: Document }>({
const { document } = await apiClient<{ document: AsDto<Document> }>({
method: 'POST',
path: `/api/organizations/${organizationId}/documents`,
body: getFormData({ file }),
});
return {
document: {
...document,
createdAt: new Date(document.createdAt),
updatedAt: document.updatedAt ? new Date(document.updatedAt) : undefined,
},
document: coerceDates(document),
};
}
@@ -40,7 +37,7 @@ export async function fetchOrganizationDocuments({
const {
documents,
documentsCount,
} = await apiClient<{ documents: Document[]; documentsCount: number }>({
} = await apiClient<{ documents: AsDto<Document>[]; documentsCount: number }>({
method: 'GET',
path: `/api/organizations/${organizationId}/documents`,
query: {
@@ -52,11 +49,7 @@ export async function fetchOrganizationDocuments({
return {
documentsCount,
documents: documents.map(document => ({
...document,
createdAt: new Date(document.createdAt),
updatedAt: document.updatedAt ? new Date(document.updatedAt) : undefined,
})),
documents: documents.map(coerceDates),
};
}
@@ -72,7 +65,7 @@ export async function fetchOrganizationDeletedDocuments({
const {
documents,
documentsCount,
} = await apiClient<{ documents: Document[]; documentsCount: number }>({
} = await apiClient<{ documents: AsDto<Document>[]; documentsCount: number }>({
method: 'GET',
path: `/api/organizations/${organizationId}/documents/deleted`,
query: {
@@ -83,11 +76,7 @@ export async function fetchOrganizationDeletedDocuments({
return {
documentsCount,
documents: documents.map(document => ({
...document,
createdAt: new Date(document.createdAt),
updatedAt: document.updatedAt ? new Date(document.updatedAt) : undefined,
})),
documents: documents.map(coerceDates),
};
}
@@ -124,17 +113,13 @@ export async function fetchDocument({
documentId: string;
organizationId: string;
}) {
const { document } = await apiClient<{ document: Document }>({
const { document } = await apiClient<{ document: AsDto<Document> }>({
method: 'GET',
path: `/api/organizations/${organizationId}/documents/${documentId}`,
});
return {
document: {
...document,
createdAt: new Date(document.createdAt),
updatedAt: document.updatedAt ? new Date(document.updatedAt) : undefined,
},
document: coerceDates(document),
};
}
@@ -167,7 +152,7 @@ export async function searchDocuments({
}) {
const {
documents,
} = await apiClient<{ documents: Document[] }>({
} = await apiClient<{ documents: AsDto<Document>[] }>({
method: 'GET',
path: `/api/organizations/${organizationId}/documents/search`,
query: {
@@ -178,11 +163,7 @@ export async function searchDocuments({
});
return {
documents: documents.map(document => ({
...document,
createdAt: new Date(document.createdAt),
updatedAt: document.updatedAt ? new Date(document.updatedAt) : undefined,
})),
documents: documents.map(coerceDates),
};
}
@@ -194,3 +175,64 @@ export async function getOrganizationDocumentsStats({ organizationId }: { organi
return { organizationStats };
}
export async function deleteAllTrashDocuments({ organizationId }: { organizationId: string }) {
await apiClient({
method: 'DELETE',
path: `/api/organizations/${organizationId}/documents/trash`,
});
}
export async function deleteTrashDocument({ documentId, organizationId }: { documentId: string; organizationId: string }) {
await apiClient({
method: 'DELETE',
path: `/api/organizations/${organizationId}/documents/trash/${documentId}`,
});
}
export async function updateDocument({
documentId,
organizationId,
content,
name,
}: {
documentId: string;
organizationId: string;
content?: string;
name?: string;
}) {
const { document } = await apiClient<{ document: AsDto<Document> }>({
method: 'PATCH',
path: `/api/organizations/${organizationId}/documents/${documentId}`,
body: { content, name },
});
return {
document: coerceDates(document),
};
}
export async function fetchDocumentActivities({
documentId,
organizationId,
pageIndex,
pageSize,
}: {
documentId: string;
organizationId: string;
pageIndex: number;
pageSize: number;
}) {
const { activities } = await apiClient<{ activities: AsDto<DocumentActivity>[] }>({
method: 'GET',
path: `/api/organizations/${organizationId}/documents/${documentId}/activity`,
query: {
pageIndex,
pageSize,
},
});
return {
activities: activities.map(coerceDates),
};
}

View File

@@ -1,4 +1,6 @@
import type { Tag } from '../tags/tags.types';
import type { User } from '../users/users.types';
import type { DOCUMENT_ACTIVITY_EVENTS } from './documents.constants';
export type Document = {
id: string;
@@ -11,5 +13,20 @@ export type Document = {
isDeleted?: boolean;
deletedAt?: Date;
deletedBy?: string;
content: string;
tags: Tag[];
};
export type DocumentActivityEvent = (typeof DOCUMENT_ACTIVITY_EVENTS)[keyof typeof DOCUMENT_ACTIVITY_EVENTS];
export type DocumentActivity = {
id: string;
documentId: string;
event: DocumentActivityEvent;
eventData: Record<string, unknown>;
userId?: string;
createdAt: Date;
updatedAt?: Date;
tag?: Pick<Tag, 'id' | 'name' | 'color' | 'description'>;
user?: Pick<User, 'id' | 'name'>;
};

View File

@@ -1,17 +1,23 @@
import type { Component } from 'solid-js';
import type { Document } from '../documents.types';
import { useParams } from '@solidjs/router';
import { keepPreviousData, useMutation, useQuery } from '@tanstack/solid-query';
import { createSignal, Show, Suspense } from 'solid-js';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { useConfirmModal } from '@/modules/shared/confirm';
import { timeAgo } from '@/modules/shared/date/time-ago';
import { queryClient } from '@/modules/shared/query/query-client';
import { Alert, AlertDescription } from '@/modules/ui/components/alert';
import { Button } from '@/modules/ui/components/button';
import { useParams } from '@solidjs/router';
import { createQuery, keepPreviousData } from '@tanstack/solid-query';
import { type Component, createSignal, Show, Suspense } from 'solid-js';
import { createToast } from '@/modules/ui/components/sonner';
import { DocumentsPaginatedList } from '../components/documents-list.component';
import { useRestoreDocument } from '../documents.composables';
import { fetchOrganizationDeletedDocuments } from '../documents.services';
import { deleteAllTrashDocuments, deleteTrashDocument, fetchOrganizationDeletedDocuments } from '../documents.services';
const RestoreDocumentButton: Component<{ document: Document }> = (props) => {
const { getIsRestoring, restore } = useRestoreDocument();
const { t } = useI18n();
return (
<Button
@@ -21,11 +27,118 @@ const RestoreDocumentButton: Component<{ document: Document }> = (props) => {
isLoading={getIsRestoring()}
>
{ getIsRestoring()
? (<>Restoring...</>)
? (<>{t('documents.deleted.restoring')}</>)
: (
<>
<div class="i-tabler-refresh size-4 mr-2" />
Restore
{t('documents.actions.restore')}
</>
)}
</Button>
);
};
const PermanentlyDeleteTrashDocumentButton: Component<{ document: Document; organizationId: string }> = (props) => {
const { confirm } = useConfirmModal();
const { t } = useI18n();
const deleteMutation = useMutation(() => ({
mutationFn: async () => {
await deleteTrashDocument({ documentId: props.document.id, organizationId: props.organizationId });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['organizations', props.organizationId, 'documents', 'deleted'] });
createToast({
message: t('trash.deleted.success.title'),
description: t('trash.deleted.success.description'),
});
},
}));
const handleClick = async () => {
if (!await confirm({
title: t('trash.delete.confirm.title'),
message: t('trash.delete.confirm.description'),
confirmButton: {
text: t('trash.delete.confirm.label'),
variant: 'destructive',
},
cancelButton: {
text: t('trash.delete.confirm.cancel'),
},
})) {
return;
}
deleteMutation.mutate();
};
return (
<Button
variant="outline"
size="sm"
onClick={handleClick}
isLoading={deleteMutation.isPending}
class="text-red-500 hover:text-red-600"
>
{deleteMutation.isPending
? (<>{t('documents.deleted.deleting')}</>)
: (
<>
<div class="i-tabler-trash size-4 mr-2" />
{t('trash.delete.button')}
</>
)}
</Button>
);
};
const DeleteAllTrashDocumentsButton: Component<{ organizationId: string }> = (props) => {
const { confirm } = useConfirmModal();
const { t } = useI18n();
const deleteAllMutation = useMutation(() => ({
mutationFn: async () => {
await deleteAllTrashDocuments({ organizationId: props.organizationId });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['organizations', props.organizationId, 'documents', 'deleted'] });
},
}));
const handleClick = async () => {
if (!await confirm({
title: t('trash.delete-all.confirm.title'),
message: t('trash.delete-all.confirm.description'),
confirmButton: {
text: t('trash.delete-all.confirm.label'),
variant: 'destructive',
},
cancelButton: {
text: t('trash.delete-all.confirm.cancel'),
},
})) {
return;
}
deleteAllMutation.mutate();
};
return (
<Button
variant="outline"
size="sm"
onClick={handleClick}
isLoading={deleteAllMutation.isPending}
class="text-red-500 hover:text-red-600"
>
{deleteAllMutation.isPending
? (<>{t('documents.deleted.deleting')}</>)
: (
<>
<div class="i-tabler-trash size-4 mr-2" />
{t('trash.delete-all.button')}
</>
)}
</Button>
@@ -36,8 +149,9 @@ export const DeletedDocumentsPage: Component = () => {
const [getPagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 100 });
const params = useParams();
const { config } = useConfig();
const { t } = useI18n();
const query = createQuery(() => ({
const query = useQuery(() => ({
queryKey: ['organizations', params.organizationId, 'documents', 'deleted', getPagination()],
queryFn: () => fetchOrganizationDeletedDocuments({
organizationId: params.organizationId,
@@ -48,16 +162,12 @@ export const DeletedDocumentsPage: Component = () => {
return (
<div class="p-6 mt-4 pb-32">
<h1 class="text-2xl font-bold">Deleted documents</h1>
<h1 class="text-2xl font-bold">{t('documents.deleted.title')}</h1>
<Alert variant="muted" class="my-4 flex items-center gap-6 xl:gap-4">
<div class="i-tabler-info-circle size-10 xl:size-8 text-primary flex-shrink-0 hidden sm:block" />
<AlertDescription>
All deleted documents are stored in the trash bin for
{' '}
{config.documents.deletedDocumentsRetentionDays}
{' '}
days. Passing this delay, the documents will be permanently deleted, and you will not be able to restore them.
{t('documents.deleted.retention-notice', { days: config.documents.deletedDocumentsRetentionDays })}
</AlertDescription>
</Alert>
@@ -65,18 +175,18 @@ export const DeletedDocumentsPage: Component = () => {
<Show when={query.data?.documents.length === 0}>
<div class="flex flex-col items-center justify-center gap-2 pt-24 mx-auto max-w-md text-center">
<div class="i-tabler-trash text-primary size-12" aria-hidden="true" />
<div class="text-xl font-medium">No deleted documents</div>
<div class="text-sm text-muted-foreground">
You have no deleted documents. Documents that are deleted will be moved to the trash bin for
{' '}
{config.documents.deletedDocumentsRetentionDays}
{' '}
days.
<div class="text-xl font-medium">{t('documents.deleted.empty.title')}</div>
<div class="text-sm text-muted-foreground">
{t('documents.deleted.empty.description', { days: config.documents.deletedDocumentsRetentionDays })}
</div>
</div>
</Show>
<Show when={query.data && query.data?.documents.length > 0}>
<div class="flex items-center justify-end gap-2">
<DeleteAllTrashDocumentsButton organizationId={params.organizationId} />
</div>
<DocumentsPaginatedList
documents={query.data?.documents ?? []}
documentsCount={query.data?.documentsCount ?? 0}
@@ -87,7 +197,7 @@ export const DeletedDocumentsPage: Component = () => {
id: 'deletion',
cell: data => (
<div class="text-muted-foreground hidden sm:block">
Deleted
{t('documents.deleted.deleted-at')}
{' '}
<span class="text-foreground font-bold" title={data.row.original.deletedAt?.toLocaleString()}>{timeAgo({ date: data.row.original.deletedAt! })}</span>
</div>
@@ -96,8 +206,9 @@ export const DeletedDocumentsPage: Component = () => {
{
id: 'actions',
cell: data => (
<div class="flex items-center justify-end">
<div class="flex items-center justify-end gap-2">
<RestoreDocumentButton document={data.row.original} />
<PermanentlyDeleteTrashDocumentButton document={data.row.original} organizationId={params.organizationId} />
</div>
),
},

View File

@@ -1,19 +1,31 @@
import type { Component, JSX } from 'solid-js';
import type { DocumentActivity } from '../documents.types';
import { formatBytes, safely } from '@corentinth/chisels';
import { A, useNavigate, useParams, useSearchParams } from '@solidjs/router';
import { createQueries, useInfiniteQuery } from '@tanstack/solid-query';
import { createEffect, createSignal, For, Match, Show, Suspense, Switch } from 'solid-js';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { timeAgo } from '@/modules/shared/date/time-ago';
import { downloadFile } from '@/modules/shared/files/download';
import { queryClient } from '@/modules/shared/query/query-client';
import { cn } from '@/modules/shared/style/cn';
import { DocumentTagPicker } from '@/modules/tags/components/tag-picker.component';
import { TagLink } from '@/modules/tags/components/tag.component';
import { CreateTagModal } from '@/modules/tags/pages/tags.page';
import { addTagToDocument, removeTagFromDocument } from '@/modules/tags/tags.services';
import { Alert } from '@/modules/ui/components/alert';
import { Alert, AlertDescription } from '@/modules/ui/components/alert';
import { Button } from '@/modules/ui/components/button';
import { Separator } from '@/modules/ui/components/separator';
import { formatBytes } from '@corentinth/chisels';
import { useNavigate, useParams } from '@solidjs/router';
import { createQueries } from '@tanstack/solid-query';
import { type Component, For, type JSX, Show, Suspense } from 'solid-js';
import { createToast } from '@/modules/ui/components/sonner';
import { Tabs, TabsContent, TabsIndicator, TabsList, TabsTrigger } from '@/modules/ui/components/tabs';
import { TextArea } from '@/modules/ui/components/textarea';
import { TextFieldRoot } from '@/modules/ui/components/textfield';
import { DocumentPreview } from '../components/document-preview.component';
import { getDaysBeforePermanentDeletion } from '../document.models';
import { useRenameDocumentDialog } from '../components/rename-document-button.component';
import { getDaysBeforePermanentDeletion, getDocumentActivityIcon } from '../document.models';
import { useDeleteDocument, useRestoreDocument } from '../documents.composables';
import { fetchDocument, fetchDocumentFile } from '../documents.services';
import { fetchDocument, fetchDocumentActivities, fetchDocumentFile, updateDocument } from '../documents.services';
import '@pdfslick/solid/dist/pdf_viewer.css';
type KeyValueItem = {
@@ -29,7 +41,7 @@ const KeyValues: Component<{ data?: KeyValueItem[] }> = (props) => {
<For each={props.data}>
{item => (
<tr>
<td class="py-1 pr-2 text-sm text-muted-foreground flex items-center gap-2">
<td class="py-1 pr-2 text-sm text-muted-foreground flex items-center gap-2 whitespace-nowrap">
{item.icon && <div class={item.icon}></div>}
{item.label}
</td>
@@ -42,12 +54,78 @@ const KeyValues: Component<{ data?: KeyValueItem[] }> = (props) => {
);
};
export const DocumentPage: Component = () => {
const ActivityItem: Component<{ activity: DocumentActivity }> = (props) => {
const { t, te } = useI18n();
const params = useParams();
return (
<div class="border-b py-3 flex items-center gap-2">
<div>
<div class={cn(getDocumentActivityIcon({ event: props.activity.event }), 'size-6 text-muted-foreground')} />
</div>
<div>
<Switch fallback={<span class="text-sm">{t(`activity.document.${props.activity.event}`)}</span>}>
<Match when={['tagged', 'untagged'].includes(props.activity.event)}>
<span class="text-sm flex items-baseline gap-1">
{te(`activity.document.${props.activity.event}`, { tag: props.activity.tag ? <TagLink {...props.activity.tag} organizationId={params.organizationId} class="text-xs" /> : undefined })}
</span>
</Match>
<Match when={props.activity.event === 'updated' && (props.activity.eventData.updatedFields as string[]).length === 1}>
<span class="text-sm flex items-baseline gap-1">
{te(`activity.document.updated.single`, {
field: <span class="font-bold">{(props.activity.eventData.updatedFields as string[])[0]}</span>,
})}
</span>
</Match>
<Match when={props.activity.event === 'updated' && (props.activity.eventData.updatedFields as string[]).length > 1}>
<span class="text-sm flex items-baseline gap-1">
{te(`activity.document.updated.multiple`, { fields: (props.activity.eventData.updatedFields as string[]).join(', ') })}
</span>
</Match>
</Switch>
<div class="flex items-center gap-1 text-xs text-muted-foreground">
<span title={props.activity.createdAt.toLocaleString()}>{timeAgo({ date: props.activity.createdAt })}</span>
<Show when={props.activity.user}>
{getUser => (
<span>{te('activity.document.user.name', { name: <A href={`/organizations/${params.organizationId}/members`} class="underline hover:text-primary transition">{getUser().name}</A> })}</span>
)}
</Show>
</div>
</div>
</div>
);
};
const tabs = ['info', 'content', 'activity'] as const;
type Tab = typeof tabs[number];
export const DocumentPage: Component = () => {
const { t } = useI18n();
const params = useParams();
const [searchParams, setSearchParams] = useSearchParams();
const { deleteDocument } = useDeleteDocument();
const { restore, getIsRestoring } = useRestoreDocument();
const navigate = useNavigate();
const { config } = useConfig();
const { openRenameDialog } = useRenameDocumentDialog();
const getInitialTab = (): Tab => {
const tab = searchParams.tab;
if (tab && typeof tab === 'string' && tabs.includes(tab as Tab)) {
return tab as Tab;
}
return 'info';
};
const [getTab, setTab] = createSignal<Tab>(getInitialTab());
createEffect(() => {
setSearchParams({ tab: getTab() }, { replace: true });
});
const queries = createQueries(() => ({
queries: [
@@ -62,6 +140,30 @@ export const DocumentPage: Component = () => {
],
}));
const activityPageSize = 20;
const activityQuery = useInfiniteQuery(() => ({
enabled: getTab() === 'activity',
queryKey: ['organizations', params.organizationId, 'documents', params.documentId, 'activity'],
queryFn: async ({ pageParam }) => {
const { activities } = await fetchDocumentActivities({
documentId: params.documentId,
organizationId: params.organizationId,
pageIndex: pageParam,
pageSize: activityPageSize,
});
return activities;
},
getNextPageParam: (lastPage, _pages, lastPageParam) => {
if (lastPage.length < activityPageSize) {
return undefined;
}
return lastPageParam + 1;
},
initialPageParam: 0,
}));
const deleteDoc = async () => {
if (!queries[0].data) {
return;
@@ -82,6 +184,44 @@ export const DocumentPage: Component = () => {
const getDataUrl = () => queries[1].data ? URL.createObjectURL(queries[1].data) : undefined;
const [isEditing, setIsEditing] = createSignal(false);
const [editedContent, setEditedContent] = createSignal('');
const [isSaving, setIsSaving] = createSignal(false);
const handleEdit = () => {
setEditedContent(queries[0].data?.document.content ?? '');
setIsEditing(true);
};
const handleCancel = () => {
setIsEditing(false);
setEditedContent('');
};
const handleSave = async () => {
if (!queries[0].data?.document) {
return;
}
setIsSaving(true);
const [, error] = await safely(updateDocument({
documentId: queries[0].data.document.id,
organizationId: params.organizationId,
content: editedContent(),
}));
setIsSaving(false);
setIsEditing(false);
if (error) {
createToast({ type: 'error', message: 'Failed to update document content' });
return;
}
createToast({ type: 'success', message: 'Document content updated' });
await queryClient.invalidateQueries({
queryKey: ['organizations', params.organizationId, 'documents', params.documentId],
});
};
return (
<div class="p-6 flex gap-6 h-full flex-col md:flex-row max-w-7xl mx-auto">
<Suspense>
@@ -90,7 +230,21 @@ export const DocumentPage: Component = () => {
{getDocument => (
<div class="flex gap-4 md:pr-6">
<div class="flex-1">
<h1 class="text-xl font-semibold">{getDocument().name}</h1>
<Button
variant="ghost"
class="flex items-center gap-2 group bg-transparent! px-0 text-left h-auto"
onClick={() => openRenameDialog({
documentId: getDocument().id,
organizationId: params.organizationId,
documentName: getDocument().name,
})}
>
<h1 class="text-xl font-semibold lh-tight" title={getDocument().name}>
{getDocument().name}
</h1>
<div class="i-tabler-pencil size-4 text-muted-foreground group-hover:text-foreground transition-colors flex-shrink-0"></div>
</Button>
<p class="text-sm text-muted-foreground mb-6">{getDocument().id}</p>
<div class="flex gap-2 mb-2">
@@ -100,7 +254,7 @@ export const DocumentPage: Component = () => {
size="sm"
>
<div class="i-tabler-download size-4 mr-2"></div>
Download
{t('documents.actions.download')}
</Button>
<Button
@@ -109,7 +263,7 @@ export const DocumentPage: Component = () => {
size="sm"
>
<div class="i-tabler-eye size-4 mr-2"></div>
Open in new tab
{t('documents.actions.open-in-new-tab')}
</Button>
{getDocument().isDeleted
@@ -121,7 +275,7 @@ export const DocumentPage: Component = () => {
isLoading={getIsRestoring()}
>
<div class="i-tabler-refresh size-4 mr-2"></div>
Restore
{t('documents.actions.restore')}
</Button>
)
: (
@@ -131,80 +285,191 @@ export const DocumentPage: Component = () => {
onClick={deleteDoc}
>
<div class="i-tabler-trash size-4 mr-2"></div>
Delete
{t('documents.actions.delete')}
</Button>
)}
</div>
<DocumentTagPicker
organizationId={params.organizationId}
documentId={params.documentId}
tags={getDocument().tags}
onTagAdded={async ({ tag }) => {
await addTagToDocument({
documentId: params.documentId,
organizationId: params.organizationId,
tagId: tag.id,
});
}}
<div class="flex gap-2 sm:items-center sm:flex-row flex-col">
<div class="flex-1">
<DocumentTagPicker
organizationId={params.organizationId}
tagIds={getDocument().tags.map(tag => tag.id)}
onTagAdded={async ({ tag }) => {
await addTagToDocument({
documentId: params.documentId,
organizationId: params.organizationId,
tagId: tag.id,
});
}}
onTagRemoved={async ({ tag }) => {
await removeTagFromDocument({
documentId: params.documentId,
organizationId: params.organizationId,
tagId: tag.id,
});
}}
/>
onTagRemoved={async ({ tag }) => {
await removeTagFromDocument({
documentId: params.documentId,
organizationId: params.organizationId,
tagId: tag.id,
});
}}
/>
</div>
<CreateTagModal organizationId={params.organizationId}>
{params => (
<Button variant="outline" {...params}>
<div class="i-tabler-plus size-4 mr-2"></div>
{t('tagging-rules.form.tags.add-tag')}
</Button>
)}
</CreateTagModal>
</div>
{getDocument().isDeleted && (
<Alert variant="destructive" class="mt-6">
This document has been deleted and will be permanently removed in
{' '}
{getDaysBeforePermanentDeletion({
{t('documents.deleted.message', { days: getDaysBeforePermanentDeletion({
document: getDocument(),
deletedDocumentsRetentionDays: config.documents.deletedDocumentsRetentionDays,
})}
{' '}
days.
}) ?? 0 })}
</Alert>
)}
<Separator class="my-6" />
<Separator class="my-3" />
<Tabs value={getTab()} onChange={setTab} class="w-full">
<TabsList class="w-full h-8">
<TabsTrigger value="info">{t('documents.tabs.info')}</TabsTrigger>
<TabsTrigger value="content">{t('documents.tabs.content')}</TabsTrigger>
<TabsTrigger value="activity">{t('documents.tabs.activity')}</TabsTrigger>
<TabsIndicator />
</TabsList>
<TabsContent value="info">
<KeyValues data={[
{
label: t('documents.info.id'),
value: getDocument().id,
icon: 'i-tabler-id',
},
{
label: t('documents.info.name'),
value: (
<Button
variant="ghost"
class="flex items-center gap-2 group bg-transparent! p-0 h-auto text-left"
onClick={() => openRenameDialog({
documentId: getDocument().id,
organizationId: params.organizationId,
documentName: getDocument().name,
})}
>
{getDocument().name}
<div class="i-tabler-pencil size-4 text-muted-foreground group-hover:text-foreground transition-colors flex-shrink-0"></div>
</Button>
),
icon: 'i-tabler-file-text',
},
{
label: t('documents.info.type'),
value: getDocument().mimeType,
icon: 'i-tabler-file-unknown',
},
{
label: t('documents.info.size'),
value: formatBytes({ bytes: getDocument().originalSize, base: 1000 }),
icon: 'i-tabler-weight',
},
{
label: t('documents.info.created-at'),
value: timeAgo({ date: getDocument().createdAt }),
icon: 'i-tabler-calendar',
},
{
label: t('documents.info.updated-at'),
value: getDocument().updatedAt ? timeAgo({ date: getDocument().updatedAt! }) : <span class="text-muted-foreground">{t('documents.info.never')}</span>,
icon: 'i-tabler-calendar',
},
]}
/>
</TabsContent>
<TabsContent value="content">
<Show
when={isEditing()}
fallback={(
<div class="flex flex-col gap-2">
<div class="whitespace-pre-wrap font-mono text-sm bg-muted p-4 rounded-md max-h-[400px] overflow-auto">
{queries[0].data?.document.content}
</div>
<div class="flex justify-end">
<Button variant="outline" onClick={handleEdit}>
<div class="i-tabler-edit size-4 mr-2" />
{t('documents.actions.edit')}
</Button>
</div>
<Alert variant="muted" class="my-4 flex items-center gap-2">
<div class="i-tabler-info-circle size-8 flex-shrink-0" />
<AlertDescription>
{t('documents.content.alert')}
</AlertDescription>
</Alert>
</div>
)}
>
<div class="flex flex-col gap-2">
<TextFieldRoot>
<TextArea
value={editedContent()}
onInput={e => setEditedContent(e.currentTarget.value)}
class="font-mono min-h-[200px]"
/>
</TextFieldRoot>
<div class="flex justify-end gap-2">
<Button variant="outline" onClick={handleCancel} disabled={isSaving()}>
{t('documents.actions.cancel')}
</Button>
<Button onClick={handleSave} disabled={isSaving()}>
{isSaving() ? t('documents.actions.saving') : t('documents.actions.save')}
</Button>
</div>
</div>
</Show>
</TabsContent>
<TabsContent value="activity">
<Show when={activityQuery.data?.pages}>
{getActivitiesPages => (
<div class="flex flex-col">
<For each={getActivitiesPages() ?? []}>
{activities => (
<For each={activities}>
{activity => (
<ActivityItem activity={activity} />
)}
</For>
)}
</For>
<Show
when={activityQuery.hasNextPage}
fallback={(
<div class="text-sm text-muted-foreground text-center py-4">
{t('activity.no-more-activities')}
</div>
)}
>
<Button
variant="outline"
onClick={() => activityQuery.fetchNextPage()}
isLoading={activityQuery.isFetchingNextPage}
>
{t('activity.load-more')}
</Button>
</Show>
</div>
)}
</Show>
</TabsContent>
</Tabs>
<KeyValues data={[
{
label: 'ID',
value: getDocument().id,
icon: 'i-tabler-id',
},
{
label: 'Name',
value: getDocument().name,
icon: 'i-tabler-file-text',
},
{
label: 'Type',
value: getDocument().mimeType,
icon: 'i-tabler-file-unknown',
},
{
label: 'Size',
value: formatBytes({ bytes: getDocument().originalSize, base: 1000 }),
icon: 'i-tabler-weight',
},
{
label: 'Created At',
value: timeAgo({ date: getDocument().createdAt }),
icon: 'i-tabler-calendar',
},
{
label: 'Updated At',
value: getDocument().updatedAt ? timeAgo({ date: getDocument().updatedAt! }) : <span class="text-muted-foreground">Never</span>,
icon: 'i-tabler-calendar',
},
]}
/>
</div>
</div>
)}

View File

@@ -1,16 +1,19 @@
import { fetchOrganization } from '@/modules/organizations/organizations.services';
import { Tag } from '@/modules/tags/components/tag.component';
import { fetchTags } from '@/modules/tags/tags.services';
import type { Component } from 'solid-js';
import { useParams, useSearchParams } from '@solidjs/router';
import { createQueries, keepPreviousData } from '@tanstack/solid-query';
import { castArray } from 'lodash-es';
import { type Component, createSignal, For, Show, Suspense } from 'solid-js';
import { createSignal, For, Show, Suspense } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { fetchOrganization } from '@/modules/organizations/organizations.services';
import { Tag } from '@/modules/tags/components/tag.component';
import { fetchTags } from '@/modules/tags/tags.services';
import { DocumentUploadArea } from '../components/document-upload-area.component';
import { createdAtColumn, DocumentsPaginatedList, standardActionsColumn, tagsColumn } from '../components/documents-list.component';
import { fetchOrganizationDocuments } from '../documents.services';
export const DocumentsPage: Component = () => {
const params = useParams();
const { t } = useI18n();
const [searchParams, setSearchParams] = useSearchParams();
const [getPagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 100 });
@@ -50,11 +53,11 @@ export const DocumentsPage: Component = () => {
? (
<>
<h2 class="text-xl font-bold ">
No documents
{t('documents.list.no-documents.title')}
</h2>
<p class="text-muted-foreground mt-1 mb-6">
There are no documents in this organization yet. Start by uploading some documents.
{t('documents.list.no-documents.description')}
</p>
<DocumentUploadArea />
@@ -64,7 +67,7 @@ export const DocumentsPage: Component = () => {
: (
<>
<h2 class="text-lg font-semibold mb-4">
Documents
{t('documents.list.title')}
</h2>
<Show when={hasFilters()}>
<div class="flex flex-wrap gap-2 mb-4">
@@ -82,7 +85,7 @@ export const DocumentsPage: Component = () => {
<Show when={hasFilters() && query[0].data?.documentsCount === 0}>
<p class="text-muted-foreground mt-1 mb-6">
No documents found
{t('documents.list.no-results')}
</p>
</Show>

View File

@@ -1,4 +1,10 @@
export const locales = [
{ key: 'en', name: 'English' },
{ key: 'fr', name: 'Français' },
];
{ key: 'de', name: 'Deutsch' },
{ key: 'pt-BR', name: 'Português Brasileiro' },
{ key: 'pt', name: 'Português Europeu' },
{ key: 'pl', name: 'Polski' },
{ key: 'ro', name: 'Română' },
{ key: 'es', name: 'Español' },
] as const;

View File

@@ -30,7 +30,13 @@ export function findMatchingLocale({
export function createTranslator<Dict extends Record<string, string>>({ getDictionary }: { getDictionary: () => Dict }) {
return (key: keyof Dict, args?: Record<string, string | number>) => {
let translation: string = getDictionary()[key] ?? key;
const translationFromDictionary = getDictionary()[key];
if (!translationFromDictionary && import.meta.env.DEV) {
console.warn(`Translation not found for key: ${String(key)}`);
}
let translation: string = translationFromDictionary ?? key;
if (args) {
for (const [key, value] of Object.entries(args)) {

View File

@@ -2,7 +2,7 @@ import type { Accessor, ParentComponent, Setter } from 'solid-js';
import type { LocaleKeys } from './locales.types';
import { makePersisted } from '@solid-primitives/storage';
import { createContext, createEffect, createResource, createSignal, Show, useContext } from 'solid-js';
import defaultDict from '../../locales/en.yml?flattened';
import defaultDict from '../../locales/en.yml';
import { locales } from './i18n.constants';
import { createFragmentTranslator, createTranslator, findMatchingLocale } from './i18n.models';
@@ -28,7 +28,7 @@ export function useI18n() {
}
async function fetchDictionary(locale: Locale): Promise<Dictionary> {
const { default: dict } = await import(`../../locales/${locale}.yml?flattened`);
const { default: dict } = await import(`../../locales/${locale}.yml`);
return {
...defaultDict,

Some files were not shown because too many files have changed in this diff Show More