Compare commits

...

160 commits

Author SHA1 Message Date
Corentin Thomasset
7854f6b1a1
chore(pnpm): upgrade pnpm to 10.33.0 (#1051)
* chore(pnpm): upgrade pnpm to 10.33.0

* Update .changeset/smooth-peaches-wash.md

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-16 22:12:34 +00:00
Corentin Thomasset
2b0c258f8a
feat(custom-properties): migrate from zod to valibot validation (#1049) 2026-04-16 23:07:15 +02:00
Corentin Thomasset
988383ea57
refactor(server): cleanup no longer used zod schemas (#1048) 2026-04-16 15:38:00 +00:00
Corentin Thomasset
a574fb7206
refactor(server): migrate make-user-admin script to valibot (#1047) 2026-04-16 15:23:57 +00:00
Corentin Thomasset
6a8ade711b
refactor(webhooks): migrate from zod to valibot for route validation (#1046) 2026-04-16 17:21:35 +02:00
Corentin Thomasset
e05e6e86f0
refactor(tags): migrate route validation from zod to valibot (#1044) 2026-04-16 17:11:20 +02:00
Corentin Thomasset
fb8cc381cc
refactor(users): migrate route validation from zod to valibot (#1045) 2026-04-16 14:58:47 +00:00
Corentin Thomasset
4f25ecd9d2
refactor(tagging-rules): migrate route validation from zod to valibot (#1043) 2026-04-16 16:16:13 +02:00
Corentin Thomasset
ad3fabf564
refactor(subscriptions): migrate from zod to valibot route validation (#1042) 2026-04-16 15:34:28 +02:00
Corentin Thomasset
123b7c1784
refactor(organizations): migrate route validation from zod to valibot (#1041) 2026-04-16 15:21:48 +02:00
Corentin Thomasset
4fe1731323
refactor(invitations): migrate from zod to valibot route validation (#1040) 2026-04-16 13:17:54 +00:00
Corentin Thomasset
ff2ae64abe
refactor(intake-emails): migrate route validation from zod to valibot (#1039) 2026-04-16 12:49:37 +00:00
Corentin Thomasset
791d08c486
refactor(documents): migrate route validation from zod to valibot (#1038) 2026-04-16 13:35:59 +02:00
Corentin Thomasset
612fcb7563
refactor(document-activity): migrate validation from zod to valibot (#1037) 2026-04-16 13:05:36 +02:00
Corentin Thomasset
488997f580
refactor(custom-properties): migrate custom-properties routes to valibot (#1036) 2026-04-16 01:22:28 +02:00
Corentin Thomasset
9ad1c16b45
feat(api-keys): migrate validation from zod to valibot (#1035) 2026-04-15 23:10:25 +00:00
Corentin Thomasset
c5ccac53c2
feat(client): support yaml file content preview (#1034) 2026-04-15 19:38:58 +00:00
Corentin Thomasset
5d55e41c3b
refactor(server): migrate admin endpoint validation from zod to valibot (#1033) 2026-04-15 18:17:07 +02:00
Corentin Thomasset
401efe2fa4
refactor(schemas): renamed zod schemas files to legacy (#1031) 2026-04-12 19:32:57 +02:00
Corentin Thomasset
366e4eead1
test(validation): add valibot validation middleware (#1029) 2026-04-09 20:41:31 +00:00
Corentin Thomasset
6c1e45acf1
refactor(validation): rename validation functions to legacy namespace (#1028) 2026-04-09 21:03:40 +02:00
Corentin Thomasset
b154d2f363
fix(ui): remove shadows from ui-component in light mode (#1027) 2026-04-07 00:08:21 +02:00
Corentin Thomasset
5f8e2f6375
refactor(webhooks): add a dedicated webhook response api formatter (#1026) 2026-04-06 23:24:42 +02:00
Corentin Thomasset
9c6985b51f
feat(command-palette): auto-select input text when reopening with existing query (#1025)
existing query
2026-04-06 20:12:35 +00:00
Corentin Thomasset
2fcbe0bc89
refactor(documents): extract document ownership validation to usecases (#1023) 2026-04-05 22:56:16 +02:00
Corentin Thomasset
5b3a85795c
refactor(server): normalize file naming to plural webhooks (#1022) 2026-04-05 17:51:33 +02:00
Corentin Thomasset
133d235ccd
fix(client): prevent long document names from breaking table layout (#1021) 2026-04-05 17:03:50 +02:00
John Cuba
5bdf0dab1f
feat(client): add search params pagination sync in the home page (#1015)
* feat(client): add pagination sinc with search params

* chore(version): added changeset

* refactor(client): auto lint

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2026-04-04 22:10:12 +02:00
Corentin Thomasset
015bb53498
feat(webhooks): add size limits to API parameters (#1020) 2026-04-04 21:59:37 +02:00
John Cuba
07d7109a46
fix(client): remove useless close buttons (#1016)
* fix(client): remove close button

* fix(client): remove webkit X button on search input

* chore(version): added changeset

* chore(version): added changeset

Removed the native clear button from the search bar specifically in Safari.

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2026-04-04 19:22:11 +00:00
Corentin Thomasset
f0b346760d
chore(server): removed lodash-es dependency (#1019) 2026-04-04 16:19:13 +00:00
Corentin Thomasset
8984179a76
refactor(server): add in house omit utility (#1018) 2026-04-04 18:08:18 +02:00
Corentin Thomasset
1548535f1e
refactor(tests): replace lodash merge with in house deepMerge (#1017) 2026-04-04 17:31:03 +02:00
John Cuba
ad5e42d445
fix(client): improved responsivness of the layout
* fix(client): menu by hamburger on lg layout

* fix(client): make table scrollable on md layout

* chore(version): added changeset

Increased the sidebar collapsing breakpoint for better UX.

* chore(version): added changeset

Prevented horizontal scrolling in admin panels for users and organizations tables.

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2026-04-04 01:55:21 +00:00
Corentin Thomasset
cee14d96a5
refactor(server): removed lodash from migration script (#1014) 2026-04-04 01:36:06 +00:00
Corentin Thomasset
4a10ace601
refactor(server): custom in house omitUndefined (#1013) 2026-04-04 03:22:04 +02:00
Corentin Thomasset
927c7d6b31
fix(errors): stop creating empty error causes (#1010) 2026-04-04 00:43:23 +00:00
Corentin Thomasset
59a62cbf6a
chore(server): remove skipped uniq benchmark file (#1009) 2026-04-04 00:18:26 +00:00
Corentin Thomasset
5b0d747d29
refactor(server): replace lodash pick/merge with explicit object (#1008)
construction
2026-04-04 02:12:11 +02:00
Corentin Thomasset
db988e990d
refactor(server): custom pick utility to replace lodash-es (#1007) 2026-04-04 02:01:10 +02:00
Corentin Thomasset
ef89ea6751
refactor(server): replaced remaning lodash-es get with type-safe property access (#1006)
property access
2026-04-03 23:25:23 +00:00
Corentin Thomasset
da86b93439
refactor(roles): replace lodash map with native array method (#1005) 2026-04-03 21:43:29 +00:00
Corentin Thomasset
e11fb5352a
chore(shared): replace lodash memoize with chisels memoizeOnce (#1004) 2026-04-03 23:28:28 +02:00
Corentin Thomasset
f713a9801a
fix(static): replace memoized index.html with direct fs read (#1002) 2026-04-03 18:12:42 +02:00
Corentin Thomasset
fe724fbb06
chore(docs): remove lodash-es dependency (#1001) 2026-04-03 16:58:31 +02:00
Corentin Thomasset
ac11aa50a4
chore(lecture): upgrade unpdf to v1.4.0 (#1000) 2026-04-03 14:25:28 +00:00
Corentin Thomasset
f248b283dc
chore(lecture): upgrade fast-xml-parser to v5.5.10 (#999) 2026-04-03 14:22:05 +00:00
Corentin Thomasset
b29396a197
chore(release): update versions (#982)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-02 22:34:34 +02:00
Corentin Thomasset
c252a5945f
fix(changeset): update app version from patch to minor (#998) 2026-04-02 20:25:53 +00:00
Corentin Thomasset
7d22a31511
refactor(changesest): mention ghsa and credit reporter (#997) 2026-04-02 20:11:09 +00:00
Corentin Thomasset
327eda0001
fix(emails): sanitize user name in email content (#995) 2026-04-02 17:06:24 +02:00
Corentin Thomasset
17b501a996
feat(webhooks): implement SSRF protection for webhook URLs (#993) 2026-04-02 14:38:20 +00:00
Corentin Thomasset
9ee776f1c5
feat(api-keys): add expiration check to getApiKeyByHash function (#994) 2026-04-02 16:29:01 +02:00
Corentin Thomasset
884d470410
feat(api-keys): removed api-keys creation endpoints unused params to prevent confusions (#986) 2026-04-02 16:09:38 +02:00
Corentin Thomasset
9039b4806e
fix(errors): return 409 status code for existing tag errors in API (#992) 2026-04-02 13:55:40 +02:00
Luke Milby
4b77e41242
fix(docs): add search query in get documents endpoint doc
* Change API endpoint body format from form-data to JSON

* Add 'searchQuery' parameter to API endpoint documentation

Updated the API documentation to include the 'searchQuery' parameter for filtering results.
2026-03-31 13:35:22 +00:00
Corentin Thomasset
00cfbf88df
fix(config): properly generate config schema for ide autocompletion (#985) 2026-03-29 00:26:38 +00:00
Corentin Thomasset
8fe222bb8f
refactor(validation): migrated from zod to valibot for the app config validation (#984) 2026-03-28 23:33:48 +00:00
Corentin Thomasset
7d34241bbf
chore(deps): update valibot version to 1.3.1 (#983) 2026-03-28 18:24:08 +01:00
Corentin Thomasset
f336b842c6
refactor(topbar): moved theme switcher to user settings dropdown (#981) 2026-03-28 18:07:55 +01:00
Corentin Thomasset
5555934847
refactor(theme): in-house theme management (#979) 2026-03-28 17:21:00 +01:00
Luke Milby
2ca5634166
fix(docs): proper API body type to update document (#978) 2026-03-28 15:12:47 +01:00
Andreas Frank
882fee6e73
docs: improve cloudflare email worker intake guide (#975) 2026-03-26 20:46:50 +01:00
Corentin Thomasset
31e0813429
fix(comparison): refresh container size values for paperless comparison (#973) 2026-03-26 20:28:12 +01:00
Corentin Thomasset
1c1e904ba1
fix(demo): custom property name normalization for search (#974) 2026-03-26 19:17:31 +00:00
Corentin Thomasset
ce1e891ec1
feat(docs): enable new features documentations (#971) 2026-03-26 15:17:40 +01:00
Corentin Thomasset
30bd0778b3
chore(release): update versions (#917)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-26 14:55:40 +01:00
Anton Palmqvist
595b5a9149
feat(i18n): updated Swedish translation with custom properties (#972)
* Updated Swedish translation with the new custom properties

* Update apps/papra-client/src/locales/sv.dictionary.ts

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

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-26 13:18:51 +00:00
Corentin Thomasset
5900674083
feat(search): add support for quoted search filters (#970) 2026-03-26 00:33:17 +01:00
Anton Palmqvist
74828e8ad6
feat(i18n): add Swedish (sv) translations (#968)
* Added Swedish translation

* fix(i18n): added types

* Update apps/papra-client/src/locales/sv.dictionary.ts

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

* Update apps/papra-client/src/locales/sv.dictionary.ts

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

* Update apps/papra-client/src/locales/sv.dictionary.ts

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

* Update apps/papra-client/src/locales/sv.dictionary.ts

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

* feat(version): added changeset

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-25 20:20:05 +00:00
Corentin Thomasset
812e7c317e
feat(tags): add sorting functionality to tags list table (#969) 2026-03-25 19:41:42 +00:00
Corentin Thomasset
b900a1d947
feat(demo): added custom properties in demo (#967) 2026-03-25 16:16:55 +00:00
Corentin Thomasset
d47d6b29a6
feat(i18n): custom properties translations (#966)
* feat(i18n): custom properties translations

* Apply suggestions from code review

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-25 15:45:31 +00:00
Corentin Thomasset
87d80af2ac
feat(search): support custom properties (#965) 2026-03-25 16:39:59 +01:00
Corentin Thomasset
7c2b2d27cd
feat(documents): add custom properties (#956) 2026-03-25 13:40:57 +00:00
Corentin Thomasset
db6badbc3c
feat(pdf-extractor): add support for 1-bit grayscale image extraction (#953) 2026-03-21 20:42:42 +01:00
Corentin Thomasset
ec740ed168
feat(extractors): add support for .xlsx and .ods file content extraction (#949) 2026-03-15 23:19:24 +01:00
Corentin Thomasset
725eaff4b0
feat(pdf-extractor): add page rendering fallback for pdf without text nor image (#948) 2026-03-11 21:16:15 +01:00
Corentin Thomasset
27fe78501d
refactor(demo): support now for date filters (#947) 2026-03-08 01:44:19 +00:00
Corentin Thomasset
c7089610ac
feat(demo): add document date filtering conditions (#946) 2026-03-08 01:34:24 +00:00
Corentin Thomasset
31e27d5e1e
feat(search): add document date filtering (#945) 2026-03-08 02:23:04 +01:00
Corentin Thomasset
3bde35bb07
refactor(query-builder): mutualized search issue pattern (#944) 2026-03-08 00:09:33 +00:00
Corentin Thomasset
a7b18cec6b
feat(documents): add document date (#943) 2026-03-08 00:43:55 +01:00
Corentin Thomasset
e1314a42f2
fix(docs): prevent confusion with e2ee (#942) 2026-03-06 17:29:55 +01:00
Corentin Thomasset
305921e779
fix(docker): proper root dockerignore symlink path (#940)
* fix(docker): proper root dockerignore symlink path

* refactor(docker): mutualized dockerignore
2026-03-04 23:06:49 +01:00
Corentin Thomasset
87a94ab567
chore(docker): removed corepack installation (#939) 2026-03-04 21:56:38 +01:00
Corentin Thomasset
f11c2b9064
chore(deps): update to pnpm 10.30.3 (#938) 2026-03-04 21:28:03 +01:00
Corentin Thomasset
69ab83c504
refactor(tests): disposable temporary directory utility (#936) 2026-03-03 22:38:14 +01:00
Corentin Thomasset
62e9e66638
chore(version): add document storage migration script changeset (#935) 2026-03-03 21:17:12 +00:00
Corentin Thomasset
69f8f0e13b
feat(maintenance): add document storage migration script (#934) 2026-03-03 21:45:27 +01:00
Corentin Thomasset
c4645eae9c
feat(doc): add storage key pattern documentation (#932) 2026-03-01 23:44:19 +01:00
Corentin Thomasset
6be6beae90
feat(storage): add document storage key pattern (#921) 2026-03-01 22:33:02 +01:00
Corentin Thomasset
41e9f33b06
feat(documents): add content extraction option (#930) 2026-03-01 00:08:05 +01:00
Corentin Thomasset
01e4540927
feat(readme): add shout-out in sponsor (#929) 2026-02-28 20:53:42 +00:00
Corentin Thomasset
077e4294fb
chore(config): added the possibility to exclude config variables from the doc website (#922) 2026-02-19 23:30:30 +00:00
Corentin Thomasset
f29ebc5de7
feat(storage): no longer return storageKey on save (#919) 2026-02-19 15:51:56 +01:00
Corentin Thomasset
105353ff00
feat(test): no isolate (#918) 2026-02-18 23:10:11 +00:00
Corentin Thomasset
65c2bea4c3
feat(storage): add safeguards to prevent file overwrites (#916) 2026-02-19 00:02:41 +01:00
Corentin Thomasset
1a0a900fd9
chore(deps): deduplicated transitive dependencies (#913) 2026-02-18 17:48:58 +01:00
Corentin Thomasset
aca98084ca
chore(release): update versions (#912)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-18 15:44:36 +01:00
Corentin Thomasset
548608be39
fix(pdf-viewer): resolve empty outline panel issue (#911) 2026-02-18 01:07:19 +01:00
Corentin Thomasset
21e5c313a6
refactor(pdf-viewer): renamed sidebar components (#910) 2026-02-17 22:56:04 +01:00
Corentin Thomasset
a096360eeb
chore(release): update versions (#909)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-17 22:31:03 +01:00
Corentin Thomasset
2143728157
refactor(about-dialog): moved to a global provider for the about dialog (#908) 2026-02-17 18:11:43 +01:00
Corentin Thomasset
1228c1c36d
chore(release): update versions (#812)
* chore(release): update versions

* fix(changelog): proper changesets credits for image size reduction

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-17 15:18:29 +01:00
Corentin Thomasset
ad5393aeb3
chore(changeset): update app changesets for february calver (#904) 2026-02-17 14:04:32 +00:00
Corentin Thomasset
d13c74f0db
feat(command-palette): retain search query and results on reopen (#903) 2026-02-17 00:03:41 +00:00
Corentin Thomasset
f978fdd314
chore(changeset): acknowledge vulnerability disclosure (#902) 2026-02-17 00:05:45 +01:00
Corentin Thomasset
9058f9e08c
feat(tags): add tag creation limit (#900) 2026-02-16 02:17:53 +01:00
Corentin Thomasset
d1eae05dd3
feat(documents): merged the similar search and listing api endpoints (#899) 2026-02-16 01:08:16 +01:00
Corentin Thomasset
fdd955e20c
feat(intake): implement MIME type coercion for intake email attachments (#896) 2026-02-15 15:23:34 +01:00
Corentin Thomasset
77186da42c
feat(documents): add complete PDF viewer (#894) 2026-02-15 01:47:40 +01:00
Corentin Thomasset
0c62716e5d
fix(tags, webhooks): prevent unauthorized listing (#893) 2026-02-14 17:25:38 +01:00
Corentin Thomasset
1c1d273fbd
fix(intake-emails): enforce length requirement for webhook secret (#892) 2026-02-14 16:44:29 +01:00
Corentin Thomasset
86805aeae0
feat(website): add sponsorship page (#891) 2026-02-14 02:20:54 +00:00
Corentin Thomasset
1624efc9d9
refactor(posthog): normalize endpoint env var name (#890) 2026-02-14 00:29:49 +00:00
Corentin Thomasset
fb323cdeb3
fix(changeset): correct package name (#887) 2026-02-10 23:19:10 +00:00
Corentin Thomasset
1b7d79408c
refactor(organization): moved dedicated last-organization logic in dedicated composable (#886) 2026-02-11 00:13:35 +01:00
Corentin Thomasset
b766d1ec1a
fix(demo): proper computation of tag documents count (#885) 2026-02-09 14:56:10 +00:00
Corentin Thomasset
63ddecf489
feat(pagination): synchronize pagination state in URL for documents list page (#883) 2026-02-09 14:18:44 +00:00
Corentin Thomasset
4c7da4b674
feat(ingestion-folders): added Synology files from ignored patterns (#884) 2026-02-09 14:14:45 +01:00
Corentin Thomasset
4f5b29b7ed
fix(config): no longer log on stderr when git is not available (#880) 2026-02-08 22:53:19 +00:00
Corentin Thomasset
bd57373d60
refactor(az-blob): mutualize client building (#878) 2026-02-06 23:25:01 +01:00
Corentin Thomasset
e2caea9e92
feat(storage): add fileExists method to all storage drivers (#877) 2026-02-06 21:57:24 +00:00
Corentin Thomasset
5285796233
feat(i18n): add tag picker translations (#876) 2026-02-06 00:29:06 +01:00
Corentin Thomasset
685d3a4041
feat(demo): hide demo popup by ctrl-click (#875) 2026-02-05 22:25:51 +01:00
Corentin Thomasset
4c772105f1
feat(command-palette): use toArrayIf instead of ternary (#874) 2026-02-05 21:43:53 +01:00
Corentin Thomasset
de9ca03915
feat(tag-list): add loading placeholder to tag picker (#871) 2026-02-05 01:11:44 +00:00
Corentin Thomasset
69633fb9ea
chore(version): added changeset for the new tag picker (#870) 2026-02-05 00:31:47 +00:00
Corentin Thomasset
7629055daa
fix(tag-list): prevent flash by suspensing tag fetching (#869) 2026-02-05 00:58:38 +01:00
Corentin Thomasset
77f8511627
feat(demo): made demo search behave like db fts5 (#868) 2026-02-04 23:44:50 +00:00
Corentin Thomasset
faebe93594
feat(tags): better picker (#867) 2026-02-05 00:00:07 +01:00
Parker Brown
85e1c862de
fix(client): simplify document timestamp labels (#863)
Update English translations to use shorter timestamp labels in the documents UI. Replaced "Created at"/"Created At" with "Created", "Deleted at" with "Deleted", and "Updated At" with "Updated" in the en.dictionary.ts file to produce more concise table headers and info labels.
2026-02-02 13:11:34 +01:00
Corentin Thomasset
afcfcf75cb
fix(client): replaced better-auth useSession with custom integration (#859) 2026-01-31 20:14:48 +00:00
Corentin Thomasset
3f4ca07a5d
feat(tagging-rules): prevented multiple tagging-rules creation/update form submissions (#855) 2026-01-31 16:56:39 +01:00
Corentin Thomasset
ac78626607
test(tags): add tests for tag creation and update uniqueness checks (#853) 2026-01-31 16:56:16 +01:00
Corentin Thomasset
cb8b4bb521
fix(demo): tag uniq constraint in demo (#854) 2026-01-31 15:54:23 +01:00
Corentin Thomasset
316a8c2f9c
feat(tags): prevented multiple tags creation/update form submissions (#851) 2026-01-31 14:17:28 +00:00
Corentin Thomasset
71872db367
fix(tags): add explicit error for duplicate tag updates (#852) 2026-01-31 14:16:32 +00:00
Corentin Thomasset
c9d1d64b91
refactor(mobile): cleaned up the document viewer component (#839) 2026-01-29 21:00:29 +00:00
Corentin Thomasset
53b179260d
feat(mobile): view text based files (#838) 2026-01-29 21:27:41 +01:00
Corentin Thomasset
fe284fc3d7
feat(mobile): enhance document actions sheet (#837) 2026-01-29 17:37:56 +00:00
Corentin Thomasset
5250d20e26
fix(mobile): update ActivityIndicator color to primaryForeground (#836) 2026-01-29 16:02:41 +00:00
Corentin Thomasset
a754c68a11
feat(mobile): add server URL validation (#835) 2026-01-29 15:52:26 +00:00
Corentin Thomasset
2d44c2b043
chore(lint): update eslint and config (#834) 2026-01-29 13:32:56 +00:00
Corentin Thomasset
f7b03368d6
fix(mobile): stop redirecting to org creation when invalid server (#833) 2026-01-29 14:05:43 +01:00
Corentin Thomasset
f3a9dca1a0
refactor(mobile): replace custom file size formatting with formatBytes (#832) 2026-01-29 12:37:11 +00:00
Corentin Thomasset
0a0b7ed9f2
refactor(mobile): regrouped document related screen/components (#831) 2026-01-29 12:28:25 +00:00
Corentin Thomasset
393a15593f
feat(tags): add button to generate random color (#829) 2026-01-28 18:09:37 +00:00
Corentin Thomasset
ca2ef2866b
feat(search): case-insensitive tag filtering (#827) 2026-01-28 18:13:36 +01:00
Corentin Thomasset
494aa5b882
feat(tags): trim tag names and descriptions (#826) 2026-01-28 17:36:14 +01:00
Corentin Thomasset
7c581fa343
fix(migration): proper iteration for tags normalization migrations (#825) 2026-01-28 14:01:41 +00:00
Corentin Thomasset
2ac51dc066
refactor(server): remove legacy migration journals (#824) 2026-01-28 12:44:29 +00:00
Corentin Thomasset
b6951ea05a
feat(tags): enforce case-insensitive unique constraint for tag names (#822) 2026-01-28 13:07:36 +01:00
Corentin Thomasset
3fa398c928
refactor(date): replace date-fns with in house implementations (#820) 2026-01-27 13:57:57 +01:00
Corentin Thomasset
46d8d2d45e
feat(version): added changeset for rootless shrink (#815) 2026-01-26 15:35:29 +00:00
Corentin Thomasset
3945c7924e
refactor(server): cleaned some useless lodash (#813) 2026-01-26 15:55:07 +01:00
Ruben Aleman
1d5ada8522
feat(docker): avoid duplicated files from chown (#810) 2026-01-26 14:30:39 +00:00
Corentin Thomasset
1eeb3df4a2
feat(client): add user settings dropdown in org creation page (#811) 2026-01-26 13:57:47 +00:00
Corentin Thomasset
b458a22d85
feat(docs): add versioning and tagging documentation (#797) 2026-01-26 01:18:49 +01:00
531 changed files with 23616 additions and 26760 deletions

View file

@ -0,0 +1,5 @@
---
"@papra/app": patch
---
Removed logging of a polluting empty error cause (with stack trace) when an error is thrown without a cause.

View file

@ -0,0 +1,5 @@
---
"@papra/app": patch
---
Synchronized the document pagination of the home page in query params to permit sharing and navigation.

View file

@ -0,0 +1,5 @@
---
"@papra/app": patch
---
Added content preview for yaml files

View file

@ -0,0 +1,5 @@
---
"@papra/app": patch
---
Removed weird shadows on ui components in light mode

View file

@ -0,0 +1,9 @@
---
"@papra/app": patch
---
Added some size limits on the webhooks creation and update API endpoints parameters.
- Names are limited to 128 characters.
- Secret keys are limited to 256 characters.
- URLs are limited to 2048 characters.

View file

@ -0,0 +1,5 @@
---
"@papra/app": patch
---
When reopening the quick search modal with existing query, the input content is automatically selected to allow easy replacement or editing.

View file

@ -0,0 +1,5 @@
---
"@papra/app": patch
---
Changed the server endpoint validation library from `zod` to `valibot`, and improved some validation schemas in the process.

View file

@ -0,0 +1,5 @@
---
"@papra/app": patch
---
Removed native clear button of search bar in safari.

View file

@ -0,0 +1,5 @@
---
"@papra/app": patch
---
Updated pnpm to 10.33.0.

View file

@ -0,0 +1,5 @@
---
"@papra/app": patch
---
Increased the sidebar collapsing breakpoint to improve the UX on tablets and small laptops.

View file

@ -0,0 +1,5 @@
---
"@papra/app": patch
---
Removed useless close button in the small-screen sidebar sheet.

View file

@ -0,0 +1,5 @@
---
"@papra/app": patch
---
Prevented the users and organizations tables from forcing horizontal scrolling in the admin panels.

View file

@ -0,0 +1,5 @@
---
"@papra/app": patch
---
Prevented long documents name from pushing the right columns out of the container.

View file

@ -1 +0,0 @@
packages/docker/.dockerignore

45
.dockerignore Normal file
View file

@ -0,0 +1,45 @@
.git
node_modules
.pnp
.pnp.*
*.log
*.local
.dockerignore
.env
**/.env
Dockerfile
fly.toml
.wrangler
coverage
cache
.zed
*.vars
*.db
*.db-shm
*.db-wal
*.sqlite
*.sqlite-shm
*.sqlite-wal
.output
.data
.nuxt
.nitro
.cache
dist
dist-app
dist-node
dist-cloudflare
local-documents
ingestion
.cursorrules
*.traineddata
*.md
.eslintcache
.claude
.opencode
AGENTS.md

3
.gitignore vendored
View file

@ -45,4 +45,5 @@ ingestion
.eslintcache .eslintcache
.claude .claude
.opencode .opencode
AGENTS.md

View file

@ -39,7 +39,7 @@ A live demo of the platform is available at [demo.papra.app](https://demo.papra.
## Project Status ## Project Status
Papra is under active development, the core functionalities are stable and usable. With lots of features and improvements added regularly. Papra is under active development, the core functionalities are stable and usable. With lots of features and improvements added regularly.
Feedback and bug reports are highly appreciated to help us improve the platform. Feedback and bug reports are highly appreciated to help us improve the platform.
@ -61,16 +61,17 @@ Feedback and bug reports are highly appreciated to help us improve the platform.
- **CLI**: Manage your documents from the command line. - **CLI**: Manage your documents from the command line.
- **API, SDK and webhooks**: Build your own applications on top of Papra. - **API, SDK and webhooks**: Build your own applications on top of Papra.
- **i18n**: Support for multiple languages. - **i18n**: Support for multiple languages.
- *Coming soon:* **Document sharing**: Share documents with others. - _Coming soon:_ **Document sharing**: Share documents with others.
- *Coming soon:* **Document requests**: Generate upload links for people to add documents. - _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:_ **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:_ **Desktop app**: Access and upload documents from your computer.
- *Coming maybe one day:* **Browser extension**: Upload documents from your browser. - _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. - _Coming maybe one day:_ **AI**: Use AI to help you manage or tag your documents.
## Sponsors ## Support
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. 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](https://papra.app/contact) to discuss a sponsorship.
## Self-hosting ## Self-hosting
@ -138,6 +139,7 @@ This project would not have been possible without the inspiration and work of ot
- **[Paperless-ngx](https://paperless-ngx.com/)**: A full-featured document management system. - **[Paperless-ngx](https://paperless-ngx.com/)**: A full-featured document management system.
## Contact Information ## Sponsors
Please use the issue tracker on GitHub for any questions or feedback. Shout-out to our current sponsors:
![GitHub Sponsors](https://cdn.jsdelivr.net/gh/papra-hq/static/assets/sponsors.svg)

View file

@ -9,7 +9,7 @@ import { sidebar } from './src/content/navigation';
import posthogRawScript from './src/scripts/posthog.script.js?raw'; import posthogRawScript from './src/scripts/posthog.script.js?raw';
const posthogApiKey = env.POSTHOG_API_KEY; const posthogApiKey = env.POSTHOG_API_KEY;
const posthogApiHost = env.POSTHOG_API_HOST ?? 'https://eu.i.posthog.com'; const posthogApiHost = env.POSTHOG_HOST ?? 'https://eu.i.posthog.com';
const isPosthogEnabled = Boolean(posthogApiKey); const isPosthogEnabled = Boolean(posthogApiKey);
const posthogScript = posthogRawScript.replace('[POSTHOG-API-KEY]', posthogApiKey ?? '').replace('[POSTHOG-API-HOST]', posthogApiHost); const posthogScript = posthogRawScript.replace('[POSTHOG-API-KEY]', posthogApiKey ?? '').replace('[POSTHOG-API-HOST]', posthogApiHost);

View file

@ -12,6 +12,7 @@ export default antfu({
], ],
rules: { rules: {
'pnpm/json-enforce-catalog': 'off',
// To allow export on top of files // To allow export on top of files
'ts/no-use-before-define': ['error', { allowNamedExports: true, functions: false }], 'ts/no-use-before-define': ['error', { allowNamedExports: true, functions: false }],
'curly': ['error', 'all'], 'curly': ['error', 'all'],

View file

@ -19,6 +19,7 @@
"dependencies": { "dependencies": {
"@astrojs/solid-js": "^5.1.0", "@astrojs/solid-js": "^5.1.0",
"@astrojs/starlight": "^0.34.3", "@astrojs/starlight": "^0.34.3",
"@valibot/to-json-schema": "^1.6.0",
"astro": "catalog:", "astro": "catalog:",
"sharp": "^0.32.5", "sharp": "^0.32.5",
"shiki": "^3.4.2", "shiki": "^3.4.2",
@ -26,19 +27,16 @@
"starlight-theme-rapide": "^0.5.0", "starlight-theme-rapide": "^0.5.0",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"unocss-preset-animations": "catalog:", "unocss-preset-animations": "catalog:",
"yaml": "^2.8.0", "valibot": "catalog:",
"zod": "^3.25.67", "yaml": "^2.8.0"
"zod-to-json-schema": "^3.24.5"
}, },
"devDependencies": { "devDependencies": {
"@antfu/eslint-config": "catalog:", "@antfu/eslint-config": "catalog:",
"@iconify-json/tabler": "catalog:", "@iconify-json/tabler": "catalog:",
"@types/lodash-es": "^4.17.12",
"@unocss/reset": "catalog:", "@unocss/reset": "catalog:",
"eslint": "catalog:", "eslint": "catalog:",
"eslint-plugin-astro": "^1.3.1", "eslint-plugin-astro": "^1.3.1",
"figue": "^3.1.1", "figue": "catalog:",
"lodash-es": "^4.17.21",
"marked": "^15.0.6", "marked": "^15.0.6",
"typescript": "catalog:", "typescript": "catalog:",
"unocss": "catalog:", "unocss": "catalog:",

View file

@ -1,5 +1,4 @@
import type { ConfigDefinition, ConfigDefinitionElement } from 'figue'; import type { ConfigDefinition, ConfigDefinitionElement } from 'figue';
import { castArray, isArray, isEmpty, isNil } from 'lodash-es';
import { configDefinition } from '../../papra-server/src/modules/config/config'; import { configDefinition } from '../../papra-server/src/modules/config/config';
import { renderMarkdown } from './markdown'; import { renderMarkdown } from './markdown';
@ -28,10 +27,14 @@ function formatDoc(doc: string | undefined): string {
return `${coerced}.`; return `${coerced}.`;
} }
function getIsEmptyDefaultValue(defaultValue: unknown): boolean {
return defaultValue === undefined || defaultValue === null || defaultValue === '' || (Array.isArray(defaultValue) && defaultValue.length === 0);
}
const rows = configDetails const rows = configDetails
.filter(({ path }) => path[0] !== 'env') .filter(({ showInDocumentation }) => showInDocumentation !== false)
.map(({ doc, default: defaultValue, env, path }) => { .map(({ doc, default: defaultValue, env, path }) => {
const isEmptyDefaultValue = isNil(defaultValue) || (isArray(defaultValue) && isEmpty(defaultValue)) || defaultValue === ''; const isEmptyDefaultValue = getIsEmptyDefaultValue(defaultValue);
const rawDocumentation = formatDoc(doc); const rawDocumentation = formatDoc(doc);
@ -47,7 +50,7 @@ const rows = configDetails
}); });
const mdSections = rows.map(({ documentation, env, path, defaultValue }) => { const mdSections = rows.map(({ documentation, env, path, defaultValue }) => {
const envs = castArray(env); const envs = Array.isArray(env) ? env : [env];
const [firstEnv, ...restEnvs] = envs; const [firstEnv, ...restEnvs] = envs;
return ` return `
@ -84,8 +87,8 @@ function wrapText(text: string, maxLength = 75) {
} }
const fullDotEnv = rows.map(({ env, defaultValue, documentation }) => { const fullDotEnv = rows.map(({ env, defaultValue, documentation }) => {
const isEmptyDefaultValue = isNil(defaultValue) || (isArray(defaultValue) && isEmpty(defaultValue)) || defaultValue === ''; const isEmptyDefaultValue = getIsEmptyDefaultValue(defaultValue);
const envs = castArray(env); const envs = Array.isArray(env) ? env : [env];
const [firstEnv] = envs; const [firstEnv] = envs;
return [ return [

View file

@ -87,11 +87,11 @@ The code for the Email Worker proxy is available in the [papra-hq/email-proxy](h
# Tell your Papra instance that it can generate any email address from the # Tell your Papra instance that it can generate any email address from the
# domain you setup in the Email Worker as it's a wildcard redirection # domain you setup in the Email Worker as it's a wildcard redirection
INTAKE_EMAILS_DRIVER=random-username INTAKE_EMAILS_DRIVER=catch-all
# This is the domain from which the intake email will be generated # This is the domain from which the intake email will be generated
# eg. `domain.com` # eg. `domain.com`
INTAKE_EMAILS_EMAIL_GENERATION_DOMAIN=papra.email INTAKE_EMAILS_CATCH_ALL_DOMAIN=papra.email
# This is the secret key to authenticate the webhook requests # This is the secret key to authenticate the webhook requests
# set the same as the `WEBHOOK_SECRET` variable in the Email Worker # set the same as the `WEBHOOK_SECRET` variable in the Email Worker

View file

@ -13,7 +13,7 @@ import EncryptionKeyGenerator from '../../../components/encryption-key-generator
Document encryption is available in Papra v0.9.0 and above. Document encryption is available in Papra v0.9.0 and above.
</Aside> </Aside>
Document encryption in Papra provides end-to-end protection for your stored documents using industry-standard AES-256-GCM encryption. This guide will walk you through enabling encryption, understanding how it works, and managing encryption keys. Document encryption in Papra provides protection for your stored documents using industry-standard AES-256-GCM encryption. This guide will walk you through enabling encryption, understanding how it works, and managing encryption keys.
## How Encryption Works ## How Encryption Works

View file

@ -33,7 +33,9 @@ Use filters to search specific document properties. Current supported filters:
- `content:` - Search in document content - `content:` - Search in document content
- `tag:` - Search by tag name or ID - `tag:` - Search by tag name or ID
- `created:` - Filter by document creation date - `created:` - Filter by document creation date
- `date:` - Filter by document date (the date associated with the document)
- `has:tags` - Check if documents have any tags, it can be negated with `-has:tags` or `NOT has:tags` to find untagged documents - `has:tags` - Check if documents have any tags, it can be negated with `-has:tags` or `NOT has:tags` to find untagged documents
- `has:date` - Check if documents have a date set, it can be negated with `-has:date` or `NOT has:date` to find documents without a date
### Filter Examples ### Filter Examples
@ -69,6 +71,8 @@ The `has:` filter checks whether documents have certain properties:
- `has:tags` - Documents with at least one tag - `has:tags` - Documents with at least one tag
- `-has:tags` or `NOT has:tags` - Documents without any tags - `-has:tags` or `NOT has:tags` - Documents without any tags
- `has:date` - Documents with a date set
- `-has:date` or `NOT has:date` - Documents without a date
#### Existence Filter Examples #### Existence Filter Examples
@ -84,11 +88,17 @@ has:tags invoice
# Find tagged documents that are not archived # Find tagged documents that are not archived
has:tags NOT tag:archived has:tags NOT tag:archived
# Find documents without a date set
-has:date
# Find documents that have a date and a specific tag
has:date tag:invoice
``` ```
### Date Filter ### Date Filter
The `created:` filter supports date comparisons with the following operators: The `created:` and `date:` filters support date comparisons with the following operators:
- `=` - Exact date match - `=` - Exact date match
- `>` - After the date - `>` - After the date
@ -127,6 +137,13 @@ created:<=2024-06-30
# Finds invoices created only in 2024 # Finds invoices created only in 2024
tag:invoice created:>2024 created:<2025 tag:invoice created:>2024 created:<2025
# Finds documents with a document date after January 1st, 2024
date:>2024
# Finds documents with a document date on or before now
date:<=now
``` ```

View file

@ -0,0 +1,255 @@
---
title: Document Storage Key Patterns
description: Guide to configuring how Papra generates storage keys (file paths) for your documents, including pattern syntax, available expressions, transformers, and conflict resolution.
slug: guides/storage-key-patterns
---
import { Aside } from '@astrojs/starlight/components';
<Aside type="note">
This feature is available starting from Papra v26.3.0
</Aside>
When a document is uploaded to Papra, the system generates a **storage key** that determines where and how the file is stored in the underlying storage backend (filesystem, S3, Azure Blob Storage, etc.). By default, Papra uses a legacy system that generates opaque storage keys based on the document ID. With storage key patterns, you can define a human-readable, customizable naming scheme for your stored files.
## How It Works
A **storage key pattern** is a string template containing **expressions** enclosed in double curly braces (`{{ }}`). When a document is uploaded, each expression is evaluated using the document's metadata and replaced with the resulting value.
For example, the default pattern:
```
{{organization.id}}/{{document.name}}
```
For a document named `invoice-2025.pdf` in organization `org_123456789012345678901234`, this produces:
```
org_123456789012345678901234/invoice-2025.pdf
```
### Storage Key Persistence
The generated storage key is stored in the database alongside each document record. This means that **changing the pattern configuration does not affect existing documents**, only newly uploaded documents will use the updated pattern. Existing documents retain their original storage keys and continue to be served from their original location.
<Aside type="tip">
A migration tool to reprocess storage keys for existing documents (allowing you to move files from an old pattern to a new one, or even to a different storage backend) will be available in a future release.
</Aside>
## Enabling Storage Key Patterns
At the moment, Papra uses the legacy storage key system for backward compatibility. To enable the new pattern-based system, set the following environment variable:
```bash
DOCUMENT_STORAGE_USE_LEGACY_STORAGE_KEY_DEFINITION_SYSTEM=false
```
Then configure your desired pattern:
```bash
DOCUMENT_STORAGE_KEY_PATTERN={{organization.id}}/{{document.name}}
```
<Aside type="caution">
If you switch from the legacy system to the pattern-based system on an existing instance, only new documents will use the new pattern. Existing documents will continue to use their legacy storage keys (`{{organization.id}}/originals/{{document.id}}`) and remain accessible without any changes needed.
</Aside>
### Docker Compose Example
```yaml title="docker-compose.yml" ins={9-10}
services:
papra:
container_name: papra
image: ghcr.io/papra-hq/papra:latest
restart: unless-stopped
environment:
# ... other environment variables ...
- DOCUMENT_STORAGE_USE_LEGACY_STORAGE_KEY_DEFINITION_SYSTEM=false
- DOCUMENT_STORAGE_KEY_PATTERN={{organization.id}}/{{document.name}}
volumes:
- ./app-data:/app/app-data
ports:
- "1221:1221"
```
## Pattern Syntax
A pattern is a string that can contain:
- **Literal text**: Any text outside of `{{ }}` is kept as-is (e.g., path separators `/`, prefixes, etc.)
- **Expressions**: Placeholders enclosed in `{{` and `}}` that are evaluated at upload time
- **Transformers**: Optional functions applied to expression values using the pipe `|` operator
The general syntax for an expression with transformers is:
```
{{expression | transformer1 | transformer2 arg1 arg2}}
```
Multiple transformers can be chained, and each transformer receives the output of the previous one.
## Available Expressions
| Expression | Description | Example Output |
|-----------|-------------|----------------|
| `document.id` | The unique document identifier | `doc_123456789012345678901234` |
| `document.name` | The original file name (sanitized for safe filesystem use) | `invoice-2025.pdf` |
| `organization.id` | The organization identifier | `org_123456789012345678901234` |
| `currentDate` | The current date and time in ISO 8601 format | `2025-06-15T14:30:00.000Z` |
| `currentDate.yyyy` | Current year (4 digits) | `2025` |
| `currentDate.MM` | Current month (2 digits, zero-padded) | `06` |
| `currentDate.dd` | Current day of month (2 digits, zero-padded) | `15` |
| `currentDate.HH` | Current hour (2 digits, 24-hour, zero-padded) | `14` |
| `currentDate.mm` | Current minute (2 digits, zero-padded) | `30` |
| `currentDate.ss` | Current second (2 digits, zero-padded) | `00` |
| `currentDate.SSS` | Current millisecond (3 digits, zero-padded) | `000` |
| `random` | A random 8-character alphanumeric string | `k9x2m4pq` |
Example:
```
{{organization.id}}/{{currentDate.yyyy}}/{{currentDate.MM}}/{{document.name}}
# Result: org_123456789012345678901234/2025/06/invoice-2025.pdf
```
<Aside type="note">
The `document.name` expression automatically sanitizes the file name by replacing unsafe filesystem characters with underscores (`_`).
</Aside>
## Available Transformers
Transformers modify the value of an expression. They are applied using the pipe (`|`) operator after the expression name.
| Transformer | Arguments | Description | Example |
|------------|-----------|-------------|---------|
| `uppercase` | None | Converts the value to uppercase | `{{document.name \| uppercase}}` |
| `lowercase` | None | Converts the value to lowercase | `{{document.name \| lowercase}}` |
| `formatDate` | Optional format string (default: `{yyyy}-{MM}-{dd}`) | Formats a date value | `{{currentDate \| formatDate {yyyy}/{MM}}}` |
| `padStart` | Target length, optional pad character (default: space) | Pads the start of the value | `{{currentDate.MM \| padStart 2 0}}` |
| `padEnd` | Target length, optional pad character (default: space) | Pads the end of the value | `{{document.id \| padEnd 20 _}}` |
### Transformer Chaining
Transformers can be chained to apply multiple transformations in sequence:
```
{{document.name | lowercase | padEnd 20 _}}
```
### Transformer Arguments
Arguments are space-separated after the transformer name. If an argument contains spaces, wrap it in double quotes:
```
{{currentDate | formatDate {yyyy}-{MM}-{dd}}}
```
## Pattern Examples
Here are some common patterns for different use cases:
### Organize by Organization and Document Name (Default)
```bash
DOCUMENT_STORAGE_KEY_PATTERN={{organization.id}}/{{document.name}}
# Result: org_abc123/invoice-2025.pdf
```
### Organize by Date
```bash
DOCUMENT_STORAGE_KEY_PATTERN={{currentDate.yyyy}}/{{currentDate.MM}}/{{document.name}}
# Result: 2025/06/invoice-2025.pdf
```
### Organize by Date with Unique ID
```bash
DOCUMENT_STORAGE_KEY_PATTERN={{organization.id}}/{{currentDate.yyyy}}/{{currentDate.MM}}/{{document.id}}-{{document.name}}
# Result: org_123456789012345678901234/2025/06/doc_123456789012345678901234-invoice-2025.pdf
```
## Conflict Resolution
When using patterns that don't guarantee uniqueness (e.g., patterns based on `document.name` alone), two documents could generate the same storage key. Papra handles this automatically to prevent data loss using a two-stage conflict resolution mechanism.
### Stage 1: Incremental Suffix
When a storage key already exists, Papra appends an incrementing numeric suffix before the file extension:
```
org_abc123/invoice.pdf # original (already exists)
org_abc123/invoice_1.pdf # first conflict
org_abc123/invoice_2.pdf # second conflict
org_abc123/invoice_3.pdf # third conflict
...
org_abc123/invoice_9.pdf # ninth conflict (default max)
```
The suffix is inserted before the file extension and after the file name, separated by an underscore. For files without an extension, the suffix is appended at the end (e.g., `README` becomes `README_1`).
By default, up to **9 incremental suffix attempts** are made (configurable, see below). This means a total of **10 possible slots** for a given storage key: the original plus 9 suffixed variants.
### Stage 2: Random Suffix Fallback
If all incremental suffix attempts are exhausted (i.e., all 10 slots are taken), Papra falls back to appending a random 8-character alphanumeric string as a suffix:
```
org_abc123/invoice_k9x2m4pq.pdf # random suffix fallback
```
This provides a very high probability of finding an available key (with 8-character random suffix, need more than 17 million files with same name and path to have a 50% chance of collision). If even this random suffix collides (extremely unlikely), the upload will fail with an error rather than overwriting existing data.
<Aside type="tip">
If you frequently encounter conflicts, consider adding `{{document.id}}` or `{{random}}` to your pattern to guarantee uniqueness.
</Aside>
### Conflict Resolution Configuration
| Variable | Description | Default |
|----------|-------------|---------|
| `DOCUMENT_STORAGE_PATTERN_MAX_INCREMENTAL_SUFFIX_ATTEMPTS` | How many incremental suffixes to try (e.g., `_1`, `_2`, ...). Set to `0` to skip incremental suffixes entirely. | `9` |
| `DOCUMENT_STORAGE_PATTERN_ENABLE_RANDOM_SUFFIX_FALLBACK` | Whether to try a random suffix if all incremental attempts are exhausted | `true` |
### Disabling Conflict Resolution
If you want to disable conflict resolution, and want to reject uploads that would cause a storage key collision (not sure why you would want this, but it's possible), set:
```bash
DOCUMENT_STORAGE_PATTERN_MAX_INCREMENTAL_SUFFIX_ATTEMPTS=0
DOCUMENT_STORAGE_PATTERN_ENABLE_RANDOM_SUFFIX_FALLBACK=false
```
## Configuration Reference
| Variable | Description | Default |
|----------|-------------|---------|
| `DOCUMENT_STORAGE_USE_LEGACY_STORAGE_KEY_DEFINITION_SYSTEM` | Use the legacy storage key format (`{orgId}/originals/{docId}.{ext}`). Set to `false` to enable patterns. | `true` |
| `DOCUMENT_STORAGE_KEY_PATTERN` | The pattern template for generating storage keys | `{{organization.id}}/{{document.name}}` |
| `DOCUMENT_STORAGE_PATTERN_MAX_INCREMENTAL_SUFFIX_ATTEMPTS` | Maximum number of incremental suffix attempts for conflict resolution | `9` |
| `DOCUMENT_STORAGE_PATTERN_ENABLE_RANDOM_SUFFIX_FALLBACK` | Enable random suffix fallback when all incremental suffixes are exhausted | `true` |
## Legacy System
The legacy storage key system generates keys in the format:
```
{{organization.id}}/originals/{{document.id}}
```
For example: `org_abc123/originals/doc_123456789012345678901234`
Since the legacy system uses the unique document ID as the file name, conflicts are impossible and no suffix mechanism is needed. At the moment, this system remains the default while the new pattern-based system is being developed and tested.
To keep using the legacy system, either leave the configuration unchanged or explicitly set:
```bash
DOCUMENT_STORAGE_USE_LEGACY_STORAGE_KEY_DEFINITION_SYSTEM=true
```
## Pattern Validation
Storage key patterns are validated at application startup. If a pattern contains an invalid expression or an unknown transformer, Papra will refuse to start and report the error. This prevents misconfigured patterns from causing issues at upload time.
A pattern is invalid if:
- It references an expression that does not exist (e.g., `{{unknown.field}}`)
- It uses a transformer that does not exist (e.g., `{{document.name | nonexistent}}`)
- It ends with a `/` (trailing slashes are not allowed)

View file

@ -0,0 +1,250 @@
---
title: Migrate Document Storage
description: Move documents between storage backends or change storage settings using the storage migration maintenance script.
slug: guides/migrate-document-storage
---
import { Steps } from '@astrojs/starlight/components';
import { Aside } from '@astrojs/starlight/components';
import { Tabs, TabItem } from '@astrojs/starlight/components';
<Aside type="note">
This migration script is available starting from Papra v26.3.0
</Aside>
The storage migration script copies every document from one storage backend to another, streaming files one at a time to keep memory usage flat. Encryption is handled transparently — documents are decrypted on the way out of the source and re-encrypted (or left plain) on the way into the destination, depending on each side's configuration.
## When to Use This Script
- **Changing storage key pattern** — after updating `DOCUMENT_STORAGE_KEY_PATTERN` or switching off the legacy key system, existing documents keep their old storage keys; use this script to re-copy them so they land under the new pattern (see [Storage Key Patterns](/guides/storage-key-patterns))
- **Moving to cloud storage** — migrating from a local filesystem to S3, Azure Blob Storage, or another S3-compatible provider
- **Switching cloud providers** — moving files between S3 and Azure Blob, or between S3-compatible services
- **Changing storage root** — relocating the filesystem storage directory to a different path or volume
<Aside type="note">
If you only want to encrypt existing documents without changing anything else, use the dedicated [`maintenance:encrypt-all-documents`](/guides/document-encryption#migrating-existing-documents-to-encrypted-format) script instead. The storage migration script will fail if source and destination resolve to the same path for the same key, because storage drivers refuse to overwrite existing files.
</Aside>
## How It Works
<Steps>
1. **Source and destination configs are built** from the base environment variables, with your `--from` and `--to` overrides applied on top. Database configuration always comes from the running environment — only storage settings are overridden.
2. **Documents are iterated in batches** (including soft-deleted ones), so the script can handle large document collections without loading everything into memory.
3. **Each document is streamed** from the source storage (decrypting if the source has encryption enabled), then written to the destination (encrypting if the destination has encryption enabled).
4. **The new storage key is computed** using the destination's pattern configuration (via `DOCUMENT_STORAGE_KEY_PATTERN`), and both the storage key and encryption metadata are updated in the database. This is what allows a no-args invocation to re-key all documents after a pattern change.
5. **If `--delete-source` is passed**, the original file is removed from the source backend after a successful copy.
6. **Errors are per-document** — a single failed document is logged and skipped; the rest of the migration continues.
</Steps>
## CLI Reference
```
maintenance:migrate-document-storage [options]
Options:
--from <KEY=VALUE> Source storage environment variable (repeatable)
--to <KEY=VALUE> Destination storage environment variable (repeatable)
--delete-source Delete source files after a successful copy
--dry-run Preview what would be migrated without touching any files
-h, --help Show this help message
```
`--from` and `--to` accept any `DOCUMENT_STORAGE_*` environment variable. Values are merged on top of the current environment, so you only need to specify the settings that differ from your running configuration.
## Running the Migration
<Aside type="caution">
Back up your documents and database before running the migration. While the script is non-destructive by default (it copies, not moves), a backup is the safest way to recover from unexpected issues.
</Aside>
<Steps>
1. **Run a dry run first**
The dry run logs every document that would be migrated without reading or writing any files.
<Tabs>
<TabItem label="Docker Compose">
```bash
docker compose exec papra pnpm maintenance:migrate-document-storage \
--from DOCUMENT_STORAGE_DRIVER=filesystem \
--to DOCUMENT_STORAGE_DRIVER=s3 \
--to DOCUMENT_STORAGE_S3_BUCKET_NAME=my-bucket \
--to DOCUMENT_STORAGE_S3_REGION=us-east-1 \
--dry-run
```
</TabItem>
<TabItem label="Docker">
```bash
docker exec -it papra pnpm maintenance:migrate-document-storage \
--from DOCUMENT_STORAGE_DRIVER=filesystem \
--to DOCUMENT_STORAGE_DRIVER=s3 \
--to DOCUMENT_STORAGE_S3_BUCKET_NAME=my-bucket \
--to DOCUMENT_STORAGE_S3_REGION=us-east-1 \
--dry-run
```
</TabItem>
<TabItem label="Source Installation">
```bash
pnpm maintenance:migrate-document-storage \
--from DOCUMENT_STORAGE_DRIVER=filesystem \
--to DOCUMENT_STORAGE_DRIVER=s3 \
--to DOCUMENT_STORAGE_S3_BUCKET_NAME=my-bucket \
--to DOCUMENT_STORAGE_S3_REGION=us-east-1 \
--dry-run
```
</TabItem>
</Tabs>
2. **Run the actual migration**
Once you are happy with the dry run output, run without `--dry-run`. Add `--delete-source` if you want source files removed after each successful copy (USE WITH CAUTION).
<Tabs>
<TabItem label="Docker Compose">
```bash
docker compose exec papra pnpm maintenance:migrate-document-storage \
--from DOCUMENT_STORAGE_DRIVER=filesystem \
--to DOCUMENT_STORAGE_DRIVER=s3 \
--to DOCUMENT_STORAGE_S3_BUCKET_NAME=my-bucket \
--to DOCUMENT_STORAGE_S3_REGION=us-east-1
```
</TabItem>
<TabItem label="Docker">
```bash
docker exec -it papra pnpm maintenance:migrate-document-storage \
--from DOCUMENT_STORAGE_DRIVER=filesystem \
--to DOCUMENT_STORAGE_DRIVER=s3 \
--to DOCUMENT_STORAGE_S3_BUCKET_NAME=my-bucket \
--to DOCUMENT_STORAGE_S3_REGION=us-east-1
```
</TabItem>
<TabItem label="Source Installation">
```bash
pnpm maintenance:migrate-document-storage \
--from DOCUMENT_STORAGE_DRIVER=filesystem \
--to DOCUMENT_STORAGE_DRIVER=s3 \
--to DOCUMENT_STORAGE_S3_BUCKET_NAME=my-bucket \
--to DOCUMENT_STORAGE_S3_REGION=us-east-1
```
</TabItem>
</Tabs>
3. **Update your Papra configuration**
Point your running Papra instance to the new storage backend and restart it.
4. **Verify the migration**
Open Papra and check that documents are accessible and downloadable. If anything looks wrong, your original files are still in the source backend (unless you used `--delete-source`).
</Steps>
## Examples
### Filesystem → S3
Move documents from a local directory to an S3 bucket, using AWS credentials already in the environment:
```bash
docker compose exec papra pnpm maintenance:migrate-document-storage \
--from DOCUMENT_STORAGE_DRIVER=filesystem \
--from DOCUMENT_STORAGE_FILESYSTEM_ROOT=./app-data/documents \
--to DOCUMENT_STORAGE_DRIVER=s3 \
--to DOCUMENT_STORAGE_S3_BUCKET_NAME=my-papra-bucket \
--to DOCUMENT_STORAGE_S3_REGION=us-east-1
```
### S3 → Azure Blob Storage
```bash
docker compose exec papra pnpm maintenance:migrate-document-storage \
--from DOCUMENT_STORAGE_DRIVER=s3 \
--from DOCUMENT_STORAGE_S3_BUCKET_NAME=old-bucket \
--from DOCUMENT_STORAGE_S3_REGION=us-east-1 \
--to DOCUMENT_STORAGE_DRIVER=azure-blob \
--to DOCUMENT_STORAGE_AZURE_BLOB_ACCOUNT_NAME=myaccount \
--to DOCUMENT_STORAGE_AZURE_BLOB_CONTAINER_NAME=papra-documents
```
### S3 → S3-Compatible (e.g. Cloudflare R2, MinIO)
```bash
docker compose exec papra pnpm maintenance:migrate-document-storage \
--from DOCUMENT_STORAGE_DRIVER=s3 \
--from DOCUMENT_STORAGE_S3_BUCKET_NAME=old-bucket \
--from DOCUMENT_STORAGE_S3_REGION=us-east-1 \
--to DOCUMENT_STORAGE_DRIVER=s3 \
--to DOCUMENT_STORAGE_S3_BUCKET_NAME=new-bucket \
--to DOCUMENT_STORAGE_S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com \
--to DOCUMENT_STORAGE_S3_REGION=auto
```
### Add Encryption While Moving to S3
Move from an unencrypted filesystem to an encrypted S3 backend in one step:
```bash
docker compose exec papra pnpm maintenance:migrate-document-storage \
--from DOCUMENT_STORAGE_DRIVER=filesystem \
--to DOCUMENT_STORAGE_DRIVER=s3 \
--to DOCUMENT_STORAGE_S3_BUCKET_NAME=my-papra-bucket \
--to DOCUMENT_STORAGE_S3_REGION=us-east-1 \
--to DOCUMENT_STORAGE_ENCRYPTION_IS_ENABLED=true \
--to DOCUMENT_STORAGE_DOCUMENT_KEY_ENCRYPTION_KEYS=<your-encryption-key>
```
After running this, update your Papra environment variables to include the encryption settings so new documents are also encrypted.
### Change Storage Key Pattern
Each document's storage key is saved in the database when it is uploaded, so Papra always knows where to find a file regardless of what the current pattern setting is. Changing `DOCUMENT_STORAGE_KEY_PATTERN` (or disabling the legacy key system) only affects new uploads — existing documents keep their old keys.
To re-key existing documents, first update your Papra configuration to the desired pattern, then run the migration script without any arguments. The script will read each document from its stored key and write it back under the key your new pattern produces, updating the database record in the process.
```bash
# 1. Update your Papra config (e.g. in .env or docker-compose.yml):
# DOCUMENT_STORAGE_USE_LEGACY_STORAGE_KEY_DEFINITION_SYSTEM=false
# DOCUMENT_STORAGE_KEY_PATTERN={{organization.id}}/{{document.name}}
# 2. Run the migration (no --from / --to needed when staying on the same backend):
docker compose exec papra pnpm maintenance:migrate-document-storage
```
The `--from` / `--to` overrides are only necessary when you are also changing the backend driver or root path at the same time.
### Change Filesystem Root Path
If you are moving the storage directory to a new volume, override only the path:
```bash
docker compose exec papra pnpm maintenance:migrate-document-storage \
--from DOCUMENT_STORAGE_DRIVER=filesystem \
--from DOCUMENT_STORAGE_FILESYSTEM_ROOT=/old-volume/documents \
--to DOCUMENT_STORAGE_DRIVER=filesystem \
--to DOCUMENT_STORAGE_FILESYSTEM_ROOT=/new-volume/documents
```
## Troubleshooting
**"File already exists" or overwrite error**
The source and destination resolve to the same storage location. This happens when you pass the same driver and path on both sides. Use the `maintenance:encrypt-all-documents` script for in-place encryption instead.
**Individual documents fail but migration continues**
Failed documents are logged with their ID, name, and error message. The migration reports a final `succeeded / failed` count. After fixing the underlying issue (permissions, connectivity, missing credentials), you can re-run the script — already-migrated files will fail to overwrite, so re-run with the source pointing to the correct set of remaining files.
**Migration is slow**
The script streams one document at a time. Performance depends on document size and the latency between source and destination storage. For large collections, consider running the migration during a maintenance window.
**Container exits before migration finishes**
For long-running migrations in Docker, make sure no timeout is configured on `docker exec`.

View file

@ -155,7 +155,7 @@ List all documents in the organization.
- Query parameters - Query parameters
- `pageIndex`: (optional, default: 0) The page index to start from. - `pageIndex`: (optional, default: 0) The page index to start from.
- `pageSize`: (optional, default: 100) The number of documents to return. - `pageSize`: (optional, default: 100) The number of documents to return.
- `tags`: (optional) The tags IDs to filter by. - `searchQuery`: (optional) Search query, can reference [Filters](https://docs.papra.app/guides/advanced-search/#filters) for a more defined search
- Response (JSON) - Response (JSON)
- `documents`: The list of documents. - `documents`: The list of documents.
- `documentsCount`: The total number of documents. - `documentsCount`: The total number of documents.
@ -236,7 +236,7 @@ Get the statistics (number of documents and total size) of the documents in the
Change the name or content (for search purposes) of a document. Change the name or content (for search purposes) of a document.
- Required API key permissions: `documents:update` - Required API key permissions: `documents:update`
- Body (form-data) - Body (JSON)
- `name`: (optional) The document name. - `name`: (optional) The document name.
- `content`: (optional) The document content. - `content`: (optional) The document content.
- Response (JSON) - Response (JSON)

View file

@ -0,0 +1,74 @@
---
title: Versioning & Tagging
description: Understanding Papra's versioning strategy for the monorepo packages and Docker images.
slug: resources/versioning-and-tagging
---
Papra uses different versioning strategies depending on the type of package in the monorepo.
## Monorepo Structure
Papra is organized as a monorepo containing multiple packages, each with its own release lifecycle:
- **Library packages** (e.g., `@papra/api-sdk`, `@papra/cli`, `@papra/webhooks`) follow **SemVer** (Semantic Versioning)
- **Application** (`@papra/app`) follows **CalVer** (Calendar Versioning)
## Semantic Versioning (SemVer)
Library packages follow the standard [SemVer](https://semver.org/) format `MAJOR.MINOR.PATCH`:
- **MAJOR**: Incompatible API changes
- **MINOR**: Backward-compatible functionality additions
- **PATCH**: Backward-compatible bug fixes
**Examples:**
- `@papra/api-sdk@1.2.3`
- `@papra/cli@0.2.3`
- `@papra/webhooks@0.3.2`
## Calendar Versioning (CalVer)
Since **v0.9.6**, the app uses CalVer with the format `YY.M.N`:
- **YY**: Last 2 digits of the year
- **M**: Month number (1-12, not zero-padded, where 1 = January, 12 = December)
- **N**: Release number for that month (starting at 0)
**Examples:**
- `26.1.0`: First release of January 2026
- `26.1.1`: Second release of January 2026
- `26.5.11`: 12th release of May 2026
## Git Tags
All packages in the monorepo are tagged and GitHub-released using the format `package-name@version`:
```
@papra/api-sdk@1.2.3
@papra/cli@0.2.3
@papra/webhooks@0.3.2
@papra/app@26.0.0
```
:::note[Historical Note]
Up to and including **v26.0.0**, the application package was named `@papra/docker` and git tags followed the format `@papra/docker@X.Y.Z`. Starting from **>v26.0.0**, the package was renamed to `@papra/app` and tags follow the format `@papra/app@X.Y.Z`.
:::
## Docker Images
Docker images follow the application package versioning and use the same version numbers. Images are automatically built and pushed to the container registry via CI/CD pipeline when a git tag is pushed.
**Docker image tags:**
```bash
# Specific version
docker pull ghcr.io/papra-hq/papra:26.0.0
docker pull corentinth/papra:26.0.0
# Latest versions
docker pull ghcr.io/papra-hq/papra:latest
docker pull corentinth/papra:latest
```
See [Self-hosting documentation](/self-hosting/using-docker) for more details on using the Docker images.
The Docker image version always matches the `@papra/app` package version, ensuring consistency across distribution methods.

View file

@ -41,6 +41,10 @@ export const sidebar = [
label: 'Document Encryption', label: 'Document Encryption',
slug: 'guides/document-encryption', slug: 'guides/document-encryption',
}, },
{
label: 'Migrate Document Storage',
slug: 'guides/migrate-document-storage',
},
{ {
label: 'Tagging Rules', label: 'Tagging Rules',
slug: 'guides/tagging-rules', slug: 'guides/tagging-rules',
@ -53,6 +57,10 @@ export const sidebar = [
label: 'Advanced Search', label: 'Advanced Search',
slug: 'guides/advanced-search', slug: 'guides/advanced-search',
}, },
{
label: 'Storage Key Patterns',
slug: 'guides/storage-key-patterns',
},
], ],
}, },
{ {
@ -94,6 +102,10 @@ export const sidebar = [
label: 'API Endpoints', label: 'API Endpoints',
slug: 'resources/api-endpoints', slug: 'resources/api-endpoints',
}, },
{
label: 'Versioning & Tagging',
slug: 'resources/versioning-and-tagging',
},
], ],
}, },
] satisfies StarlightUserConfig['sidebar']; ] satisfies StarlightUserConfig['sidebar'];

View file

@ -112,7 +112,7 @@ const defaultCommand = `mkdir -p ./app-data/{db,documents} && docker compose up
<div class="flex items-center gap-2 mt-0 w-full"> <div class="flex items-center gap-2 mt-0 w-full">
<select class="input-field mt-0" id="intake-email-driver"> <select class="input-field mt-0" id="intake-email-driver">
<option value="owlrelay" class="bg-background">OwlRelay</option> <option value="owlrelay" class="bg-background">OwlRelay</option>
<option value="random-username" class="bg-background">Cloudflare Email Worker</option> <option value="catch-all" class="bg-background">Cloudflare Email Worker</option>
</select> </select>
</div> </div>
</div> </div>
@ -294,7 +294,7 @@ function getDockerComposeYml() {
intakeEmailEnabled && `INTAKE_EMAILS_WEBHOOK_SECRET=${webhookSecret}`, intakeEmailEnabled && `INTAKE_EMAILS_WEBHOOK_SECRET=${webhookSecret}`,
intakeEmailEnabled && intakeDriver === 'owlrelay' && owlrelayApiKeyInput.value && `OWLRELAY_API_KEY=${owlrelayApiKeyInput.value}`, intakeEmailEnabled && intakeDriver === 'owlrelay' && owlrelayApiKeyInput.value && `OWLRELAY_API_KEY=${owlrelayApiKeyInput.value}`,
intakeEmailEnabled && intakeDriver === 'owlrelay' && owlrelayWebhookUrlInput.value && `OWLRELAY_WEBHOOK_URL=${owlrelayWebhookUrlInput.value}`, intakeEmailEnabled && intakeDriver === 'owlrelay' && owlrelayWebhookUrlInput.value && `OWLRELAY_WEBHOOK_URL=${owlrelayWebhookUrlInput.value}`,
intakeEmailEnabled && intakeDriver === 'random-username' && cfEmailDomainInput.value && `INTAKE_EMAILS_EMAIL_GENERATION_DOMAIN=${cfEmailDomainInput.value}`, intakeEmailEnabled && intakeDriver === 'catch-all' && cfEmailDomainInput.value && `INTAKE_EMAILS_CATCH_ALL_DOMAIN=${cfEmailDomainInput.value}`,
].flat().filter(Boolean); ].flat().filter(Boolean);
const volumes = [ const volumes = [
@ -426,7 +426,7 @@ function handleIntakeDriverChange() {
owlrelayConfig.style.display = driver === 'owlrelay' ? 'block' : 'none'; owlrelayConfig.style.display = driver === 'owlrelay' ? 'block' : 'none';
} }
if (cfWorkerConfig) { if (cfWorkerConfig) {
cfWorkerConfig.style.display = driver === 'random-username' ? 'block' : 'none'; cfWorkerConfig.style.display = driver === 'catch-all' ? 'block' : 'none';
} }
updateDockerCompose(); updateDockerCompose();

View file

@ -17,7 +17,7 @@ const changelog = parseChangelog(rawChangelog);
> >
<p> <p>
Here are the changelogs of the docker images released by Papra.<br /> Here are the changelogs of the docker images released by Papra.<br />
For version after v0.9.6, Papra uses Calver as a versioning system with the format YY.MM.N where N is the number of releases in the month starting at 0 (e.g. 25.06.0 is the first release of June 2025). For version after v0.9.6, Papra uses Calver as a versioning system with the format YY.M.N where M is the month number (1-12, not zero-padded, where 1 = January and 12 = December) and N is the number of releases in the month starting at 0 (e.g. 26.5.11 is the 12th release of May 2026).
</p> </p>

View file

@ -1,46 +1,32 @@
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import type { ConfigDefinition } from 'figue'; import type { ConfigDefinition } from 'figue';
import { mapValues } from 'lodash-es'; import { toJsonSchema } from '@valibot/to-json-schema';
import { z } from 'zod'; import * as v from 'valibot';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { configDefinition } from '../../../papra-server/src/modules/config/config'; import { configDefinition } from '../../../papra-server/src/modules/config/config';
function buildConfigSchema({ configDefinition }: { configDefinition: ConfigDefinition }) { function buildConfigSchema({ configDefinition }: { configDefinition: ConfigDefinition }): v.GenericSchema {
const schema: any = mapValues(configDefinition, (config) => { const entries: Record<string, v.GenericSchema> = {};
if (typeof config === 'object' && config !== null && 'schema' in config && 'doc' in config) {
return config.schema; for (const [key, value] of Object.entries(configDefinition)) {
if ('schema' in value) {
entries[key] = v.optional(value.schema as v.GenericSchema);
} else { } else {
return buildConfigSchema({ entries[key] = v.optional(buildConfigSchema({ configDefinition: value }));
configDefinition: config as ConfigDefinition,
});
}
});
return z.object(schema);
}
function stripRequired(schema: any) {
if (schema.type === 'object') {
schema.required = [];
for (const key in schema.properties) {
stripRequired(schema.properties[key]);
} }
} }
}
function addSchema(schema: any) { return v.object(entries);
schema.properties.$schema = {
type: 'string',
description: 'The schema of the configuration file, to be used by IDEs to provide autocompletion and validation',
};
} }
function getConfigSchema() { function getConfigSchema() {
const schema = buildConfigSchema({ configDefinition }); const schema = buildConfigSchema({ configDefinition });
const jsonSchema = zodToJsonSchema(schema, { pipeStrategy: 'output' }); const jsonSchema = toJsonSchema(schema, { typeMode: 'input', errorMode: 'ignore' });
(jsonSchema.properties ??= {}).$schema = {
type: 'string',
description: 'The schema of the configuration file, to be used by IDEs to provide autocompletion and validation',
};
stripRequired(jsonSchema);
addSchema(jsonSchema);
return jsonSchema; return jsonSchema;
} }

View file

@ -1,3 +1,3 @@
import DocumentViewScreen from '@/modules/documents-actions/screens/document-view.screen'; import DocumentViewScreen from '@/modules/documents/screens/document-view.screen';
export default DocumentViewScreen; export default DocumentViewScreen;

View file

@ -14,6 +14,7 @@ export default antfu({
}, },
rules: { rules: {
'pnpm/json-enforce-catalog': 'off',
// To allow export on top of files // To allow export on top of files
'ts/no-use-before-define': ['error', { allowNamedExports: true, functions: false }], 'ts/no-use-before-define': ['error', { allowNamedExports: true, functions: false }],
'curly': ['error', 'all'], 'curly': ['error', 'all'],

View file

@ -1,5 +1,5 @@
import type { FetchOptions, ResponseType } from 'ofetch'; import type { FetchOptions } from 'ofetch';
import { ofetch } from 'ofetch'; import { FetchError, ofetch, ResponseType } from 'ofetch';
export { ResponseType }; export { ResponseType };
export type HttpClientOptions<R extends ResponseType = 'json'> = Omit<FetchOptions<R>, 'baseURL'> & { url: string; baseUrl?: string }; export type HttpClientOptions<R extends ResponseType = 'json'> = Omit<FetchOptions<R>, 'baseURL'> & { url: string; baseUrl?: string };
@ -10,3 +10,7 @@ export async function httpClient<A, R extends ResponseType = 'json'>({ url, base
...rest, ...rest,
}); });
} }
export function isHttpClientError(error: unknown): error is FetchError {
return error instanceof FetchError;
}

View file

@ -0,0 +1,3 @@
import Constants from 'expo-constants';
export const APP_SCHEME = String(Constants.expoConfig?.scheme ?? 'papra');

View file

@ -1,8 +1,8 @@
import { expoClient } from '@better-auth/expo/client'; import { expoClient } from '@better-auth/expo/client';
import { createAuthClient as createBetterAuthClient } from 'better-auth/react'; import { createAuthClient as createBetterAuthClient } from 'better-auth/react';
import Constants from 'expo-constants';
import * as SecureStore from 'expo-secure-store'; import * as SecureStore from 'expo-secure-store';
import { Platform } from 'react-native'; import { Platform } from 'react-native';
import { APP_SCHEME } from '../app/app.constants';
export type AuthClient = ReturnType<typeof createAuthClient>; export type AuthClient = ReturnType<typeof createAuthClient>;
@ -11,8 +11,8 @@ export function createAuthClient({ baseUrl}: { baseUrl: string }) {
baseURL: baseUrl, baseURL: baseUrl,
plugins: [ plugins: [
expoClient({ expoClient({
scheme: String(Constants.expoConfig?.scheme ?? 'papra'), scheme: APP_SCHEME,
storagePrefix: String(Constants.expoConfig?.scheme ?? 'papra'), storagePrefix: APP_SCHEME,
storage: Platform.OS === 'web' storage: Platform.OS === 'web'
? localStorage ? localStorage
: SecureStore, : SecureStore,

View file

@ -0,0 +1,76 @@
import type { ServerConfig } from '../config/config.types';
import { describe, expect, test } from 'vitest';
import { getEnabledOAuthProviders } from './auth.models';
describe('auth models', () => {
describe('getEnabledOAuthProviders', () => {
test('build an ordered list of enabled OAuth providers', () => {
expect(
getEnabledOAuthProviders({ serverConfig: {
auth: {
providers: {
google: { isEnabled: true },
github: { isEnabled: true },
customs: [
{ providerId: 'custom1', providerName: 'Custom 1' },
],
},
},
} as ServerConfig }),
).to.eql([
{ providerId: 'google', providerName: 'Google' },
{ providerId: 'github', providerName: 'GitHub' },
{ providerId: 'custom1', providerName: 'Custom 1' },
]);
expect(
getEnabledOAuthProviders({ serverConfig: {
auth: {
providers: {
google: { isEnabled: true },
github: { isEnabled: false },
customs: [
{ providerId: 'custom1', providerName: 'Custom 1' },
],
},
},
} as ServerConfig }),
).to.eql([
{ providerId: 'google', providerName: 'Google' },
{ providerId: 'custom1', providerName: 'Custom 1' },
]);
expect(
getEnabledOAuthProviders({ serverConfig: {
auth: {
providers: {
google: { isEnabled: false },
github: { isEnabled: false },
customs: [
{ providerId: 'custom1', providerName: 'Custom 1' },
],
},
},
} as ServerConfig }),
).to.eql([
{ providerId: 'custom1', providerName: 'Custom 1' },
]);
expect(
getEnabledOAuthProviders({ serverConfig: {
auth: {
providers: {
google: { isEnabled: false },
github: { isEnabled: false },
customs: [] as { providerId: string; providerName: string }[],
},
},
} as ServerConfig }),
).to.eql([]);
});
test('returns an empty array if serverConfig is undefined, like during initial loading', () => {
expect(getEnabledOAuthProviders({ serverConfig: undefined })).to.eql([]);
});
});
});

View file

@ -0,0 +1,15 @@
import type { ServerConfig } from '../config/config.types';
export function getEnabledOAuthProviders({ serverConfig }: { serverConfig?: ServerConfig }) {
if (!serverConfig) {
return [];
}
const providers = serverConfig.auth.providers;
return [
...(providers.google.isEnabled ? [{ providerId: 'google', providerName: 'Google' }] : []),
...(providers.github.isEnabled ? [{ providerId: 'github', providerName: 'GitHub' }] : []),
...providers.customs,
];
}

View file

@ -19,6 +19,7 @@ import { useAuthClient } from '@/modules/api/providers/api.provider';
import { useAlert } from '@/modules/ui/providers/alert-provider'; import { useAlert } from '@/modules/ui/providers/alert-provider';
import { useThemeColor } from '@/modules/ui/providers/use-theme-color'; import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
import { useServerConfig } from '../../config/hooks/use-server-config'; import { useServerConfig } from '../../config/hooks/use-server-config';
import { getEnabledOAuthProviders } from '../auth.models';
import { BackToServerSelectionButton } from '../components/back-to-server-selection'; import { BackToServerSelectionButton } from '../components/back-to-server-selection';
const loginSchema = v.object({ const loginSchema = v.object({
@ -34,7 +35,7 @@ export function LoginScreen() {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const { data: serverConfig, isLoading: isLoadingConfig } = useServerConfig(); const { serverConfig, isLoading: isConfigLoading } = useServerConfig();
const form = useForm({ const form = useForm({
defaultValues: { defaultValues: {
@ -80,15 +81,14 @@ export function LoginScreen() {
} }
}; };
const authConfig = serverConfig?.config?.auth; const authConfig = serverConfig?.auth;
const isEmailEnabled = authConfig?.providers?.email?.isEnabled ?? false; const isEmailEnabled = authConfig?.providers?.email?.isEnabled ?? false;
const isGoogleEnabled = authConfig?.providers?.google?.isEnabled ?? false;
const isGithubEnabled = authConfig?.providers?.github?.isEnabled ?? false; const oauthProviders = getEnabledOAuthProviders({ serverConfig });
const customProviders = authConfig?.providers?.customs ?? [];
const styles = createStyles({ themeColors }); const styles = createStyles({ themeColors });
if (isLoadingConfig) { if (isConfigLoading) {
return ( return (
<View style={[styles.container, styles.centerContent]}> <View style={[styles.container, styles.centerContent]}>
<ActivityIndicator size="large" color={themeColors.primary} /> <ActivityIndicator size="large" color={themeColors.primary} />
@ -159,7 +159,7 @@ export function LoginScreen() {
> >
{isSubmitting {isSubmitting
? ( ? (
<ActivityIndicator color="#fff" /> <ActivityIndicator color={themeColors.primaryForeground} />
) )
: ( : (
<Text style={styles.buttonText}>Sign In</Text> <Text style={styles.buttonText}>Sign In</Text>
@ -168,7 +168,7 @@ export function LoginScreen() {
</View> </View>
)} )}
{(isGoogleEnabled || isGithubEnabled || customProviders.length > 0) && ( {(oauthProviders.length > 0) && (
<> <>
{isEmailEnabled && ( {isEmailEnabled && (
<View style={styles.divider}> <View style={styles.divider}>
@ -179,25 +179,7 @@ export function LoginScreen() {
)} )}
<View style={styles.socialButtons}> <View style={styles.socialButtons}>
{isGoogleEnabled && ( {oauthProviders.map(provider => (
<TouchableOpacity
style={styles.socialButton}
onPress={async () => handleSocialSignIn('google')}
>
<Text style={styles.socialButtonText}>Continue with Google</Text>
</TouchableOpacity>
)}
{isGithubEnabled && (
<TouchableOpacity
style={styles.socialButton}
onPress={async () => handleSocialSignIn('github')}
>
<Text style={styles.socialButtonText}>Continue with GitHub</Text>
</TouchableOpacity>
)}
{customProviders.map(provider => (
<TouchableOpacity <TouchableOpacity
key={provider.providerId} key={provider.providerId}
style={styles.socialButton} style={styles.socialButton}

View file

@ -35,7 +35,7 @@ export function SignupScreen() {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const { data: serverConfig, isLoading: isLoadingConfig } = useServerConfig(); const { serverConfig, isLoading: isConfigLoading } = useServerConfig();
const form = useForm({ const form = useForm({
defaultValues: { defaultValues: {
@ -53,7 +53,7 @@ export function SignupScreen() {
await authClient.signUp.email({ name, email, password }); await authClient.signUp.email({ name, email, password });
const isEmailVerificationRequired = serverConfig?.config?.auth?.isEmailVerificationRequired ?? false; const isEmailVerificationRequired = serverConfig?.auth?.isEmailVerificationRequired ?? false;
if (isEmailVerificationRequired) { if (isEmailVerificationRequired) {
showAlert({ showAlert({
@ -75,12 +75,12 @@ export function SignupScreen() {
}, },
}); });
const authConfig = serverConfig?.config?.auth; const authConfig = serverConfig?.auth;
const isRegistrationEnabled = authConfig?.isRegistrationEnabled ?? false; const isRegistrationEnabled = authConfig?.isRegistrationEnabled ?? false;
const styles = createStyles({ themeColors }); const styles = createStyles({ themeColors });
if (isLoadingConfig) { if (isConfigLoading) {
return ( return (
<View style={[styles.container, styles.centerContent]}> <View style={[styles.container, styles.centerContent]}>
<ActivityIndicator size="large" color={themeColors.primary} /> <ActivityIndicator size="large" color={themeColors.primary} />
@ -182,7 +182,7 @@ export function SignupScreen() {
> >
{isSubmitting {isSubmitting
? ( ? (
<ActivityIndicator color="#fff" /> <ActivityIndicator color={themeColors.primaryForeground} />
) )
: ( : (
<Text style={styles.buttonText}>Sign Up</Text> <Text style={styles.buttonText}>Sign Up</Text>

View file

@ -0,0 +1,37 @@
import { describe, expect, test } from 'vitest';
import { validateServerUrl } from './config.models';
describe('config models', () => {
describe('validateServerUrl', () => {
test('non-url are rejected', () => {
expect(() => validateServerUrl({ url: 'not-a-url' })).toThrow();
expect(() => validateServerUrl({ url: '' })).toThrow();
});
test('urls are trimmed', () => {
expect(validateServerUrl({ url: ' https://example.com ' })).to.eql('https://example.com');
});
test('if the url ends with a /api it is removed', () => {
expect(validateServerUrl({ url: 'https://example.com/api' })).to.eql('https://example.com');
expect(validateServerUrl({ url: 'https://example.com/api/' })).to.eql('https://example.com');
expect(validateServerUrl({ url: 'https://example.com/papra/api/' })).to.eql('https://example.com/papra');
expect(validateServerUrl({ url: 'https://example.com/papi' })).to.eql('https://example.com/papi');
});
test('protocol must be present', () => {
expect(() => validateServerUrl({ url: 'example.com' })).toThrow();
expect(() => validateServerUrl({ url: '192.168.0.0' })).toThrow();
});
test('standard urls are returned as-is', () => {
expect(validateServerUrl({ url: 'https://example.com' })).to.eql('https://example.com');
expect(validateServerUrl({ url: 'https://example.com/' })).to.eql('https://example.com/');
expect(validateServerUrl({ url: 'http://example.com' })).to.eql('http://example.com');
expect(validateServerUrl({ url: 'https://192.168.0.0' })).to.eql('https://192.168.0.0');
expect(validateServerUrl({ url: 'https://sub.domain.example.com' })).to.eql('https://sub.domain.example.com');
expect(validateServerUrl({ url: 'https://example.com:8080' })).to.eql('https://example.com:8080');
expect(validateServerUrl({ url: 'https://example.com/papra' })).to.eql('https://example.com/papra');
});
});
});

View file

@ -0,0 +1,12 @@
import * as v from 'valibot';
const urlSchema = v.pipe(
v.string(),
v.trim(),
v.url(),
v.transform(url => url.replace(/\/api\/?$/, '')),
);
export function validateServerUrl({ url }: { url: string }) {
return v.parse(urlSchema, url);
}

View file

@ -1,31 +1,10 @@
import type { ApiClient } from '../api/api.client'; import type { ApiClient } from '../api/api.client';
import type { ServerConfig } from './config.types';
import { httpClient } from '../api/http.client'; import { httpClient } from '../api/http.client';
export async function fetchServerConfig({ apiClient}: { apiClient: ApiClient }) { export async function fetchServerConfig({ apiClient}: { apiClient: ApiClient }) {
return apiClient<{ return apiClient<{
config: { config: ServerConfig;
auth: {
isEmailVerificationRequired: boolean;
isPasswordResetEnabled: boolean;
isRegistrationEnabled: boolean;
showLegalLinksOnAuthPage: boolean;
providers: {
email: {
isEnabled: boolean;
};
github: {
isEnabled: boolean;
};
google: {
isEnabled: boolean;
};
customs: {
providerId: string;
providerName: string;
}[];
};
};
};
}>({ }>({
path: '/api/config', path: '/api/config',
}); });

View file

@ -0,0 +1,23 @@
export type ServerConfig = {
auth: {
isEmailVerificationRequired: boolean;
isPasswordResetEnabled: boolean;
isRegistrationEnabled: boolean;
showLegalLinksOnAuthPage: boolean;
providers: {
email: {
isEnabled: boolean;
};
github: {
isEnabled: boolean;
};
google: {
isEnabled: boolean;
};
customs: {
providerId: string;
providerName: string;
}[];
};
};
};

View file

@ -5,8 +5,13 @@ import { fetchServerConfig } from '../config.services';
export function useServerConfig() { export function useServerConfig() {
const apiClient = useApiClient(); const apiClient = useApiClient();
return useQuery({ const query = useQuery({
queryKey: ['server', 'config'], queryKey: ['server', 'config'],
queryFn: async () => fetchServerConfig({ apiClient }), queryFn: async () => fetchServerConfig({ apiClient }),
}); });
return {
...query,
serverConfig: query.data?.config,
};
} }

View file

@ -1,4 +1,5 @@
import type { ThemeColors } from '@/modules/ui/theme.constants'; import type { ThemeColors } from '@/modules/ui/theme.constants';
import { safelySync } from '@corentinth/chisels';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useState } from 'react'; import { useState } from 'react';
import { import {
@ -17,6 +18,7 @@ import { useAlert } from '@/modules/ui/providers/alert-provider';
import { useThemeColor } from '@/modules/ui/providers/use-theme-color'; import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
import { MANAGED_SERVER_URL } from '../config.constants'; import { MANAGED_SERVER_URL } from '../config.constants';
import { configLocalStorage } from '../config.local-storage'; import { configLocalStorage } from '../config.local-storage';
import { validateServerUrl } from '../config.models';
import { pingServer } from '../config.services'; import { pingServer } from '../config.services';
function getDefaultCustomServerUrl() { function getDefaultCustomServerUrl() {
@ -38,8 +40,20 @@ export function ServerSelectionScreen() {
const [customUrl, setCustomUrl] = useState(getDefaultCustomServerUrl()); const [customUrl, setCustomUrl] = useState(getDefaultCustomServerUrl());
const [isValidating, setIsValidating] = useState(false); const [isValidating, setIsValidating] = useState(false);
const handleValidateCustomUrl = async ({ url}: { url: string }) => { const handleValidateCustomUrl = async ({ url: rawUrl }: { url: string }) => {
setIsValidating(true); setIsValidating(true);
const [url, urlValidationError] = safelySync(() => validateServerUrl({ url: rawUrl }));
if (urlValidationError) {
showAlert({
title: 'Invalid URL',
message: 'Please enter a valid server URL. Make sure to include the protocol (http:// or https://).',
});
setIsValidating(false);
return;
}
try { try {
await pingServer({ url }); await pingServer({ url });
await configLocalStorage.setApiServerBaseUrl({ apiServerBaseUrl: url }); await configLocalStorage.setApiServerBaseUrl({ apiServerBaseUrl: url });
@ -108,7 +122,7 @@ export function ServerSelectionScreen() {
> >
{isValidating {isValidating
? ( ? (
<ActivityIndicator color="#fff" /> <ActivityIndicator color={themeColors.primaryForeground} />
) )
: ( : (
<Text style={styles.buttonText}>Continue with Managed</Text> <Text style={styles.buttonText}>Continue with Managed</Text>
@ -137,7 +151,7 @@ export function ServerSelectionScreen() {
> >
{isValidating {isValidating
? ( ? (
<ActivityIndicator color="#fff" /> <ActivityIndicator color={themeColors.primaryForeground} />
) )
: ( : (
<Text style={styles.buttonText}>Connect</Text> <Text style={styles.buttonText}>Connect</Text>

View file

@ -1,335 +0,0 @@
import type { CoerceDates } from '@/modules/api/api.models';
import type { Document } from '@/modules/documents/documents.types';
import type { ThemeColors } from '@/modules/ui/theme.constants';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { router } from 'expo-router';
import * as Sharing from 'expo-sharing';
import {
Modal,
StyleSheet,
Text,
TouchableOpacity,
TouchableWithoutFeedback,
View,
} from 'react-native';
import { useAuthClient } from '@/modules/api/providers/api.provider';
import { configLocalStorage } from '@/modules/config/config.local-storage';
import { fetchDocumentFile } from '@/modules/documents/documents.services';
import { useAlert } from '@/modules/ui/providers/alert-provider';
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
type DocumentActionSheetProps = {
visible: boolean;
document: CoerceDates<Document> | undefined;
onClose: () => void;
};
export function DocumentActionSheet({
visible,
document,
onClose,
}: DocumentActionSheetProps) {
const themeColors = useThemeColor();
const styles = createStyles({ themeColors });
const { showAlert } = useAlert();
const authClient = useAuthClient();
if (document === undefined) {
return null;
}
// Check if document can be viewed in DocumentViewerScreen
// Supported types: images (image/*) and PDFs (application/pdf)
const isViewable
= document.mimeType.startsWith('image/')
|| document.mimeType.startsWith('application/pdf');
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
const handleView = async () => {
onClose();
router.push({
pathname: '/(app)/document/view',
params: {
documentId: document.id,
organizationId: document.organizationId,
},
});
};
const handleDownloadAndShare = async () => {
const baseUrl = await configLocalStorage.getApiServerBaseUrl();
if (baseUrl == null) {
showAlert({
title: 'Error',
message: 'Base URL not found',
});
return;
}
const canShare = await Sharing.isAvailableAsync();
if (!canShare) {
showAlert({
title: 'Sharing Failed',
message: 'Sharing is not available on this device. Please share the document manually.',
});
return;
}
try {
const fileUri = await fetchDocumentFile({
document,
organizationId: document.organizationId,
baseUrl,
authClient,
});
await Sharing.shareAsync(fileUri);
} catch (error) {
console.error('Error downloading document file:', error);
showAlert({
title: 'Error',
message: 'Failed to download document file',
});
}
};
// Extract MIME type subtype, fallback to full MIME type if subtype is missing
const mimeParts = document.mimeType.split('/');
const mimeSubtype = mimeParts[1];
const displayMimeType = mimeSubtype != null && mimeSubtype !== '' ? mimeSubtype : document.mimeType;
return (
<Modal
visible={visible}
transparent
animationType="slide"
onRequestClose={onClose}
>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.overlay}>
<TouchableWithoutFeedback>
<View style={styles.sheet}>
{/* Handle bar */}
<View style={styles.handleBar} />
{/* Document info */}
<View style={styles.documentInfo}>
<Text style={styles.documentName} numberOfLines={2}>
{document.name}
</Text>
{/* Document details */}
<View style={styles.detailsContainer}>
<View style={styles.detailRow}>
<MaterialCommunityIcons
name="file"
size={14}
color={themeColors.mutedForeground}
style={styles.detailIcon}
/>
<Text style={styles.detailText}>{formatFileSize(document.originalSize)}</Text>
</View>
<View style={styles.detailRow}>
<MaterialCommunityIcons
name="calendar"
size={14}
color={themeColors.mutedForeground}
style={styles.detailIcon}
/>
<Text style={styles.detailText}>{formatDate(document.createdAt.toISOString())}</Text>
</View>
<View style={styles.detailRow}>
<MaterialCommunityIcons
name="file-document-outline"
size={14}
color={themeColors.mutedForeground}
style={styles.detailIcon}
/>
<Text style={styles.detailText} numberOfLines={1}>
{displayMimeType}
</Text>
</View>
</View>
</View>
{/* Action buttons */}
<View style={styles.actions}>
{isViewable && (
<TouchableOpacity
style={styles.actionButton}
onPress={async () => {
onClose();
await handleView();
}}
activeOpacity={0.7}
>
<View style={[styles.actionIcon, styles.viewIcon]}>
<MaterialCommunityIcons
name="eye"
size={20}
color={themeColors.primary}
/>
</View>
<Text style={styles.actionText}>View</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={styles.actionButton}
onPress={async () => {
onClose();
await handleDownloadAndShare();
}}
activeOpacity={0.7}
>
<View style={[styles.actionIcon, styles.downloadIcon]}>
<MaterialCommunityIcons
name="download"
size={20}
color={themeColors.primary}
/>
</View>
<Text style={styles.actionText}>Share</Text>
</TouchableOpacity>
</View>
{/* Cancel button */}
<TouchableOpacity
style={styles.cancelButton}
onPress={onClose}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
);
}
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
return StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'flex-end',
},
sheet: {
backgroundColor: themeColors.secondaryBackground,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
paddingBottom: 34, // Safe area for bottom
paddingTop: 16,
},
handleBar: {
width: 40,
height: 4,
backgroundColor: themeColors.border,
borderRadius: 2,
alignSelf: 'center',
marginBottom: 16,
},
documentInfo: {
paddingHorizontal: 24,
paddingVertical: 16,
borderBottomWidth: 1,
borderBottomColor: themeColors.border,
},
documentName: {
fontSize: 16,
fontWeight: '600',
color: themeColors.foreground,
textAlign: 'center',
marginBottom: 12,
},
detailsContainer: {
flexDirection: 'row',
justifyContent: 'center',
flexWrap: 'wrap',
gap: 16,
marginTop: 8,
},
detailRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
detailIcon: {
marginRight: 2,
},
detailText: {
fontSize: 12,
color: themeColors.mutedForeground,
},
actions: {
flexDirection: 'row',
paddingHorizontal: 24,
paddingVertical: 16,
gap: 16,
},
actionButton: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
backgroundColor: themeColors.secondaryBackground,
borderRadius: 12,
},
actionIcon: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 8,
},
viewIcon: {
backgroundColor: `${themeColors.primary}15`,
},
downloadIcon: {
backgroundColor: `${themeColors.primary}15`,
},
actionText: {
fontSize: 14,
fontWeight: '500',
color: themeColors.foreground,
},
cancelButton: {
marginHorizontal: 24,
marginTop: 12,
paddingVertical: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: themeColors.border,
borderRadius: 12,
},
cancelButtonText: {
fontSize: 16,
fontWeight: '600',
color: themeColors.foreground,
},
});
}

View file

@ -1,257 +0,0 @@
import type { CoerceDates } from '@/modules/api/api.models';
import type { Document } from '@/modules/documents/documents.types';
import type { ThemeColors } from '@/modules/ui/theme.constants';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useQuery } from '@tanstack/react-query';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React from 'react';
import {
ActivityIndicator,
Image,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import Pdf from 'react-native-pdf';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useApiClient, useAuthClient } from '@/modules/api/providers/api.provider';
import { configLocalStorage } from '@/modules/config/config.local-storage';
import { fetchDocument, fetchDocumentFile } from '@/modules/documents/documents.services';
import { useAlert } from '@/modules/ui/providers/alert-provider';
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
type DocumentFile = {
uri: string;
doc: CoerceDates<Document>;
};
export default function DocumentViewScreen() {
const router = useRouter();
const params = useLocalSearchParams<{ documentId: string; organizationId: string }>();
const themeColors = useThemeColor();
const styles = createStyles({ themeColors });
const { showAlert } = useAlert();
const apiClient = useApiClient();
const authClient = useAuthClient();
const { documentId, organizationId } = params;
const documentQuery = useQuery({
queryKey: ['organizations', organizationId, 'documents', documentId],
queryFn: async () => {
if (organizationId == null || documentId == null) {
throw new Error('Organization ID and Document ID are required');
}
return fetchDocument({ organizationId, documentId, apiClient });
},
enabled: organizationId != null && documentId != null,
});
const documentFileQuery = useQuery({
queryKey: ['organizations', organizationId, 'documents', documentId, 'file'],
queryFn: async () => {
if (documentQuery.data == null) {
throw new Error('Document not loaded');
}
const baseUrl = await configLocalStorage.getApiServerBaseUrl();
if (baseUrl == null) {
throw new Error('Base URL not found');
}
const fileUri = await fetchDocumentFile({
document: documentQuery.data.document,
organizationId,
baseUrl,
authClient,
});
return {
uri: fileUri,
doc: documentQuery.data.document,
} as DocumentFile;
},
enabled: documentQuery.isSuccess && documentQuery.data != null,
});
const renderHeader = (documentName: string) => {
return (
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => router.back()}
>
<MaterialCommunityIcons
name="close"
size={24}
color={themeColors.foreground}
/>
</TouchableOpacity>
<Text style={styles.headerTitle} numberOfLines={1}>
{documentName}
</Text>
<View style={styles.headerSpacer} />
</View>
);
};
const renderDocumentFile = (file: DocumentFile) => {
if (file.doc.mimeType.startsWith('image/')) {
return (
<Image
source={{ uri: file.uri }}
style={styles.pdfViewer}
/>
);
}
if (file.doc.mimeType.startsWith('application/pdf')) {
return (
<Pdf
source={{ uri: file.uri, cache: true }}
style={styles.pdfViewer}
onError={(error) => {
console.error('PDF error:', error);
showAlert({
title: 'Error',
message: 'Failed to load PDF',
});
}}
enablePaging={true}
horizontal={false}
enableAnnotationRendering={true}
fitPolicy={0}
spacing={10}
/>
);
}
return <View style={styles.pdfViewer} />;
};
const isLoading = documentQuery.isLoading || documentFileQuery.isLoading;
const error = documentQuery.error ?? documentFileQuery.error;
const documentFile = documentFileQuery.data;
const documentName = documentFile?.doc.name ?? 'Document';
return (
<SafeAreaView style={styles.container}>
{renderHeader(documentName)}
{isLoading
? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={themeColors.primary} />
<Text style={styles.loadingText}>Loading document...</Text>
</View>
)
: error != null
? (
<View style={styles.errorContainer}>
<MaterialCommunityIcons
name="file-pdf-box"
size={64}
color={themeColors.mutedForeground}
/>
<Text style={styles.errorText}>Failed to load document</Text>
<TouchableOpacity
style={styles.errorButton}
onPress={() => {
void documentQuery.refetch();
}}
>
<Text style={styles.errorButtonText}>Retry</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.errorButton}
onPress={() => router.back()}
>
<Text style={styles.errorButtonText}>Go Back</Text>
</TouchableOpacity>
</View>
)
: documentFile != null
? (
<View style={styles.pdfContainer}>
{renderDocumentFile(documentFile)}
</View>
)
: null}
</SafeAreaView>
);
}
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: themeColors.background,
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 24,
paddingVertical: 16,
borderBottomWidth: 1,
borderBottomColor: themeColors.border,
},
backButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: themeColors.secondaryBackground,
justifyContent: 'center',
alignItems: 'center',
},
headerTitle: {
flex: 1,
fontSize: 18,
fontWeight: 'bold',
color: themeColors.foreground,
marginHorizontal: 16,
},
headerSpacer: {
width: 40,
},
pdfContainer: {
flex: 1,
backgroundColor: themeColors.background,
},
pdfViewer: {
flex: 1,
width: '100%',
height: '100%',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 16,
fontSize: 16,
color: themeColors.mutedForeground,
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 32,
},
errorText: {
fontSize: 18,
color: themeColors.foreground,
marginTop: 16,
marginBottom: 24,
},
errorButton: {
paddingHorizontal: 24,
paddingVertical: 16,
backgroundColor: themeColors.secondaryBackground,
borderRadius: 12,
marginTop: 16,
},
errorButtonText: {
fontSize: 16,
fontWeight: '600',
color: themeColors.primary,
},
});
}

View file

@ -0,0 +1,256 @@
import type { CoerceDates } from '@/modules/api/api.models';
import type { Document } from '@/modules/documents/documents.types';
import type { IconName } from '@/modules/ui/components/icon';
import type { ThemeColors } from '@/modules/ui/theme.constants';
import { formatBytes } from '@corentinth/chisels';
import { router } from 'expo-router';
import * as Sharing from 'expo-sharing';
import {
Modal,
StyleSheet,
Text,
TouchableOpacity,
TouchableWithoutFeedback,
View,
} from 'react-native';
import { useAuthClient } from '@/modules/api/providers/api.provider';
import { configLocalStorage } from '@/modules/config/config.local-storage';
import { fetchDocumentFile } from '@/modules/documents/documents.services';
import { Icon } from '@/modules/ui/components/icon';
import { useAlert } from '@/modules/ui/providers/alert-provider';
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
type DocumentActionSheetProps = {
visible: boolean;
document: CoerceDates<Document> | undefined;
onClose: () => void;
excludedActions?: ActionsKey[];
};
export type ActionsKey = 'view' | 'share';
export function DocumentActionSheet({
visible,
document,
onClose,
excludedActions = [],
}: DocumentActionSheetProps) {
const themeColors = useThemeColor();
const styles = createStyles({ themeColors });
const { showAlert } = useAlert();
const authClient = useAuthClient();
if (document === undefined) {
return null;
}
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
const handleView = async () => {
onClose();
router.push({
pathname: '/(app)/document/view',
params: {
documentId: document.id,
organizationId: document.organizationId,
},
});
};
const handleDownloadAndShare = async () => {
onClose();
const baseUrl = await configLocalStorage.getApiServerBaseUrl();
if (baseUrl == null) {
showAlert({
title: 'Error',
message: 'Base URL not found',
});
return;
}
const canShare = await Sharing.isAvailableAsync();
if (!canShare) {
showAlert({
title: 'Sharing Failed',
message: 'Sharing is not available on this device. Please share the document manually.',
});
return;
}
try {
const fileUri = await fetchDocumentFile({
document,
organizationId: document.organizationId,
baseUrl,
authClient,
});
await Sharing.shareAsync(fileUri);
} catch (error) {
console.error('Error downloading document file:', error);
showAlert({
title: 'Error',
message: 'Failed to download document file',
});
}
};
// Extract MIME type subtype, fallback to full MIME type if subtype is missing
const mimeParts = document.mimeType.split('/');
const mimeSubtype = mimeParts[1];
const displayMimeType = mimeSubtype != null && mimeSubtype !== '' ? mimeSubtype.toUpperCase() : document.mimeType;
const actions: { key: ActionsKey; label: string; icon: IconName; onPress: () => void }[] = [
{
key: 'view',
label: 'View document',
icon: 'eye',
onPress: handleView,
},
{
key: 'share',
label: 'Share',
icon: 'share',
onPress: handleDownloadAndShare,
},
];
const filteredActions = actions.filter(action => !excludedActions.includes(action.key));
return (
<Modal
visible={visible}
transparent
animationType="slide"
onRequestClose={onClose}
>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.overlay}>
<TouchableWithoutFeedback>
<View style={styles.sheet}>
<View style={styles.handleBar} />
<View style={styles.header}>
<View style={styles.fileIconContainer}>
<Icon name="file-text" size={24} color={themeColors.primary} />
</View>
<View style={styles.headerContent}>
<Text style={styles.documentName} numberOfLines={2}>
{document.name}
</Text>
<Text style={styles.documentMeta}>
{displayMimeType}
{' · '}
{formatBytes({ bytes: document.originalSize })}
{' · '}
{formatDate(document.createdAt.toISOString())}
</Text>
</View>
</View>
<View style={styles.actions}>
{filteredActions.map(action => (
<TouchableOpacity
key={action.key}
style={styles.actionRow}
onPress={action.onPress}
activeOpacity={0.6}
>
<View style={styles.actionIconContainer}>
<Icon name={action.icon} size={20} color={themeColors.foreground} />
</View>
<Text style={styles.actionText}>{action.label}</Text>
</TouchableOpacity>
))}
</View>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
);
}
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
return StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'flex-end',
},
sheet: {
backgroundColor: themeColors.secondaryBackground,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
paddingBottom: 34,
},
handleBar: {
width: 36,
height: 4,
backgroundColor: themeColors.border,
borderRadius: 2,
alignSelf: 'center',
marginTop: 8,
marginBottom: 16,
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 20,
paddingBottom: 16,
borderBottomWidth: 1,
borderBottomColor: themeColors.border,
},
fileIconContainer: {
width: 48,
height: 48,
borderRadius: 8,
backgroundColor: themeColors.muted,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
headerContent: {
flex: 1,
},
documentName: {
fontSize: 16,
fontWeight: '600',
color: themeColors.foreground,
marginBottom: 4,
},
documentMeta: {
fontSize: 13,
color: themeColors.mutedForeground,
},
actions: {
paddingTop: 8,
paddingBottom: 8,
},
actionRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
paddingHorizontal: 32,
marginVertical: 4,
},
actionIconContainer: {
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
actionText: {
flex: 1,
fontSize: 16,
color: themeColors.foreground,
},
});
}

View file

@ -0,0 +1,324 @@
import type { CoerceDates } from '@/modules/api/api.models';
import type { Document } from '@/modules/documents/documents.types';
import type { ThemeColors } from '@/modules/ui/theme.constants';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useQuery } from '@tanstack/react-query';
import * as FileSystem from 'expo-file-system/legacy';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useState } from 'react';
import { ActivityIndicator, Image, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Pdf from 'react-native-pdf';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useApiClient, useAuthClient } from '@/modules/api/providers/api.provider';
import { configLocalStorage } from '@/modules/config/config.local-storage';
import { DocumentActionSheet } from '@/modules/documents/components/document-action-sheet';
import { fetchDocument, fetchDocumentFile } from '@/modules/documents/documents.services';
import { useAlert } from '@/modules/ui/providers/alert-provider';
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
type DocumentFile = {
uri: string;
doc: CoerceDates<Document>;
textContent?: string;
};
const textMimeTypes = new Set([
'application/json',
'application/xml',
'application/javascript',
'application/x-yaml',
'application/yaml',
'application/xhtml+xml',
]);
function isTextBasedFile(mimeType: string): boolean {
return mimeType.startsWith('text/') || textMimeTypes.has(mimeType);
}
function DocumentViewer({ file, styles, themeColors, onError }: {
file: DocumentFile;
styles: ReturnType<typeof createStyles>;
themeColors: ThemeColors;
onError: (message: string) => void;
}) {
const { mimeType } = file.doc;
if (mimeType.startsWith('image/')) {
return <Image source={{ uri: file.uri }} style={styles.documentViewer} />;
}
if (mimeType === 'application/pdf') {
return (
<Pdf
source={{ uri: file.uri, cache: true }}
style={styles.documentViewer}
onError={(error) => {
console.error('PDF error:', error);
onError('Failed to load PDF');
}}
enablePaging
horizontal={false}
enableAnnotationRendering
fitPolicy={0}
spacing={10}
/>
);
}
if (file.textContent != null) {
return (
<ScrollView style={styles.textViewer} contentContainerStyle={styles.textViewerContent}>
<Text style={styles.textContent} selectable>
{file.textContent}
</Text>
</ScrollView>
);
}
return (
<View style={styles.centeredContainer}>
<MaterialCommunityIcons name="file-question-outline" size={64} color={themeColors.mutedForeground} />
<Text style={styles.centeredTitle}>Preview not available</Text>
<Text style={styles.centeredText}>This file type cannot be previewed in the app</Text>
</View>
);
}
function LoadingState({ styles, themeColors }: { styles: ReturnType<typeof createStyles>; themeColors: ThemeColors }) {
return (
<View style={styles.centeredContainer}>
<ActivityIndicator size="large" color={themeColors.primary} />
<Text style={styles.centeredText}>Loading document...</Text>
</View>
);
}
function ErrorState({ styles, themeColors, onRetry, onGoBack }: {
styles: ReturnType<typeof createStyles>;
themeColors: ThemeColors;
onRetry: () => void;
onGoBack: () => void;
}) {
return (
<View style={styles.centeredContainer}>
<MaterialCommunityIcons name="file-alert-outline" size={64} color={themeColors.mutedForeground} />
<Text style={styles.centeredTitle}>Failed to load document</Text>
<TouchableOpacity style={styles.actionButton} onPress={onRetry}>
<Text style={styles.actionButtonText}>Retry</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.actionButton} onPress={onGoBack}>
<Text style={styles.actionButtonText}>Go Back</Text>
</TouchableOpacity>
</View>
);
}
export default function DocumentViewScreen() {
const router = useRouter();
const params = useLocalSearchParams<{ documentId: string; organizationId: string }>();
const themeColors = useThemeColor();
const styles = createStyles({ themeColors });
const { showAlert } = useAlert();
const apiClient = useApiClient();
const authClient = useAuthClient();
const [isActionSheetVisible, setIsActionSheetVisible] = useState(false);
const { documentId, organizationId } = params;
if (organizationId == null || documentId == null) {
showAlert({
title: 'Error',
message: 'Organization ID and Document ID are required',
});
return null;
}
const documentQuery = useQuery({
queryKey: ['organizations', organizationId, 'documents', documentId],
queryFn: async () => fetchDocument({ organizationId, documentId, apiClient }),
});
const documentFileQuery = useQuery({
queryKey: ['organizations', organizationId, 'documents', documentId, 'file'],
queryFn: async () => {
if (documentQuery.data == null) {
throw new Error('Document not loaded');
}
const baseUrl = await configLocalStorage.getApiServerBaseUrl();
if (baseUrl == null) {
throw new Error('Base URL not found');
}
const fileUri = await fetchDocumentFile({
document: documentQuery.data.document,
organizationId,
baseUrl,
authClient,
});
const doc = documentQuery.data.document;
let textContent: string | undefined;
if (isTextBasedFile(doc.mimeType)) {
textContent = await FileSystem.readAsStringAsync(fileUri);
}
return {
uri: fileUri,
doc,
textContent,
} as DocumentFile;
},
enabled: documentQuery.isSuccess && documentQuery.data != null,
});
const isLoading = documentQuery.isLoading || documentFileQuery.isLoading;
const error = documentQuery.error ?? documentFileQuery.error;
const documentFile = documentFileQuery.data;
const documentName = documentFile?.doc.name ?? 'Document';
const handleShowError = (message: string) => {
showAlert({ title: 'Error', message });
};
const renderContent = () => {
if (isLoading) {
return <LoadingState styles={styles} themeColors={themeColors} />;
}
if (error != null) {
return (
<ErrorState
styles={styles}
themeColors={themeColors}
onRetry={() => void documentQuery.refetch()}
onGoBack={() => router.back()}
/>
);
}
if (documentFile != null) {
return (
<View style={styles.documentContainer}>
<DocumentViewer
file={documentFile}
styles={styles}
themeColors={themeColors}
onError={handleShowError}
/>
</View>
);
}
return null;
};
return (
<SafeAreaView style={styles.container}>
<View style={styles.header}>
<TouchableOpacity style={styles.headerButton} onPress={() => router.back()}>
<MaterialCommunityIcons name="close" size={24} color={themeColors.foreground} />
</TouchableOpacity>
<Text style={styles.headerTitle} numberOfLines={1}>
{documentName}
</Text>
<TouchableOpacity style={styles.headerButton} onPress={() => setIsActionSheetVisible(true)}>
<MaterialCommunityIcons name="dots-vertical" size={24} color={themeColors.foreground} />
</TouchableOpacity>
</View>
<DocumentActionSheet
visible={isActionSheetVisible}
document={documentFile?.doc}
onClose={() => setIsActionSheetVisible(false)}
excludedActions={['view']}
/>
{renderContent()}
</SafeAreaView>
);
}
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: themeColors.background,
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 24,
paddingVertical: 16,
borderBottomWidth: 1,
borderBottomColor: themeColors.border,
},
headerButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: themeColors.secondaryBackground,
justifyContent: 'center',
alignItems: 'center',
},
headerTitle: {
flex: 1,
fontSize: 18,
fontWeight: 'bold',
color: themeColors.foreground,
marginHorizontal: 16,
},
documentContainer: {
flex: 1,
backgroundColor: themeColors.background,
},
documentViewer: {
flex: 1,
width: '100%',
height: '100%',
},
textViewer: {
flex: 1,
},
textViewerContent: {
padding: 16,
},
textContent: {
fontFamily: 'monospace',
fontSize: 14,
lineHeight: 20,
color: themeColors.foreground,
},
centeredContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 32,
},
centeredTitle: {
fontSize: 18,
fontWeight: '600',
color: themeColors.foreground,
marginTop: 16,
},
centeredText: {
fontSize: 14,
color: themeColors.mutedForeground,
marginTop: 8,
textAlign: 'center',
},
actionButton: {
paddingHorizontal: 24,
paddingVertical: 16,
backgroundColor: themeColors.secondaryBackground,
borderRadius: 12,
marginTop: 16,
},
actionButtonText: {
fontSize: 16,
fontWeight: '600',
color: themeColors.primary,
},
});
}

View file

@ -1,7 +1,9 @@
import type { Document } from '../documents.types'; import type { Document } from '../documents.types';
import type { CoerceDates } from '@/modules/api/api.models'; import type { CoerceDates } from '@/modules/api/api.models';
import type { ThemeColors } from '@/modules/ui/theme.constants'; import type { ThemeColors } from '@/modules/ui/theme.constants';
import { formatBytes } from '@corentinth/chisels';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { router } from 'expo-router';
import { useState } from 'react'; import { useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
@ -14,7 +16,7 @@ import {
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useApiClient } from '@/modules/api/providers/api.provider'; import { useApiClient } from '@/modules/api/providers/api.provider';
import { DocumentActionSheet } from '@/modules/documents-actions/components/document-action-sheet'; import { DocumentActionSheet } from '@/modules/documents/components/document-action-sheet';
import { OrganizationPickerButton } from '@/modules/organizations/components/organization-picker-button'; import { OrganizationPickerButton } from '@/modules/organizations/components/organization-picker-button';
import { OrganizationPickerDrawer } from '@/modules/organizations/components/organization-picker-drawer'; import { OrganizationPickerDrawer } from '@/modules/organizations/components/organization-picker-drawer';
import { useOrganizations } from '@/modules/organizations/organizations.provider'; import { useOrganizations } from '@/modules/organizations/organizations.provider';
@ -57,16 +59,6 @@ export function DocumentsListScreen() {
}).format(date); }).format(date);
}; };
const formatFileSize = (bytes: number) => {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const onRefresh = async () => { const onRefresh = async () => {
await documentsQuery.refetch(); await documentsQuery.refetch();
if (currentOrganizationId != null) { if (currentOrganizationId != null) {
@ -107,17 +99,27 @@ export function DocumentsListScreen() {
data={documentsQuery.data?.documents ?? []} data={documentsQuery.data?.documents ?? []}
keyExtractor={item => item.id} keyExtractor={item => item.id}
renderItem={({ item }) => ( renderItem={({ item }) => (
<TouchableOpacity onPress={() => setOnDocumentActionSheet(item)}> <TouchableOpacity
onPress={() => {
router.push({
pathname: '/(app)/document/view',
params: {
documentId: item.id,
organizationId: item.organizationId,
},
});
}}
>
<View style={styles.documentCard}> <View style={styles.documentCard}>
<View style={{ backgroundColor: themeColors.muted, padding: 10, borderRadius: 6, marginRight: 12 }}> <View style={{ backgroundColor: themeColors.muted, padding: 10, borderRadius: 6, marginRight: 12 }}>
<Icon name="file-text" size={24} color={themeColors.primary} /> <Icon name="file-text" size={24} color={themeColors.primary} />
</View> </View>
<View> <View style={styles.documentContent}>
<Text style={styles.documentTitle} numberOfLines={2}> <Text style={styles.documentTitle} numberOfLines={1} ellipsizeMode="tail">
{item.name} {item.name}
</Text> </Text>
<View style={styles.documentMeta}> <View style={styles.documentMeta}>
<Text style={styles.metaText}>{formatFileSize(item.originalSize)}</Text> <Text style={styles.metaText}>{formatBytes({ bytes: item.originalSize })}</Text>
<Text style={styles.metaSplitter}>-</Text> <Text style={styles.metaSplitter}>-</Text>
<Text style={styles.metaText}>{formatDate(item.createdAt)}</Text> <Text style={styles.metaText}>{formatDate(item.createdAt)}</Text>
{item.localUri !== undefined && ( {item.localUri !== undefined && (
@ -144,6 +146,16 @@ export function DocumentsListScreen() {
)} )}
</View> </View>
</View> </View>
<TouchableOpacity
style={styles.moreButton}
onPress={(e) => {
e.stopPropagation();
setOnDocumentActionSheet(item);
}}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Icon name="more-vertical" size={20} color={themeColors.mutedForeground} />
</TouchableOpacity>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
)} )}
@ -222,12 +234,17 @@ function createStyles({ themeColors }: { themeColors: ThemeColors }) {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
}, },
documentTitle: { documentContent: {
flex: 1, flex: 1,
marginRight: 8,
},
documentTitle: {
fontSize: 16, fontSize: 16,
fontWeight: '600', fontWeight: '600',
color: themeColors.foreground, color: themeColors.foreground,
marginRight: 12, },
moreButton: {
padding: 8,
}, },
documentMeta: { documentMeta: {
flexDirection: 'row', flexDirection: 'row',

View file

@ -3,6 +3,7 @@ import type { Organization } from '@/modules/organizations/organizations.types';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { createContext, useContext, useEffect, useState } from 'react'; import { createContext, useContext, useEffect, useState } from 'react';
import { isHttpClientError } from '../api/http.client';
import { useApiClient } from '../api/providers/api.provider'; import { useApiClient } from '../api/providers/api.provider';
import { organizationsLocalStorage } from './organizations.local-storage'; import { organizationsLocalStorage } from './organizations.local-storage';
import { fetchOrganizations } from './organizations.services'; import { fetchOrganizations } from './organizations.services';
@ -49,12 +50,23 @@ export function OrganizationsProvider({ children }: OrganizationsProviderProps)
setCurrentOrganizationIdState(organizationId); setCurrentOrganizationIdState(organizationId);
}; };
// Redirect to organization selection if no organizations or no current org set
useEffect(() => { useEffect(() => {
if (!isInitialized || organizationsQuery.isLoading) { if (!isInitialized || organizationsQuery.isLoading) {
return; return;
} }
if (organizationsQuery.error) {
const error = organizationsQuery.error;
if (isHttpClientError(error) && error.status === 401) {
router.replace('/auth/login');
return;
}
router.replace('/config/server-selection');
return;
}
const organizations = organizationsQuery.data?.organizations ?? []; const organizations = organizationsQuery.data?.organizations ?? [];
if (organizations.length === 0) { if (organizations.length === 0) {

View file

@ -99,7 +99,7 @@ export function OrganizationCreateScreen() {
> >
{createMutation.isPending {createMutation.isPending
? ( ? (
<ActivityIndicator color="#fff" /> <ActivityIndicator color={themeColors.primaryForeground} />
) )
: ( : (
<Text style={styles.buttonText}>Create Organization</Text> <Text style={styles.buttonText}>Create Organization</Text>

View file

@ -1,3 +1,4 @@
import { Feather } from '@expo/vector-icons'; import { Feather } from '@expo/vector-icons';
export const Icon = Feather; export const Icon = Feather;
export type IconName = React.ComponentProps<typeof Feather>['name'];

View file

@ -2,6 +2,7 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({ export default defineConfig({
test: { test: {
isolate: false,
env: { env: {
TZ: 'UTC', TZ: 'UTC',
}, },

View file

@ -12,6 +12,7 @@ export default antfu({
], ],
rules: { rules: {
'pnpm/json-enforce-catalog': 'off',
// To allow export on top of files // To allow export on top of files
'ts/no-use-before-define': ['error', { allowNamedExports: true, functions: false }], 'ts/no-use-before-define': ['error', { allowNamedExports: true, functions: false }],
'curly': ['error', 'all'], 'curly': ['error', 'all'],

View file

@ -66,14 +66,16 @@
<style>.sr-only {position: absolute;width: 1px;height: 1px;padding: 0;margin: -1px;overflow: hidden;clip: rect(0, 0, 0, 0);white-space: nowrap;border-width: 0;}</style> <style>.sr-only {position: absolute;width: 1px;height: 1px;padding: 0;margin: -1px;overflow: hidden;clip: rect(0, 0, 0, 0);white-space: nowrap;border-width: 0;}</style>
<!-- Prevent flash of wrong theme on load --> <!-- Prevent FOUC, default to dark theme -->
<script> <script>
(function () { (function () {
const stored = localStorage?.getItem('papra_color_mode') ?? 'dark'; try{
const stored = localStorage?.getItem('papra_color_mode');
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const isLight = stored === 'light' || (stored === 'system' && !systemPrefersDark);
if (stored === 'dark') { document.documentElement.setAttribute('data-kb-theme', isLight ? 'light' : 'dark');
document.documentElement.setAttribute('data-kb-theme', 'dark'); } catch {}
}
})(); })();
</script> </script>
</head> </head>

View file

@ -29,6 +29,7 @@
"dependencies": { "dependencies": {
"@branchlet/core": "^1.0.0", "@branchlet/core": "^1.0.0",
"@corentinth/chisels": "catalog:", "@corentinth/chisels": "catalog:",
"@corvu/calendar": "^0.1.2",
"@corvu/otp-field": "^0.1.4", "@corvu/otp-field": "^0.1.4",
"@kobalte/core": "^0.13.10", "@kobalte/core": "^0.13.10",
"@kobalte/utils": "^0.9.1", "@kobalte/utils": "^0.9.1",

View file

@ -72,4 +72,7 @@
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
}
input[type="search"]::-webkit-search-cancel-button {
-webkit-appearance: none;
} }

View file

@ -1,7 +1,5 @@
/* @refresh reload */ /* @refresh reload */
import type { ConfigColorMode } from '@kobalte/core/color-mode';
import { ColorModeProvider, createLocalStorageManager } from '@kobalte/core/color-mode';
import { Router } from '@solidjs/router'; import { Router } from '@solidjs/router';
import { QueryClientProvider } from '@tanstack/solid-query'; import { QueryClientProvider } from '@tanstack/solid-query';
@ -12,8 +10,10 @@ import { isDemoMode } from './modules/config/config';
import { ConfigProvider } from './modules/config/config.provider'; import { ConfigProvider } from './modules/config/config.provider';
import { RenameDocumentDialogProvider } from './modules/documents/components/rename-document-button.component'; import { RenameDocumentDialogProvider } from './modules/documents/components/rename-document-button.component';
import { I18nProvider } from './modules/i18n/i18n.provider'; import { I18nProvider } from './modules/i18n/i18n.provider';
import { AboutDialogProvider } from './modules/shared/components/about-dialog';
import { ConfirmModalProvider } from './modules/shared/confirm'; import { ConfirmModalProvider } from './modules/shared/confirm';
import { queryClient } from './modules/shared/query/query-client'; import { queryClient } from './modules/shared/query/query-client';
import { ThemeProvider } from './modules/theme/theme.provider';
import { IdentifyUser } from './modules/tracking/components/identify-user.component'; import { IdentifyUser } from './modules/tracking/components/identify-user.component';
import { PageViewTracker } from './modules/tracking/components/pageview-tracker.component'; import { PageViewTracker } from './modules/tracking/components/pageview-tracker.component';
import { Toaster } from './modules/ui/components/sonner'; import { Toaster } from './modules/ui/components/sonner';
@ -27,10 +27,6 @@ const DemoIndicator = isDemoMode
render( render(
() => { () => {
const initialColorMode: ConfigColorMode = 'dark';
const colorModeStorageKey = 'papra_color_mode';
const localStorageManager = createLocalStorageManager(colorModeStorageKey);
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Router <Router
@ -41,23 +37,22 @@ render(
<IdentifyUser /> <IdentifyUser />
<I18nProvider> <I18nProvider>
<ConfirmModalProvider> <ConfirmModalProvider>
<ColorModeProvider <ThemeProvider>
initialColorMode={initialColorMode}
storageManager={localStorageManager}
>
<CommandPaletteProvider> <CommandPaletteProvider>
<ConfigProvider> <ConfigProvider>
<RenameDocumentDialogProvider> <AboutDialogProvider>
<div class="min-h-screen font-sans text-sm font-400"> <RenameDocumentDialogProvider>
{props.children} <div class="min-h-screen font-sans text-sm font-400">
</div> {props.children}
</RenameDocumentDialogProvider> </div>
{DemoIndicator && <DemoIndicator />} </RenameDocumentDialogProvider>
{DemoIndicator && <DemoIndicator />}
</AboutDialogProvider>
</ConfigProvider> </ConfigProvider>
<Toaster /> <Toaster />
</CommandPaletteProvider> </CommandPaletteProvider>
</ColorModeProvider> </ThemeProvider>
</ConfirmModalProvider> </ConfirmModalProvider>
</I18nProvider> </I18nProvider>

View file

@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
'documents.info.created-at': 'Erstellt am', 'documents.info.created-at': 'Erstellt am',
'documents.info.updated-at': 'Aktualisiert am', 'documents.info.updated-at': 'Aktualisiert am',
'documents.info.never': 'Nie', 'documents.info.never': 'Nie',
'documents.info.document-date': 'Datum',
'documents.info.no-date': 'Kein Datum',
'documents.info.today': 'Heute',
'custom-properties.types.text': 'Text',
'custom-properties.types.number': 'Zahl',
'custom-properties.types.date': 'Datum',
'custom-properties.types.boolean': 'Boolescher Wert',
'custom-properties.types.select': 'Auswahl',
'custom-properties.types.multi_select': 'Mehrfachauswahl',
'custom-properties.types.user_relation': 'Benutzer',
'custom-properties.types.document_relation': 'Dokument',
'custom-properties.list.title': 'Benutzerdefinierte Eigenschaften',
'custom-properties.list.description': 'Definieren Sie benutzerdefinierte Metadatenfelder für Ihre Dokumente. Eigenschaften können Text, Zahlen, Datum, Wahrheitswerte oder Auswahllisten sein.',
'custom-properties.list.create-button': 'Eigenschaft erstellen',
'custom-properties.list.empty.title': 'Benutzerdefinierte Eigenschaften',
'custom-properties.list.empty.description': 'Mit benutzerdefinierten Eigenschaften können Sie strukturierte Metadaten zu Ihren Dokumenten hinzufügen, z. B. Ablaufdaten, Firmennamen oder Beträge.',
'custom-properties.list.table.name': 'Name',
'custom-properties.list.table.type': 'Typ',
'custom-properties.list.table.description': 'Beschreibung',
'custom-properties.list.table.created': 'Erstellt',
'custom-properties.list.table.actions': 'Aktionen',
'custom-properties.list.table.no-description': 'Keine Beschreibung',
'custom-properties.list.delete.confirm-title': 'Benutzerdefinierte Eigenschaft löschen',
'custom-properties.list.delete.confirm-message': 'Möchten Sie die benutzerdefinierte Eigenschaft „{{ name }}” wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.',
'custom-properties.list.delete.confirm-button': 'Löschen',
'custom-properties.list.delete.success': 'Benutzerdefinierte Eigenschaft erfolgreich gelöscht',
'custom-properties.list.delete.error': 'Benutzerdefinierte Eigenschaft konnte nicht gelöscht werden',
'custom-properties.create.title': 'Benutzerdefinierte Eigenschaft erstellen',
'custom-properties.create.submit': 'Eigenschaft erstellen',
'custom-properties.create.success': 'Benutzerdefinierte Eigenschaft erfolgreich erstellt',
'custom-properties.create.error': 'Benutzerdefinierte Eigenschaft konnte nicht erstellt werden',
'custom-properties.update.title': 'Benutzerdefinierte Eigenschaft bearbeiten',
'custom-properties.update.submit': 'Änderungen speichern',
'custom-properties.update.success': 'Benutzerdefinierte Eigenschaft erfolgreich aktualisiert',
'custom-properties.update.error': 'Benutzerdefinierte Eigenschaft konnte nicht aktualisiert werden',
'custom-properties.form.name.label': 'Name',
'custom-properties.form.name.placeholder': 'z. B. Rechnungsbetrag',
'custom-properties.form.name.required': 'Name ist erforderlich',
'custom-properties.form.name.max-length': 'Der Name darf höchstens 255 Zeichen lang sein',
'custom-properties.form.description.label': 'Beschreibung',
'custom-properties.form.description.optional': '(optional)',
'custom-properties.form.description.placeholder': 'Beschreiben Sie, wofür diese Eigenschaft verwendet wird',
'custom-properties.form.description.max-length': 'Die Beschreibung darf höchstens 1000 Zeichen lang sein',
'custom-properties.form.type.label': 'Typ',
'custom-properties.form.type.immutable': 'Der Eigenschaftstyp kann nach der Erstellung nicht geändert werden.',
'custom-properties.form.options.title': 'Optionen',
'custom-properties.form.options.description': 'Definieren Sie die verfügbaren Auswahlmöglichkeiten für diese Eigenschaft.',
'custom-properties.form.options.name.placeholder': 'Optionsname',
'custom-properties.form.options.name.required': 'Optionsname ist erforderlich',
'custom-properties.form.options.name.max-length': 'Der Optionsname darf höchstens 255 Zeichen lang sein',
'custom-properties.form.options.validation.required': 'Bitte fügen Sie mindestens eine Option hinzu',
'custom-properties.form.options.add': 'Option hinzufügen',
'custom-properties.form.cancel': 'Abbrechen',
'custom-properties.form.save-error': 'Beim Speichern der Eigenschaftsdefinition ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.',
'documents.custom-properties.section-title': 'Eigenschaften',
'documents.custom-properties.no-value': 'Nicht festgelegt',
'documents.custom-properties.text-placeholder': 'Wert eingeben...',
'documents.custom-properties.save': 'Speichern',
'documents.custom-properties.clear': 'Löschen',
'documents.custom-properties.document-relation-search-placeholder': 'Dokumente suchen...',
'documents.custom-properties.user-relation-manage': 'Benutzer verwalten',
'documents.custom-properties.document-relation-manage': 'Dokumente verwalten',
'documents.custom-properties.no-results': 'Keine Ergebnisse',
'documents.rename.title': 'Dokument umbenennen', 'documents.rename.title': 'Dokument umbenennen',
'documents.rename.form.name.label': 'Name', 'documents.rename.form.name.label': 'Name',
@ -381,6 +450,71 @@ export const translations: Partial<TranslationsDictionary> = {
'documents.preview.unknown-file-type': 'Kein Vorschau verfügbar für diesen Dateityp', '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', 'documents.preview.binary-file': 'Dies scheint eine Binärdatei zu sein und kann nicht als Text angezeigt werden',
'documents.open-with.label': 'Öffnen mit',
'documents.open-with.pdf-viewer': 'PDF-Viewer',
'documents.pdf-viewer.loading': 'PDF wird geladen',
'documents.pdf-viewer.not-a-pdf': 'Dieses Dokument ist kein PDF und kann nicht im PDF-Viewer geöffnet werden.',
'documents.pdf-viewer.toolbar.hide-sidebar': 'Seitenleiste ausblenden',
'documents.pdf-viewer.toolbar.show-sidebar': 'Seitenleiste einblenden',
'documents.pdf-viewer.toolbar.previous-page': 'Vorherige Seite',
'documents.pdf-viewer.toolbar.next-page': 'Nächste Seite',
'documents.pdf-viewer.toolbar.fit-width': 'Seitenbreite',
'documents.pdf-viewer.toolbar.fit-page': 'Ganze Seite',
'documents.pdf-viewer.toolbar.rotate-clockwise': 'Im Uhrzeigersinn drehen',
'documents.pdf-viewer.toolbar.download': 'Herunterladen',
'documents.pdf-viewer.toolbar.print': 'Drucken',
'documents.pdf-viewer.zoom.zoom-out': 'Verkleinern',
'documents.pdf-viewer.zoom.zoom-in': 'Vergrößern',
'documents.pdf-viewer.zoom.auto': 'Automatisch',
'documents.pdf-viewer.zoom.actual-size': 'Tatsächliche Größe',
'documents.pdf-viewer.zoom.page-fit': 'Seite anpassen',
'documents.pdf-viewer.zoom.page-width': 'Seitenbreite',
'documents.pdf-viewer.more-actions.label': 'Weitere Aktionen',
'documents.pdf-viewer.more-actions.presentation-mode': 'Präsentationsmodus',
'documents.pdf-viewer.more-actions.download': 'Herunterladen',
'documents.pdf-viewer.more-actions.print': 'Drucken',
'documents.pdf-viewer.more-actions.go-to-first-page': 'Zur ersten Seite',
'documents.pdf-viewer.more-actions.go-to-last-page': 'Zur letzten Seite',
'documents.pdf-viewer.more-actions.rotate-clockwise': 'Im Uhrzeigersinn drehen',
'documents.pdf-viewer.more-actions.rotate-counterclockwise': 'Gegen den Uhrzeigersinn drehen',
'documents.pdf-viewer.more-actions.page-scrolling': 'Seitenweises Scrollen',
'documents.pdf-viewer.more-actions.vertical-scrolling': 'Vertikales Scrollen',
'documents.pdf-viewer.more-actions.horizontal-scrolling': 'Horizontales Scrollen',
'documents.pdf-viewer.more-actions.wrapped-scrolling': 'Umbrochenes Scrollen',
'documents.pdf-viewer.more-actions.no-spreads': 'Keine Doppelseiten',
'documents.pdf-viewer.more-actions.odd-spreads': 'Ungerade Doppelseiten',
'documents.pdf-viewer.more-actions.even-spreads': 'Gerade Doppelseiten',
'documents.pdf-viewer.more-actions.document-properties': 'Dokumenteigenschaften',
'documents.pdf-viewer.properties.title': 'Dokumenteigenschaften',
'documents.pdf-viewer.properties.na': 'N/V',
'documents.pdf-viewer.properties.file-name': 'Dateiname',
'documents.pdf-viewer.properties.file-size': 'Dateigröße',
'documents.pdf-viewer.properties.doc-title': 'Titel',
'documents.pdf-viewer.properties.author': 'Autor',
'documents.pdf-viewer.properties.subject': 'Betreff',
'documents.pdf-viewer.properties.keywords': 'Schlüsselwörter',
'documents.pdf-viewer.properties.creation-date': 'Erstellungsdatum',
'documents.pdf-viewer.properties.modification-date': 'Änderungsdatum',
'documents.pdf-viewer.properties.creator': 'Erstellt mit',
'documents.pdf-viewer.properties.pdf-producer': 'PDF-Erzeuger',
'documents.pdf-viewer.properties.pdf-version': 'PDF-Version',
'documents.pdf-viewer.properties.page-count': 'Seitenanzahl',
'documents.pdf-viewer.properties.page-size': 'Seitengröße',
'documents.pdf-viewer.properties.fast-web-view': 'Schnelle Webanzeige',
'documents.pdf-viewer.properties.yes': 'Ja',
'documents.pdf-viewer.properties.no': 'Nein',
'documents.pdf-viewer.sidebar.page-thumbnails': 'Seitenvorschau',
'documents.pdf-viewer.sidebar.document-outline': 'Dokumentstruktur',
'documents.pdf-viewer.sidebar.attachments': 'Anhänge',
'documents.pdf-viewer.thumbnails.page-alt': 'Seite {{ page }}',
'trash.delete-all.button': 'Alles löschen', 'trash.delete-all.button': 'Alles löschen',
'trash.delete-all.confirm.title': 'Alle Dokumente dauerhaft 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.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.',
@ -443,6 +577,10 @@ export const translations: Partial<TranslationsDictionary> = {
'tags.table.headers.documents': 'Dokumente', 'tags.table.headers.documents': 'Dokumente',
'tags.table.headers.created': 'Erstellt', 'tags.table.headers.created': 'Erstellt',
'tags.table.headers.actions': 'Aktionen', 'tags.table.headers.actions': 'Aktionen',
'tags.picker.search-placeholder': 'Tags suchen...',
'tags.picker.filter-placeholder': 'Tags filtern...',
'tags.picker.create-new-with-name': 'Neuen Tag "{{ name }}" erstellen',
'tags.picker.create-new': 'Neuen Tag erstellen',
// Tagging rules // Tagging rules
@ -605,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
'webhooks.create.form.name.label': 'Webhook-Name', 'webhooks.create.form.name.label': 'Webhook-Name',
'webhooks.create.form.name.placeholder': 'Webhook-Namen eingeben', 'webhooks.create.form.name.placeholder': 'Webhook-Namen eingeben',
'webhooks.create.form.name.required': 'Name ist erforderlich', 'webhooks.create.form.name.required': 'Name ist erforderlich',
'webhooks.create.form.name.max-length': 'Der Name darf maximal 128 Zeichen lang sein',
'webhooks.create.form.url.label': 'Webhook-URL', 'webhooks.create.form.url.label': 'Webhook-URL',
'webhooks.create.form.url.placeholder': 'Webhook-URL eingeben', 'webhooks.create.form.url.placeholder': 'Webhook-URL eingeben',
'webhooks.create.form.url.required': 'URL ist erforderlich', 'webhooks.create.form.url.required': 'URL ist erforderlich',
@ -639,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
'layout.menu.home': 'Startseite', 'layout.menu.home': 'Startseite',
'layout.menu.documents': 'Dokumente', 'layout.menu.documents': 'Dokumente',
'layout.menu.tags': 'Tags', 'layout.menu.tags': 'Tags',
'layout.menu.custom-properties': 'Benutzerdefinierte Eigenschaften',
'layout.menu.tagging-rules': 'Tagging-Regeln', 'layout.menu.tagging-rules': 'Tagging-Regeln',
'layout.menu.deleted-documents': 'Gelöschte Dokumente', 'layout.menu.deleted-documents': 'Gelöschte Dokumente',
'layout.menu.organization-settings': 'Einstellungen', 'layout.menu.organization-settings': 'Einstellungen',
@ -668,6 +808,7 @@ export const translations: Partial<TranslationsDictionary> = {
'user-menu.api-keys': 'API-Schlüssel', 'user-menu.api-keys': 'API-Schlüssel',
'user-menu.invitations': 'Einladungen', 'user-menu.invitations': 'Einladungen',
'user-menu.language': 'Sprache', 'user-menu.language': 'Sprache',
'user-menu.theme': 'Design',
'user-menu.about': 'Über Papra', 'user-menu.about': 'Über Papra',
'user-menu.logout': 'Abmelden', 'user-menu.logout': 'Abmelden',
@ -693,10 +834,12 @@ export const translations: Partial<TranslationsDictionary> = {
'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.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.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', 'api-errors.tags.already_exists': 'Ein Tag mit diesem Namen existiert bereits für diese Organisation',
'api-errors.tags.organization_limit_reached': 'Die maximale Anzahl an Tags für diese Organisation wurde erreicht.',
'api-errors.internal.error': 'Beim Verarbeiten Ihrer Anfrage ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.', 'api-errors.internal.error': 'Beim Verarbeiten Ihrer Anfrage ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.',
'api-errors.auth.invalid_origin': 'Ungültige Anwendungs-Ursprung. Wenn Sie Papra selbst hosten, stellen Sie sicher, dass Ihre APP_BASE_URL-Umgebungsvariable mit Ihrer aktuellen URL übereinstimmt. Weitere Details finden Sie unter https://docs.papra.app/resources/troubleshooting/#invalid-application-origin', 'api-errors.auth.invalid_origin': 'Ungültige Anwendungs-Ursprung. Wenn Sie Papra selbst hosten, stellen Sie sicher, dass Ihre APP_BASE_URL-Umgebungsvariable mit Ihrer aktuellen URL übereinstimmt. Weitere Details finden Sie unter https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'Die maximale Anzahl an Mitgliedern und ausstehenden Einladungen für diese Organisation wurde erreicht. Bitte aktualisieren Sie Ihren Plan, um weitere Mitglieder hinzuzufügen.', 'api-errors.organization.max_members_count_reached': 'Die maximale Anzahl an Mitgliedern und ausstehenden Einladungen für diese Organisation wurde erreicht. Bitte aktualisieren Sie Ihren Plan, um weitere Mitglieder hinzuzufügen.',
'api-errors.organization.has_active_subscription': 'Organisation mit aktivem Abonnement kann nicht gelöscht werden. Bitte kündigen Sie zuerst Ihr Abonnement über die Schaltfläche Abonnement verwalten oben.', 'api-errors.organization.has_active_subscription': 'Organisation mit aktivem Abonnement kann nicht gelöscht werden. Bitte kündigen Sie zuerst Ihr Abonnement über die Schaltfläche Abonnement verwalten oben.',
'api-errors.webhooks.ssrf_unsafe_url': 'Die angegebene URL ist nicht erlaubt. Webhook-URLs dürfen nicht auf private oder reservierte IP-Adressen verweisen.',
// Better auth api errors // Better auth api errors
'api-errors.USER_NOT_FOUND': 'Benutzer nicht gefunden', 'api-errors.USER_NOT_FOUND': 'Benutzer nicht gefunden',
'api-errors.FAILED_TO_CREATE_USER': 'Fehler beim Erstellen des Benutzers', 'api-errors.FAILED_TO_CREATE_USER': 'Fehler beim Erstellen des Benutzers',
@ -752,6 +895,7 @@ export const translations: Partial<TranslationsDictionary> = {
'color-picker.lightness': 'Helligkeit', 'color-picker.lightness': 'Helligkeit',
'color-picker.select-color': 'Farbe auswählen', 'color-picker.select-color': 'Farbe auswählen',
'color-picker.select-a-color': 'Eine Farbe auswählen', 'color-picker.select-a-color': 'Eine Farbe auswählen',
'color-picker.random-color': 'Zufällige Farbe',
// Subscriptions // Subscriptions

View file

@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
'documents.info.created-at': 'Ημερομηνία δημιουργίας', 'documents.info.created-at': 'Ημερομηνία δημιουργίας',
'documents.info.updated-at': 'Ημερομηνία ενημέρωσης', 'documents.info.updated-at': 'Ημερομηνία ενημέρωσης',
'documents.info.never': 'Ποτέ', 'documents.info.never': 'Ποτέ',
'documents.info.document-date': 'Ημερομηνία',
'documents.info.no-date': 'Χωρίς ημερομηνία',
'documents.info.today': 'Σήμερα',
'custom-properties.types.text': 'Κείμενο',
'custom-properties.types.number': 'Αριθμός',
'custom-properties.types.date': 'Ημερομηνία',
'custom-properties.types.boolean': 'Λογική τιμή',
'custom-properties.types.select': 'Επιλογή',
'custom-properties.types.multi_select': 'Πολλαπλή επιλογή',
'custom-properties.types.user_relation': 'Χρήστης',
'custom-properties.types.document_relation': 'Έγγραφο',
'custom-properties.list.title': 'Προσαρμοσμένες ιδιότητες',
'custom-properties.list.description': 'Ορίστε προσαρμοσμένα πεδία μεταδεδομένων για τα έγγραφά σας. Οι ιδιότητες μπορεί να είναι κείμενο, αριθμοί, ημερομηνίες, λογικές τιμές ή λίστες επιλογών.',
'custom-properties.list.create-button': 'Δημιουργία ιδιότητας',
'custom-properties.list.empty.title': 'Προσαρμοσμένες ιδιότητες',
'custom-properties.list.empty.description': 'Οι προσαρμοσμένες ιδιότητες σάς επιτρέπουν να προσθέτετε δομημένα μεταδεδομένα στα έγγραφά σας, όπως ημερομηνίες λήξης, ονόματα εταιρειών ή ποσά.',
'custom-properties.list.table.name': 'Όνομα',
'custom-properties.list.table.type': 'Τύπος',
'custom-properties.list.table.description': 'Περιγραφή',
'custom-properties.list.table.created': 'Δημιουργήθηκε',
'custom-properties.list.table.actions': 'Ενέργειες',
'custom-properties.list.table.no-description': 'Χωρίς περιγραφή',
'custom-properties.list.delete.confirm-title': 'Διαγραφή προσαρμοσμένης ιδιότητας',
'custom-properties.list.delete.confirm-message': 'Είστε βέβαιοι ότι θέλετε να διαγράψετε την προσαρμοσμένη ιδιότητα «{{ name }}»; Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.',
'custom-properties.list.delete.confirm-button': 'Διαγραφή',
'custom-properties.list.delete.success': 'Η προσαρμοσμένη ιδιότητα διαγράφηκε με επιτυχία',
'custom-properties.list.delete.error': 'Αποτυχία διαγραφής προσαρμοσμένης ιδιότητας',
'custom-properties.create.title': 'Δημιουργία προσαρμοσμένης ιδιότητας',
'custom-properties.create.submit': 'Δημιουργία ιδιότητας',
'custom-properties.create.success': 'Η προσαρμοσμένη ιδιότητα δημιουργήθηκε με επιτυχία',
'custom-properties.create.error': 'Αποτυχία δημιουργίας προσαρμοσμένης ιδιότητας',
'custom-properties.update.title': 'Επεξεργασία προσαρμοσμένης ιδιότητας',
'custom-properties.update.submit': 'Αποθήκευση αλλαγών',
'custom-properties.update.success': 'Η προσαρμοσμένη ιδιότητα ενημερώθηκε με επιτυχία',
'custom-properties.update.error': 'Αποτυχία ενημέρωσης προσαρμοσμένης ιδιότητας',
'custom-properties.form.name.label': 'Όνομα',
'custom-properties.form.name.placeholder': 'π.χ. Ποσό τιμολογίου',
'custom-properties.form.name.required': 'Το όνομα είναι υποχρεωτικό',
'custom-properties.form.name.max-length': 'Το όνομα πρέπει να έχει το πολύ 255 χαρακτήρες',
'custom-properties.form.description.label': 'Περιγραφή',
'custom-properties.form.description.optional': '(προαιρετικό)',
'custom-properties.form.description.placeholder': 'Περιγράψτε για τι χρησιμοποιείται αυτή η ιδιότητα',
'custom-properties.form.description.max-length': 'Η περιγραφή πρέπει να έχει το πολύ 1000 χαρακτήρες',
'custom-properties.form.type.label': 'Τύπος',
'custom-properties.form.type.immutable': 'Ο τύπος ιδιότητας δεν μπορεί να αλλάξει μετά τη δημιουργία.',
'custom-properties.form.options.title': 'Επιλογές',
'custom-properties.form.options.description': 'Ορίστε τις διαθέσιμες επιλογές για αυτή την ιδιότητα.',
'custom-properties.form.options.name.placeholder': 'Όνομα επιλογής',
'custom-properties.form.options.name.required': 'Το όνομα επιλογής είναι υποχρεωτικό',
'custom-properties.form.options.name.max-length': 'Το όνομα επιλογής πρέπει να έχει το πολύ 255 χαρακτήρες',
'custom-properties.form.options.validation.required': 'Προσθέστε τουλάχιστον μία επιλογή',
'custom-properties.form.options.add': 'Προσθήκη επιλογής',
'custom-properties.form.cancel': 'Ακύρωση',
'custom-properties.form.save-error': 'Παρουσιάστηκε σφάλμα κατά την αποθήκευση του ορισμού ιδιότητας. Δοκιμάστε ξανά.',
'documents.custom-properties.section-title': 'Ιδιότητες',
'documents.custom-properties.no-value': 'Μη ορισμένο',
'documents.custom-properties.text-placeholder': 'Εισάγετε μια τιμή...',
'documents.custom-properties.save': 'Αποθήκευση',
'documents.custom-properties.clear': 'Εκκαθάριση',
'documents.custom-properties.document-relation-search-placeholder': 'Αναζήτηση εγγράφων...',
'documents.custom-properties.user-relation-manage': 'Διαχείριση χρηστών',
'documents.custom-properties.document-relation-manage': 'Διαχείριση εγγράφων',
'documents.custom-properties.no-results': 'Δεν βρέθηκαν αποτελέσματα',
'documents.rename.title': 'Μετονομασία εγγράφου', 'documents.rename.title': 'Μετονομασία εγγράφου',
'documents.rename.form.name.label': 'Όνομα', 'documents.rename.form.name.label': 'Όνομα',
@ -381,6 +450,71 @@ export const translations: Partial<TranslationsDictionary> = {
'documents.preview.unknown-file-type': 'Δεν υπάρχει διαθέσιμη προεπισκόπηση για αυτόν τον τύπο αρχείου', 'documents.preview.unknown-file-type': 'Δεν υπάρχει διαθέσιμη προεπισκόπηση για αυτόν τον τύπο αρχείου',
'documents.preview.binary-file': 'Αυτό φαίνεται να είναι δυαδικό αρχείο και δεν μπορεί να εμφανιστεί ως κείμενο', 'documents.preview.binary-file': 'Αυτό φαίνεται να είναι δυαδικό αρχείο και δεν μπορεί να εμφανιστεί ως κείμενο',
'documents.open-with.label': 'Άνοιγμα με',
'documents.open-with.pdf-viewer': 'Πρόγραμμα προβολής PDF',
'documents.pdf-viewer.loading': 'Φόρτωση PDF',
'documents.pdf-viewer.not-a-pdf': 'Αυτό το έγγραφο δεν είναι PDF και δεν μπορεί να ανοιχτεί στο πρόγραμμα προβολής PDF.',
'documents.pdf-viewer.toolbar.hide-sidebar': 'Απόκρυψη πλαϊνής στήλης',
'documents.pdf-viewer.toolbar.show-sidebar': 'Εμφάνιση πλαϊνής στήλης',
'documents.pdf-viewer.toolbar.previous-page': 'Προηγούμενη σελίδα',
'documents.pdf-viewer.toolbar.next-page': 'Επόμενη σελίδα',
'documents.pdf-viewer.toolbar.fit-width': 'Προσαρμογή πλάτους',
'documents.pdf-viewer.toolbar.fit-page': 'Προσαρμογή σελίδας',
'documents.pdf-viewer.toolbar.rotate-clockwise': 'Δεξιόστροφη περιστροφή',
'documents.pdf-viewer.toolbar.download': 'Λήψη',
'documents.pdf-viewer.toolbar.print': 'Εκτύπωση',
'documents.pdf-viewer.zoom.zoom-out': 'Σμίκρυνση',
'documents.pdf-viewer.zoom.zoom-in': 'Μεγέθυνση',
'documents.pdf-viewer.zoom.auto': 'Αυτόματο',
'documents.pdf-viewer.zoom.actual-size': 'Πραγματικό μέγεθος',
'documents.pdf-viewer.zoom.page-fit': 'Προσαρμογή σελίδας',
'documents.pdf-viewer.zoom.page-width': 'Πλάτος σελίδας',
'documents.pdf-viewer.more-actions.label': 'Περισσότερες ενέργειες',
'documents.pdf-viewer.more-actions.presentation-mode': 'Λειτουργία παρουσίασης',
'documents.pdf-viewer.more-actions.download': 'Λήψη',
'documents.pdf-viewer.more-actions.print': 'Εκτύπωση',
'documents.pdf-viewer.more-actions.go-to-first-page': 'Μετάβαση στην πρώτη σελίδα',
'documents.pdf-viewer.more-actions.go-to-last-page': 'Μετάβαση στην τελευταία σελίδα',
'documents.pdf-viewer.more-actions.rotate-clockwise': 'Δεξιόστροφη περιστροφή',
'documents.pdf-viewer.more-actions.rotate-counterclockwise': 'Αριστερόστροφη περιστροφή',
'documents.pdf-viewer.more-actions.page-scrolling': 'Κύλιση ανά σελίδα',
'documents.pdf-viewer.more-actions.vertical-scrolling': 'Κατακόρυφη κύλιση',
'documents.pdf-viewer.more-actions.horizontal-scrolling': 'Οριζόντια κύλιση',
'documents.pdf-viewer.more-actions.wrapped-scrolling': 'Αναδιπλούμενη κύλιση',
'documents.pdf-viewer.more-actions.no-spreads': 'Χωρίς ανάπτυγμα',
'documents.pdf-viewer.more-actions.odd-spreads': 'Μονά αναπτύγματα',
'documents.pdf-viewer.more-actions.even-spreads': 'Ζυγά αναπτύγματα',
'documents.pdf-viewer.more-actions.document-properties': 'Ιδιότητες εγγράφου',
'documents.pdf-viewer.properties.title': 'Ιδιότητες εγγράφου',
'documents.pdf-viewer.properties.na': 'Δ/Υ',
'documents.pdf-viewer.properties.file-name': 'Όνομα αρχείου',
'documents.pdf-viewer.properties.file-size': 'Μέγεθος αρχείου',
'documents.pdf-viewer.properties.doc-title': 'Τίτλος',
'documents.pdf-viewer.properties.author': 'Συγγραφέας',
'documents.pdf-viewer.properties.subject': 'Θέμα',
'documents.pdf-viewer.properties.keywords': 'Λέξεις-κλειδιά',
'documents.pdf-viewer.properties.creation-date': 'Ημερομηνία δημιουργίας',
'documents.pdf-viewer.properties.modification-date': 'Ημερομηνία τροποποίησης',
'documents.pdf-viewer.properties.creator': 'Δημιουργός',
'documents.pdf-viewer.properties.pdf-producer': 'Παραγωγός PDF',
'documents.pdf-viewer.properties.pdf-version': 'Έκδοση PDF',
'documents.pdf-viewer.properties.page-count': 'Αριθμός σελίδων',
'documents.pdf-viewer.properties.page-size': 'Μέγεθος σελίδας',
'documents.pdf-viewer.properties.fast-web-view': 'Γρήγορη προβολή ιστού',
'documents.pdf-viewer.properties.yes': 'Ναι',
'documents.pdf-viewer.properties.no': 'Όχι',
'documents.pdf-viewer.sidebar.page-thumbnails': 'Μικρογραφίες σελίδων',
'documents.pdf-viewer.sidebar.document-outline': 'Δομή εγγράφου',
'documents.pdf-viewer.sidebar.attachments': 'Συνημμένα',
'documents.pdf-viewer.thumbnails.page-alt': 'Σελίδα {{ page }}',
'trash.delete-all.button': 'Διαγραφή όλων', 'trash.delete-all.button': 'Διαγραφή όλων',
'trash.delete-all.confirm.title': 'Οριστική διαγραφή όλων των εγγράφων;', 'trash.delete-all.confirm.title': 'Οριστική διαγραφή όλων των εγγράφων;',
'trash.delete-all.confirm.description': 'Είστε βέβαιοι ότι θέλετε να τα διαγράψετε οριστικά; Η ενέργεια δεν μπορεί να αναιρεθεί.', 'trash.delete-all.confirm.description': 'Είστε βέβαιοι ότι θέλετε να τα διαγράψετε οριστικά; Η ενέργεια δεν μπορεί να αναιρεθεί.',
@ -443,6 +577,10 @@ export const translations: Partial<TranslationsDictionary> = {
'tags.table.headers.documents': 'Έγγραφα', 'tags.table.headers.documents': 'Έγγραφα',
'tags.table.headers.created': 'Δημιουργήθηκε', 'tags.table.headers.created': 'Δημιουργήθηκε',
'tags.table.headers.actions': 'Ενέργειες', 'tags.table.headers.actions': 'Ενέργειες',
'tags.picker.search-placeholder': 'Αναζήτηση ετικετών...',
'tags.picker.filter-placeholder': 'Φιλτράρισμα ετικετών...',
'tags.picker.create-new-with-name': 'Δημιουργία νέας ετικέτας "{{ name }}"',
'tags.picker.create-new': 'Δημιουργία νέας ετικέτας',
// Tagging rules // Tagging rules
@ -605,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
'webhooks.create.form.name.label': 'Όνομα webhook', 'webhooks.create.form.name.label': 'Όνομα webhook',
'webhooks.create.form.name.placeholder': 'Εισαγάγετε όνομα', 'webhooks.create.form.name.placeholder': 'Εισαγάγετε όνομα',
'webhooks.create.form.name.required': 'Το όνομα είναι υποχρεωτικό', 'webhooks.create.form.name.required': 'Το όνομα είναι υποχρεωτικό',
'webhooks.create.form.name.max-length': 'Το όνομα πρέπει να έχει το πολύ 128 χαρακτήρες',
'webhooks.create.form.url.label': 'URL Webhook', 'webhooks.create.form.url.label': 'URL Webhook',
'webhooks.create.form.url.placeholder': 'Εισαγάγετε URL', 'webhooks.create.form.url.placeholder': 'Εισαγάγετε URL',
'webhooks.create.form.url.required': 'Το URL είναι υποχρεωτικό', 'webhooks.create.form.url.required': 'Το URL είναι υποχρεωτικό',
@ -639,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
'layout.menu.home': 'Αρχική', 'layout.menu.home': 'Αρχική',
'layout.menu.documents': 'Έγγραφα', 'layout.menu.documents': 'Έγγραφα',
'layout.menu.tags': 'Ετικέτες', 'layout.menu.tags': 'Ετικέτες',
'layout.menu.custom-properties': 'Προσαρμοσμένες ιδιότητες',
'layout.menu.tagging-rules': 'Κανόνες ετικετοποίησης', 'layout.menu.tagging-rules': 'Κανόνες ετικετοποίησης',
'layout.menu.deleted-documents': 'Διαγεγραμμένα έγγραφα', 'layout.menu.deleted-documents': 'Διαγεγραμμένα έγγραφα',
'layout.menu.organization-settings': 'Ρυθμίσεις', 'layout.menu.organization-settings': 'Ρυθμίσεις',
@ -668,6 +808,7 @@ export const translations: Partial<TranslationsDictionary> = {
'user-menu.api-keys': 'API keys', 'user-menu.api-keys': 'API keys',
'user-menu.invitations': 'Προσκλήσεις', 'user-menu.invitations': 'Προσκλήσεις',
'user-menu.language': 'Γλώσσα', 'user-menu.language': 'Γλώσσα',
'user-menu.theme': 'Θέμα',
'user-menu.about': 'Σχετικά με το Papra', 'user-menu.about': 'Σχετικά με το Papra',
'user-menu.logout': 'Αποσύνδεση', 'user-menu.logout': 'Αποσύνδεση',
@ -693,10 +834,12 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.user.organization_invitation_limit_reached': 'Φτάσατε το ημερήσιο όριο προσκλήσεων. Δοκιμάστε αύριο.', 'api-errors.user.organization_invitation_limit_reached': 'Φτάσατε το ημερήσιο όριο προσκλήσεων. Δοκιμάστε αύριο.',
'api-errors.demo.not_available': 'Η δυνατότητα δεν είναι διαθέσιμη στο demo', 'api-errors.demo.not_available': 'Η δυνατότητα δεν είναι διαθέσιμη στο demo',
'api-errors.tags.already_exists': 'Υπάρχει ήδη ετικέτα με αυτό το όνομα', 'api-errors.tags.already_exists': 'Υπάρχει ήδη ετικέτα με αυτό το όνομα',
'api-errors.tags.organization_limit_reached': 'Συμπληρώθηκε ο μέγιστος αριθμός ετικετών για αυτόν τον οργανισμό.',
'api-errors.internal.error': 'Προέκυψε σφάλμα. Δοκιμάστε αργότερα.', 'api-errors.internal.error': 'Προέκυψε σφάλμα. Δοκιμάστε αργότερα.',
'api-errors.auth.invalid_origin': 'Μη έγκυρη προέλευση εφαρμογής. Βεβαιωθείτε ότι το APP_BASE_URL ταιριάζει με το τρέχον URL. Δείτε https://docs.papra.app/resources/troubleshooting/#invalid-application-origin', 'api-errors.auth.invalid_origin': 'Μη έγκυρη προέλευση εφαρμογής. Βεβαιωθείτε ότι το APP_BASE_URL ταιριάζει με το τρέχον URL. Δείτε https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'Φτάσατε το μέγιστο αριθμό μελών/προσκλήσεων. Αναβαθμίστε το πλάνο σας.', 'api-errors.organization.max_members_count_reached': 'Φτάσατε το μέγιστο αριθμό μελών/προσκλήσεων. Αναβαθμίστε το πλάνο σας.',
'api-errors.organization.has_active_subscription': 'Δεν είναι δυνατή η διαγραφή με ενεργή συνδρομή. Ακυρώστε πρώτα μέσω "Διαχείριση συνδρομής".', 'api-errors.organization.has_active_subscription': 'Δεν είναι δυνατή η διαγραφή με ενεργή συνδρομή. Ακυρώστε πρώτα μέσω "Διαχείριση συνδρομής".',
'api-errors.webhooks.ssrf_unsafe_url': 'Η παρεχόμενη URL δεν επιτρέπεται. Οι URL webhook δεν πρέπει να δείχνουν σε ιδιωτικές ή δεσμευμένες διευθύνσεις IP.',
// Better auth api errors // Better auth api errors
'api-errors.USER_NOT_FOUND': 'Ο χρήστης δεν βρέθηκε', 'api-errors.USER_NOT_FOUND': 'Ο χρήστης δεν βρέθηκε',
'api-errors.FAILED_TO_CREATE_USER': 'Αποτυχία δημιουργίας χρήστη', 'api-errors.FAILED_TO_CREATE_USER': 'Αποτυχία δημιουργίας χρήστη',
@ -752,6 +895,7 @@ export const translations: Partial<TranslationsDictionary> = {
'color-picker.lightness': 'Φωτεινότητα', 'color-picker.lightness': 'Φωτεινότητα',
'color-picker.select-color': 'Επιλογή χρώματος', 'color-picker.select-color': 'Επιλογή χρώματος',
'color-picker.select-a-color': 'Επιλέξτε χρώμα', 'color-picker.select-a-color': 'Επιλέξτε χρώμα',
'color-picker.random-color': 'Τυχαίο χρώμα',
// Subscriptions // Subscriptions

View file

@ -322,8 +322,8 @@ export const translations = {
'documents.list.no-documents.description': 'There are no documents in this organization yet. Start by uploading some 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.list.no-results': 'No documents found',
'documents.list.table.headers.file-name': 'File name', 'documents.list.table.headers.file-name': 'File name',
'documents.list.table.headers.created': 'Created at', 'documents.list.table.headers.created': 'Created',
'documents.list.table.headers.deleted': 'Deleted at', 'documents.list.table.headers.deleted': 'Deleted',
'documents.list.table.headers.actions': 'Actions', 'documents.list.table.headers.actions': 'Actions',
'documents.list.table.headers.tags': 'Tags', 'documents.list.table.headers.tags': 'Tags',
'documents.list.search.placeholder': 'Search documents...', 'documents.list.search.placeholder': 'Search documents...',
@ -349,9 +349,78 @@ export const translations = {
'documents.info.name': 'Name', 'documents.info.name': 'Name',
'documents.info.type': 'Type', 'documents.info.type': 'Type',
'documents.info.size': 'Size', 'documents.info.size': 'Size',
'documents.info.created-at': 'Created At', 'documents.info.created-at': 'Created',
'documents.info.updated-at': 'Updated At', 'documents.info.updated-at': 'Updated',
'documents.info.never': 'Never', 'documents.info.never': 'Never',
'documents.info.document-date': 'Date',
'documents.info.no-date': 'No date',
'documents.info.today': 'Today',
'custom-properties.types.text': 'Text',
'custom-properties.types.number': 'Number',
'custom-properties.types.date': 'Date',
'custom-properties.types.boolean': 'Boolean',
'custom-properties.types.select': 'Select',
'custom-properties.types.multi_select': 'Multi-select',
'custom-properties.types.user_relation': 'User',
'custom-properties.types.document_relation': 'Document',
'custom-properties.list.title': 'Custom Properties',
'custom-properties.list.description': 'Define custom metadata fields for your documents. Properties can be text, numbers, dates, booleans, or selection lists.',
'custom-properties.list.create-button': 'Create property',
'custom-properties.list.empty.title': 'Custom Properties',
'custom-properties.list.empty.description': 'Custom properties let you add structured metadata to your documents, such as expiration dates, company names, or amounts.',
'custom-properties.list.table.name': 'Name',
'custom-properties.list.table.type': 'Type',
'custom-properties.list.table.description': 'Description',
'custom-properties.list.table.created': 'Created',
'custom-properties.list.table.actions': 'Actions',
'custom-properties.list.table.no-description': 'No description',
'custom-properties.list.delete.confirm-title': 'Delete custom property',
'custom-properties.list.delete.confirm-message': 'Are you sure you want to delete the custom property "{{ name }}"? This action cannot be undone.',
'custom-properties.list.delete.confirm-button': 'Delete',
'custom-properties.list.delete.success': 'Custom property deleted successfully',
'custom-properties.list.delete.error': 'Failed to delete custom property',
'custom-properties.create.title': 'Create Custom Property',
'custom-properties.create.submit': 'Create property',
'custom-properties.create.success': 'Custom property created successfully',
'custom-properties.create.error': 'Failed to create custom property',
'custom-properties.update.title': 'Update Custom Property',
'custom-properties.update.submit': 'Save changes',
'custom-properties.update.success': 'Custom property updated successfully',
'custom-properties.update.error': 'Failed to update custom property',
'custom-properties.form.name.label': 'Name',
'custom-properties.form.name.placeholder': 'e.g. Invoice Amount',
'custom-properties.form.name.required': 'Name is required',
'custom-properties.form.name.max-length': 'Name must be at most 255 characters',
'custom-properties.form.description.label': 'Description',
'custom-properties.form.description.optional': '(optional)',
'custom-properties.form.description.placeholder': 'Describe what this property is used for',
'custom-properties.form.description.max-length': 'Description must be at most 1000 characters',
'custom-properties.form.type.label': 'Type',
'custom-properties.form.type.immutable': 'Property type cannot be changed after creation.',
'custom-properties.form.options.title': 'Options',
'custom-properties.form.options.description': 'Define the choices available for this property.',
'custom-properties.form.options.name.placeholder': 'Option name',
'custom-properties.form.options.name.required': 'Option name is required',
'custom-properties.form.options.name.max-length': 'Option name must be at most 255 characters',
'custom-properties.form.options.validation.required': 'Please add at least one option',
'custom-properties.form.options.add': 'Add option',
'custom-properties.form.cancel': 'Cancel',
'custom-properties.form.save-error': 'An error occurred while saving the property definition. Please try again.',
'documents.custom-properties.section-title': 'Properties',
'documents.custom-properties.no-value': 'Not set',
'documents.custom-properties.text-placeholder': 'Enter a value...',
'documents.custom-properties.save': 'Save',
'documents.custom-properties.clear': 'Clear',
'documents.custom-properties.document-relation-search-placeholder': 'Search documents...',
'documents.custom-properties.user-relation-manage': 'Manage users',
'documents.custom-properties.document-relation-manage': 'Manage documents',
'documents.custom-properties.no-results': 'No results',
'documents.rename.title': 'Rename document', 'documents.rename.title': 'Rename document',
'documents.rename.form.name.label': 'Name', 'documents.rename.form.name.label': 'Name',
@ -379,6 +448,71 @@ export const translations = {
'documents.preview.unknown-file-type': 'No preview available for this file type', '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', 'documents.preview.binary-file': 'This appears to be a binary file and cannot be displayed as text',
'documents.open-with.label': 'Open with',
'documents.open-with.pdf-viewer': 'PDF viewer',
'documents.pdf-viewer.loading': 'Loading PDF',
'documents.pdf-viewer.not-a-pdf': 'This document is not a PDF and cannot be opened in the PDF viewer.',
'documents.pdf-viewer.toolbar.hide-sidebar': 'Hide sidebar',
'documents.pdf-viewer.toolbar.show-sidebar': 'Show sidebar',
'documents.pdf-viewer.toolbar.previous-page': 'Previous page',
'documents.pdf-viewer.toolbar.next-page': 'Next page',
'documents.pdf-viewer.toolbar.fit-width': 'Fit width',
'documents.pdf-viewer.toolbar.fit-page': 'Fit page',
'documents.pdf-viewer.toolbar.rotate-clockwise': 'Rotate clockwise',
'documents.pdf-viewer.toolbar.download': 'Download',
'documents.pdf-viewer.toolbar.print': 'Print',
'documents.pdf-viewer.zoom.zoom-out': 'Zoom out',
'documents.pdf-viewer.zoom.zoom-in': 'Zoom in',
'documents.pdf-viewer.zoom.auto': 'Auto',
'documents.pdf-viewer.zoom.actual-size': 'Actual size',
'documents.pdf-viewer.zoom.page-fit': 'Page fit',
'documents.pdf-viewer.zoom.page-width': 'Page width',
'documents.pdf-viewer.more-actions.label': 'More actions',
'documents.pdf-viewer.more-actions.presentation-mode': 'Presentation mode',
'documents.pdf-viewer.more-actions.download': 'Download',
'documents.pdf-viewer.more-actions.print': 'Print',
'documents.pdf-viewer.more-actions.go-to-first-page': 'Go to first page',
'documents.pdf-viewer.more-actions.go-to-last-page': 'Go to last page',
'documents.pdf-viewer.more-actions.rotate-clockwise': 'Rotate clockwise',
'documents.pdf-viewer.more-actions.rotate-counterclockwise': 'Rotate counterclockwise',
'documents.pdf-viewer.more-actions.page-scrolling': 'Page scrolling',
'documents.pdf-viewer.more-actions.vertical-scrolling': 'Vertical scrolling',
'documents.pdf-viewer.more-actions.horizontal-scrolling': 'Horizontal scrolling',
'documents.pdf-viewer.more-actions.wrapped-scrolling': 'Wrapped scrolling',
'documents.pdf-viewer.more-actions.no-spreads': 'No spreads',
'documents.pdf-viewer.more-actions.odd-spreads': 'Odd spreads',
'documents.pdf-viewer.more-actions.even-spreads': 'Even spreads',
'documents.pdf-viewer.more-actions.document-properties': 'Document properties',
'documents.pdf-viewer.properties.title': 'Document properties',
'documents.pdf-viewer.properties.na': 'N/A',
'documents.pdf-viewer.properties.file-name': 'File name',
'documents.pdf-viewer.properties.file-size': 'File size',
'documents.pdf-viewer.properties.doc-title': 'Title',
'documents.pdf-viewer.properties.author': 'Author',
'documents.pdf-viewer.properties.subject': 'Subject',
'documents.pdf-viewer.properties.keywords': 'Keywords',
'documents.pdf-viewer.properties.creation-date': 'Creation date',
'documents.pdf-viewer.properties.modification-date': 'Modification date',
'documents.pdf-viewer.properties.creator': 'Creator',
'documents.pdf-viewer.properties.pdf-producer': 'PDF Producer',
'documents.pdf-viewer.properties.pdf-version': 'PDF Version',
'documents.pdf-viewer.properties.page-count': 'Page count',
'documents.pdf-viewer.properties.page-size': 'Page size',
'documents.pdf-viewer.properties.fast-web-view': 'Fast Web View',
'documents.pdf-viewer.properties.yes': 'Yes',
'documents.pdf-viewer.properties.no': 'No',
'documents.pdf-viewer.sidebar.page-thumbnails': 'Page thumbnails',
'documents.pdf-viewer.sidebar.document-outline': 'Document outline',
'documents.pdf-viewer.sidebar.attachments': 'Attachments',
'documents.pdf-viewer.thumbnails.page-alt': 'Page {{ page }}',
'trash.delete-all.button': 'Delete all', 'trash.delete-all.button': 'Delete all',
'trash.delete-all.confirm.title': 'Permanently delete all documents?', '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.description': 'Are you sure you want to permanently delete all documents from the trash? This action cannot be undone.',
@ -441,6 +575,10 @@ export const translations = {
'tags.table.headers.documents': 'Documents', 'tags.table.headers.documents': 'Documents',
'tags.table.headers.created': 'Created', 'tags.table.headers.created': 'Created',
'tags.table.headers.actions': 'Actions', 'tags.table.headers.actions': 'Actions',
'tags.picker.search-placeholder': 'Search tags...',
'tags.picker.filter-placeholder': 'Filter tags...',
'tags.picker.create-new-with-name': 'Create new tag "{{ name }}"',
'tags.picker.create-new': 'Create new tag',
// Tagging rules // Tagging rules
@ -603,6 +741,7 @@ export const translations = {
'webhooks.create.form.name.label': 'Webhook name', 'webhooks.create.form.name.label': 'Webhook name',
'webhooks.create.form.name.placeholder': 'Enter webhook name', 'webhooks.create.form.name.placeholder': 'Enter webhook name',
'webhooks.create.form.name.required': 'Name is required', 'webhooks.create.form.name.required': 'Name is required',
'webhooks.create.form.name.max-length': 'Name must be at most 128 characters',
'webhooks.create.form.url.label': 'Webhook URL', 'webhooks.create.form.url.label': 'Webhook URL',
'webhooks.create.form.url.placeholder': 'Enter webhook URL', 'webhooks.create.form.url.placeholder': 'Enter webhook URL',
'webhooks.create.form.url.required': 'URL is required', 'webhooks.create.form.url.required': 'URL is required',
@ -637,6 +776,7 @@ export const translations = {
'layout.menu.home': 'Home', 'layout.menu.home': 'Home',
'layout.menu.documents': 'Documents', 'layout.menu.documents': 'Documents',
'layout.menu.tags': 'Tags', 'layout.menu.tags': 'Tags',
'layout.menu.custom-properties': 'Custom Properties',
'layout.menu.tagging-rules': 'Tagging rules', 'layout.menu.tagging-rules': 'Tagging rules',
'layout.menu.deleted-documents': 'Deleted documents', 'layout.menu.deleted-documents': 'Deleted documents',
'layout.menu.organization-settings': 'Settings', 'layout.menu.organization-settings': 'Settings',
@ -666,6 +806,7 @@ export const translations = {
'user-menu.api-keys': 'API keys', 'user-menu.api-keys': 'API keys',
'user-menu.invitations': 'Invitations', 'user-menu.invitations': 'Invitations',
'user-menu.language': 'Language', 'user-menu.language': 'Language',
'user-menu.theme': 'Theme',
'user-menu.about': 'About Papra', 'user-menu.about': 'About Papra',
'user-menu.logout': 'Logout', 'user-menu.logout': 'Logout',
@ -691,10 +832,12 @@ export const translations = {
'api-errors.user.organization_invitation_limit_reached': 'The maximum number of invitations has been reached for today. Please try again tomorrow.', '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.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', 'api-errors.tags.already_exists': 'A tag with this name already exists for this organization',
'api-errors.tags.organization_limit_reached': 'The maximum number of tags for this organization has been reached.',
'api-errors.internal.error': 'An error occurred while processing your request. Please try again later.', 'api-errors.internal.error': 'An error occurred while processing your request. Please try again later.',
'api-errors.auth.invalid_origin': 'Invalid application origin. If you are self-hosting Papra, ensure your APP_BASE_URL environment variable matches your current url. For more details see https://docs.papra.app/resources/troubleshooting/#invalid-application-origin', 'api-errors.auth.invalid_origin': 'Invalid application origin. If you are self-hosting Papra, ensure your APP_BASE_URL environment variable matches your current url. For more details see https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'The maximum number of members and pending invitations for this organization has been reached. Please upgrade your plan to add more members.', 'api-errors.organization.max_members_count_reached': 'The maximum number of members and pending invitations for this organization has been reached. Please upgrade your plan to add more members.',
'api-errors.organization.has_active_subscription': 'Cannot delete organization with an active subscription. Please cancel your subscription first using the Manage Subscription button above.', 'api-errors.organization.has_active_subscription': 'Cannot delete organization with an active subscription. Please cancel your subscription first using the Manage Subscription button above.',
'api-errors.webhooks.ssrf_unsafe_url': 'The provided URL is not allowed. Webhook URLs must not point to private or reserved IP addresses.',
// Better auth api errors // Better auth api errors
'api-errors.USER_NOT_FOUND': 'User not found', 'api-errors.USER_NOT_FOUND': 'User not found',
'api-errors.FAILED_TO_CREATE_USER': 'Failed to create user', 'api-errors.FAILED_TO_CREATE_USER': 'Failed to create user',
@ -750,6 +893,7 @@ export const translations = {
'color-picker.lightness': 'Lightness', 'color-picker.lightness': 'Lightness',
'color-picker.select-color': 'Select color', 'color-picker.select-color': 'Select color',
'color-picker.select-a-color': 'Select a color', 'color-picker.select-a-color': 'Select a color',
'color-picker.random-color': 'Random color',
// Subscriptions // Subscriptions

View file

@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
'documents.info.created-at': 'Creado el', 'documents.info.created-at': 'Creado el',
'documents.info.updated-at': 'Actualizado el', 'documents.info.updated-at': 'Actualizado el',
'documents.info.never': 'Nunca', 'documents.info.never': 'Nunca',
'documents.info.document-date': 'Fecha',
'documents.info.no-date': 'Sin fecha',
'documents.info.today': 'Hoy',
'custom-properties.types.text': 'Texto',
'custom-properties.types.number': 'Número',
'custom-properties.types.date': 'Fecha',
'custom-properties.types.boolean': 'Booleano',
'custom-properties.types.select': 'Selección',
'custom-properties.types.multi_select': 'Selección múltiple',
'custom-properties.types.user_relation': 'Usuario',
'custom-properties.types.document_relation': 'Documento',
'custom-properties.list.title': 'Propiedades personalizadas',
'custom-properties.list.description': 'Define campos de metadatos personalizados para tus documentos. Las propiedades pueden ser texto, números, fechas, valores booleanos o listas de selección.',
'custom-properties.list.create-button': 'Crear propiedad',
'custom-properties.list.empty.title': 'Propiedades personalizadas',
'custom-properties.list.empty.description': 'Las propiedades personalizadas te permiten añadir metadatos estructurados a tus documentos, como fechas de vencimiento, nombres de empresa o importes.',
'custom-properties.list.table.name': 'Nombre',
'custom-properties.list.table.type': 'Tipo',
'custom-properties.list.table.description': 'Descripción',
'custom-properties.list.table.created': 'Creado',
'custom-properties.list.table.actions': 'Acciones',
'custom-properties.list.table.no-description': 'Sin descripción',
'custom-properties.list.delete.confirm-title': 'Eliminar propiedad personalizada',
'custom-properties.list.delete.confirm-message': '¿Estás seguro de que deseas eliminar la propiedad personalizada "{{ name }}"? Esta acción no se puede deshacer.',
'custom-properties.list.delete.confirm-button': 'Eliminar',
'custom-properties.list.delete.success': 'Propiedad personalizada eliminada correctamente',
'custom-properties.list.delete.error': 'Error al eliminar la propiedad personalizada',
'custom-properties.create.title': 'Crear propiedad personalizada',
'custom-properties.create.submit': 'Crear propiedad',
'custom-properties.create.success': 'Propiedad personalizada creada correctamente',
'custom-properties.create.error': 'Error al crear la propiedad personalizada',
'custom-properties.update.title': 'Editar propiedad personalizada',
'custom-properties.update.submit': 'Guardar cambios',
'custom-properties.update.success': 'Propiedad personalizada actualizada correctamente',
'custom-properties.update.error': 'Error al actualizar la propiedad personalizada',
'custom-properties.form.name.label': 'Nombre',
'custom-properties.form.name.placeholder': 'p. ej. Importe de factura',
'custom-properties.form.name.required': 'El nombre es obligatorio',
'custom-properties.form.name.max-length': 'El nombre debe tener como máximo 255 caracteres',
'custom-properties.form.description.label': 'Descripción',
'custom-properties.form.description.optional': '(opcional)',
'custom-properties.form.description.placeholder': 'Describe para qué se usa esta propiedad',
'custom-properties.form.description.max-length': 'La descripción debe tener como máximo 1000 caracteres',
'custom-properties.form.type.label': 'Tipo',
'custom-properties.form.type.immutable': 'El tipo de propiedad no puede modificarse después de su creación.',
'custom-properties.form.options.title': 'Opciones',
'custom-properties.form.options.description': 'Define las opciones disponibles para esta propiedad.',
'custom-properties.form.options.name.placeholder': 'Nombre de la opción',
'custom-properties.form.options.name.required': 'El nombre de la opción es obligatorio',
'custom-properties.form.options.name.max-length': 'El nombre de la opción debe tener como máximo 255 caracteres',
'custom-properties.form.options.validation.required': 'Por favor, añade al menos una opción',
'custom-properties.form.options.add': 'Añadir opción',
'custom-properties.form.cancel': 'Cancelar',
'custom-properties.form.save-error': 'Se produjo un error al guardar la definición de la propiedad. Por favor, inténtalo de nuevo.',
'documents.custom-properties.section-title': 'Propiedades',
'documents.custom-properties.no-value': 'No definido',
'documents.custom-properties.text-placeholder': 'Introduce un valor...',
'documents.custom-properties.save': 'Guardar',
'documents.custom-properties.clear': 'Limpiar',
'documents.custom-properties.document-relation-search-placeholder': 'Buscar documentos...',
'documents.custom-properties.user-relation-manage': 'Gestionar usuarios',
'documents.custom-properties.document-relation-manage': 'Gestionar documentos',
'documents.custom-properties.no-results': 'Sin resultados',
'documents.rename.title': 'Renombrar documento', 'documents.rename.title': 'Renombrar documento',
'documents.rename.form.name.label': 'Nombre', 'documents.rename.form.name.label': 'Nombre',
@ -381,6 +450,71 @@ export const translations: Partial<TranslationsDictionary> = {
'documents.preview.unknown-file-type': 'No hay vista previa disponible para este tipo de archivo', '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', 'documents.preview.binary-file': 'Este parece ser un archivo binario y no puede mostrarse como texto',
'documents.open-with.label': 'Abrir con',
'documents.open-with.pdf-viewer': 'Visor de PDF',
'documents.pdf-viewer.loading': 'Cargando PDF',
'documents.pdf-viewer.not-a-pdf': 'Este documento no es un PDF y no se puede abrir en el visor de PDF.',
'documents.pdf-viewer.toolbar.hide-sidebar': 'Ocultar barra lateral',
'documents.pdf-viewer.toolbar.show-sidebar': 'Mostrar barra lateral',
'documents.pdf-viewer.toolbar.previous-page': 'Página anterior',
'documents.pdf-viewer.toolbar.next-page': 'Página siguiente',
'documents.pdf-viewer.toolbar.fit-width': 'Ajustar al ancho',
'documents.pdf-viewer.toolbar.fit-page': 'Ajustar a la página',
'documents.pdf-viewer.toolbar.rotate-clockwise': 'Girar en sentido horario',
'documents.pdf-viewer.toolbar.download': 'Descargar',
'documents.pdf-viewer.toolbar.print': 'Imprimir',
'documents.pdf-viewer.zoom.zoom-out': 'Reducir',
'documents.pdf-viewer.zoom.zoom-in': 'Ampliar',
'documents.pdf-viewer.zoom.auto': 'Automático',
'documents.pdf-viewer.zoom.actual-size': 'Tamaño real',
'documents.pdf-viewer.zoom.page-fit': 'Ajustar a la página',
'documents.pdf-viewer.zoom.page-width': 'Ancho de página',
'documents.pdf-viewer.more-actions.label': 'Más acciones',
'documents.pdf-viewer.more-actions.presentation-mode': 'Modo presentación',
'documents.pdf-viewer.more-actions.download': 'Descargar',
'documents.pdf-viewer.more-actions.print': 'Imprimir',
'documents.pdf-viewer.more-actions.go-to-first-page': 'Ir a la primera página',
'documents.pdf-viewer.more-actions.go-to-last-page': 'Ir a la última página',
'documents.pdf-viewer.more-actions.rotate-clockwise': 'Girar en sentido horario',
'documents.pdf-viewer.more-actions.rotate-counterclockwise': 'Girar en sentido antihorario',
'documents.pdf-viewer.more-actions.page-scrolling': 'Desplazamiento por página',
'documents.pdf-viewer.more-actions.vertical-scrolling': 'Desplazamiento vertical',
'documents.pdf-viewer.more-actions.horizontal-scrolling': 'Desplazamiento horizontal',
'documents.pdf-viewer.more-actions.wrapped-scrolling': 'Desplazamiento continuo',
'documents.pdf-viewer.more-actions.no-spreads': 'Sin dobles páginas',
'documents.pdf-viewer.more-actions.odd-spreads': 'Dobles páginas impares',
'documents.pdf-viewer.more-actions.even-spreads': 'Dobles páginas pares',
'documents.pdf-viewer.more-actions.document-properties': 'Propiedades del documento',
'documents.pdf-viewer.properties.title': 'Propiedades del documento',
'documents.pdf-viewer.properties.na': 'N/D',
'documents.pdf-viewer.properties.file-name': 'Nombre del archivo',
'documents.pdf-viewer.properties.file-size': 'Tamaño del archivo',
'documents.pdf-viewer.properties.doc-title': 'Título',
'documents.pdf-viewer.properties.author': 'Autor',
'documents.pdf-viewer.properties.subject': 'Asunto',
'documents.pdf-viewer.properties.keywords': 'Palabras clave',
'documents.pdf-viewer.properties.creation-date': 'Fecha de creación',
'documents.pdf-viewer.properties.modification-date': 'Fecha de modificación',
'documents.pdf-viewer.properties.creator': 'Creado con',
'documents.pdf-viewer.properties.pdf-producer': 'Productor PDF',
'documents.pdf-viewer.properties.pdf-version': 'Versión PDF',
'documents.pdf-viewer.properties.page-count': 'Número de páginas',
'documents.pdf-viewer.properties.page-size': 'Tamaño de página',
'documents.pdf-viewer.properties.fast-web-view': 'Vista rápida web',
'documents.pdf-viewer.properties.yes': 'Sí',
'documents.pdf-viewer.properties.no': 'No',
'documents.pdf-viewer.sidebar.page-thumbnails': 'Miniaturas de páginas',
'documents.pdf-viewer.sidebar.document-outline': 'Esquema del documento',
'documents.pdf-viewer.sidebar.attachments': 'Adjuntos',
'documents.pdf-viewer.thumbnails.page-alt': 'Página {{ page }}',
'trash.delete-all.button': 'Eliminar todo', 'trash.delete-all.button': 'Eliminar todo',
'trash.delete-all.confirm.title': '¿Eliminar permanentemente todos los documentos?', '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.description': '¿Estás seguro de que deseas eliminar permanentemente todos los documentos de la papelera? Esta acción no se puede deshacer.',
@ -443,6 +577,10 @@ export const translations: Partial<TranslationsDictionary> = {
'tags.table.headers.documents': 'Documentos', 'tags.table.headers.documents': 'Documentos',
'tags.table.headers.created': 'Creado', 'tags.table.headers.created': 'Creado',
'tags.table.headers.actions': 'Acciones', 'tags.table.headers.actions': 'Acciones',
'tags.picker.search-placeholder': 'Buscar etiquetas...',
'tags.picker.filter-placeholder': 'Filtrar etiquetas...',
'tags.picker.create-new-with-name': 'Crear nueva etiqueta "{{ name }}"',
'tags.picker.create-new': 'Crear nueva etiqueta',
// Tagging rules // Tagging rules
@ -605,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
'webhooks.create.form.name.label': 'Nombre del webhook', 'webhooks.create.form.name.label': 'Nombre del webhook',
'webhooks.create.form.name.placeholder': 'Ingresa el 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.name.required': 'El nombre es obligatorio',
'webhooks.create.form.name.max-length': 'El nombre debe tener como máximo 128 caracteres',
'webhooks.create.form.url.label': 'URL del webhook', 'webhooks.create.form.url.label': 'URL del webhook',
'webhooks.create.form.url.placeholder': 'Ingresa la 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.required': 'La URL es obligatoria',
@ -639,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
'layout.menu.home': 'Inicio', 'layout.menu.home': 'Inicio',
'layout.menu.documents': 'Documentos', 'layout.menu.documents': 'Documentos',
'layout.menu.tags': 'Etiquetas', 'layout.menu.tags': 'Etiquetas',
'layout.menu.custom-properties': 'Propiedades personalizadas',
'layout.menu.tagging-rules': 'Reglas de etiquetado', 'layout.menu.tagging-rules': 'Reglas de etiquetado',
'layout.menu.deleted-documents': 'Documentos eliminados', 'layout.menu.deleted-documents': 'Documentos eliminados',
'layout.menu.organization-settings': 'Configuración', 'layout.menu.organization-settings': 'Configuración',
@ -668,6 +808,7 @@ export const translations: Partial<TranslationsDictionary> = {
'user-menu.api-keys': 'Claves API', 'user-menu.api-keys': 'Claves API',
'user-menu.invitations': 'Invitaciones', 'user-menu.invitations': 'Invitaciones',
'user-menu.language': 'Idioma', 'user-menu.language': 'Idioma',
'user-menu.theme': 'Tema',
'user-menu.about': 'Acerca de Papra', 'user-menu.about': 'Acerca de Papra',
'user-menu.logout': 'Cerrar sesión', 'user-menu.logout': 'Cerrar sesión',
@ -693,10 +834,12 @@ export const translations: Partial<TranslationsDictionary> = {
'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.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.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', 'api-errors.tags.already_exists': 'Ya existe una etiqueta con este nombre en esta organización',
'api-errors.tags.organization_limit_reached': 'Se ha alcanzado el número máximo de etiquetas para esta organización.',
'api-errors.internal.error': 'Ocurrió un error al procesar tu solicitud. Por favor, inténtalo de nuevo.', 'api-errors.internal.error': 'Ocurrió un error al procesar tu solicitud. Por favor, inténtalo de nuevo.',
'api-errors.auth.invalid_origin': 'Origen de la aplicación inválido. Si estás alojando Papra, asegúrate de que la variable de entorno APP_BASE_URL coincida con tu URL actual. Para más detalles, consulta https://docs.papra.app/resources/troubleshooting/#invalid-application-origin', 'api-errors.auth.invalid_origin': 'Origen de la aplicación inválido. Si estás alojando Papra, asegúrate de que la variable de entorno APP_BASE_URL coincida con tu URL actual. Para más detalles, consulta https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'Se ha alcanzado el número máximo de miembros e invitaciones pendientes para esta organización. Por favor, actualiza tu plan para añadir más miembros.', 'api-errors.organization.max_members_count_reached': 'Se ha alcanzado el número máximo de miembros e invitaciones pendientes para esta organización. Por favor, actualiza tu plan para añadir más miembros.',
'api-errors.organization.has_active_subscription': 'No se puede eliminar la organización con una suscripción activa. Por favor, cancela tu suscripción primero usando el botón Gestionar Suscripción arriba.', 'api-errors.organization.has_active_subscription': 'No se puede eliminar la organización con una suscripción activa. Por favor, cancela tu suscripción primero usando el botón Gestionar Suscripción arriba.',
'api-errors.webhooks.ssrf_unsafe_url': 'La URL proporcionada no está permitida. Las URLs de webhook no deben apuntar a direcciones IP privadas o reservadas.',
// Better auth api errors // Better auth api errors
'api-errors.USER_NOT_FOUND': 'Usuario no encontrado', 'api-errors.USER_NOT_FOUND': 'Usuario no encontrado',
'api-errors.FAILED_TO_CREATE_USER': 'Error al crear usuario', 'api-errors.FAILED_TO_CREATE_USER': 'Error al crear usuario',
@ -752,6 +895,7 @@ export const translations: Partial<TranslationsDictionary> = {
'color-picker.lightness': 'Luminosidad', 'color-picker.lightness': 'Luminosidad',
'color-picker.select-color': 'Seleccionar color', 'color-picker.select-color': 'Seleccionar color',
'color-picker.select-a-color': 'Selecciona un color', 'color-picker.select-a-color': 'Selecciona un color',
'color-picker.random-color': 'Color aleatorio',
// Subscriptions // Subscriptions

View file

@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
'documents.info.created-at': 'Créé le', 'documents.info.created-at': 'Créé le',
'documents.info.updated-at': 'Mis à jour le', 'documents.info.updated-at': 'Mis à jour le',
'documents.info.never': 'Jamais', 'documents.info.never': 'Jamais',
'documents.info.document-date': 'Date',
'documents.info.no-date': 'Pas de date',
'documents.info.today': 'Aujourd\'hui',
'custom-properties.types.text': 'Texte',
'custom-properties.types.number': 'Nombre',
'custom-properties.types.date': 'Date',
'custom-properties.types.boolean': 'Booléen',
'custom-properties.types.select': 'Sélection',
'custom-properties.types.multi_select': 'Sélection multiple',
'custom-properties.types.user_relation': 'Utilisateur',
'custom-properties.types.document_relation': 'Document',
'custom-properties.list.title': 'Propriétés personnalisées',
'custom-properties.list.description': 'Définissez des champs de métadonnées personnalisés pour vos documents. Les propriétés peuvent être du texte, des nombres, des dates, des booléens ou des listes de sélection.',
'custom-properties.list.create-button': 'Créer une propriété',
'custom-properties.list.empty.title': 'Propriétés personnalisées',
'custom-properties.list.empty.description': 'Les propriétés personnalisées vous permettent d\'ajouter des métadonnées structurées à vos documents, comme des dates d\'expiration, des noms d\'entreprise ou des montants.',
'custom-properties.list.table.name': 'Nom',
'custom-properties.list.table.type': 'Type',
'custom-properties.list.table.description': 'Description',
'custom-properties.list.table.created': 'Créé le',
'custom-properties.list.table.actions': 'Actions',
'custom-properties.list.table.no-description': 'Aucune description',
'custom-properties.list.delete.confirm-title': 'Supprimer la propriété personnalisée',
'custom-properties.list.delete.confirm-message': 'Êtes-vous sûr de vouloir supprimer la propriété personnalisée « {{ name }} » ? Cette action est irréversible.',
'custom-properties.list.delete.confirm-button': 'Supprimer',
'custom-properties.list.delete.success': 'Propriété personnalisée supprimée avec succès',
'custom-properties.list.delete.error': 'Échec de la suppression de la propriété personnalisée',
'custom-properties.create.title': 'Créer une propriété personnalisée',
'custom-properties.create.submit': 'Créer la propriété',
'custom-properties.create.success': 'Propriété personnalisée créée avec succès',
'custom-properties.create.error': 'Échec de la création de la propriété personnalisée',
'custom-properties.update.title': 'Modifier la propriété personnalisée',
'custom-properties.update.submit': 'Enregistrer les modifications',
'custom-properties.update.success': 'Propriété personnalisée mise à jour avec succès',
'custom-properties.update.error': 'Échec de la mise à jour de la propriété personnalisée',
'custom-properties.form.name.label': 'Nom',
'custom-properties.form.name.placeholder': 'ex. Montant de la facture',
'custom-properties.form.name.required': 'Le nom est requis',
'custom-properties.form.name.max-length': 'Le nom ne peut pas dépasser 255 caractères',
'custom-properties.form.description.label': 'Description',
'custom-properties.form.description.optional': '(facultatif)',
'custom-properties.form.description.placeholder': 'Décrivez à quoi sert cette propriété',
'custom-properties.form.description.max-length': 'La description ne peut pas dépasser 1000 caractères',
'custom-properties.form.type.label': 'Type',
'custom-properties.form.type.immutable': 'Le type de propriété ne peut pas être modifié après la création.',
'custom-properties.form.options.title': 'Options',
'custom-properties.form.options.description': 'Définissez les choix disponibles pour cette propriété.',
'custom-properties.form.options.name.placeholder': 'Nom de l\'option',
'custom-properties.form.options.name.required': 'Le nom de l\'option est requis',
'custom-properties.form.options.name.max-length': 'Le nom de l\'option ne peut pas dépasser 255 caractères',
'custom-properties.form.options.validation.required': 'Veuillez ajouter au moins une option',
'custom-properties.form.options.add': 'Ajouter une option',
'custom-properties.form.cancel': 'Annuler',
'custom-properties.form.save-error': 'Une erreur s\'est produite lors de l\'enregistrement de la propriété. Veuillez réessayer.',
'documents.custom-properties.section-title': 'Propriétés',
'documents.custom-properties.no-value': 'Non défini',
'documents.custom-properties.text-placeholder': 'Saisir une valeur...',
'documents.custom-properties.save': 'Enregistrer',
'documents.custom-properties.clear': 'Effacer',
'documents.custom-properties.document-relation-search-placeholder': 'Rechercher des documents...',
'documents.custom-properties.user-relation-manage': 'Gérer les utilisateurs',
'documents.custom-properties.document-relation-manage': 'Gérer les documents',
'documents.custom-properties.no-results': 'Aucun résultat',
'documents.rename.title': 'Renommer le document', 'documents.rename.title': 'Renommer le document',
'documents.rename.form.name.label': 'Nom', 'documents.rename.form.name.label': 'Nom',
@ -381,6 +450,71 @@ export const translations: Partial<TranslationsDictionary> = {
'documents.preview.unknown-file-type': 'Aucun aperçu disponible pour ce type de fichier', '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', 'documents.preview.binary-file': 'Cela semble être un fichier binaire et ne peut pas être affiché en texte',
'documents.open-with.label': 'Ouvrir avec',
'documents.open-with.pdf-viewer': 'Visionneuse PDF',
'documents.pdf-viewer.loading': 'Chargement du PDF',
'documents.pdf-viewer.not-a-pdf': 'Ce document n\'est pas un PDF et ne peut pas être ouvert dans la visionneuse PDF.',
'documents.pdf-viewer.toolbar.hide-sidebar': 'Masquer le panneau latéral',
'documents.pdf-viewer.toolbar.show-sidebar': 'Afficher le panneau latéral',
'documents.pdf-viewer.toolbar.previous-page': 'Page précédente',
'documents.pdf-viewer.toolbar.next-page': 'Page suivante',
'documents.pdf-viewer.toolbar.fit-width': 'Ajuster à la largeur',
'documents.pdf-viewer.toolbar.fit-page': 'Ajuster à la page',
'documents.pdf-viewer.toolbar.rotate-clockwise': 'Rotation horaire',
'documents.pdf-viewer.toolbar.download': 'Télécharger',
'documents.pdf-viewer.toolbar.print': 'Imprimer',
'documents.pdf-viewer.zoom.zoom-out': 'Dézoomer',
'documents.pdf-viewer.zoom.zoom-in': 'Zoomer',
'documents.pdf-viewer.zoom.auto': 'Automatique',
'documents.pdf-viewer.zoom.actual-size': 'Taille réelle',
'documents.pdf-viewer.zoom.page-fit': 'Ajuster à la page',
'documents.pdf-viewer.zoom.page-width': 'Largeur de page',
'documents.pdf-viewer.more-actions.label': 'Plus d\'actions',
'documents.pdf-viewer.more-actions.presentation-mode': 'Mode présentation',
'documents.pdf-viewer.more-actions.download': 'Télécharger',
'documents.pdf-viewer.more-actions.print': 'Imprimer',
'documents.pdf-viewer.more-actions.go-to-first-page': 'Aller à la première page',
'documents.pdf-viewer.more-actions.go-to-last-page': 'Aller à la dernière page',
'documents.pdf-viewer.more-actions.rotate-clockwise': 'Rotation horaire',
'documents.pdf-viewer.more-actions.rotate-counterclockwise': 'Rotation antihoraire',
'documents.pdf-viewer.more-actions.page-scrolling': 'Défilement par page',
'documents.pdf-viewer.more-actions.vertical-scrolling': 'Défilement vertical',
'documents.pdf-viewer.more-actions.horizontal-scrolling': 'Défilement horizontal',
'documents.pdf-viewer.more-actions.wrapped-scrolling': 'Défilement continu',
'documents.pdf-viewer.more-actions.no-spreads': 'Pas de double page',
'documents.pdf-viewer.more-actions.odd-spreads': 'Doubles pages impaires',
'documents.pdf-viewer.more-actions.even-spreads': 'Doubles pages paires',
'documents.pdf-viewer.more-actions.document-properties': 'Propriétés du document',
'documents.pdf-viewer.properties.title': 'Propriétés du document',
'documents.pdf-viewer.properties.na': 'N/D',
'documents.pdf-viewer.properties.file-name': 'Nom du fichier',
'documents.pdf-viewer.properties.file-size': 'Taille du fichier',
'documents.pdf-viewer.properties.doc-title': 'Titre',
'documents.pdf-viewer.properties.author': 'Auteur',
'documents.pdf-viewer.properties.subject': 'Sujet',
'documents.pdf-viewer.properties.keywords': 'Mots-clés',
'documents.pdf-viewer.properties.creation-date': 'Date de création',
'documents.pdf-viewer.properties.modification-date': 'Date de modification',
'documents.pdf-viewer.properties.creator': 'Créé avec',
'documents.pdf-viewer.properties.pdf-producer': 'Producteur PDF',
'documents.pdf-viewer.properties.pdf-version': 'Version PDF',
'documents.pdf-viewer.properties.page-count': 'Nombre de pages',
'documents.pdf-viewer.properties.page-size': 'Taille de la page',
'documents.pdf-viewer.properties.fast-web-view': 'Affichage web rapide',
'documents.pdf-viewer.properties.yes': 'Oui',
'documents.pdf-viewer.properties.no': 'Non',
'documents.pdf-viewer.sidebar.page-thumbnails': 'Vignettes des pages',
'documents.pdf-viewer.sidebar.document-outline': 'Structure du document',
'documents.pdf-viewer.sidebar.attachments': 'Pièces jointes',
'documents.pdf-viewer.thumbnails.page-alt': 'Page {{ page }}',
'trash.delete-all.button': 'Supprimer tous les documents', 'trash.delete-all.button': 'Supprimer tous les documents',
'trash.delete-all.confirm.title': 'Supprimer définitivement 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.description': 'Êtes-vous sûr de vouloir supprimer définitivement tous les documents de la corbeille ? Cette action est irréversible.',
@ -443,6 +577,10 @@ export const translations: Partial<TranslationsDictionary> = {
'tags.table.headers.documents': 'Documents', 'tags.table.headers.documents': 'Documents',
'tags.table.headers.created': 'Date de création', 'tags.table.headers.created': 'Date de création',
'tags.table.headers.actions': 'Actions', 'tags.table.headers.actions': 'Actions',
'tags.picker.search-placeholder': 'Rechercher des tags...',
'tags.picker.filter-placeholder': 'Filtrer les tags...',
'tags.picker.create-new-with-name': 'Créer un nouveau tag "{{ name }}"',
'tags.picker.create-new': 'Créer un nouveau tag',
// Tagging rules // Tagging rules
@ -605,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
'webhooks.create.form.name.label': 'Nom du webhook', 'webhooks.create.form.name.label': 'Nom du webhook',
'webhooks.create.form.name.placeholder': 'Entrez le 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.name.required': 'Le nom est requis',
'webhooks.create.form.name.max-length': 'Le nom doit contenir au maximum 128 caractères',
'webhooks.create.form.url.label': 'URL du webhook', 'webhooks.create.form.url.label': 'URL du webhook',
'webhooks.create.form.url.placeholder': 'Entrez l\'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.required': 'L\'URL est requise',
@ -639,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
'layout.menu.home': 'Accueil', 'layout.menu.home': 'Accueil',
'layout.menu.documents': 'Documents', 'layout.menu.documents': 'Documents',
'layout.menu.tags': 'Tags', 'layout.menu.tags': 'Tags',
'layout.menu.custom-properties': 'Propriétés personnalisées',
'layout.menu.tagging-rules': 'Règles de catégorisation', 'layout.menu.tagging-rules': 'Règles de catégorisation',
'layout.menu.deleted-documents': 'Documents supprimés', 'layout.menu.deleted-documents': 'Documents supprimés',
'layout.menu.organization-settings': 'Paramètres', 'layout.menu.organization-settings': 'Paramètres',
@ -668,6 +808,7 @@ export const translations: Partial<TranslationsDictionary> = {
'user-menu.api-keys': 'Clés d\'API', 'user-menu.api-keys': 'Clés d\'API',
'user-menu.invitations': 'Invitations', 'user-menu.invitations': 'Invitations',
'user-menu.language': 'Langue', 'user-menu.language': 'Langue',
'user-menu.theme': 'Thème',
'user-menu.about': 'À propos de Papra', 'user-menu.about': 'À propos de Papra',
'user-menu.logout': 'Déconnexion', 'user-menu.logout': 'Déconnexion',
@ -693,10 +834,12 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.user.organization_invitation_limit_reached': 'Le nombre maximum d\'invitations a été atteint pour aujourd\'hui. Veuillez réessayer demain.', '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.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', 'api-errors.tags.already_exists': 'Un tag avec ce nom existe déjà pour cette organisation',
'api-errors.tags.organization_limit_reached': 'Le nombre maximum de tags pour cette organisation a été atteint.',
'api-errors.internal.error': 'Une erreur est survenue lors du traitement de votre requête. Veuillez réessayer.', 'api-errors.internal.error': 'Une erreur est survenue lors du traitement de votre requête. Veuillez réessayer.',
'api-errors.auth.invalid_origin': 'Origine de l\'application invalide. Si vous hébergez Papra, assurez-vous que la variable d\'environnement APP_BASE_URL correspond à votre URL actuelle. Pour plus de détails, consultez https://docs.papra.app/resources/troubleshooting/#invalid-application-origin', 'api-errors.auth.invalid_origin': 'Origine de l\'application invalide. Si vous hébergez Papra, assurez-vous que la variable d\'environnement APP_BASE_URL correspond à votre URL actuelle. Pour plus de détails, consultez https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'Le nombre maximum de membres et d\'invitations en attente pour cette organisation a été atteint. Veuillez mettre à niveau votre plan pour ajouter plus de membres.', 'api-errors.organization.max_members_count_reached': 'Le nombre maximum de membres et d\'invitations en attente pour cette organisation a été atteint. Veuillez mettre à niveau votre plan pour ajouter plus de membres.',
'api-errors.organization.has_active_subscription': 'Impossible de supprimer l\'organisation avec un abonnement actif. Veuillez d\'abord annuler votre abonnement en utilisant le bouton Gérer l\'abonnement ci-dessus.', 'api-errors.organization.has_active_subscription': 'Impossible de supprimer l\'organisation avec un abonnement actif. Veuillez d\'abord annuler votre abonnement en utilisant le bouton Gérer l\'abonnement ci-dessus.',
'api-errors.webhooks.ssrf_unsafe_url': 'L\'URL fournie n\'est pas autorisée. Les URLs de webhook ne doivent pas pointer vers des adresses IP privées ou réservées.',
// Better auth api errors // Better auth api errors
'api-errors.USER_NOT_FOUND': 'Utilisateur introuvable', 'api-errors.USER_NOT_FOUND': 'Utilisateur introuvable',
'api-errors.FAILED_TO_CREATE_USER': 'Échec de la création de l\'utilisateur', 'api-errors.FAILED_TO_CREATE_USER': 'Échec de la création de l\'utilisateur',
@ -752,6 +895,7 @@ export const translations: Partial<TranslationsDictionary> = {
'color-picker.lightness': 'Luminosité', 'color-picker.lightness': 'Luminosité',
'color-picker.select-color': 'Sélectionner la couleur', 'color-picker.select-color': 'Sélectionner la couleur',
'color-picker.select-a-color': 'Sélectionner une couleur', 'color-picker.select-a-color': 'Sélectionner une couleur',
'color-picker.random-color': 'Couleur aléatoire',
// Subscriptions // Subscriptions

View file

@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
'documents.info.created-at': 'Creato il', 'documents.info.created-at': 'Creato il',
'documents.info.updated-at': 'Aggiornato il', 'documents.info.updated-at': 'Aggiornato il',
'documents.info.never': 'Mai', 'documents.info.never': 'Mai',
'documents.info.document-date': 'Data',
'documents.info.no-date': 'Nessuna data',
'documents.info.today': 'Oggi',
'custom-properties.types.text': 'Testo',
'custom-properties.types.number': 'Numero',
'custom-properties.types.date': 'Data',
'custom-properties.types.boolean': 'Booleano',
'custom-properties.types.select': 'Selezione',
'custom-properties.types.multi_select': 'Selezione multipla',
'custom-properties.types.user_relation': 'Utente',
'custom-properties.types.document_relation': 'Documento',
'custom-properties.list.title': 'Proprietà personalizzate',
'custom-properties.list.description': 'Definisci campi di metadati personalizzati per i tuoi documenti. Le proprietà possono essere testo, numeri, date, valori booleani o liste di selezione.',
'custom-properties.list.create-button': 'Crea proprietà',
'custom-properties.list.empty.title': 'Proprietà personalizzate',
'custom-properties.list.empty.description': 'Le proprietà personalizzate ti permettono di aggiungere metadati strutturati ai tuoi documenti, come date di scadenza, nomi aziendali o importi.',
'custom-properties.list.table.name': 'Nome',
'custom-properties.list.table.type': 'Tipo',
'custom-properties.list.table.description': 'Descrizione',
'custom-properties.list.table.created': 'Creato',
'custom-properties.list.table.actions': 'Azioni',
'custom-properties.list.table.no-description': 'Nessuna descrizione',
'custom-properties.list.delete.confirm-title': 'Elimina proprietà personalizzata',
'custom-properties.list.delete.confirm-message': 'Sei sicuro di voler eliminare la proprietà personalizzata "{{ name }}"? Questa azione non può essere annullata.',
'custom-properties.list.delete.confirm-button': 'Elimina',
'custom-properties.list.delete.success': 'Proprietà personalizzata eliminata con successo',
'custom-properties.list.delete.error': 'Impossibile eliminare la proprietà personalizzata',
'custom-properties.create.title': 'Crea proprietà personalizzata',
'custom-properties.create.submit': 'Crea proprietà',
'custom-properties.create.success': 'Proprietà personalizzata creata con successo',
'custom-properties.create.error': 'Impossibile creare la proprietà personalizzata',
'custom-properties.update.title': 'Modifica proprietà personalizzata',
'custom-properties.update.submit': 'Salva modifiche',
'custom-properties.update.success': 'Proprietà personalizzata aggiornata con successo',
'custom-properties.update.error': 'Impossibile aggiornare la proprietà personalizzata',
'custom-properties.form.name.label': 'Nome',
'custom-properties.form.name.placeholder': 'es. Importo fattura',
'custom-properties.form.name.required': 'Il nome è obbligatorio',
'custom-properties.form.name.max-length': 'Il nome deve contenere al massimo 255 caratteri',
'custom-properties.form.description.label': 'Descrizione',
'custom-properties.form.description.optional': '(facoltativo)',
'custom-properties.form.description.placeholder': 'Descrivi a cosa serve questa proprietà',
'custom-properties.form.description.max-length': 'La descrizione deve contenere al massimo 1000 caratteri',
'custom-properties.form.type.label': 'Tipo',
'custom-properties.form.type.immutable': 'Il tipo di proprietà non può essere modificato dopo la creazione.',
'custom-properties.form.options.title': 'Opzioni',
'custom-properties.form.options.description': 'Definisci le scelte disponibili per questa proprietà.',
'custom-properties.form.options.name.placeholder': 'Nome opzione',
'custom-properties.form.options.name.required': 'Il nome dell\'opzione è obbligatorio',
'custom-properties.form.options.name.max-length': 'Il nome dell\'opzione deve contenere al massimo 255 caratteri',
'custom-properties.form.options.validation.required': 'Aggiungi almeno un\'opzione',
'custom-properties.form.options.add': 'Aggiungi opzione',
'custom-properties.form.cancel': 'Annulla',
'custom-properties.form.save-error': 'Si è verificato un errore durante il salvataggio della definizione della proprietà. Riprova.',
'documents.custom-properties.section-title': 'Proprietà',
'documents.custom-properties.no-value': 'Non impostato',
'documents.custom-properties.text-placeholder': 'Inserisci un valore...',
'documents.custom-properties.save': 'Salva',
'documents.custom-properties.clear': 'Cancella',
'documents.custom-properties.document-relation-search-placeholder': 'Cerca documenti...',
'documents.custom-properties.user-relation-manage': 'Gestisci utenti',
'documents.custom-properties.document-relation-manage': 'Gestisci documenti',
'documents.custom-properties.no-results': 'Nessun risultato',
'documents.rename.title': 'Rinomina documento', 'documents.rename.title': 'Rinomina documento',
'documents.rename.form.name.label': 'Nome', 'documents.rename.form.name.label': 'Nome',
@ -381,6 +450,71 @@ export const translations: Partial<TranslationsDictionary> = {
'documents.preview.unknown-file-type': 'Nessuna anteprima disponibile per questo tipo di file', 'documents.preview.unknown-file-type': 'Nessuna anteprima disponibile per questo tipo di file',
'documents.preview.binary-file': 'Sembra essere un file binario e non può essere visualizzato come testo', 'documents.preview.binary-file': 'Sembra essere un file binario e non può essere visualizzato come testo',
'documents.open-with.label': 'Apri con',
'documents.open-with.pdf-viewer': 'Visualizzatore PDF',
'documents.pdf-viewer.loading': 'Caricamento PDF',
'documents.pdf-viewer.not-a-pdf': 'Questo documento non è un PDF e non può essere aperto nel visualizzatore PDF.',
'documents.pdf-viewer.toolbar.hide-sidebar': 'Nascondi barra laterale',
'documents.pdf-viewer.toolbar.show-sidebar': 'Mostra barra laterale',
'documents.pdf-viewer.toolbar.previous-page': 'Pagina precedente',
'documents.pdf-viewer.toolbar.next-page': 'Pagina successiva',
'documents.pdf-viewer.toolbar.fit-width': 'Adatta alla larghezza',
'documents.pdf-viewer.toolbar.fit-page': 'Adatta alla pagina',
'documents.pdf-viewer.toolbar.rotate-clockwise': 'Ruota in senso orario',
'documents.pdf-viewer.toolbar.download': 'Scarica',
'documents.pdf-viewer.toolbar.print': 'Stampa',
'documents.pdf-viewer.zoom.zoom-out': 'Riduci',
'documents.pdf-viewer.zoom.zoom-in': 'Ingrandisci',
'documents.pdf-viewer.zoom.auto': 'Automatico',
'documents.pdf-viewer.zoom.actual-size': 'Dimensione reale',
'documents.pdf-viewer.zoom.page-fit': 'Adatta alla pagina',
'documents.pdf-viewer.zoom.page-width': 'Larghezza pagina',
'documents.pdf-viewer.more-actions.label': 'Altre azioni',
'documents.pdf-viewer.more-actions.presentation-mode': 'Modalità presentazione',
'documents.pdf-viewer.more-actions.download': 'Scarica',
'documents.pdf-viewer.more-actions.print': 'Stampa',
'documents.pdf-viewer.more-actions.go-to-first-page': 'Vai alla prima pagina',
'documents.pdf-viewer.more-actions.go-to-last-page': 'Vai all\'ultima pagina',
'documents.pdf-viewer.more-actions.rotate-clockwise': 'Ruota in senso orario',
'documents.pdf-viewer.more-actions.rotate-counterclockwise': 'Ruota in senso antiorario',
'documents.pdf-viewer.more-actions.page-scrolling': 'Scorrimento per pagina',
'documents.pdf-viewer.more-actions.vertical-scrolling': 'Scorrimento verticale',
'documents.pdf-viewer.more-actions.horizontal-scrolling': 'Scorrimento orizzontale',
'documents.pdf-viewer.more-actions.wrapped-scrolling': 'Scorrimento continuo',
'documents.pdf-viewer.more-actions.no-spreads': 'Nessuna doppia pagina',
'documents.pdf-viewer.more-actions.odd-spreads': 'Doppie pagine dispari',
'documents.pdf-viewer.more-actions.even-spreads': 'Doppie pagine pari',
'documents.pdf-viewer.more-actions.document-properties': 'Proprietà del documento',
'documents.pdf-viewer.properties.title': 'Proprietà del documento',
'documents.pdf-viewer.properties.na': 'N/D',
'documents.pdf-viewer.properties.file-name': 'Nome file',
'documents.pdf-viewer.properties.file-size': 'Dimensione file',
'documents.pdf-viewer.properties.doc-title': 'Titolo',
'documents.pdf-viewer.properties.author': 'Autore',
'documents.pdf-viewer.properties.subject': 'Oggetto',
'documents.pdf-viewer.properties.keywords': 'Parole chiave',
'documents.pdf-viewer.properties.creation-date': 'Data di creazione',
'documents.pdf-viewer.properties.modification-date': 'Data di modifica',
'documents.pdf-viewer.properties.creator': 'Creato con',
'documents.pdf-viewer.properties.pdf-producer': 'Produttore PDF',
'documents.pdf-viewer.properties.pdf-version': 'Versione PDF',
'documents.pdf-viewer.properties.page-count': 'Numero di pagine',
'documents.pdf-viewer.properties.page-size': 'Dimensione pagina',
'documents.pdf-viewer.properties.fast-web-view': 'Visualizzazione web rapida',
'documents.pdf-viewer.properties.yes': 'Sì',
'documents.pdf-viewer.properties.no': 'No',
'documents.pdf-viewer.sidebar.page-thumbnails': 'Miniature delle pagine',
'documents.pdf-viewer.sidebar.document-outline': 'Struttura del documento',
'documents.pdf-viewer.sidebar.attachments': 'Allegati',
'documents.pdf-viewer.thumbnails.page-alt': 'Pagina {{ page }}',
'trash.delete-all.button': 'Elimina tutto', 'trash.delete-all.button': 'Elimina tutto',
'trash.delete-all.confirm.title': 'Eliminare definitivamente tutti i documenti?', 'trash.delete-all.confirm.title': 'Eliminare definitivamente tutti i documenti?',
'trash.delete-all.confirm.description': 'Sei sicuro di voler eliminare definitivamente tutti i documenti dal cestino? Questa azione non può essere annullata.', 'trash.delete-all.confirm.description': 'Sei sicuro di voler eliminare definitivamente tutti i documenti dal cestino? Questa azione non può essere annullata.',
@ -443,6 +577,10 @@ export const translations: Partial<TranslationsDictionary> = {
'tags.table.headers.documents': 'Documenti', 'tags.table.headers.documents': 'Documenti',
'tags.table.headers.created': 'Creato', 'tags.table.headers.created': 'Creato',
'tags.table.headers.actions': 'Azioni', 'tags.table.headers.actions': 'Azioni',
'tags.picker.search-placeholder': 'Cerca tag...',
'tags.picker.filter-placeholder': 'Filtra tag...',
'tags.picker.create-new-with-name': 'Crea nuovo tag "{{ name }}"',
'tags.picker.create-new': 'Crea nuovo tag',
// Tagging rules // Tagging rules
@ -605,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
'webhooks.create.form.name.label': 'Nome webhook', 'webhooks.create.form.name.label': 'Nome webhook',
'webhooks.create.form.name.placeholder': 'Inserisci nome webhook', 'webhooks.create.form.name.placeholder': 'Inserisci nome webhook',
'webhooks.create.form.name.required': 'Il nome è obbligatorio', 'webhooks.create.form.name.required': 'Il nome è obbligatorio',
'webhooks.create.form.name.max-length': 'Il nome deve contenere al massimo 128 caratteri',
'webhooks.create.form.url.label': 'URL webhook', 'webhooks.create.form.url.label': 'URL webhook',
'webhooks.create.form.url.placeholder': 'Inserisci URL webhook', 'webhooks.create.form.url.placeholder': 'Inserisci URL webhook',
'webhooks.create.form.url.required': 'L\'URL è obbligatorio', 'webhooks.create.form.url.required': 'L\'URL è obbligatorio',
@ -639,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
'layout.menu.home': 'Home', 'layout.menu.home': 'Home',
'layout.menu.documents': 'Documenti', 'layout.menu.documents': 'Documenti',
'layout.menu.tags': 'Tag', 'layout.menu.tags': 'Tag',
'layout.menu.custom-properties': 'Proprietà personalizzate',
'layout.menu.tagging-rules': 'Regole di tagging', 'layout.menu.tagging-rules': 'Regole di tagging',
'layout.menu.deleted-documents': 'Documenti eliminati', 'layout.menu.deleted-documents': 'Documenti eliminati',
'layout.menu.organization-settings': 'Impostazioni', 'layout.menu.organization-settings': 'Impostazioni',
@ -668,6 +808,7 @@ export const translations: Partial<TranslationsDictionary> = {
'user-menu.api-keys': 'Chiavi API', 'user-menu.api-keys': 'Chiavi API',
'user-menu.invitations': 'Inviti', 'user-menu.invitations': 'Inviti',
'user-menu.language': 'Lingua', 'user-menu.language': 'Lingua',
'user-menu.theme': 'Tema',
'user-menu.about': 'Informazioni su Papra', 'user-menu.about': 'Informazioni su Papra',
'user-menu.logout': 'Esci', 'user-menu.logout': 'Esci',
@ -693,10 +834,12 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.user.organization_invitation_limit_reached': 'È stato raggiunto il numero massimo di inviti per oggi. Riprova domani.', 'api-errors.user.organization_invitation_limit_reached': 'È stato raggiunto il numero massimo di inviti per oggi. Riprova domani.',
'api-errors.demo.not_available': 'Questa funzionalità non è disponibile nella demo', 'api-errors.demo.not_available': 'Questa funzionalità non è disponibile nella demo',
'api-errors.tags.already_exists': 'Esiste già un tag con questo nome per questa organizzazione', 'api-errors.tags.already_exists': 'Esiste già un tag con questo nome per questa organizzazione',
'api-errors.tags.organization_limit_reached': 'Il numero massimo di tag per questa organizzazione è stato raggiunto.',
'api-errors.internal.error': 'Si è verificato un errore durante l\'elaborazione della richiesta. Riprova.', 'api-errors.internal.error': 'Si è verificato un errore durante l\'elaborazione della richiesta. Riprova.',
'api-errors.auth.invalid_origin': 'Origine dell\'applicazione non valida. Se stai ospitando Papra, assicurati che la variabile di ambiente APP_BASE_URL corrisponda all\'URL corrente. Per maggiori dettagli, consulta https://docs.papra.app/resources/troubleshooting/#invalid-application-origin', 'api-errors.auth.invalid_origin': 'Origine dell\'applicazione non valida. Se stai ospitando Papra, assicurati che la variabile di ambiente APP_BASE_URL corrisponda all\'URL corrente. Per maggiori dettagli, consulta https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'È stato raggiunto il numero massimo di membri e inviti in sospeso per questa organizzazione. Aggiorna il tuo piano per aggiungere altri membri.', 'api-errors.organization.max_members_count_reached': 'È stato raggiunto il numero massimo di membri e inviti in sospeso per questa organizzazione. Aggiorna il tuo piano per aggiungere altri membri.',
'api-errors.organization.has_active_subscription': 'Impossibile eliminare l\'organizzazione con un abbonamento attivo. Si prega di annullare prima l\'abbonamento utilizzando il pulsante Gestisci abbonamento sopra.', 'api-errors.organization.has_active_subscription': 'Impossibile eliminare l\'organizzazione con un abbonamento attivo. Si prega di annullare prima l\'abbonamento utilizzando il pulsante Gestisci abbonamento sopra.',
'api-errors.webhooks.ssrf_unsafe_url': 'L\'URL fornito non è consentito. Gli URL dei webhook non devono puntare a indirizzi IP privati o riservati.',
// Better auth api errors // Better auth api errors
'api-errors.USER_NOT_FOUND': 'Utente non trovato', 'api-errors.USER_NOT_FOUND': 'Utente non trovato',
'api-errors.FAILED_TO_CREATE_USER': 'Impossibile creare l\'utente', 'api-errors.FAILED_TO_CREATE_USER': 'Impossibile creare l\'utente',
@ -752,6 +895,7 @@ export const translations: Partial<TranslationsDictionary> = {
'color-picker.lightness': 'Luminosità', 'color-picker.lightness': 'Luminosità',
'color-picker.select-color': 'Seleziona colore', 'color-picker.select-color': 'Seleziona colore',
'color-picker.select-a-color': 'Seleziona un colore', 'color-picker.select-a-color': 'Seleziona un colore',
'color-picker.random-color': 'Colore casuale',
// Subscriptions // Subscriptions

View file

@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
'documents.info.created-at': 'Aangemaakt op', 'documents.info.created-at': 'Aangemaakt op',
'documents.info.updated-at': 'Bijgewerkt op', 'documents.info.updated-at': 'Bijgewerkt op',
'documents.info.never': 'Nooit', 'documents.info.never': 'Nooit',
'documents.info.document-date': 'Datum',
'documents.info.no-date': 'Geen datum',
'documents.info.today': 'Vandaag',
'custom-properties.types.text': 'Tekst',
'custom-properties.types.number': 'Getal',
'custom-properties.types.date': 'Datum',
'custom-properties.types.boolean': 'Booleaans',
'custom-properties.types.select': 'Selectie',
'custom-properties.types.multi_select': 'Meervoudige selectie',
'custom-properties.types.user_relation': 'Gebruiker',
'custom-properties.types.document_relation': 'Document',
'custom-properties.list.title': 'Aangepaste eigenschappen',
'custom-properties.list.description': 'Definieer aangepaste metadatavelden voor uw documenten. Eigenschappen kunnen tekst, getallen, datums, booleaanse waarden of selectielijsten zijn.',
'custom-properties.list.create-button': 'Eigenschap aanmaken',
'custom-properties.list.empty.title': 'Aangepaste eigenschappen',
'custom-properties.list.empty.description': 'Met aangepaste eigenschappen kunt u gestructureerde metadata aan uw documenten toevoegen, zoals vervaldatums, bedrijfsnamen of bedragen.',
'custom-properties.list.table.name': 'Naam',
'custom-properties.list.table.type': 'Type',
'custom-properties.list.table.description': 'Beschrijving',
'custom-properties.list.table.created': 'Aangemaakt',
'custom-properties.list.table.actions': 'Acties',
'custom-properties.list.table.no-description': 'Geen beschrijving',
'custom-properties.list.delete.confirm-title': 'Aangepaste eigenschap verwijderen',
'custom-properties.list.delete.confirm-message': 'Weet u zeker dat u de aangepaste eigenschap "{{ name }}" wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.',
'custom-properties.list.delete.confirm-button': 'Verwijderen',
'custom-properties.list.delete.success': 'Aangepaste eigenschap succesvol verwijderd',
'custom-properties.list.delete.error': 'Verwijderen van aangepaste eigenschap mislukt',
'custom-properties.create.title': 'Aangepaste eigenschap aanmaken',
'custom-properties.create.submit': 'Eigenschap aanmaken',
'custom-properties.create.success': 'Aangepaste eigenschap succesvol aangemaakt',
'custom-properties.create.error': 'Aanmaken van aangepaste eigenschap mislukt',
'custom-properties.update.title': 'Aangepaste eigenschap bewerken',
'custom-properties.update.submit': 'Wijzigingen opslaan',
'custom-properties.update.success': 'Aangepaste eigenschap succesvol bijgewerkt',
'custom-properties.update.error': 'Bijwerken van aangepaste eigenschap mislukt',
'custom-properties.form.name.label': 'Naam',
'custom-properties.form.name.placeholder': 'bijv. Factuurbedrag',
'custom-properties.form.name.required': 'Naam is verplicht',
'custom-properties.form.name.max-length': 'De naam mag maximaal 255 tekens bevatten',
'custom-properties.form.description.label': 'Beschrijving',
'custom-properties.form.description.optional': '(optioneel)',
'custom-properties.form.description.placeholder': 'Beschrijf waarvoor deze eigenschap wordt gebruikt',
'custom-properties.form.description.max-length': 'De beschrijving mag maximaal 1000 tekens bevatten',
'custom-properties.form.type.label': 'Type',
'custom-properties.form.type.immutable': 'Het type eigenschap kan na aanmaken niet worden gewijzigd.',
'custom-properties.form.options.title': 'Opties',
'custom-properties.form.options.description': 'Definieer de beschikbare keuzes voor deze eigenschap.',
'custom-properties.form.options.name.placeholder': 'Optienaam',
'custom-properties.form.options.name.required': 'Optienaam is verplicht',
'custom-properties.form.options.name.max-length': 'De optienaam mag maximaal 255 tekens bevatten',
'custom-properties.form.options.validation.required': 'Voeg ten minste één optie toe',
'custom-properties.form.options.add': 'Optie toevoegen',
'custom-properties.form.cancel': 'Annuleren',
'custom-properties.form.save-error': 'Er is een fout opgetreden bij het opslaan van de eigenschapsdefinitie. Probeer het opnieuw.',
'documents.custom-properties.section-title': 'Eigenschappen',
'documents.custom-properties.no-value': 'Niet ingesteld',
'documents.custom-properties.text-placeholder': 'Voer een waarde in...',
'documents.custom-properties.save': 'Opslaan',
'documents.custom-properties.clear': 'Wissen',
'documents.custom-properties.document-relation-search-placeholder': 'Documenten zoeken...',
'documents.custom-properties.user-relation-manage': 'Gebruikers beheren',
'documents.custom-properties.document-relation-manage': 'Documenten beheren',
'documents.custom-properties.no-results': 'Geen resultaten',
'documents.rename.title': 'Document hernoemen', 'documents.rename.title': 'Document hernoemen',
'documents.rename.form.name.label': 'Naam', 'documents.rename.form.name.label': 'Naam',
@ -381,6 +450,71 @@ export const translations: Partial<TranslationsDictionary> = {
'documents.preview.unknown-file-type': 'Geen voorbeeld beschikbaar voor dit bestandstype', 'documents.preview.unknown-file-type': 'Geen voorbeeld beschikbaar voor dit bestandstype',
'documents.preview.binary-file': 'Dit lijkt een binaire bestandsinhoud te zijn en kan niet als tekst worden weergegeven', 'documents.preview.binary-file': 'Dit lijkt een binaire bestandsinhoud te zijn en kan niet als tekst worden weergegeven',
'documents.open-with.label': 'Openen met',
'documents.open-with.pdf-viewer': 'PDF-viewer',
'documents.pdf-viewer.loading': 'PDF laden',
'documents.pdf-viewer.not-a-pdf': 'Dit document is geen PDF en kan niet worden geopend in de PDF-viewer.',
'documents.pdf-viewer.toolbar.hide-sidebar': 'Zijbalk verbergen',
'documents.pdf-viewer.toolbar.show-sidebar': 'Zijbalk tonen',
'documents.pdf-viewer.toolbar.previous-page': 'Vorige pagina',
'documents.pdf-viewer.toolbar.next-page': 'Volgende pagina',
'documents.pdf-viewer.toolbar.fit-width': 'Breedte aanpassen',
'documents.pdf-viewer.toolbar.fit-page': 'Pagina aanpassen',
'documents.pdf-viewer.toolbar.rotate-clockwise': 'Rechtsom draaien',
'documents.pdf-viewer.toolbar.download': 'Downloaden',
'documents.pdf-viewer.toolbar.print': 'Afdrukken',
'documents.pdf-viewer.zoom.zoom-out': 'Uitzoomen',
'documents.pdf-viewer.zoom.zoom-in': 'Inzoomen',
'documents.pdf-viewer.zoom.auto': 'Automatisch',
'documents.pdf-viewer.zoom.actual-size': 'Werkelijke grootte',
'documents.pdf-viewer.zoom.page-fit': 'Pagina aanpassen',
'documents.pdf-viewer.zoom.page-width': 'Paginabreedte',
'documents.pdf-viewer.more-actions.label': 'Meer acties',
'documents.pdf-viewer.more-actions.presentation-mode': 'Presentatiemodus',
'documents.pdf-viewer.more-actions.download': 'Downloaden',
'documents.pdf-viewer.more-actions.print': 'Afdrukken',
'documents.pdf-viewer.more-actions.go-to-first-page': 'Naar eerste pagina',
'documents.pdf-viewer.more-actions.go-to-last-page': 'Naar laatste pagina',
'documents.pdf-viewer.more-actions.rotate-clockwise': 'Rechtsom draaien',
'documents.pdf-viewer.more-actions.rotate-counterclockwise': 'Linksom draaien',
'documents.pdf-viewer.more-actions.page-scrolling': 'Paginagewijs scrollen',
'documents.pdf-viewer.more-actions.vertical-scrolling': 'Verticaal scrollen',
'documents.pdf-viewer.more-actions.horizontal-scrolling': 'Horizontaal scrollen',
'documents.pdf-viewer.more-actions.wrapped-scrolling': 'Doorlopend scrollen',
'documents.pdf-viewer.more-actions.no-spreads': 'Geen dubbele pagina\'s',
'documents.pdf-viewer.more-actions.odd-spreads': 'Oneven dubbele pagina\'s',
'documents.pdf-viewer.more-actions.even-spreads': 'Even dubbele pagina\'s',
'documents.pdf-viewer.more-actions.document-properties': 'Documenteigenschappen',
'documents.pdf-viewer.properties.title': 'Documenteigenschappen',
'documents.pdf-viewer.properties.na': 'N.v.t.',
'documents.pdf-viewer.properties.file-name': 'Bestandsnaam',
'documents.pdf-viewer.properties.file-size': 'Bestandsgrootte',
'documents.pdf-viewer.properties.doc-title': 'Titel',
'documents.pdf-viewer.properties.author': 'Auteur',
'documents.pdf-viewer.properties.subject': 'Onderwerp',
'documents.pdf-viewer.properties.keywords': 'Trefwoorden',
'documents.pdf-viewer.properties.creation-date': 'Aanmaakdatum',
'documents.pdf-viewer.properties.modification-date': 'Wijzigingsdatum',
'documents.pdf-viewer.properties.creator': 'Gemaakt met',
'documents.pdf-viewer.properties.pdf-producer': 'PDF-producent',
'documents.pdf-viewer.properties.pdf-version': 'PDF-versie',
'documents.pdf-viewer.properties.page-count': 'Aantal pagina\'s',
'documents.pdf-viewer.properties.page-size': 'Paginagrootte',
'documents.pdf-viewer.properties.fast-web-view': 'Snelle webweergave',
'documents.pdf-viewer.properties.yes': 'Ja',
'documents.pdf-viewer.properties.no': 'Nee',
'documents.pdf-viewer.sidebar.page-thumbnails': 'Paginaminiaturen',
'documents.pdf-viewer.sidebar.document-outline': 'Documentstructuur',
'documents.pdf-viewer.sidebar.attachments': 'Bijlagen',
'documents.pdf-viewer.thumbnails.page-alt': 'Pagina {{ page }}',
'trash.delete-all.button': 'Alles verwijderen', 'trash.delete-all.button': 'Alles verwijderen',
'trash.delete-all.confirm.title': 'Alle documenten permanent verwijderen?', 'trash.delete-all.confirm.title': 'Alle documenten permanent verwijderen?',
'trash.delete-all.confirm.description': 'Weet u zeker dat u alle documenten uit de prullenbak permanent wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.', 'trash.delete-all.confirm.description': 'Weet u zeker dat u alle documenten uit de prullenbak permanent wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.',
@ -443,6 +577,10 @@ export const translations: Partial<TranslationsDictionary> = {
'tags.table.headers.documents': 'Documenten', 'tags.table.headers.documents': 'Documenten',
'tags.table.headers.created': 'Aangemaakt', 'tags.table.headers.created': 'Aangemaakt',
'tags.table.headers.actions': 'Acties', 'tags.table.headers.actions': 'Acties',
'tags.picker.search-placeholder': 'Labels zoeken...',
'tags.picker.filter-placeholder': 'Labels filteren...',
'tags.picker.create-new-with-name': 'Nieuw label "{{ name }}" aanmaken',
'tags.picker.create-new': 'Nieuw label aanmaken',
// Tagging rules // Tagging rules
@ -605,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
'webhooks.create.form.name.label': 'Webhooknaam', 'webhooks.create.form.name.label': 'Webhooknaam',
'webhooks.create.form.name.placeholder': 'Voer een webhooknaam in', 'webhooks.create.form.name.placeholder': 'Voer een webhooknaam in',
'webhooks.create.form.name.required': 'Naam is verplicht', 'webhooks.create.form.name.required': 'Naam is verplicht',
'webhooks.create.form.name.max-length': 'De naam mag maximaal 128 tekens bevatten',
'webhooks.create.form.url.label': 'Webhook-URL', 'webhooks.create.form.url.label': 'Webhook-URL',
'webhooks.create.form.url.placeholder': 'Voer een webhook-URL in', 'webhooks.create.form.url.placeholder': 'Voer een webhook-URL in',
'webhooks.create.form.url.required': 'URL is verplicht', 'webhooks.create.form.url.required': 'URL is verplicht',
@ -639,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
'layout.menu.home': 'Start', 'layout.menu.home': 'Start',
'layout.menu.documents': 'Documenten', 'layout.menu.documents': 'Documenten',
'layout.menu.tags': 'Labels', 'layout.menu.tags': 'Labels',
'layout.menu.custom-properties': 'Aangepaste eigenschappen',
'layout.menu.tagging-rules': 'Labelregels', 'layout.menu.tagging-rules': 'Labelregels',
'layout.menu.deleted-documents': 'Verwijderde documenten', 'layout.menu.deleted-documents': 'Verwijderde documenten',
'layout.menu.organization-settings': 'Instellingen', 'layout.menu.organization-settings': 'Instellingen',
@ -668,6 +808,7 @@ export const translations: Partial<TranslationsDictionary> = {
'user-menu.api-keys': 'API-sleutels', 'user-menu.api-keys': 'API-sleutels',
'user-menu.invitations': 'Uitnodigingen', 'user-menu.invitations': 'Uitnodigingen',
'user-menu.language': 'Taal', 'user-menu.language': 'Taal',
'user-menu.theme': 'Thema',
'user-menu.about': 'Over Papra', 'user-menu.about': 'Over Papra',
'user-menu.logout': 'Uitloggen', 'user-menu.logout': 'Uitloggen',
@ -693,10 +834,12 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.user.organization_invitation_limit_reached': 'Het maximale aantal uitnodigingen voor vandaag is bereikt. Probeer het morgen opnieuw.', 'api-errors.user.organization_invitation_limit_reached': 'Het maximale aantal uitnodigingen voor vandaag is bereikt. Probeer het morgen opnieuw.',
'api-errors.demo.not_available': 'Deze functie is niet beschikbaar in de demo', 'api-errors.demo.not_available': 'Deze functie is niet beschikbaar in de demo',
'api-errors.tags.already_exists': 'Er bestaat al een label met deze naam voor deze organisatie', 'api-errors.tags.already_exists': 'Er bestaat al een label met deze naam voor deze organisatie',
'api-errors.tags.organization_limit_reached': 'Het maximale aantal labels voor deze organisatie is bereikt.',
'api-errors.internal.error': 'Er is een fout opgetreden bij het verwerken van uw verzoek. Probeer het later opnieuw.', 'api-errors.internal.error': 'Er is een fout opgetreden bij het verwerken van uw verzoek. Probeer het later opnieuw.',
'api-errors.auth.invalid_origin': 'Ongeldige applicatiebron. Als u Papra zelf host, zorg ervoor dat uw APP_BASE_URL-omgeving variabele overeenkomt met uw huidige URL. Zie https://docs.papra.app/resources/troubleshooting/#invalid-application-origin voor meer informatie.', 'api-errors.auth.invalid_origin': 'Ongeldige applicatiebron. Als u Papra zelf host, zorg ervoor dat uw APP_BASE_URL-omgeving variabele overeenkomt met uw huidige URL. Zie https://docs.papra.app/resources/troubleshooting/#invalid-application-origin voor meer informatie.',
'api-errors.organization.max_members_count_reached': 'Het maximale aantal leden en openstaande uitnodigingen voor deze organisatie is bereikt. Upgrade uw plan om meer leden toe te voegen.', 'api-errors.organization.max_members_count_reached': 'Het maximale aantal leden en openstaande uitnodigingen voor deze organisatie is bereikt. Upgrade uw plan om meer leden toe te voegen.',
'api-errors.organization.has_active_subscription': 'Kan de organisatie niet verwijderen vanwege een actief abonnement. Annuleer eerst uw abonnement via de knop Abonnement beheren.', 'api-errors.organization.has_active_subscription': 'Kan de organisatie niet verwijderen vanwege een actief abonnement. Annuleer eerst uw abonnement via de knop Abonnement beheren.',
'api-errors.webhooks.ssrf_unsafe_url': 'De opgegeven URL is niet toegestaan. Webhook-URL\'s mogen niet verwijzen naar privé- of gereserveerde IP-adressen.',
// Better auth api errors // Better auth api errors
'api-errors.USER_NOT_FOUND': 'Gebruiker niet gevonden', 'api-errors.USER_NOT_FOUND': 'Gebruiker niet gevonden',
'api-errors.FAILED_TO_CREATE_USER': 'Kan gebruiker niet aanmaken', 'api-errors.FAILED_TO_CREATE_USER': 'Kan gebruiker niet aanmaken',
@ -752,6 +895,7 @@ export const translations: Partial<TranslationsDictionary> = {
'color-picker.lightness': 'Helderheid', 'color-picker.lightness': 'Helderheid',
'color-picker.select-color': 'Kleur selecteren', 'color-picker.select-color': 'Kleur selecteren',
'color-picker.select-a-color': 'Selecteer een kleur', 'color-picker.select-a-color': 'Selecteer een kleur',
'color-picker.random-color': 'Willekeurige kleur',
// Subscriptions // Subscriptions

View file

@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
'documents.info.created-at': 'Utworzono', 'documents.info.created-at': 'Utworzono',
'documents.info.updated-at': 'Zaktualizowano', 'documents.info.updated-at': 'Zaktualizowano',
'documents.info.never': 'Nigdy', 'documents.info.never': 'Nigdy',
'documents.info.document-date': 'Data',
'documents.info.no-date': 'Brak daty',
'documents.info.today': 'Dzisiaj',
'custom-properties.types.text': 'Tekst',
'custom-properties.types.number': 'Liczba',
'custom-properties.types.date': 'Data',
'custom-properties.types.boolean': 'Wartość logiczna',
'custom-properties.types.select': 'Wybór',
'custom-properties.types.multi_select': 'Wybór wielokrotny',
'custom-properties.types.user_relation': 'Użytkownik',
'custom-properties.types.document_relation': 'Dokument',
'custom-properties.list.title': 'Właściwości niestandardowe',
'custom-properties.list.description': 'Zdefiniuj niestandardowe pola metadanych dla swoich dokumentów. Właściwości mogą być tekstem, liczbami, datami, wartościami logicznymi lub listami wyboru.',
'custom-properties.list.create-button': 'Utwórz właściwość',
'custom-properties.list.empty.title': 'Właściwości niestandardowe',
'custom-properties.list.empty.description': 'Właściwości niestandardowe pozwalają dodawać ustrukturyzowane metadane do dokumentów, takie jak daty wygaśnięcia, nazwy firm lub kwoty.',
'custom-properties.list.table.name': 'Nazwa',
'custom-properties.list.table.type': 'Typ',
'custom-properties.list.table.description': 'Opis',
'custom-properties.list.table.created': 'Utworzono',
'custom-properties.list.table.actions': 'Działania',
'custom-properties.list.table.no-description': 'Brak opisu',
'custom-properties.list.delete.confirm-title': 'Usuń właściwość niestandardową',
'custom-properties.list.delete.confirm-message': 'Czy na pewno chcesz usunąć właściwość niestandardową „{{ name }}”? Tej operacji nie można cofnąć.',
'custom-properties.list.delete.confirm-button': 'Usuń',
'custom-properties.list.delete.success': 'Właściwość niestandardowa została pomyślnie usunięta',
'custom-properties.list.delete.error': 'Nie udało się usunąć właściwości niestandardowej',
'custom-properties.create.title': 'Utwórz właściwość niestandardową',
'custom-properties.create.submit': 'Utwórz właściwość',
'custom-properties.create.success': 'Właściwość niestandardowa została pomyślnie utworzona',
'custom-properties.create.error': 'Nie udało się utworzyć właściwości niestandardowej',
'custom-properties.update.title': 'Edytuj właściwość niestandardową',
'custom-properties.update.submit': 'Zapisz zmiany',
'custom-properties.update.success': 'Właściwość niestandardowa została pomyślnie zaktualizowana',
'custom-properties.update.error': 'Nie udało się zaktualizować właściwości niestandardowej',
'custom-properties.form.name.label': 'Nazwa',
'custom-properties.form.name.placeholder': 'np. Kwota faktury',
'custom-properties.form.name.required': 'Nazwa jest wymagana',
'custom-properties.form.name.max-length': 'Nazwa może mieć co najwyżej 255 znaków',
'custom-properties.form.description.label': 'Opis',
'custom-properties.form.description.optional': '(opcjonalnie)',
'custom-properties.form.description.placeholder': 'Opisz, do czego służy ta właściwość',
'custom-properties.form.description.max-length': 'Opis może mieć co najwyżej 1000 znaków',
'custom-properties.form.type.label': 'Typ',
'custom-properties.form.type.immutable': 'Typ właściwości nie może zostać zmieniony po jej utworzeniu.',
'custom-properties.form.options.title': 'Opcje',
'custom-properties.form.options.description': 'Zdefiniuj dostępne opcje dla tej właściwości.',
'custom-properties.form.options.name.placeholder': 'Nazwa opcji',
'custom-properties.form.options.name.required': 'Nazwa opcji jest wymagana',
'custom-properties.form.options.name.max-length': 'Nazwa opcji może mieć co najwyżej 255 znaków',
'custom-properties.form.options.validation.required': 'Dodaj co najmniej jedną opcję',
'custom-properties.form.options.add': 'Dodaj opcję',
'custom-properties.form.cancel': 'Anuluj',
'custom-properties.form.save-error': 'Wystąpił błąd podczas zapisywania definicji właściwości. Spróbuj ponownie.',
'documents.custom-properties.section-title': 'Właściwości',
'documents.custom-properties.no-value': 'Nie ustawiono',
'documents.custom-properties.text-placeholder': 'Wprowadź wartość...',
'documents.custom-properties.save': 'Zapisz',
'documents.custom-properties.clear': 'Wyczyść',
'documents.custom-properties.document-relation-search-placeholder': 'Szukaj dokumentów...',
'documents.custom-properties.user-relation-manage': 'Zarządzaj użytkownikami',
'documents.custom-properties.document-relation-manage': 'Zarządzaj dokumentami',
'documents.custom-properties.no-results': 'Brak wyników',
'documents.rename.title': 'Zmień nazwę dokumentu', 'documents.rename.title': 'Zmień nazwę dokumentu',
'documents.rename.form.name.label': 'Nazwa', 'documents.rename.form.name.label': 'Nazwa',
@ -381,6 +450,71 @@ export const translations: Partial<TranslationsDictionary> = {
'documents.preview.unknown-file-type': 'Brak podglądu dla tego typu pliku', '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', 'documents.preview.binary-file': 'To wydaje się być plikiem binarnym i nie może być wyświetlane jako tekst',
'documents.open-with.label': 'Otwórz za pomocą',
'documents.open-with.pdf-viewer': 'Przeglądarka PDF',
'documents.pdf-viewer.loading': 'Ładowanie PDF',
'documents.pdf-viewer.not-a-pdf': 'Ten dokument nie jest plikiem PDF i nie może zostać otwarty w przeglądarce PDF.',
'documents.pdf-viewer.toolbar.hide-sidebar': 'Ukryj panel boczny',
'documents.pdf-viewer.toolbar.show-sidebar': 'Pokaż panel boczny',
'documents.pdf-viewer.toolbar.previous-page': 'Poprzednia strona',
'documents.pdf-viewer.toolbar.next-page': 'Następna strona',
'documents.pdf-viewer.toolbar.fit-width': 'Dopasuj do szerokości',
'documents.pdf-viewer.toolbar.fit-page': 'Dopasuj do strony',
'documents.pdf-viewer.toolbar.rotate-clockwise': 'Obróć w prawo',
'documents.pdf-viewer.toolbar.download': 'Pobierz',
'documents.pdf-viewer.toolbar.print': 'Drukuj',
'documents.pdf-viewer.zoom.zoom-out': 'Pomniejsz',
'documents.pdf-viewer.zoom.zoom-in': 'Powiększ',
'documents.pdf-viewer.zoom.auto': 'Automatycznie',
'documents.pdf-viewer.zoom.actual-size': 'Rzeczywisty rozmiar',
'documents.pdf-viewer.zoom.page-fit': 'Dopasuj do strony',
'documents.pdf-viewer.zoom.page-width': 'Szerokość strony',
'documents.pdf-viewer.more-actions.label': 'Więcej działań',
'documents.pdf-viewer.more-actions.presentation-mode': 'Tryb prezentacji',
'documents.pdf-viewer.more-actions.download': 'Pobierz',
'documents.pdf-viewer.more-actions.print': 'Drukuj',
'documents.pdf-viewer.more-actions.go-to-first-page': 'Przejdź do pierwszej strony',
'documents.pdf-viewer.more-actions.go-to-last-page': 'Przejdź do ostatniej strony',
'documents.pdf-viewer.more-actions.rotate-clockwise': 'Obróć w prawo',
'documents.pdf-viewer.more-actions.rotate-counterclockwise': 'Obróć w lewo',
'documents.pdf-viewer.more-actions.page-scrolling': 'Przewijanie stronami',
'documents.pdf-viewer.more-actions.vertical-scrolling': 'Przewijanie pionowe',
'documents.pdf-viewer.more-actions.horizontal-scrolling': 'Przewijanie poziome',
'documents.pdf-viewer.more-actions.wrapped-scrolling': 'Przewijanie ciągłe',
'documents.pdf-viewer.more-actions.no-spreads': 'Bez rozkładówek',
'documents.pdf-viewer.more-actions.odd-spreads': 'Nieparzyste rozkładówki',
'documents.pdf-viewer.more-actions.even-spreads': 'Parzyste rozkładówki',
'documents.pdf-viewer.more-actions.document-properties': 'Właściwości dokumentu',
'documents.pdf-viewer.properties.title': 'Właściwości dokumentu',
'documents.pdf-viewer.properties.na': 'Brak',
'documents.pdf-viewer.properties.file-name': 'Nazwa pliku',
'documents.pdf-viewer.properties.file-size': 'Rozmiar pliku',
'documents.pdf-viewer.properties.doc-title': 'Tytuł',
'documents.pdf-viewer.properties.author': 'Autor',
'documents.pdf-viewer.properties.subject': 'Temat',
'documents.pdf-viewer.properties.keywords': 'Słowa kluczowe',
'documents.pdf-viewer.properties.creation-date': 'Data utworzenia',
'documents.pdf-viewer.properties.modification-date': 'Data modyfikacji',
'documents.pdf-viewer.properties.creator': 'Utworzono za pomocą',
'documents.pdf-viewer.properties.pdf-producer': 'Producent PDF',
'documents.pdf-viewer.properties.pdf-version': 'Wersja PDF',
'documents.pdf-viewer.properties.page-count': 'Liczba stron',
'documents.pdf-viewer.properties.page-size': 'Rozmiar strony',
'documents.pdf-viewer.properties.fast-web-view': 'Szybki podgląd webowy',
'documents.pdf-viewer.properties.yes': 'Tak',
'documents.pdf-viewer.properties.no': 'Nie',
'documents.pdf-viewer.sidebar.page-thumbnails': 'Miniatury stron',
'documents.pdf-viewer.sidebar.document-outline': 'Struktura dokumentu',
'documents.pdf-viewer.sidebar.attachments': 'Załączniki',
'documents.pdf-viewer.thumbnails.page-alt': 'Strona {{ page }}',
'trash.delete-all.button': 'Usuń wszystkie', 'trash.delete-all.button': 'Usuń wszystkie',
'trash.delete-all.confirm.title': 'Trwale usunąć wszystkie dokumenty?', '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.description': 'Czy na pewno chcesz trwale usunąć wszystkie dokumenty z kosza? Ta akcja nie może być cofnięta.',
@ -443,6 +577,10 @@ export const translations: Partial<TranslationsDictionary> = {
'tags.table.headers.documents': 'Dokumenty', 'tags.table.headers.documents': 'Dokumenty',
'tags.table.headers.created': 'Utworzono', 'tags.table.headers.created': 'Utworzono',
'tags.table.headers.actions': 'Akcje', 'tags.table.headers.actions': 'Akcje',
'tags.picker.search-placeholder': 'Szukaj tagów...',
'tags.picker.filter-placeholder': 'Filtruj tagi...',
'tags.picker.create-new-with-name': 'Utwórz nowy tag "{{ name }}"',
'tags.picker.create-new': 'Utwórz nowy tag',
// Tagging rules // Tagging rules
@ -605,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
'webhooks.create.form.name.label': 'Nazwa webhooka', 'webhooks.create.form.name.label': 'Nazwa webhooka',
'webhooks.create.form.name.placeholder': 'Wprowadź nazwę webhooka', 'webhooks.create.form.name.placeholder': 'Wprowadź nazwę webhooka',
'webhooks.create.form.name.required': 'Nazwa jest wymagana', 'webhooks.create.form.name.required': 'Nazwa jest wymagana',
'webhooks.create.form.name.max-length': 'Nazwa może mieć maksymalnie 128 znaków',
'webhooks.create.form.url.label': 'URL webhooka', 'webhooks.create.form.url.label': 'URL webhooka',
'webhooks.create.form.url.placeholder': 'Wprowadź URL webhooka', 'webhooks.create.form.url.placeholder': 'Wprowadź URL webhooka',
'webhooks.create.form.url.required': 'URL jest wymagany', 'webhooks.create.form.url.required': 'URL jest wymagany',
@ -639,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
'layout.menu.home': 'Strona główna', 'layout.menu.home': 'Strona główna',
'layout.menu.documents': 'Dokumenty', 'layout.menu.documents': 'Dokumenty',
'layout.menu.tags': 'Tagi', 'layout.menu.tags': 'Tagi',
'layout.menu.custom-properties': 'Właściwości niestandardowe',
'layout.menu.tagging-rules': 'Zasady tagowania', 'layout.menu.tagging-rules': 'Zasady tagowania',
'layout.menu.deleted-documents': 'Usunięte dokumenty', 'layout.menu.deleted-documents': 'Usunięte dokumenty',
'layout.menu.organization-settings': 'Ustawienia', 'layout.menu.organization-settings': 'Ustawienia',
@ -668,6 +808,7 @@ export const translations: Partial<TranslationsDictionary> = {
'user-menu.api-keys': 'Klucze API', 'user-menu.api-keys': 'Klucze API',
'user-menu.invitations': 'Zaproszenia', 'user-menu.invitations': 'Zaproszenia',
'user-menu.language': 'Język', 'user-menu.language': 'Język',
'user-menu.theme': 'Motyw',
'user-menu.about': 'O Papra', 'user-menu.about': 'O Papra',
'user-menu.logout': 'Wyloguj', 'user-menu.logout': 'Wyloguj',
@ -693,10 +834,12 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.user.organization_invitation_limit_reached': 'Osiągnięto maksymalną liczbę zaproszeń na dzisiaj. Spróbuj ponownie jutro.', '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.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', 'api-errors.tags.already_exists': 'Tag o tej nazwie już istnieje w tej organizacji',
'api-errors.tags.organization_limit_reached': 'Osiągnięto maksymalną liczbę tagów dla tej organizacji.',
'api-errors.internal.error': 'Wystąpił błąd podczas przetwarzania żądania. Spróbuj ponownie później.', 'api-errors.internal.error': 'Wystąpił błąd podczas przetwarzania żądania. Spróbuj ponownie później.',
'api-errors.auth.invalid_origin': 'Nieprawidłowa lokalizacja aplikacji. Jeśli hostujesz Papra, upewnij się, że zmienna środowiskowa APP_BASE_URL odpowiada bieżącemu adresowi URL. Aby uzyskać więcej informacji, zobacz https://docs.papra.app/resources/troubleshooting/#invalid-application-origin', 'api-errors.auth.invalid_origin': 'Nieprawidłowa lokalizacja aplikacji. Jeśli hostujesz Papra, upewnij się, że zmienna środowiskowa APP_BASE_URL odpowiada bieżącemu adresowi URL. Aby uzyskać więcej informacji, zobacz https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'Osiągnięto maksymalną liczbę członków i oczekujących zaproszeń dla tej organizacji. Zaktualizuj swój plan, aby dodać więcej członków.', 'api-errors.organization.max_members_count_reached': 'Osiągnięto maksymalną liczbę członków i oczekujących zaproszeń dla tej organizacji. Zaktualizuj swój plan, aby dodać więcej członków.',
'api-errors.organization.has_active_subscription': 'Nie można usunąć organizacji z aktywną subskrypcją. Proszę najpierw anulować subskrypcję za pomocą przycisku Zarządzaj subskrypcją powyżej.', 'api-errors.organization.has_active_subscription': 'Nie można usunąć organizacji z aktywną subskrypcją. Proszę najpierw anulować subskrypcję za pomocą przycisku Zarządzaj subskrypcją powyżej.',
'api-errors.webhooks.ssrf_unsafe_url': 'Podany adres URL jest niedozwolony. Adresy URL webhooków nie mogą wskazywać na prywatne lub zarezerwowane adresy IP.',
// Better auth api errors // Better auth api errors
'api-errors.USER_NOT_FOUND': 'Nie znaleziono użytkownika', 'api-errors.USER_NOT_FOUND': 'Nie znaleziono użytkownika',
'api-errors.FAILED_TO_CREATE_USER': 'Nie udało się utworzyć użytkownika', 'api-errors.FAILED_TO_CREATE_USER': 'Nie udało się utworzyć użytkownika',
@ -752,6 +895,7 @@ export const translations: Partial<TranslationsDictionary> = {
'color-picker.lightness': 'Jasność', 'color-picker.lightness': 'Jasność',
'color-picker.select-color': 'Wybierz kolor', 'color-picker.select-color': 'Wybierz kolor',
'color-picker.select-a-color': 'Wybierz kolor', 'color-picker.select-a-color': 'Wybierz kolor',
'color-picker.random-color': 'Losowy kolor',
// Subscriptions // Subscriptions

View file

@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
'documents.info.created-at': 'Criado em', 'documents.info.created-at': 'Criado em',
'documents.info.updated-at': 'Atualizado em', 'documents.info.updated-at': 'Atualizado em',
'documents.info.never': 'Nunca', 'documents.info.never': 'Nunca',
'documents.info.document-date': 'Data',
'documents.info.no-date': 'Sem data',
'documents.info.today': 'Hoje',
'custom-properties.types.text': 'Texto',
'custom-properties.types.number': 'Número',
'custom-properties.types.date': 'Data',
'custom-properties.types.boolean': 'Booleano',
'custom-properties.types.select': 'Seleção',
'custom-properties.types.multi_select': 'Seleção múltipla',
'custom-properties.types.user_relation': 'Usuário',
'custom-properties.types.document_relation': 'Documento',
'custom-properties.list.title': 'Propriedades personalizadas',
'custom-properties.list.description': 'Defina campos de metadados personalizados para seus documentos. As propriedades podem ser texto, números, datas, booleanos ou listas de seleção.',
'custom-properties.list.create-button': 'Criar propriedade',
'custom-properties.list.empty.title': 'Propriedades personalizadas',
'custom-properties.list.empty.description': 'As propriedades personalizadas permitem adicionar metadados estruturados aos seus documentos, como datas de vencimento, nomes de empresas ou valores.',
'custom-properties.list.table.name': 'Nome',
'custom-properties.list.table.type': 'Tipo',
'custom-properties.list.table.description': 'Descrição',
'custom-properties.list.table.created': 'Criado em',
'custom-properties.list.table.actions': 'Ações',
'custom-properties.list.table.no-description': 'Sem descrição',
'custom-properties.list.delete.confirm-title': 'Excluir propriedade personalizada',
'custom-properties.list.delete.confirm-message': 'Tem certeza que deseja excluir a propriedade personalizada "{{ name }}"? Esta ação não pode ser desfeita.',
'custom-properties.list.delete.confirm-button': 'Excluir',
'custom-properties.list.delete.success': 'Propriedade personalizada excluída com sucesso',
'custom-properties.list.delete.error': 'Falha ao excluir a propriedade personalizada',
'custom-properties.create.title': 'Criar propriedade personalizada',
'custom-properties.create.submit': 'Criar propriedade',
'custom-properties.create.success': 'Propriedade personalizada criada com sucesso',
'custom-properties.create.error': 'Falha ao criar a propriedade personalizada',
'custom-properties.update.title': 'Editar propriedade personalizada',
'custom-properties.update.submit': 'Salvar alterações',
'custom-properties.update.success': 'Propriedade personalizada atualizada com sucesso',
'custom-properties.update.error': 'Falha ao atualizar a propriedade personalizada',
'custom-properties.form.name.label': 'Nome',
'custom-properties.form.name.placeholder': 'ex.: Valor da fatura',
'custom-properties.form.name.required': 'O nome é obrigatório',
'custom-properties.form.name.max-length': 'O nome deve ter no máximo 255 caracteres',
'custom-properties.form.description.label': 'Descrição',
'custom-properties.form.description.optional': '(opcional)',
'custom-properties.form.description.placeholder': 'Descreva para que esta propriedade é usada',
'custom-properties.form.description.max-length': 'A descrição deve ter no máximo 1000 caracteres',
'custom-properties.form.type.label': 'Tipo',
'custom-properties.form.type.immutable': 'O tipo de propriedade não pode ser alterado após a criação.',
'custom-properties.form.options.title': 'Opções',
'custom-properties.form.options.description': 'Defina as escolhas disponíveis para esta propriedade.',
'custom-properties.form.options.name.placeholder': 'Nome da opção',
'custom-properties.form.options.name.required': 'O nome da opção é obrigatório',
'custom-properties.form.options.name.max-length': 'O nome da opção deve ter no máximo 255 caracteres',
'custom-properties.form.options.validation.required': 'Adicione pelo menos uma opção',
'custom-properties.form.options.add': 'Adicionar opção',
'custom-properties.form.cancel': 'Cancelar',
'custom-properties.form.save-error': 'Ocorreu um erro ao salvar a definição da propriedade. Tente novamente.',
'documents.custom-properties.section-title': 'Propriedades',
'documents.custom-properties.no-value': 'Não definido',
'documents.custom-properties.text-placeholder': 'Digite um valor...',
'documents.custom-properties.save': 'Salvar',
'documents.custom-properties.clear': 'Limpar',
'documents.custom-properties.document-relation-search-placeholder': 'Pesquisar documentos...',
'documents.custom-properties.user-relation-manage': 'Gerenciar usuários',
'documents.custom-properties.document-relation-manage': 'Gerenciar documentos',
'documents.custom-properties.no-results': 'Nenhum resultado',
'documents.rename.title': 'Renomear documento', 'documents.rename.title': 'Renomear documento',
'documents.rename.form.name.label': 'Nome', 'documents.rename.form.name.label': 'Nome',
@ -381,6 +450,71 @@ export const translations: Partial<TranslationsDictionary> = {
'documents.preview.unknown-file-type': 'Pré-visualização não disponível para este tipo de arquivo', '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', 'documents.preview.binary-file': 'Arquivos binários não podem ser exibidos como texto',
'documents.open-with.label': 'Abrir com',
'documents.open-with.pdf-viewer': 'Visualizador de PDF',
'documents.pdf-viewer.loading': 'Carregando PDF',
'documents.pdf-viewer.not-a-pdf': 'Este documento não é um PDF e não pode ser aberto no visualizador de PDF.',
'documents.pdf-viewer.toolbar.hide-sidebar': 'Ocultar painel lateral',
'documents.pdf-viewer.toolbar.show-sidebar': 'Mostrar painel lateral',
'documents.pdf-viewer.toolbar.previous-page': 'Página anterior',
'documents.pdf-viewer.toolbar.next-page': 'Próxima página',
'documents.pdf-viewer.toolbar.fit-width': 'Ajustar à largura',
'documents.pdf-viewer.toolbar.fit-page': 'Ajustar à página',
'documents.pdf-viewer.toolbar.rotate-clockwise': 'Girar no sentido horário',
'documents.pdf-viewer.toolbar.download': 'Baixar',
'documents.pdf-viewer.toolbar.print': 'Imprimir',
'documents.pdf-viewer.zoom.zoom-out': 'Diminuir',
'documents.pdf-viewer.zoom.zoom-in': 'Aumentar',
'documents.pdf-viewer.zoom.auto': 'Automático',
'documents.pdf-viewer.zoom.actual-size': 'Tamanho real',
'documents.pdf-viewer.zoom.page-fit': 'Ajustar à página',
'documents.pdf-viewer.zoom.page-width': 'Largura da página',
'documents.pdf-viewer.more-actions.label': 'Mais ações',
'documents.pdf-viewer.more-actions.presentation-mode': 'Modo apresentação',
'documents.pdf-viewer.more-actions.download': 'Baixar',
'documents.pdf-viewer.more-actions.print': 'Imprimir',
'documents.pdf-viewer.more-actions.go-to-first-page': 'Ir para a primeira página',
'documents.pdf-viewer.more-actions.go-to-last-page': 'Ir para a última página',
'documents.pdf-viewer.more-actions.rotate-clockwise': 'Girar no sentido horário',
'documents.pdf-viewer.more-actions.rotate-counterclockwise': 'Girar no sentido anti-horário',
'documents.pdf-viewer.more-actions.page-scrolling': 'Rolagem por página',
'documents.pdf-viewer.more-actions.vertical-scrolling': 'Rolagem vertical',
'documents.pdf-viewer.more-actions.horizontal-scrolling': 'Rolagem horizontal',
'documents.pdf-viewer.more-actions.wrapped-scrolling': 'Rolagem contínua',
'documents.pdf-viewer.more-actions.no-spreads': 'Sem páginas duplas',
'documents.pdf-viewer.more-actions.odd-spreads': 'Páginas duplas ímpares',
'documents.pdf-viewer.more-actions.even-spreads': 'Páginas duplas pares',
'documents.pdf-viewer.more-actions.document-properties': 'Propriedades do documento',
'documents.pdf-viewer.properties.title': 'Propriedades do documento',
'documents.pdf-viewer.properties.na': 'N/D',
'documents.pdf-viewer.properties.file-name': 'Nome do arquivo',
'documents.pdf-viewer.properties.file-size': 'Tamanho do arquivo',
'documents.pdf-viewer.properties.doc-title': 'Título',
'documents.pdf-viewer.properties.author': 'Autor',
'documents.pdf-viewer.properties.subject': 'Assunto',
'documents.pdf-viewer.properties.keywords': 'Palavras-chave',
'documents.pdf-viewer.properties.creation-date': 'Data de criação',
'documents.pdf-viewer.properties.modification-date': 'Data de modificação',
'documents.pdf-viewer.properties.creator': 'Criado com',
'documents.pdf-viewer.properties.pdf-producer': 'Produtor PDF',
'documents.pdf-viewer.properties.pdf-version': 'Versão PDF',
'documents.pdf-viewer.properties.page-count': 'Número de páginas',
'documents.pdf-viewer.properties.page-size': 'Tamanho da página',
'documents.pdf-viewer.properties.fast-web-view': 'Visualização rápida na web',
'documents.pdf-viewer.properties.yes': 'Sim',
'documents.pdf-viewer.properties.no': 'Não',
'documents.pdf-viewer.sidebar.page-thumbnails': 'Miniaturas das páginas',
'documents.pdf-viewer.sidebar.document-outline': 'Estrutura do documento',
'documents.pdf-viewer.sidebar.attachments': 'Anexos',
'documents.pdf-viewer.thumbnails.page-alt': 'Página {{ page }}',
'trash.delete-all.button': 'Excluir tudo', 'trash.delete-all.button': 'Excluir tudo',
'trash.delete-all.confirm.title': 'Excluir todos os documentos permanentemente?', '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.description': 'Tem certeza de que deseja excluir permanentemente todos os documentos da lixeira? Esta ação não poderá ser desfeita.',
@ -443,6 +577,10 @@ export const translations: Partial<TranslationsDictionary> = {
'tags.table.headers.documents': 'Documentos', 'tags.table.headers.documents': 'Documentos',
'tags.table.headers.created': 'Criado em', 'tags.table.headers.created': 'Criado em',
'tags.table.headers.actions': 'Ações', 'tags.table.headers.actions': 'Ações',
'tags.picker.search-placeholder': 'Pesquisar tags...',
'tags.picker.filter-placeholder': 'Filtrar tags...',
'tags.picker.create-new-with-name': 'Criar nova tag "{{ name }}"',
'tags.picker.create-new': 'Criar nova tag',
// Tagging rules // Tagging rules
@ -605,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
'webhooks.create.form.name.label': 'Nome do webhook', 'webhooks.create.form.name.label': 'Nome do webhook',
'webhooks.create.form.name.placeholder': 'Insira o 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.name.required': 'O nome é obrigatório',
'webhooks.create.form.name.max-length': 'O nome deve ter no máximo 128 caracteres',
'webhooks.create.form.url.label': 'URL do Webhook', 'webhooks.create.form.url.label': 'URL do Webhook',
'webhooks.create.form.url.placeholder': 'Insira a 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.required': 'A URL é obrigatória',
@ -639,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
'layout.menu.home': 'Início', 'layout.menu.home': 'Início',
'layout.menu.documents': 'Documentos', 'layout.menu.documents': 'Documentos',
'layout.menu.tags': 'Tags', 'layout.menu.tags': 'Tags',
'layout.menu.custom-properties': 'Propriedades personalizadas',
'layout.menu.tagging-rules': 'Regras de marcação', 'layout.menu.tagging-rules': 'Regras de marcação',
'layout.menu.deleted-documents': 'Documentos excluídos', 'layout.menu.deleted-documents': 'Documentos excluídos',
'layout.menu.organization-settings': 'Configurações', 'layout.menu.organization-settings': 'Configurações',
@ -668,6 +808,7 @@ export const translations: Partial<TranslationsDictionary> = {
'user-menu.api-keys': 'Chaves de API', 'user-menu.api-keys': 'Chaves de API',
'user-menu.invitations': 'Convites', 'user-menu.invitations': 'Convites',
'user-menu.language': 'Idioma', 'user-menu.language': 'Idioma',
'user-menu.theme': 'Tema',
'user-menu.about': 'Sobre o Papra', 'user-menu.about': 'Sobre o Papra',
'user-menu.logout': 'Sair', 'user-menu.logout': 'Sair',
@ -693,10 +834,12 @@ export const translations: Partial<TranslationsDictionary> = {
'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.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.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', 'api-errors.tags.already_exists': 'Já existe uma tag com este nome nesta organização',
'api-errors.tags.organization_limit_reached': 'O número máximo de tags para esta organização foi atingido.',
'api-errors.internal.error': 'Ocorreu um erro ao processar sua solicitação. Por favor, tente novamente.', 'api-errors.internal.error': 'Ocorreu um erro ao processar sua solicitação. Por favor, tente novamente.',
'api-errors.auth.invalid_origin': 'Origem da aplicação inválida. Se você está hospedando o Papra, certifique-se de que a variável de ambiente APP_BASE_URL corresponde à sua URL atual. Para mais detalhes, consulte https://docs.papra.app/resources/troubleshooting/#invalid-application-origin', 'api-errors.auth.invalid_origin': 'Origem da aplicação inválida. Se você está hospedando o Papra, certifique-se de que a variável de ambiente APP_BASE_URL corresponde à sua URL atual. Para mais detalhes, consulte https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'O número máximo de membros e convites pendentes para esta organização foi atingido. Atualize seu plano para adicionar mais membros.', 'api-errors.organization.max_members_count_reached': 'O número máximo de membros e convites pendentes para esta organização foi atingido. Atualize seu plano para adicionar mais membros.',
'api-errors.organization.has_active_subscription': 'Não é possível excluir a organização com uma assinatura ativa. Por favor, cancele sua assinatura primeiro usando o botão Gerenciar Assinatura acima.', 'api-errors.organization.has_active_subscription': 'Não é possível excluir a organização com uma assinatura ativa. Por favor, cancele sua assinatura primeiro usando o botão Gerenciar Assinatura acima.',
'api-errors.webhooks.ssrf_unsafe_url': 'A URL fornecida não é permitida. URLs de webhook não devem apontar para endereços IP privados ou reservados.',
// Better auth api errors // Better auth api errors
'api-errors.USER_NOT_FOUND': 'Usuário não encontrado', 'api-errors.USER_NOT_FOUND': 'Usuário não encontrado',
'api-errors.FAILED_TO_CREATE_USER': 'Falha ao criar usuário', 'api-errors.FAILED_TO_CREATE_USER': 'Falha ao criar usuário',
@ -752,6 +895,7 @@ export const translations: Partial<TranslationsDictionary> = {
'color-picker.lightness': 'Brilho', 'color-picker.lightness': 'Brilho',
'color-picker.select-color': 'Selecionar cor', 'color-picker.select-color': 'Selecionar cor',
'color-picker.select-a-color': 'Selecione uma cor', 'color-picker.select-a-color': 'Selecione uma cor',
'color-picker.random-color': 'Cor aleatória',
// Subscriptions // Subscriptions

View file

@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
'documents.info.created-at': 'Criado em', 'documents.info.created-at': 'Criado em',
'documents.info.updated-at': 'Atualizado em', 'documents.info.updated-at': 'Atualizado em',
'documents.info.never': 'Nunca', 'documents.info.never': 'Nunca',
'documents.info.document-date': 'Data',
'documents.info.no-date': 'Sem data',
'documents.info.today': 'Hoje',
'custom-properties.types.text': 'Texto',
'custom-properties.types.number': 'Número',
'custom-properties.types.date': 'Data',
'custom-properties.types.boolean': 'Booleano',
'custom-properties.types.select': 'Seleção',
'custom-properties.types.multi_select': 'Seleção múltipla',
'custom-properties.types.user_relation': 'Utilizador',
'custom-properties.types.document_relation': 'Documento',
'custom-properties.list.title': 'Propriedades personalizadas',
'custom-properties.list.description': 'Defina campos de metadados personalizados para os seus documentos. As propriedades podem ser texto, números, datas, booleanos ou listas de seleção.',
'custom-properties.list.create-button': 'Criar propriedade',
'custom-properties.list.empty.title': 'Propriedades personalizadas',
'custom-properties.list.empty.description': 'As propriedades personalizadas permitem adicionar metadados estruturados aos seus documentos, como datas de validade, nomes de empresas ou montantes.',
'custom-properties.list.table.name': 'Nome',
'custom-properties.list.table.type': 'Tipo',
'custom-properties.list.table.description': 'Descrição',
'custom-properties.list.table.created': 'Criado',
'custom-properties.list.table.actions': 'Ações',
'custom-properties.list.table.no-description': 'Sem descrição',
'custom-properties.list.delete.confirm-title': 'Eliminar propriedade personalizada',
'custom-properties.list.delete.confirm-message': 'Tem a certeza de que pretende eliminar a propriedade personalizada "{{ name }}"? Esta ação não pode ser desfeita.',
'custom-properties.list.delete.confirm-button': 'Eliminar',
'custom-properties.list.delete.success': 'Propriedade personalizada eliminada com sucesso',
'custom-properties.list.delete.error': 'Falha ao eliminar a propriedade personalizada',
'custom-properties.create.title': 'Criar propriedade personalizada',
'custom-properties.create.submit': 'Criar propriedade',
'custom-properties.create.success': 'Propriedade personalizada criada com sucesso',
'custom-properties.create.error': 'Falha ao criar a propriedade personalizada',
'custom-properties.update.title': 'Editar propriedade personalizada',
'custom-properties.update.submit': 'Guardar alterações',
'custom-properties.update.success': 'Propriedade personalizada atualizada com sucesso',
'custom-properties.update.error': 'Falha ao atualizar a propriedade personalizada',
'custom-properties.form.name.label': 'Nome',
'custom-properties.form.name.placeholder': 'ex.: Montante da fatura',
'custom-properties.form.name.required': 'O nome é obrigatório',
'custom-properties.form.name.max-length': 'O nome deve ter no máximo 255 caracteres',
'custom-properties.form.description.label': 'Descrição',
'custom-properties.form.description.optional': '(opcional)',
'custom-properties.form.description.placeholder': 'Descreva para que serve esta propriedade',
'custom-properties.form.description.max-length': 'A descrição deve ter no máximo 1000 caracteres',
'custom-properties.form.type.label': 'Tipo',
'custom-properties.form.type.immutable': 'O tipo de propriedade não pode ser alterado após a criação.',
'custom-properties.form.options.title': 'Opções',
'custom-properties.form.options.description': 'Defina as escolhas disponíveis para esta propriedade.',
'custom-properties.form.options.name.placeholder': 'Nome da opção',
'custom-properties.form.options.name.required': 'O nome da opção é obrigatório',
'custom-properties.form.options.name.max-length': 'O nome da opção deve ter no máximo 255 caracteres',
'custom-properties.form.options.validation.required': 'Adicione pelo menos uma opção',
'custom-properties.form.options.add': 'Adicionar opção',
'custom-properties.form.cancel': 'Cancelar',
'custom-properties.form.save-error': 'Ocorreu um erro ao guardar a definição da propriedade. Tente novamente.',
'documents.custom-properties.section-title': 'Propriedades',
'documents.custom-properties.no-value': 'Não definido',
'documents.custom-properties.text-placeholder': 'Introduzir um valor...',
'documents.custom-properties.save': 'Guardar',
'documents.custom-properties.clear': 'Limpar',
'documents.custom-properties.document-relation-search-placeholder': 'Pesquisar documentos...',
'documents.custom-properties.user-relation-manage': 'Gerir utilizadores',
'documents.custom-properties.document-relation-manage': 'Gerir documentos',
'documents.custom-properties.no-results': 'Sem resultados',
'documents.rename.title': 'Renomear documento', 'documents.rename.title': 'Renomear documento',
'documents.rename.form.name.label': 'Nome', 'documents.rename.form.name.label': 'Nome',
@ -381,6 +450,71 @@ export const translations: Partial<TranslationsDictionary> = {
'documents.preview.unknown-file-type': 'Não há pré-visualização disponível para este tipo de ficheiro', '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', 'documents.preview.binary-file': 'Este parece ser um ficheiro binário e não pode ser exibido como texto',
'documents.open-with.label': 'Abrir com',
'documents.open-with.pdf-viewer': 'Visualizador de PDF',
'documents.pdf-viewer.loading': 'A carregar PDF',
'documents.pdf-viewer.not-a-pdf': 'Este documento não é um PDF e não pode ser aberto no visualizador de PDF.',
'documents.pdf-viewer.toolbar.hide-sidebar': 'Ocultar painel lateral',
'documents.pdf-viewer.toolbar.show-sidebar': 'Mostrar painel lateral',
'documents.pdf-viewer.toolbar.previous-page': 'Página anterior',
'documents.pdf-viewer.toolbar.next-page': 'Página seguinte',
'documents.pdf-viewer.toolbar.fit-width': 'Ajustar à largura',
'documents.pdf-viewer.toolbar.fit-page': 'Ajustar à página',
'documents.pdf-viewer.toolbar.rotate-clockwise': 'Rodar no sentido horário',
'documents.pdf-viewer.toolbar.download': 'Transferir',
'documents.pdf-viewer.toolbar.print': 'Imprimir',
'documents.pdf-viewer.zoom.zoom-out': 'Diminuir',
'documents.pdf-viewer.zoom.zoom-in': 'Aumentar',
'documents.pdf-viewer.zoom.auto': 'Automático',
'documents.pdf-viewer.zoom.actual-size': 'Tamanho real',
'documents.pdf-viewer.zoom.page-fit': 'Ajustar à página',
'documents.pdf-viewer.zoom.page-width': 'Largura da página',
'documents.pdf-viewer.more-actions.label': 'Mais ações',
'documents.pdf-viewer.more-actions.presentation-mode': 'Modo de apresentação',
'documents.pdf-viewer.more-actions.download': 'Transferir',
'documents.pdf-viewer.more-actions.print': 'Imprimir',
'documents.pdf-viewer.more-actions.go-to-first-page': 'Ir para a primeira página',
'documents.pdf-viewer.more-actions.go-to-last-page': 'Ir para a última página',
'documents.pdf-viewer.more-actions.rotate-clockwise': 'Rodar no sentido horário',
'documents.pdf-viewer.more-actions.rotate-counterclockwise': 'Rodar no sentido anti-horário',
'documents.pdf-viewer.more-actions.page-scrolling': 'Deslocamento por página',
'documents.pdf-viewer.more-actions.vertical-scrolling': 'Deslocamento vertical',
'documents.pdf-viewer.more-actions.horizontal-scrolling': 'Deslocamento horizontal',
'documents.pdf-viewer.more-actions.wrapped-scrolling': 'Deslocamento contínuo',
'documents.pdf-viewer.more-actions.no-spreads': 'Sem páginas duplas',
'documents.pdf-viewer.more-actions.odd-spreads': 'Páginas duplas ímpares',
'documents.pdf-viewer.more-actions.even-spreads': 'Páginas duplas pares',
'documents.pdf-viewer.more-actions.document-properties': 'Propriedades do documento',
'documents.pdf-viewer.properties.title': 'Propriedades do documento',
'documents.pdf-viewer.properties.na': 'N/D',
'documents.pdf-viewer.properties.file-name': 'Nome do ficheiro',
'documents.pdf-viewer.properties.file-size': 'Tamanho do ficheiro',
'documents.pdf-viewer.properties.doc-title': 'Título',
'documents.pdf-viewer.properties.author': 'Autor',
'documents.pdf-viewer.properties.subject': 'Assunto',
'documents.pdf-viewer.properties.keywords': 'Palavras-chave',
'documents.pdf-viewer.properties.creation-date': 'Data de criação',
'documents.pdf-viewer.properties.modification-date': 'Data de modificação',
'documents.pdf-viewer.properties.creator': 'Criado com',
'documents.pdf-viewer.properties.pdf-producer': 'Produtor PDF',
'documents.pdf-viewer.properties.pdf-version': 'Versão PDF',
'documents.pdf-viewer.properties.page-count': 'Número de páginas',
'documents.pdf-viewer.properties.page-size': 'Tamanho da página',
'documents.pdf-viewer.properties.fast-web-view': 'Visualização web rápida',
'documents.pdf-viewer.properties.yes': 'Sim',
'documents.pdf-viewer.properties.no': 'Não',
'documents.pdf-viewer.sidebar.page-thumbnails': 'Miniaturas das páginas',
'documents.pdf-viewer.sidebar.document-outline': 'Estrutura do documento',
'documents.pdf-viewer.sidebar.attachments': 'Anexos',
'documents.pdf-viewer.thumbnails.page-alt': 'Página {{ page }}',
'trash.delete-all.button': 'Eliminar tudo', 'trash.delete-all.button': 'Eliminar tudo',
'trash.delete-all.confirm.title': 'Eliminar permanentemente todos os documentos?', '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.description': 'Tem a certeza de que quer eliminar permanentemente todos os documentos da reciclagem? Esta ação não pode ser desfeita.',
@ -443,6 +577,10 @@ export const translations: Partial<TranslationsDictionary> = {
'tags.table.headers.documents': 'Documentos', 'tags.table.headers.documents': 'Documentos',
'tags.table.headers.created': 'Criado', 'tags.table.headers.created': 'Criado',
'tags.table.headers.actions': 'Ações', 'tags.table.headers.actions': 'Ações',
'tags.picker.search-placeholder': 'Pesquisar etiquetas...',
'tags.picker.filter-placeholder': 'Filtrar etiquetas...',
'tags.picker.create-new-with-name': 'Criar nova etiqueta "{{ name }}"',
'tags.picker.create-new': 'Criar nova etiqueta',
// Tagging rules // Tagging rules
@ -605,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
'webhooks.create.form.name.label': 'Nome do webhook', 'webhooks.create.form.name.label': 'Nome do webhook',
'webhooks.create.form.name.placeholder': 'Insira o 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.name.required': 'O nome é obrigatório',
'webhooks.create.form.name.max-length': 'O nome deve ter no máximo 128 caracteres',
'webhooks.create.form.url.label': 'URL do Webhook', 'webhooks.create.form.url.label': 'URL do Webhook',
'webhooks.create.form.url.placeholder': 'Insira o 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.required': 'O URL é obrigatória',
@ -639,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
'layout.menu.home': 'Início', 'layout.menu.home': 'Início',
'layout.menu.documents': 'Documentos', 'layout.menu.documents': 'Documentos',
'layout.menu.tags': 'Tags', 'layout.menu.tags': 'Tags',
'layout.menu.custom-properties': 'Propriedades personalizadas',
'layout.menu.tagging-rules': 'Regras de etiquetagem', 'layout.menu.tagging-rules': 'Regras de etiquetagem',
'layout.menu.deleted-documents': 'Documentos eliminados', 'layout.menu.deleted-documents': 'Documentos eliminados',
'layout.menu.organization-settings': 'Definições', 'layout.menu.organization-settings': 'Definições',
@ -668,6 +808,7 @@ export const translations: Partial<TranslationsDictionary> = {
'user-menu.api-keys': 'Chaves API', 'user-menu.api-keys': 'Chaves API',
'user-menu.invitations': 'Convites', 'user-menu.invitations': 'Convites',
'user-menu.language': 'Linguagem', 'user-menu.language': 'Linguagem',
'user-menu.theme': 'Tema',
'user-menu.about': 'Acerca do Papra', 'user-menu.about': 'Acerca do Papra',
'user-menu.logout': 'Sair', 'user-menu.logout': 'Sair',
@ -693,10 +834,12 @@ export const translations: Partial<TranslationsDictionary> = {
'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.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.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', 'api-errors.tags.already_exists': 'Já existe uma etiqueta com este nome nesta organização',
'api-errors.tags.organization_limit_reached': 'O número máximo de etiquetas para esta organização foi atingido.',
'api-errors.internal.error': 'Ocorreu um erro ao processar a solicitação. Por favor, tente novamente.', 'api-errors.internal.error': 'Ocorreu um erro ao processar a solicitação. Por favor, tente novamente.',
'api-errors.auth.invalid_origin': 'Origem da aplicação inválida. Se você está hospedando o Papra, certifique-se de que a variável de ambiente APP_BASE_URL corresponde à sua URL atual. Para mais detalhes, consulte https://docs.papra.app/resources/troubleshooting/#invalid-application-origin', 'api-errors.auth.invalid_origin': 'Origem da aplicação inválida. Se você está hospedando o Papra, certifique-se de que a variável de ambiente APP_BASE_URL corresponde à sua URL atual. Para mais detalhes, consulte https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'O número máximo de membros e convites pendentes para esta organização foi atingido. Atualize o seu plano para adicionar mais membros.', 'api-errors.organization.max_members_count_reached': 'O número máximo de membros e convites pendentes para esta organização foi atingido. Atualize o seu plano para adicionar mais membros.',
'api-errors.organization.has_active_subscription': 'Não é possível eliminar a organização com uma subscrição ativa. Por favor, cancele a sua subscrição primeiro usando o botão Gerir Subscrição acima.', 'api-errors.organization.has_active_subscription': 'Não é possível eliminar a organização com uma subscrição ativa. Por favor, cancele a sua subscrição primeiro usando o botão Gerir Subscrição acima.',
'api-errors.webhooks.ssrf_unsafe_url': 'O URL fornecido não é permitido. Os URLs de webhook não devem apontar para endereços IP privados ou reservados.',
// Better auth api errors // Better auth api errors
'api-errors.USER_NOT_FOUND': 'Utilizador não encontrado', 'api-errors.USER_NOT_FOUND': 'Utilizador não encontrado',
'api-errors.FAILED_TO_CREATE_USER': 'Falha ao criar utilizador', 'api-errors.FAILED_TO_CREATE_USER': 'Falha ao criar utilizador',
@ -752,6 +895,7 @@ export const translations: Partial<TranslationsDictionary> = {
'color-picker.lightness': 'Brilho', 'color-picker.lightness': 'Brilho',
'color-picker.select-color': 'Selecionar cor', 'color-picker.select-color': 'Selecionar cor',
'color-picker.select-a-color': 'Selecione uma cor', 'color-picker.select-a-color': 'Selecione uma cor',
'color-picker.random-color': 'Cor aleatória',
// Subscriptions // Subscriptions

View file

@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
'documents.info.created-at': 'Creat la', 'documents.info.created-at': 'Creat la',
'documents.info.updated-at': 'Actualizat la', 'documents.info.updated-at': 'Actualizat la',
'documents.info.never': 'Niciodată', 'documents.info.never': 'Niciodată',
'documents.info.document-date': 'Data',
'documents.info.no-date': 'Fără dată',
'documents.info.today': 'Astăzi',
'custom-properties.types.text': 'Text',
'custom-properties.types.number': 'Număr',
'custom-properties.types.date': 'Dată',
'custom-properties.types.boolean': 'Boolean',
'custom-properties.types.select': 'Selectare',
'custom-properties.types.multi_select': 'Selectare multiplă',
'custom-properties.types.user_relation': 'Utilizator',
'custom-properties.types.document_relation': 'Document',
'custom-properties.list.title': 'Proprietăți personalizate',
'custom-properties.list.description': 'Definiți câmpuri de metadate personalizate pentru documentele dvs. Proprietățile pot fi text, numere, date, valori booleene sau liste de selectare.',
'custom-properties.list.create-button': 'Creare proprietate',
'custom-properties.list.empty.title': 'Proprietăți personalizate',
'custom-properties.list.empty.description': 'Proprietățile personalizate vă permit să adăugați metadate structurate la documentele dvs., precum date de expirare, nume de companii sau sume.',
'custom-properties.list.table.name': 'Nume',
'custom-properties.list.table.type': 'Tip',
'custom-properties.list.table.description': 'Descriere',
'custom-properties.list.table.created': 'Creat',
'custom-properties.list.table.actions': 'Acțiuni',
'custom-properties.list.table.no-description': 'Fără descriere',
'custom-properties.list.delete.confirm-title': 'Ștergere proprietate personalizată',
'custom-properties.list.delete.confirm-message': 'Sigur doriți să ștergeți proprietatea personalizată „{{ name }}”? Această acțiune nu poate fi anulată.',
'custom-properties.list.delete.confirm-button': 'Șterge',
'custom-properties.list.delete.success': 'Proprietatea personalizată a fost ștearsă cu succes',
'custom-properties.list.delete.error': 'Ștergerea proprietății personalizate a eșuat',
'custom-properties.create.title': 'Creare proprietate personalizată',
'custom-properties.create.submit': 'Creare proprietate',
'custom-properties.create.success': 'Proprietatea personalizată a fost creată cu succes',
'custom-properties.create.error': 'Crearea proprietății personalizate a eșuat',
'custom-properties.update.title': 'Editare proprietate personalizată',
'custom-properties.update.submit': 'Salvare modificări',
'custom-properties.update.success': 'Proprietatea personalizată a fost actualizată cu succes',
'custom-properties.update.error': 'Actualizarea proprietății personalizate a eșuat',
'custom-properties.form.name.label': 'Nume',
'custom-properties.form.name.placeholder': 'ex.: Suma facturii',
'custom-properties.form.name.required': 'Numele este obligatoriu',
'custom-properties.form.name.max-length': 'Numele trebuie să aibă cel mult 255 de caractere',
'custom-properties.form.description.label': 'Descriere',
'custom-properties.form.description.optional': '(opțional)',
'custom-properties.form.description.placeholder': 'Descrieți la ce servește această proprietate',
'custom-properties.form.description.max-length': 'Descrierea trebuie să aibă cel mult 1000 de caractere',
'custom-properties.form.type.label': 'Tip',
'custom-properties.form.type.immutable': 'Tipul proprietății nu poate fi modificat după creare.',
'custom-properties.form.options.title': 'Opțiuni',
'custom-properties.form.options.description': 'Definiți opțiunile disponibile pentru această proprietate.',
'custom-properties.form.options.name.placeholder': 'Denumire opțiune',
'custom-properties.form.options.name.required': 'Denumirea opțiunii este obligatorie',
'custom-properties.form.options.name.max-length': 'Denumirea opțiunii trebuie să aibă cel mult 255 de caractere',
'custom-properties.form.options.validation.required': 'Adăugați cel puțin o opțiune',
'custom-properties.form.options.add': 'Adăugare opțiune',
'custom-properties.form.cancel': 'Anulare',
'custom-properties.form.save-error': 'A apărut o eroare la salvarea definiției proprietății. Vă rugăm să încercați din nou.',
'documents.custom-properties.section-title': 'Proprietăți',
'documents.custom-properties.no-value': 'Nesetat',
'documents.custom-properties.text-placeholder': 'Introduceți o valoare...',
'documents.custom-properties.save': 'Salvare',
'documents.custom-properties.clear': 'Golire',
'documents.custom-properties.document-relation-search-placeholder': 'Căutare documente...',
'documents.custom-properties.user-relation-manage': 'Gestionare utilizatori',
'documents.custom-properties.document-relation-manage': 'Gestionare documente',
'documents.custom-properties.no-results': 'Niciun rezultat',
'documents.rename.title': 'Redenumește documentul', 'documents.rename.title': 'Redenumește documentul',
'documents.rename.form.name.label': 'Nume', 'documents.rename.form.name.label': 'Nume',
@ -381,6 +450,71 @@ export const translations: Partial<TranslationsDictionary> = {
'documents.preview.unknown-file-type': 'Nicio previzualizare disponibilă pentru acest tip de fișier', 'documents.preview.unknown-file-type': 'Nicio previzualizare disponibilă pentru acest tip de fișier',
'documents.preview.binary-file': 'Acesta pare a fi un fișier binar și nu poate fi afișat ca text', 'documents.preview.binary-file': 'Acesta pare a fi un fișier binar și nu poate fi afișat ca text',
'documents.open-with.label': 'Deschide cu',
'documents.open-with.pdf-viewer': 'Vizualizator PDF',
'documents.pdf-viewer.loading': 'Se încarcă PDF-ul',
'documents.pdf-viewer.not-a-pdf': 'Acest document nu este un PDF și nu poate fi deschis în vizualizatorul PDF.',
'documents.pdf-viewer.toolbar.hide-sidebar': 'Ascunde panoul lateral',
'documents.pdf-viewer.toolbar.show-sidebar': 'Afișează panoul lateral',
'documents.pdf-viewer.toolbar.previous-page': 'Pagina anterioară',
'documents.pdf-viewer.toolbar.next-page': 'Pagina următoare',
'documents.pdf-viewer.toolbar.fit-width': 'Potrivire la lățime',
'documents.pdf-viewer.toolbar.fit-page': 'Potrivire la pagină',
'documents.pdf-viewer.toolbar.rotate-clockwise': 'Rotire în sensul acelor de ceasornic',
'documents.pdf-viewer.toolbar.download': 'Descarcă',
'documents.pdf-viewer.toolbar.print': 'Tipărește',
'documents.pdf-viewer.zoom.zoom-out': 'Micșorează',
'documents.pdf-viewer.zoom.zoom-in': 'Mărește',
'documents.pdf-viewer.zoom.auto': 'Automat',
'documents.pdf-viewer.zoom.actual-size': 'Dimensiune reală',
'documents.pdf-viewer.zoom.page-fit': 'Potrivire la pagină',
'documents.pdf-viewer.zoom.page-width': 'Lățimea paginii',
'documents.pdf-viewer.more-actions.label': 'Mai multe acțiuni',
'documents.pdf-viewer.more-actions.presentation-mode': 'Mod prezentare',
'documents.pdf-viewer.more-actions.download': 'Descarcă',
'documents.pdf-viewer.more-actions.print': 'Tipărește',
'documents.pdf-viewer.more-actions.go-to-first-page': 'Mergi la prima pagină',
'documents.pdf-viewer.more-actions.go-to-last-page': 'Mergi la ultima pagină',
'documents.pdf-viewer.more-actions.rotate-clockwise': 'Rotire în sensul acelor de ceasornic',
'documents.pdf-viewer.more-actions.rotate-counterclockwise': 'Rotire în sens invers acelor de ceasornic',
'documents.pdf-viewer.more-actions.page-scrolling': 'Derulare pe pagini',
'documents.pdf-viewer.more-actions.vertical-scrolling': 'Derulare verticală',
'documents.pdf-viewer.more-actions.horizontal-scrolling': 'Derulare orizontală',
'documents.pdf-viewer.more-actions.wrapped-scrolling': 'Derulare continuă',
'documents.pdf-viewer.more-actions.no-spreads': 'Fără pagini duble',
'documents.pdf-viewer.more-actions.odd-spreads': 'Pagini duble impare',
'documents.pdf-viewer.more-actions.even-spreads': 'Pagini duble pare',
'documents.pdf-viewer.more-actions.document-properties': 'Proprietățile documentului',
'documents.pdf-viewer.properties.title': 'Proprietățile documentului',
'documents.pdf-viewer.properties.na': 'N/D',
'documents.pdf-viewer.properties.file-name': 'Numele fișierului',
'documents.pdf-viewer.properties.file-size': 'Dimensiunea fișierului',
'documents.pdf-viewer.properties.doc-title': 'Titlu',
'documents.pdf-viewer.properties.author': 'Autor',
'documents.pdf-viewer.properties.subject': 'Subiect',
'documents.pdf-viewer.properties.keywords': 'Cuvinte cheie',
'documents.pdf-viewer.properties.creation-date': 'Data creării',
'documents.pdf-viewer.properties.modification-date': 'Data modificării',
'documents.pdf-viewer.properties.creator': 'Creat cu',
'documents.pdf-viewer.properties.pdf-producer': 'Producător PDF',
'documents.pdf-viewer.properties.pdf-version': 'Versiune PDF',
'documents.pdf-viewer.properties.page-count': 'Număr de pagini',
'documents.pdf-viewer.properties.page-size': 'Dimensiunea paginii',
'documents.pdf-viewer.properties.fast-web-view': 'Vizualizare web rapidă',
'documents.pdf-viewer.properties.yes': 'Da',
'documents.pdf-viewer.properties.no': 'Nu',
'documents.pdf-viewer.sidebar.page-thumbnails': 'Miniaturi pagini',
'documents.pdf-viewer.sidebar.document-outline': 'Structura documentului',
'documents.pdf-viewer.sidebar.attachments': 'Atașamente',
'documents.pdf-viewer.thumbnails.page-alt': 'Pagina {{ page }}',
'trash.delete-all.button': 'Șterge tot', 'trash.delete-all.button': 'Șterge tot',
'trash.delete-all.confirm.title': 'Ștergi definitiv toate documentele?', 'trash.delete-all.confirm.title': 'Ștergi definitiv toate documentele?',
'trash.delete-all.confirm.description': 'Ești sigur că dorești să ștergi definitiv toate documentele din coșul de gunoi? Această acțiune nu poate fi anulată.', 'trash.delete-all.confirm.description': 'Ești sigur că dorești să ștergi definitiv toate documentele din coșul de gunoi? Această acțiune nu poate fi anulată.',
@ -443,6 +577,10 @@ export const translations: Partial<TranslationsDictionary> = {
'tags.table.headers.documents': 'Documente', 'tags.table.headers.documents': 'Documente',
'tags.table.headers.created': 'Creat la', 'tags.table.headers.created': 'Creat la',
'tags.table.headers.actions': 'Acțiuni', 'tags.table.headers.actions': 'Acțiuni',
'tags.picker.search-placeholder': 'Caută etichete...',
'tags.picker.filter-placeholder': 'Filtrează etichete...',
'tags.picker.create-new-with-name': 'Creează etichetă nouă "{{ name }}"',
'tags.picker.create-new': 'Creează etichetă nouă',
// Tagging rules // Tagging rules
@ -605,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
'webhooks.create.form.name.label': 'Nume webhook', 'webhooks.create.form.name.label': 'Nume webhook',
'webhooks.create.form.name.placeholder': 'Introdu numele webhook-ului', 'webhooks.create.form.name.placeholder': 'Introdu numele webhook-ului',
'webhooks.create.form.name.required': 'Numele este obligatoriu', 'webhooks.create.form.name.required': 'Numele este obligatoriu',
'webhooks.create.form.name.max-length': 'Numele trebuie să aibă cel mult 128 de caractere',
'webhooks.create.form.url.label': 'URL webhook', 'webhooks.create.form.url.label': 'URL webhook',
'webhooks.create.form.url.placeholder': 'Introdu URL-ul webhook-ului', 'webhooks.create.form.url.placeholder': 'Introdu URL-ul webhook-ului',
'webhooks.create.form.url.required': 'URL-ul este obligatoriu', 'webhooks.create.form.url.required': 'URL-ul este obligatoriu',
@ -639,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
'layout.menu.home': 'Acasă', 'layout.menu.home': 'Acasă',
'layout.menu.documents': 'Documente', 'layout.menu.documents': 'Documente',
'layout.menu.tags': 'Etichete', 'layout.menu.tags': 'Etichete',
'layout.menu.custom-properties': 'Proprietăți personalizate',
'layout.menu.tagging-rules': 'Reguli de etichetare', 'layout.menu.tagging-rules': 'Reguli de etichetare',
'layout.menu.deleted-documents': 'Documente șterse', 'layout.menu.deleted-documents': 'Documente șterse',
'layout.menu.organization-settings': 'Setări organizație', 'layout.menu.organization-settings': 'Setări organizație',
@ -668,6 +808,7 @@ export const translations: Partial<TranslationsDictionary> = {
'user-menu.api-keys': 'Chei API', 'user-menu.api-keys': 'Chei API',
'user-menu.invitations': 'Invitații', 'user-menu.invitations': 'Invitații',
'user-menu.language': 'Limbă', 'user-menu.language': 'Limbă',
'user-menu.theme': 'Temă',
'user-menu.about': 'Despre Papra', 'user-menu.about': 'Despre Papra',
'user-menu.logout': 'Deconectare', 'user-menu.logout': 'Deconectare',
@ -693,10 +834,12 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.user.organization_invitation_limit_reached': 'Numărul maxim de invitații a fost atins pentru astazi. Te rugăm să încerci din nou mâine.', 'api-errors.user.organization_invitation_limit_reached': 'Numărul maxim de invitații a fost atins pentru astazi. Te rugăm să încerci din nou mâine.',
'api-errors.demo.not_available': 'Această functie nu este disponibila în demo', 'api-errors.demo.not_available': 'Această functie nu este disponibila în demo',
'api-errors.tags.already_exists': 'O etichetă cu acest nume există deja pentru aceasta organizație', 'api-errors.tags.already_exists': 'O etichetă cu acest nume există deja pentru aceasta organizație',
'api-errors.tags.organization_limit_reached': 'Numărul maxim de etichete pentru această organizație a fost atins.',
'api-errors.internal.error': 'A apărut o eroare la procesarea cererii. Te rugăm să încerci din nou.', 'api-errors.internal.error': 'A apărut o eroare la procesarea cererii. Te rugăm să încerci din nou.',
'api-errors.auth.invalid_origin': 'Origine invalidă a aplicației. Dacă hospedezi Papra, asigură-te că variabila de mediu APP_BASE_URL corespunde URL-ului actual. Pentru mai multe detalii, consulta https://docs.papra.app/resources/troubleshooting/#invalid-application-origin', 'api-errors.auth.invalid_origin': 'Origine invalidă a aplicației. Dacă hospedezi Papra, asigură-te că variabila de mediu APP_BASE_URL corespunde URL-ului actual. Pentru mai multe detalii, consulta https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'Numărul maxim de membri și invitații în așteptare pentru această organizație a fost atins. Te rugăm să îți actualizezi planul pentru a adăuga mai mulți membri.', 'api-errors.organization.max_members_count_reached': 'Numărul maxim de membri și invitații în așteptare pentru această organizație a fost atins. Te rugăm să îți actualizezi planul pentru a adăuga mai mulți membri.',
'api-errors.organization.has_active_subscription': 'Nu se poate șterge organizația cu un abonament activ. Vă rugăm să anulați mai întâi abonamentul folosind butonul Gestionați abonamentul de mai sus.', 'api-errors.organization.has_active_subscription': 'Nu se poate șterge organizația cu un abonament activ. Vă rugăm să anulați mai întâi abonamentul folosind butonul Gestionați abonamentul de mai sus.',
'api-errors.webhooks.ssrf_unsafe_url': 'URL-ul furnizat nu este permis. URL-urile webhook nu trebuie să indice spre adrese IP private sau rezervate.',
// Better auth api errors // Better auth api errors
'api-errors.USER_NOT_FOUND': 'Utilizatorul nu a fost găsit', 'api-errors.USER_NOT_FOUND': 'Utilizatorul nu a fost găsit',
'api-errors.FAILED_TO_CREATE_USER': 'Eroare la crearea utilizatorului', 'api-errors.FAILED_TO_CREATE_USER': 'Eroare la crearea utilizatorului',
@ -752,6 +895,7 @@ export const translations: Partial<TranslationsDictionary> = {
'color-picker.lightness': 'Luminozitate', 'color-picker.lightness': 'Luminozitate',
'color-picker.select-color': 'Selectează culoarea', 'color-picker.select-color': 'Selectează culoarea',
'color-picker.select-a-color': 'Selectează o culoare', 'color-picker.select-a-color': 'Selectează o culoare',
'color-picker.random-color': 'Culoare aleatorie',
// Subscriptions // Subscriptions

View file

@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
'documents.info.created-at': 'Создан', 'documents.info.created-at': 'Создан',
'documents.info.updated-at': 'Обновлён', 'documents.info.updated-at': 'Обновлён',
'documents.info.never': 'Никогда', 'documents.info.never': 'Никогда',
'documents.info.document-date': 'Дата',
'documents.info.no-date': 'Нет даты',
'documents.info.today': 'Сегодня',
'custom-properties.types.text': 'Текст',
'custom-properties.types.number': 'Число',
'custom-properties.types.date': 'Дата',
'custom-properties.types.boolean': 'Логическое значение',
'custom-properties.types.select': 'Выбор',
'custom-properties.types.multi_select': 'Множественный выбор',
'custom-properties.types.user_relation': 'Пользователь',
'custom-properties.types.document_relation': 'Документ',
'custom-properties.list.title': 'Пользовательские свойства',
'custom-properties.list.description': 'Задайте пользовательские поля метаданных для ваших документов. Свойства могут быть текстом, числами, датами, логическими значениями или списками выбора.',
'custom-properties.list.create-button': 'Создать свойство',
'custom-properties.list.empty.title': 'Пользовательские свойства',
'custom-properties.list.empty.description': 'Пользовательские свойства позволяют добавлять структурированные метаданные к документам, например даты истечения срока, названия компаний или суммы.',
'custom-properties.list.table.name': 'Название',
'custom-properties.list.table.type': 'Тип',
'custom-properties.list.table.description': 'Описание',
'custom-properties.list.table.created': 'Создано',
'custom-properties.list.table.actions': 'Действия',
'custom-properties.list.table.no-description': 'Нет описания',
'custom-properties.list.delete.confirm-title': 'Удалить пользовательское свойство',
'custom-properties.list.delete.confirm-message': 'Вы уверены, что хотите удалить пользовательское свойство «{{ name }}»? Это действие нельзя отменить.',
'custom-properties.list.delete.confirm-button': 'Удалить',
'custom-properties.list.delete.success': 'Пользовательское свойство успешно удалено',
'custom-properties.list.delete.error': 'Не удалось удалить пользовательское свойство',
'custom-properties.create.title': 'Создание пользовательского свойства',
'custom-properties.create.submit': 'Создать свойство',
'custom-properties.create.success': 'Пользовательское свойство успешно создано',
'custom-properties.create.error': 'Не удалось создать пользовательское свойство',
'custom-properties.update.title': 'Изменение пользовательского свойства',
'custom-properties.update.submit': 'Сохранить изменения',
'custom-properties.update.success': 'Пользовательское свойство успешно обновлено',
'custom-properties.update.error': 'Не удалось обновить пользовательское свойство',
'custom-properties.form.name.label': 'Название',
'custom-properties.form.name.placeholder': 'например, Сумма счёта',
'custom-properties.form.name.required': 'Название обязательно',
'custom-properties.form.name.max-length': 'Название должно содержать не более 255 символов',
'custom-properties.form.description.label': 'Описание',
'custom-properties.form.description.optional': '(необязательно)',
'custom-properties.form.description.placeholder': 'Опишите, для чего используется это свойство',
'custom-properties.form.description.max-length': 'Описание должно содержать не более 1000 символов',
'custom-properties.form.type.label': 'Тип',
'custom-properties.form.type.immutable': 'Тип свойства нельзя изменить после создания.',
'custom-properties.form.options.title': 'Варианты',
'custom-properties.form.options.description': 'Задайте доступные варианты для этого свойства.',
'custom-properties.form.options.name.placeholder': 'Название варианта',
'custom-properties.form.options.name.required': 'Название варианта обязательно',
'custom-properties.form.options.name.max-length': 'Название варианта должно содержать не более 255 символов',
'custom-properties.form.options.validation.required': 'Добавьте хотя бы один вариант',
'custom-properties.form.options.add': 'Добавить вариант',
'custom-properties.form.cancel': 'Отмена',
'custom-properties.form.save-error': 'Произошла ошибка при сохранении определения свойства. Пожалуйста, попробуйте ещё раз.',
'documents.custom-properties.section-title': 'Свойства',
'documents.custom-properties.no-value': 'Не задано',
'documents.custom-properties.text-placeholder': 'Введите значение...',
'documents.custom-properties.save': 'Сохранить',
'documents.custom-properties.clear': 'Очистить',
'documents.custom-properties.document-relation-search-placeholder': 'Поиск документов...',
'documents.custom-properties.user-relation-manage': 'Управление пользователями',
'documents.custom-properties.document-relation-manage': 'Управление документами',
'documents.custom-properties.no-results': 'Нет результатов',
'documents.rename.title': 'Переименовывание документа', 'documents.rename.title': 'Переименовывание документа',
'documents.rename.form.name.label': 'Имя', 'documents.rename.form.name.label': 'Имя',
@ -381,6 +450,71 @@ export const translations: Partial<TranslationsDictionary> = {
'documents.preview.unknown-file-type': 'Предпросмотр для этого типа файлов недоступен', 'documents.preview.unknown-file-type': 'Предпросмотр для этого типа файлов недоступен',
'documents.preview.binary-file': 'Это двоичный файл, который не может быть отображён как текст', 'documents.preview.binary-file': 'Это двоичный файл, который не может быть отображён как текст',
'documents.open-with.label': 'Открыть с помощью',
'documents.open-with.pdf-viewer': 'Просмотр PDF',
'documents.pdf-viewer.loading': 'Загрузка PDF',
'documents.pdf-viewer.not-a-pdf': 'Этот документ не является PDF и не может быть открыт в просмотрщике PDF.',
'documents.pdf-viewer.toolbar.hide-sidebar': 'Скрыть боковую панель',
'documents.pdf-viewer.toolbar.show-sidebar': 'Показать боковую панель',
'documents.pdf-viewer.toolbar.previous-page': 'Предыдущая страница',
'documents.pdf-viewer.toolbar.next-page': 'Следующая страница',
'documents.pdf-viewer.toolbar.fit-width': 'По ширине',
'documents.pdf-viewer.toolbar.fit-page': 'По размеру страницы',
'documents.pdf-viewer.toolbar.rotate-clockwise': 'Повернуть по часовой стрелке',
'documents.pdf-viewer.toolbar.download': 'Скачать',
'documents.pdf-viewer.toolbar.print': 'Печать',
'documents.pdf-viewer.zoom.zoom-out': 'Уменьшить',
'documents.pdf-viewer.zoom.zoom-in': 'Увеличить',
'documents.pdf-viewer.zoom.auto': 'Авто',
'documents.pdf-viewer.zoom.actual-size': 'Реальный размер',
'documents.pdf-viewer.zoom.page-fit': 'По размеру страницы',
'documents.pdf-viewer.zoom.page-width': 'По ширине страницы',
'documents.pdf-viewer.more-actions.label': 'Ещё',
'documents.pdf-viewer.more-actions.presentation-mode': 'Режим презентации',
'documents.pdf-viewer.more-actions.download': 'Скачать',
'documents.pdf-viewer.more-actions.print': 'Печать',
'documents.pdf-viewer.more-actions.go-to-first-page': 'Перейти к первой странице',
'documents.pdf-viewer.more-actions.go-to-last-page': 'Перейти к последней странице',
'documents.pdf-viewer.more-actions.rotate-clockwise': 'Повернуть по часовой стрелке',
'documents.pdf-viewer.more-actions.rotate-counterclockwise': 'Повернуть против часовой стрелки',
'documents.pdf-viewer.more-actions.page-scrolling': 'Постраничная прокрутка',
'documents.pdf-viewer.more-actions.vertical-scrolling': 'Вертикальная прокрутка',
'documents.pdf-viewer.more-actions.horizontal-scrolling': 'Горизонтальная прокрутка',
'documents.pdf-viewer.more-actions.wrapped-scrolling': 'Непрерывная прокрутка',
'documents.pdf-viewer.more-actions.no-spreads': 'Без разворотов',
'documents.pdf-viewer.more-actions.odd-spreads': 'Нечётные развороты',
'documents.pdf-viewer.more-actions.even-spreads': 'Чётные развороты',
'documents.pdf-viewer.more-actions.document-properties': 'Свойства документа',
'documents.pdf-viewer.properties.title': 'Свойства документа',
'documents.pdf-viewer.properties.na': 'Н/Д',
'documents.pdf-viewer.properties.file-name': 'Имя файла',
'documents.pdf-viewer.properties.file-size': 'Размер файла',
'documents.pdf-viewer.properties.doc-title': 'Название',
'documents.pdf-viewer.properties.author': 'Автор',
'documents.pdf-viewer.properties.subject': 'Тема',
'documents.pdf-viewer.properties.keywords': 'Ключевые слова',
'documents.pdf-viewer.properties.creation-date': 'Дата создания',
'documents.pdf-viewer.properties.modification-date': 'Дата изменения',
'documents.pdf-viewer.properties.creator': 'Создано в',
'documents.pdf-viewer.properties.pdf-producer': 'Создатель PDF',
'documents.pdf-viewer.properties.pdf-version': 'Версия PDF',
'documents.pdf-viewer.properties.page-count': 'Количество страниц',
'documents.pdf-viewer.properties.page-size': 'Размер страницы',
'documents.pdf-viewer.properties.fast-web-view': 'Быстрый просмотр в браузере',
'documents.pdf-viewer.properties.yes': 'Да',
'documents.pdf-viewer.properties.no': 'Нет',
'documents.pdf-viewer.sidebar.page-thumbnails': 'Миниатюры страниц',
'documents.pdf-viewer.sidebar.document-outline': 'Структура документа',
'documents.pdf-viewer.sidebar.attachments': 'Вложения',
'documents.pdf-viewer.thumbnails.page-alt': 'Страница {{ page }}',
'trash.delete-all.button': 'Удалить всё', 'trash.delete-all.button': 'Удалить всё',
'trash.delete-all.confirm.title': 'Окончательно удалить все документы?', 'trash.delete-all.confirm.title': 'Окончательно удалить все документы?',
'trash.delete-all.confirm.description': 'Вы уверены, что хотите окончательно удалить все документы из корзины? Это действие нельзя отменить.', 'trash.delete-all.confirm.description': 'Вы уверены, что хотите окончательно удалить все документы из корзины? Это действие нельзя отменить.',
@ -443,6 +577,10 @@ export const translations: Partial<TranslationsDictionary> = {
'tags.table.headers.documents': 'Документы', 'tags.table.headers.documents': 'Документы',
'tags.table.headers.created': 'Создан', 'tags.table.headers.created': 'Создан',
'tags.table.headers.actions': 'Действия', 'tags.table.headers.actions': 'Действия',
'tags.picker.search-placeholder': 'Искать теги...',
'tags.picker.filter-placeholder': 'Фильтровать теги...',
'tags.picker.create-new-with-name': 'Создать новый тег "{{ name }}"',
'tags.picker.create-new': 'Создать новый тег',
// Tagging rules // Tagging rules
@ -605,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
'webhooks.create.form.name.label': 'Название вебхука', 'webhooks.create.form.name.label': 'Название вебхука',
'webhooks.create.form.name.placeholder': 'Введите название вебхука', 'webhooks.create.form.name.placeholder': 'Введите название вебхука',
'webhooks.create.form.name.required': 'Название обязательно', 'webhooks.create.form.name.required': 'Название обязательно',
'webhooks.create.form.name.max-length': 'Название должно содержать не более 128 символов',
'webhooks.create.form.url.label': 'URL вебхука', 'webhooks.create.form.url.label': 'URL вебхука',
'webhooks.create.form.url.placeholder': 'Введите URL вебхука', 'webhooks.create.form.url.placeholder': 'Введите URL вебхука',
'webhooks.create.form.url.required': 'URL обязателен', 'webhooks.create.form.url.required': 'URL обязателен',
@ -639,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
'layout.menu.home': 'Главная', 'layout.menu.home': 'Главная',
'layout.menu.documents': 'Документы', 'layout.menu.documents': 'Документы',
'layout.menu.tags': 'Теги', 'layout.menu.tags': 'Теги',
'layout.menu.custom-properties': 'Пользовательские свойства',
'layout.menu.tagging-rules': 'Правила тегирования', 'layout.menu.tagging-rules': 'Правила тегирования',
'layout.menu.deleted-documents': 'Удалённые документы', 'layout.menu.deleted-documents': 'Удалённые документы',
'layout.menu.organization-settings': 'Настройки', 'layout.menu.organization-settings': 'Настройки',
@ -668,6 +808,7 @@ export const translations: Partial<TranslationsDictionary> = {
'user-menu.api-keys': 'API ключи', 'user-menu.api-keys': 'API ключи',
'user-menu.invitations': 'Приглашения', 'user-menu.invitations': 'Приглашения',
'user-menu.language': 'Язык', 'user-menu.language': 'Язык',
'user-menu.theme': 'Тема',
'user-menu.about': 'Информация', 'user-menu.about': 'Информация',
'user-menu.logout': 'Выйти', 'user-menu.logout': 'Выйти',
@ -693,10 +834,12 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.user.organization_invitation_limit_reached': 'Достигнуто максимальное количество приглашений на сегодня. Пожалуйста, попробуйте снова завтра.', 'api-errors.user.organization_invitation_limit_reached': 'Достигнуто максимальное количество приглашений на сегодня. Пожалуйста, попробуйте снова завтра.',
'api-errors.demo.not_available': 'Эта функция недоступна в демо', 'api-errors.demo.not_available': 'Эта функция недоступна в демо',
'api-errors.tags.already_exists': 'Тег с таким именем уже существует в этой организации', 'api-errors.tags.already_exists': 'Тег с таким именем уже существует в этой организации',
'api-errors.tags.organization_limit_reached': 'Достигнуто максимальное количество тегов для этой организации.',
'api-errors.internal.error': 'Произошла ошибка при обработке вашего запроса. Пожалуйста, попробуйте позже.', 'api-errors.internal.error': 'Произошла ошибка при обработке вашего запроса. Пожалуйста, попробуйте позже.',
'api-errors.auth.invalid_origin': 'Неверный origin приложения. Если вы используете Papra на собственном хостинге, убедитесь, что переменная окружения APP_BASE_URL соответствует вашему текущему URL. Для получения дополнительной информации см. https://docs.papra.app/resources/troubleshooting/#invalid-application-origin', 'api-errors.auth.invalid_origin': 'Неверный origin приложения. Если вы используете Papra на собственном хостинге, убедитесь, что переменная окружения APP_BASE_URL соответствует вашему текущему URL. Для получения дополнительной информации см. https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': 'Достигнуто максимальное количество участников и ожидающих приглашений для этой организации. Пожалуйста, обновите ваш план, чтобы добавить больше участников.', 'api-errors.organization.max_members_count_reached': 'Достигнуто максимальное количество участников и ожидающих приглашений для этой организации. Пожалуйста, обновите ваш план, чтобы добавить больше участников.',
'api-errors.organization.has_active_subscription': 'Невозможно удалить организацию с активной подпиской. Пожалуйста, сначала отмените подписку, используя кнопку "Управление подпиской" выше.', 'api-errors.organization.has_active_subscription': 'Невозможно удалить организацию с активной подпиской. Пожалуйста, сначала отмените подписку, используя кнопку "Управление подпиской" выше.',
'api-errors.webhooks.ssrf_unsafe_url': 'Указанный URL не разрешён. URL-адреса вебхуков не должны указывать на частные или зарезервированные IP-адреса.',
// Better auth api errors // Better auth api errors
'api-errors.USER_NOT_FOUND': 'Пользователь не найден', 'api-errors.USER_NOT_FOUND': 'Пользователь не найден',
'api-errors.FAILED_TO_CREATE_USER': 'Не удалось создать пользователя', 'api-errors.FAILED_TO_CREATE_USER': 'Не удалось создать пользователя',
@ -752,6 +895,7 @@ export const translations: Partial<TranslationsDictionary> = {
'color-picker.lightness': 'Яркость', 'color-picker.lightness': 'Яркость',
'color-picker.select-color': 'Выбрать цвет', 'color-picker.select-color': 'Выбрать цвет',
'color-picker.select-a-color': 'Выберите цвет', 'color-picker.select-a-color': 'Выберите цвет',
'color-picker.random-color': 'Случайный цвет',
// Subscriptions // Subscriptions

File diff suppressed because it is too large Load diff

View file

@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
'documents.info.created-at': '创建时间', 'documents.info.created-at': '创建时间',
'documents.info.updated-at': '更新时间', 'documents.info.updated-at': '更新时间',
'documents.info.never': '从不', 'documents.info.never': '从不',
'documents.info.document-date': '日期',
'documents.info.no-date': '无日期',
'documents.info.today': '今天',
'custom-properties.types.text': '文本',
'custom-properties.types.number': '数字',
'custom-properties.types.date': '日期',
'custom-properties.types.boolean': '布尔值',
'custom-properties.types.select': '单选',
'custom-properties.types.multi_select': '多选',
'custom-properties.types.user_relation': '用户',
'custom-properties.types.document_relation': '文档',
'custom-properties.list.title': '自定义属性',
'custom-properties.list.description': '为您的文档定义自定义元数据字段。属性可以是文本、数字、日期、布尔值或选择列表。',
'custom-properties.list.create-button': '创建属性',
'custom-properties.list.empty.title': '自定义属性',
'custom-properties.list.empty.description': '自定义属性让您可以为文档添加结构化元数据,如到期日期、公司名称或金额。',
'custom-properties.list.table.name': '名称',
'custom-properties.list.table.type': '类型',
'custom-properties.list.table.description': '描述',
'custom-properties.list.table.created': '创建时间',
'custom-properties.list.table.actions': '操作',
'custom-properties.list.table.no-description': '无描述',
'custom-properties.list.delete.confirm-title': '删除自定义属性',
'custom-properties.list.delete.confirm-message': '确定要删除自定义属性"{{ name }}"吗?此操作无法撤销。',
'custom-properties.list.delete.confirm-button': '删除',
'custom-properties.list.delete.success': '自定义属性已成功删除',
'custom-properties.list.delete.error': '删除自定义属性失败',
'custom-properties.create.title': '创建自定义属性',
'custom-properties.create.submit': '创建属性',
'custom-properties.create.success': '自定义属性已成功创建',
'custom-properties.create.error': '创建自定义属性失败',
'custom-properties.update.title': '编辑自定义属性',
'custom-properties.update.submit': '保存更改',
'custom-properties.update.success': '自定义属性已成功更新',
'custom-properties.update.error': '更新自定义属性失败',
'custom-properties.form.name.label': '名称',
'custom-properties.form.name.placeholder': '例如:发票金额',
'custom-properties.form.name.required': '名称为必填项',
'custom-properties.form.name.max-length': '名称最多为 255 个字符',
'custom-properties.form.description.label': '描述',
'custom-properties.form.description.optional': '(可选)',
'custom-properties.form.description.placeholder': '描述此属性的用途',
'custom-properties.form.description.max-length': '描述最多为 1000 个字符',
'custom-properties.form.type.label': '类型',
'custom-properties.form.type.immutable': '属性类型创建后不可更改。',
'custom-properties.form.options.title': '选项',
'custom-properties.form.options.description': '定义此属性的可选值。',
'custom-properties.form.options.name.placeholder': '选项名称',
'custom-properties.form.options.name.required': '选项名称为必填项',
'custom-properties.form.options.name.max-length': '选项名称最多为 255 个字符',
'custom-properties.form.options.validation.required': '请至少添加一个选项',
'custom-properties.form.options.add': '添加选项',
'custom-properties.form.cancel': '取消',
'custom-properties.form.save-error': '保存属性定义时发生错误,请重试。',
'documents.custom-properties.section-title': '属性',
'documents.custom-properties.no-value': '未设置',
'documents.custom-properties.text-placeholder': '输入值...',
'documents.custom-properties.save': '保存',
'documents.custom-properties.clear': '清除',
'documents.custom-properties.document-relation-search-placeholder': '搜索文档...',
'documents.custom-properties.user-relation-manage': '管理用户',
'documents.custom-properties.document-relation-manage': '管理文档',
'documents.custom-properties.no-results': '无结果',
'documents.rename.title': '重命名文档', 'documents.rename.title': '重命名文档',
'documents.rename.form.name.label': '名称', 'documents.rename.form.name.label': '名称',
@ -381,6 +450,71 @@ export const translations: Partial<TranslationsDictionary> = {
'documents.preview.unknown-file-type': '此文件类型暂无预览', 'documents.preview.unknown-file-type': '此文件类型暂无预览',
'documents.preview.binary-file': '该文件似乎是二进制文件,无法以文本形式显示', 'documents.preview.binary-file': '该文件似乎是二进制文件,无法以文本形式显示',
'documents.open-with.label': '打开方式',
'documents.open-with.pdf-viewer': 'PDF 查看器',
'documents.pdf-viewer.loading': '正在加载 PDF',
'documents.pdf-viewer.not-a-pdf': '此文档不是 PDF无法在 PDF 查看器中打开。',
'documents.pdf-viewer.toolbar.hide-sidebar': '隐藏侧栏',
'documents.pdf-viewer.toolbar.show-sidebar': '显示侧栏',
'documents.pdf-viewer.toolbar.previous-page': '上一页',
'documents.pdf-viewer.toolbar.next-page': '下一页',
'documents.pdf-viewer.toolbar.fit-width': '适合宽度',
'documents.pdf-viewer.toolbar.fit-page': '适合页面',
'documents.pdf-viewer.toolbar.rotate-clockwise': '顺时针旋转',
'documents.pdf-viewer.toolbar.download': '下载',
'documents.pdf-viewer.toolbar.print': '打印',
'documents.pdf-viewer.zoom.zoom-out': '缩小',
'documents.pdf-viewer.zoom.zoom-in': '放大',
'documents.pdf-viewer.zoom.auto': '自动',
'documents.pdf-viewer.zoom.actual-size': '实际大小',
'documents.pdf-viewer.zoom.page-fit': '适合页面',
'documents.pdf-viewer.zoom.page-width': '页面宽度',
'documents.pdf-viewer.more-actions.label': '更多操作',
'documents.pdf-viewer.more-actions.presentation-mode': '演示模式',
'documents.pdf-viewer.more-actions.download': '下载',
'documents.pdf-viewer.more-actions.print': '打印',
'documents.pdf-viewer.more-actions.go-to-first-page': '转到首页',
'documents.pdf-viewer.more-actions.go-to-last-page': '转到末页',
'documents.pdf-viewer.more-actions.rotate-clockwise': '顺时针旋转',
'documents.pdf-viewer.more-actions.rotate-counterclockwise': '逆时针旋转',
'documents.pdf-viewer.more-actions.page-scrolling': '按页滚动',
'documents.pdf-viewer.more-actions.vertical-scrolling': '垂直滚动',
'documents.pdf-viewer.more-actions.horizontal-scrolling': '水平滚动',
'documents.pdf-viewer.more-actions.wrapped-scrolling': '连续滚动',
'documents.pdf-viewer.more-actions.no-spreads': '不分栏',
'documents.pdf-viewer.more-actions.odd-spreads': '奇数页分栏',
'documents.pdf-viewer.more-actions.even-spreads': '偶数页分栏',
'documents.pdf-viewer.more-actions.document-properties': '文档属性',
'documents.pdf-viewer.properties.title': '文档属性',
'documents.pdf-viewer.properties.na': '无',
'documents.pdf-viewer.properties.file-name': '文件名',
'documents.pdf-viewer.properties.file-size': '文件大小',
'documents.pdf-viewer.properties.doc-title': '标题',
'documents.pdf-viewer.properties.author': '作者',
'documents.pdf-viewer.properties.subject': '主题',
'documents.pdf-viewer.properties.keywords': '关键词',
'documents.pdf-viewer.properties.creation-date': '创建日期',
'documents.pdf-viewer.properties.modification-date': '修改日期',
'documents.pdf-viewer.properties.creator': '创建工具',
'documents.pdf-viewer.properties.pdf-producer': 'PDF 生成器',
'documents.pdf-viewer.properties.pdf-version': 'PDF 版本',
'documents.pdf-viewer.properties.page-count': '页数',
'documents.pdf-viewer.properties.page-size': '页面大小',
'documents.pdf-viewer.properties.fast-web-view': '快速 Web 视图',
'documents.pdf-viewer.properties.yes': '是',
'documents.pdf-viewer.properties.no': '否',
'documents.pdf-viewer.sidebar.page-thumbnails': '页面缩略图',
'documents.pdf-viewer.sidebar.document-outline': '文档大纲',
'documents.pdf-viewer.sidebar.attachments': '附件',
'documents.pdf-viewer.thumbnails.page-alt': '第 {{ page }} 页',
'trash.delete-all.button': '全部删除', 'trash.delete-all.button': '全部删除',
'trash.delete-all.confirm.title': '永久删除所有文档?', 'trash.delete-all.confirm.title': '永久删除所有文档?',
'trash.delete-all.confirm.description': '您确定要永久删除回收站中的所有文档吗?此操作无法撤销。', 'trash.delete-all.confirm.description': '您确定要永久删除回收站中的所有文档吗?此操作无法撤销。',
@ -443,6 +577,10 @@ export const translations: Partial<TranslationsDictionary> = {
'tags.table.headers.documents': '文档', 'tags.table.headers.documents': '文档',
'tags.table.headers.created': '创建时间', 'tags.table.headers.created': '创建时间',
'tags.table.headers.actions': '操作', 'tags.table.headers.actions': '操作',
'tags.picker.search-placeholder': '搜索标签...',
'tags.picker.filter-placeholder': '筛选标签...',
'tags.picker.create-new-with-name': '创建新标签 "{{ name }}"',
'tags.picker.create-new': '创建新标签',
// Tagging rules // Tagging rules
@ -605,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
'webhooks.create.form.name.label': 'Webhook 名称', 'webhooks.create.form.name.label': 'Webhook 名称',
'webhooks.create.form.name.placeholder': '请输入 Webhook 名称', 'webhooks.create.form.name.placeholder': '请输入 Webhook 名称',
'webhooks.create.form.name.required': '名称为必填项', 'webhooks.create.form.name.required': '名称为必填项',
'webhooks.create.form.name.max-length': '名称最多不能超过128个字符',
'webhooks.create.form.url.label': 'Webhook URL', 'webhooks.create.form.url.label': 'Webhook URL',
'webhooks.create.form.url.placeholder': '请输入 Webhook URL', 'webhooks.create.form.url.placeholder': '请输入 Webhook URL',
'webhooks.create.form.url.required': 'URL 为必填项', 'webhooks.create.form.url.required': 'URL 为必填项',
@ -639,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
'layout.menu.home': '首页', 'layout.menu.home': '首页',
'layout.menu.documents': '文档', 'layout.menu.documents': '文档',
'layout.menu.tags': '标签', 'layout.menu.tags': '标签',
'layout.menu.custom-properties': '自定义属性',
'layout.menu.tagging-rules': '标签规则', 'layout.menu.tagging-rules': '标签规则',
'layout.menu.deleted-documents': '已删除文档', 'layout.menu.deleted-documents': '已删除文档',
'layout.menu.organization-settings': '设置', 'layout.menu.organization-settings': '设置',
@ -668,6 +808,7 @@ export const translations: Partial<TranslationsDictionary> = {
'user-menu.api-keys': 'API 密钥', 'user-menu.api-keys': 'API 密钥',
'user-menu.invitations': '邀请', 'user-menu.invitations': '邀请',
'user-menu.language': '语言', 'user-menu.language': '语言',
'user-menu.theme': '主题',
'user-menu.about': '关于 Papra', 'user-menu.about': '关于 Papra',
'user-menu.logout': '登出', 'user-menu.logout': '登出',
@ -693,10 +834,12 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.user.organization_invitation_limit_reached': '今日邀请次数已达上限,请明天再试。', 'api-errors.user.organization_invitation_limit_reached': '今日邀请次数已达上限,请明天再试。',
'api-errors.demo.not_available': '此功能在演示环境中不可用', 'api-errors.demo.not_available': '此功能在演示环境中不可用',
'api-errors.tags.already_exists': '该组织已存在同名标签', 'api-errors.tags.already_exists': '该组织已存在同名标签',
'api-errors.tags.organization_limit_reached': '该组织的标签数量已达上限。',
'api-errors.internal.error': '处理请求时发生错误。请稍后重试。', 'api-errors.internal.error': '处理请求时发生错误。请稍后重试。',
'api-errors.auth.invalid_origin': '应用来源无效。如果您自托管 Papra请确保 APP_BASE_URL 环境变量与当前 URL 匹配。详情见 https://docs.papra.app/resources/troubleshooting/#invalid-application-origin', 'api-errors.auth.invalid_origin': '应用来源无效。如果您自托管 Papra请确保 APP_BASE_URL 环境变量与当前 URL 匹配。详情见 https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
'api-errors.organization.max_members_count_reached': '该组织的成员和待处理邀请数量已达上限。请升级方案以添加更多成员。', 'api-errors.organization.max_members_count_reached': '该组织的成员和待处理邀请数量已达上限。请升级方案以添加更多成员。',
'api-errors.organization.has_active_subscription': '无法删除有有效订阅的组织。请先通过上方“管理订阅”取消订阅。', 'api-errors.organization.has_active_subscription': '无法删除有有效订阅的组织。请先通过上方“管理订阅”取消订阅。',
'api-errors.webhooks.ssrf_unsafe_url': '提供的 URL 不被允许。Webhook URL 不能指向私有或保留的 IP 地址。',
// Better auth api errors // Better auth api errors
'api-errors.USER_NOT_FOUND': '未找到用户', 'api-errors.USER_NOT_FOUND': '未找到用户',
'api-errors.FAILED_TO_CREATE_USER': '创建用户失败', 'api-errors.FAILED_TO_CREATE_USER': '创建用户失败',
@ -752,6 +895,7 @@ export const translations: Partial<TranslationsDictionary> = {
'color-picker.lightness': '亮度', 'color-picker.lightness': '亮度',
'color-picker.select-color': '选择颜色', 'color-picker.select-color': '选择颜色',
'color-picker.select-a-color': '选择一个颜色', 'color-picker.select-a-color': '选择一个颜色',
'color-picker.random-color': '随机颜色',
// Subscriptions // Subscriptions

View file

@ -54,7 +54,7 @@ const AdminLayout: ParentComponent = (props) => {
<div class="w-280px flex-shrink-0 hidden md:block"> <div class="w-280px flex-shrink-0 hidden md:block">
{sidenav()} {sidenav()}
</div> </div>
<div class="flex-1 flex flex-col min-h-0"> <div class="flex-1 flex flex-col min-h-0 min-w-0">
<header class="h-14 flex items-center px-4 justify-between"> <header class="h-14 flex items-center px-4 justify-between">
<Sheet> <Sheet>
<SheetTrigger> <SheetTrigger>

View file

@ -159,7 +159,7 @@ export const AdminListOrganizationsPage: Component = () => {
</Table> </Table>
</div> </div>
<div class="flex items-center justify-between mt-4"> <div class="flex flex-col items-center justify-between mt-4 md:flex-row">
<div class="text-sm text-muted-foreground"> <div class="text-sm text-muted-foreground">
{t('admin.organizations.pagination.info', { {t('admin.organizations.pagination.info', {
start: table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1, start: table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1,

View file

@ -161,7 +161,7 @@ export const AdminListUsersPage: Component = () => {
</Table> </Table>
</div> </div>
<div class="flex items-center justify-between mt-4"> <div class="flex flex-col items-center justify-between mt-4 md:flex-row">
<div class="text-sm text-muted-foreground"> <div class="text-sm text-muted-foreground">
{t('admin.users.pagination.info', { {t('admin.users.pagination.info', {
start: table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1, start: table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1,

View file

@ -5,15 +5,15 @@ import { coerceDates } from '../shared/http/http-client.models';
export async function createApiKey({ export async function createApiKey({
name, name,
permissions, permissions,
organizationIds, // organizationIds,
allOrganizations, // allOrganizations,
expiresAt, // expiresAt,
}: { }: {
name: string; name: string;
permissions: string[]; permissions: string[];
organizationIds: string[]; // organizationIds: string[];
allOrganizations: boolean; // allOrganizations: boolean;
expiresAt?: Date; // expiresAt?: Date;
}) { }) {
const { apiKey, token } = await apiClient<{ const { apiKey, token } = await apiClient<{
apiKey: ApiKey; apiKey: ApiKey;
@ -24,9 +24,9 @@ export async function createApiKey({
body: { body: {
name, name,
permissions, permissions,
organizationIds, // organizationIds,
allOrganizations, // allOrganizations,
expiresAt, // expiresAt,
}, },
}); });

View file

@ -23,8 +23,8 @@ export const CreateApiKeyPage: Component = () => {
const { token } = await createApiKey({ const { token } = await createApiKey({
name, name,
permissions, permissions,
organizationIds: [], // organizationIds: [],
allOrganizations: false, // allOrganizations: false,
}); });
await queryClient.invalidateQueries({ queryKey: ['api-keys'] }); await queryClient.invalidateQueries({ queryKey: ['api-keys'] });

View file

@ -2,8 +2,7 @@ import type { createAuthClient } from './auth.services';
export function createDemoAuthClient() { export function createDemoAuthClient() {
const baseClient = { const baseClient = {
useSession: () => () => ({ getSession: () => ({
isPending: false,
data: { data: {
user: { user: {
id: '1', id: '1',

View file

@ -24,8 +24,8 @@ export function createAuthClient() {
requestPasswordReset: client.requestPasswordReset, requestPasswordReset: client.requestPasswordReset,
resetPassword: client.resetPassword, resetPassword: client.resetPassword,
sendVerificationEmail: client.sendVerificationEmail, sendVerificationEmail: client.sendVerificationEmail,
useSession: client.useSession,
twoFactor: client.twoFactor, twoFactor: client.twoFactor,
getSession: client.getSession,
signOut: async () => { signOut: async () => {
trackingServices.capture({ event: 'User logged out' }); trackingServices.capture({ event: 'User logged out' });
const result = await client.signOut(); const result = await client.signOut();
@ -39,7 +39,7 @@ export function createAuthClient() {
} }
export const { export const {
useSession, getSession,
signIn, signIn,
signUp, signUp,
signOut, signOut,

View file

@ -0,0 +1,18 @@
import { useQuery } from '@tanstack/solid-query';
import { getSession } from '../auth.services';
export function useSession() {
const sessionQuery = useQuery(() => ({
queryKey: ['auth', 'session'],
queryFn: () => getSession(),
}));
const getUser = () => sessionQuery.data?.data?.user;
const getIsAuthenticated = () => Boolean(getUser());
return {
sessionQuery,
getUser,
getIsAuthenticated,
};
}

View file

@ -1,26 +1,19 @@
import type { Component } from 'solid-js'; import type { ParentComponent } from 'solid-js';
import { Navigate } from '@solidjs/router'; import { Navigate } from '@solidjs/router';
import { Match, Suspense, Switch } from 'solid-js'; import { Match, Switch } from 'solid-js';
import { Dynamic } from 'solid-js/web'; import { useSession } from '../composables/use-session.composable';
import { useSession } from '../auth.services';
export function createProtectedPage({ authType, component }: { authType: 'public' | 'private' | 'public-only' | 'admin'; component: Component }) { export const PublicOnlyPage: ParentComponent = (props) => {
return () => { const { getIsAuthenticated, sessionQuery } = useSession();
const session = useSession();
const getIsAuthenticated = () => Boolean(session()?.data?.user); return (
<Switch>
return ( <Match when={!sessionQuery.isLoading && getIsAuthenticated()}>
<Suspense> <Navigate href="/" />
<Switch fallback={<Dynamic component={component} />}> </Match>
<Match when={authType === 'private' && !getIsAuthenticated()}> <Match when={!sessionQuery.isLoading && !getIsAuthenticated()}>
<Navigate href="/login" /> {props.children}
</Match> </Match>
<Match when={authType === 'public-only' && getIsAuthenticated()}> </Switch>
<Navigate href="/" /> );
</Match> };
</Switch>
</Suspense>
);
};
}

View file

@ -3,11 +3,12 @@ import { safely } from '@corentinth/chisels';
import { useNavigate, useParams } from '@solidjs/router'; import { useNavigate, useParams } from '@solidjs/router';
import { createContext, createEffect, createSignal, For, on, onCleanup, onMount, Show, useContext } from 'solid-js'; import { createContext, createEffect, createSignal, For, on, onCleanup, onMount, Show, useContext } from 'solid-js';
import { getDocumentIcon, makeDocumentSearchPermalink } from '../documents/document.models'; import { getDocumentIcon, makeDocumentSearchPermalink } from '../documents/document.models';
import { searchDocuments } from '../documents/documents.services'; import { fetchOrganizationDocuments } from '../documents/documents.services';
import { useI18n } from '../i18n/i18n.provider'; import { useI18n } from '../i18n/i18n.provider';
import { cn } from '../shared/style/cn'; import { cn } from '../shared/style/cn';
import { toArrayIf } from '../shared/utils/array';
import { debounce } from '../shared/utils/timing'; import { debounce } from '../shared/utils/timing';
import { useThemeStore } from '../theme/theme.store'; import { useTheme } from '../theme/theme.provider';
import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandLoading } from '../ui/components/command'; import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandLoading } from '../ui/components/command';
const CommandPaletteContext = createContext<{ const CommandPaletteContext = createContext<{
@ -51,14 +52,25 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
document.removeEventListener('keydown', handleKeyDown); document.removeEventListener('keydown', handleKeyDown);
}); });
let inputRef: HTMLInputElement | undefined;
createEffect(on(
getIsCommandPaletteOpen,
(isOpen) => {
if (isOpen && getSearchQuery().length > 0) {
setTimeout(() => inputRef?.select(), 0);
}
},
));
const navigate = useNavigate(); const navigate = useNavigate();
const { setColorMode } = useThemeStore(); const { setThemePreference } = useTheme();
const searchDocs = debounce(async ({ searchQuery }: { searchQuery: string }) => { const searchDocs = debounce(async ({ searchQuery }: { searchQuery: string }) => {
const [result] = await safely(searchDocuments({ searchQuery, organizationId: params.organizationId, pageIndex: 0, pageSize: 5 })); const [result] = await safely(fetchOrganizationDocuments({ searchQuery, organizationId: params.organizationId, pageIndex: 0, pageSize: 5 }));
setMatchingDocuments(result?.documents ?? []); setMatchingDocuments(result?.documents ?? []);
setMatchingDocumentsTotalCount(result?.totalCount ?? 0); setMatchingDocumentsTotalCount(result?.documentsCount ?? 0);
setIsLoading(false); setIsLoading(false);
}, 300); }, 300);
@ -74,16 +86,6 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
}, },
)); ));
createEffect(on(
getIsCommandPaletteOpen,
(isCommandPaletteOpen) => {
if (isCommandPaletteOpen) {
setMatchingDocuments([]);
setMatchingDocumentsTotalCount(0);
}
},
));
const getCommandData = (): { const getCommandData = (): {
label: string; label: string;
forceMatch?: boolean; forceMatch?: boolean;
@ -100,14 +102,15 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
forceMatch: true, forceMatch: true,
})), })),
...(getMatchingDocumentsTotalCount() > getMatchingDocuments().length ...toArrayIf(
? [{ getMatchingDocumentsTotalCount() > getMatchingDocuments().length,
label: t('command-palette.show-more-results', { count: getMatchingDocumentsTotalCount() - getMatchingDocuments().length, query: getSearchQuery() }), {
icon: 'i-tabler-search', label: t('command-palette.show-more-results', { count: getMatchingDocumentsTotalCount() - getMatchingDocuments().length, query: getSearchQuery() }),
action: () => navigate(makeDocumentSearchPermalink({ organizationId: params.organizationId, search: { query: getSearchQuery() } })), icon: 'i-tabler-search',
forceMatch: true, action: () => navigate(makeDocumentSearchPermalink({ organizationId: params.organizationId, search: { query: getSearchQuery() } })),
}] forceMatch: true,
: []), },
),
], ],
}, },
{ {
@ -116,17 +119,17 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
{ {
label: t('layout.theme.light'), label: t('layout.theme.light'),
icon: 'i-tabler-sun', icon: 'i-tabler-sun',
action: () => setColorMode({ mode: 'light' }), action: () => setThemePreference('light'),
}, },
{ {
label: t('layout.theme.dark'), label: t('layout.theme.dark'),
icon: 'i-tabler-moon', icon: 'i-tabler-moon',
action: () => setColorMode({ mode: 'dark' }), action: () => setThemePreference('dark'),
}, },
{ {
label: t('layout.theme.system'), label: t('layout.theme.system'),
icon: 'i-tabler-device-laptop', icon: 'i-tabler-device-laptop',
action: () => setColorMode({ mode: 'system' }), action: () => setThemePreference('system'),
}, },
], ],
}, },
@ -151,7 +154,12 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
onOpenChange={setIsCommandPaletteOpen} onOpenChange={setIsCommandPaletteOpen}
> >
<CommandInput onValueChange={setSearchQuery} placeholder={t('command-palette.search.placeholder')} /> <CommandInput
ref={inputRef}
value={getSearchQuery()}
onValueChange={setSearchQuery}
placeholder={t('command-palette.search.placeholder')}
/>
<CommandList> <CommandList>
<Show when={getIsLoading()}> <Show when={getIsLoading()}>
<CommandLoading> <CommandLoading>

View file

@ -0,0 +1,222 @@
import type { Component, JSX } from 'solid-js';
import type { CustomPropertyDefinition, CustomPropertyType } from '../custom-properties.types';
import { getValue, insert, remove, setValue } from '@modular-forms/solid';
import { A } from '@solidjs/router';
import { For, Show } from 'solid-js';
import * as v from 'valibot';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createForm } from '@/modules/shared/form/form';
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
import { Button } from '@/modules/ui/components/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/modules/ui/components/select';
import { TextArea } from '@/modules/ui/components/textarea';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { PROPERTY_TYPE_LABEL_I18N_KEYS } from '../custom-properties.constants';
const PROPERTY_TYPES: CustomPropertyType[] = ['text', 'number', 'date', 'boolean', 'select', 'multi_select', 'user_relation', 'document_relation'];
const SELECT_LIKE_TYPES: CustomPropertyType[] = ['select', 'multi_select'];
type OptionDraft = { id?: string; name: string };
export type PropertyDefinitionDraft = { name: string; description?: string; type: CustomPropertyType; options?: OptionDraft[] };
export const CustomPropertyDefinitionForm: Component<{
onSubmit: (args: { propertyDefinition: PropertyDefinitionDraft }) => Promise<void> | void;
organizationId: string;
propertyDefinition?: CustomPropertyDefinition;
submitButton: JSX.Element;
}> = (props) => {
const hasExistingType = () => Boolean(props.propertyDefinition?.type);
const { getErrorMessage } = useI18nApiErrors();
const { t } = useI18n();
const { form, Form, Field, FieldArray, createFormError } = createForm({
onSubmit: async ({ name, description, type, options }) => {
// TODO: make a discriminated union of the form values based on the type to avoid having to do this kind of validation
if ((!options || options.length === 0) && SELECT_LIKE_TYPES.includes(type)) {
throw createFormError({ message: t('custom-properties.form.options.validation.required') });
}
try {
await props.onSubmit({
propertyDefinition: {
name,
description,
type,
options,
},
});
} catch (error) {
const message = getErrorMessage({ error, defaultMessage: t('custom-properties.form.save-error') });
throw createFormError({ message });
}
},
schema: v.object({
name: v.pipe(
v.string(),
v.trim(),
v.minLength(1, t('custom-properties.form.name.required')),
v.maxLength(255, t('custom-properties.form.name.max-length')),
),
description: v.pipe(
v.string(),
v.trim(),
v.maxLength(1000, t('custom-properties.form.description.max-length')),
),
type: v.picklist(PROPERTY_TYPES),
options: v.optional(
v.array(v.object({
id: v.optional(v.string()),
name: v.pipe(
v.string(),
v.trim(),
v.minLength(1, t('custom-properties.form.options.name.required')),
v.maxLength(255, t('custom-properties.form.options.name.max-length')),
),
})),
[],
),
}),
initialValues: {
name: props.propertyDefinition?.name ?? '',
description: props.propertyDefinition?.description ?? '',
type: props.propertyDefinition?.type ?? 'text',
options: props.propertyDefinition?.options?.map(o => ({ id: o.id, name: o.name })) ?? [{ name: '' }],
},
});
const currentType = () => getValue(form, 'type');
const isSelectLike = () => SELECT_LIKE_TYPES.includes(currentType() as CustomPropertyType);
return (
<Form>
<Field name="name">
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col gap-1">
<TextFieldLabel for="name">{t('custom-properties.form.name.label')}</TextFieldLabel>
<TextField
type="text"
id="name"
placeholder={t('custom-properties.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>
)}
</Field>
<Field name="description">
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col gap-1 mt-6">
<TextFieldLabel for="description">
{t('custom-properties.form.description.label')}
<span class="text-muted-foreground font-normal ml-1">{t('custom-properties.form.description.optional')}</span>
</TextFieldLabel>
<TextArea
id="description"
placeholder={t('custom-properties.form.description.placeholder')}
{...inputProps}
value={field.value}
/>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</TextFieldRoot>
)}
</Field>
<Field name="type">
{field => (
<div class="flex flex-col gap-1 mt-6">
<label class="text-sm font-medium" for="type">{t('custom-properties.form.type.label')}</label>
<Select
id="type"
defaultValue={field.value ?? 'text'}
onChange={value => value && setValue(form, 'type', value as CustomPropertyType)}
options={PROPERTY_TYPES}
itemComponent={itemProps => (
<SelectItem item={itemProps.item}>{t(PROPERTY_TYPE_LABEL_I18N_KEYS[itemProps.item.rawValue as CustomPropertyType])}</SelectItem>
)}
disabled={hasExistingType()}
>
<SelectTrigger class="w-full">
<SelectValue<CustomPropertyType>>{state => t(PROPERTY_TYPE_LABEL_I18N_KEYS[state.selectedOption()])}</SelectValue>
</SelectTrigger>
<SelectContent />
</Select>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</div>
)}
</Field>
<Show when={hasExistingType()}>
<p class="text-xs text-muted-foreground">{t('custom-properties.form.type.immutable')}</p>
</Show>
<Show when={isSelectLike()}>
<p class="mb-1 font-medium mt-6">{t('custom-properties.form.options.title')}</p>
<p class="mb-3 text-sm text-muted-foreground">{t('custom-properties.form.options.description')}</p>
<FieldArray name="options">
{fieldArray => (
<div class="flex flex-col gap-3">
<For each={fieldArray.items}>
{(_, index) => (
<Field name={`options.${index()}.id`}>
{() => (
<div class="flex gap-2 items-start">
<div class="flex-1">
<Field name={`options.${index()}.name`}>
{(field, inputProps) => (
<TextFieldRoot>
<TextField
placeholder={t('custom-properties.form.options.name.placeholder')}
{...inputProps}
value={field.value}
aria-invalid={Boolean(field.error)}
/>
{field.error && <div class="text-red-500 text-xs mt-1">{field.error}</div>}
</TextFieldRoot>
)}
</Field>
</div>
<Button
variant="outline"
size="icon"
class="shrink-0"
onClick={() => remove(form, 'options', { at: index() })}
>
<div class="i-tabler-x size-4" />
</Button>
</div>
)}
</Field>
)}
</For>
<Button
variant="outline"
class="gap-2 mt-1 self-start"
onClick={() => insert(form, 'options', { value: { name: '' } })}
>
<div class="i-tabler-plus size-4" />
{t('custom-properties.form.options.add')}
</Button>
{fieldArray.error && <div class="text-red-500 text-sm">{fieldArray.error}</div>}
</div>
)}
</FieldArray>
</Show>
<div class="flex justify-end mt-8 gap-2">
<Button variant="outline" as={A} href={`/organizations/${props.organizationId}/custom-properties`}>
{t('custom-properties.form.cancel')}
</Button>
{props.submitButton}
</div>
<div class="text-red-500 text-sm mt-4">{form.response.message}</div>
</Form>
);
};

View file

@ -0,0 +1,809 @@
import type { Component } from 'solid-js';
import type { CustomPropertyDefinition } from '../custom-properties.types';
import type { Document } from '@/modules/documents/documents.types';
import Calendar from '@corvu/calendar';
import { A } from '@solidjs/router';
import { useMutation, useQuery } from '@tanstack/solid-query';
import { createMemo, createSignal, For, Match, Show, Switch as SolidSwitch, Suspense } from 'solid-js';
import { fetchOrganizationDocuments } from '@/modules/documents/documents.services';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { fetchOrganizationMembers } from '@/modules/organizations/organizations.services';
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
import { queryClient } from '@/modules/shared/query/query-client';
import { useDebounce } from '@/modules/shared/utils/timing';
import { Button } from '@/modules/ui/components/button';
import { CalendarGrid } from '@/modules/ui/components/calendar';
import { CalendarMonthYearHeader } from '@/modules/ui/components/calendar-month-year-header';
import { NumberField, NumberFieldDecrementTrigger, NumberFieldGroup, NumberFieldIncrementTrigger, NumberFieldInput } from '@/modules/ui/components/number-field';
import { Popover, PopoverContent, PopoverTrigger } from '@/modules/ui/components/popover';
import { Separator } from '@/modules/ui/components/separator';
import { createToast } from '@/modules/ui/components/sonner';
import { Switch, SwitchControl, SwitchThumb } from '@/modules/ui/components/switch';
import { TextField, TextFieldRoot } from '@/modules/ui/components/textfield';
import { rawPropertyValueAsOption, rawPropertyValueAsOptionArray, rawPropertyValueAsRelatedDocumentArray, rawPropertyValueAsUserArray } from '../custom-properties.models';
import { deleteDocumentCustomPropertyValue, setDocumentCustomPropertyValue } from '../custom-properties.services';
type SelectOption = { optionId: string; name: string };
function getDateValue(value: unknown): Date | null {
if (value === null || value === undefined) {
return null;
}
if (typeof value === 'string') {
const d = new Date(value);
return Number.isNaN(d.getTime()) ? null : d;
}
return null;
}
const TextPropertyEditor: Component<{
value: string | null;
onSave: (value: string | null) => void;
isPending: boolean;
}> = (props) => {
const { t } = useI18n();
const [open, setOpen] = createSignal(false);
const [draft, setDraft] = createSignal('');
const handleOpen = (isOpen: boolean) => {
if (isOpen) {
setDraft(props.value ?? '');
}
setOpen(isOpen);
};
const handleSave = () => {
const val = draft().trim();
props.onSave(val === '' ? null : val);
setOpen(false);
};
const handleClear = () => {
props.onSave(null);
setOpen(false);
};
return (
<Popover open={open()} onOpenChange={handleOpen} placement="bottom-start">
<PopoverTrigger
as={Button}
variant="ghost"
class="flex items-center gap-2 group bg-transparent! p-0 h-auto text-left"
disabled={props.isPending}
>
<Show
when={props.value}
keyed
fallback={<span class="text-muted-foreground">{t('documents.custom-properties.no-value')}</span>}
>
{v => <span>{v}</span>}
</Show>
<div class="i-tabler-pencil size-4 text-muted-foreground group-hover:text-foreground transition-colors flex-shrink-0" />
</PopoverTrigger>
<PopoverContent class="w-72 p-3">
<div class="flex flex-col gap-2">
<TextFieldRoot>
<TextField
value={draft()}
onInput={e => setDraft(e.currentTarget.value)}
onKeyDown={e => e.key === 'Enter' && handleSave()}
placeholder={t('documents.custom-properties.text-placeholder')}
/>
</TextFieldRoot>
<div class="flex gap-2 justify-end">
<Button variant="ghost" size="sm" onClick={handleClear}>
{t('documents.custom-properties.clear')}
</Button>
<Button size="sm" onClick={handleSave}>
{t('documents.custom-properties.save')}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
};
const NumberPropertyEditor: Component<{
value: number | null;
onSave: (value: number | null) => void;
isPending: boolean;
}> = (props) => {
const { t } = useI18n();
const [open, setOpen] = createSignal(false);
const [draft, setDraft] = createSignal<number | undefined>(undefined);
const handleOpen = (isOpen: boolean) => {
if (isOpen) {
setDraft(props.value ?? undefined);
}
setOpen(isOpen);
};
const handleSave = () => {
const val = draft();
props.onSave(val != null && Number.isFinite(val) ? val : null);
setOpen(false);
};
const handleClear = () => {
props.onSave(null);
setOpen(false);
};
const onChange = (value: number) => {
setDraft(value);
};
return (
<Popover open={open()} onOpenChange={handleOpen} placement="bottom-start">
<PopoverTrigger
as={Button}
variant="ghost"
class="flex items-center gap-2 group bg-transparent! p-0 h-auto text-left"
disabled={props.isPending}
>
<Show when={props.value != null} fallback={<span class="text-muted-foreground">{t('documents.custom-properties.no-value')}</span>}>
<span>{props.value}</span>
</Show>
<div class="i-tabler-pencil size-4 text-muted-foreground group-hover:text-foreground transition-colors flex-shrink-0" />
</PopoverTrigger>
<PopoverContent class="w-56 p-3">
<div class="flex flex-col gap-2">
<NumberField rawValue={draft()} onRawValueChange={onChange}>
<NumberFieldGroup>
<NumberFieldInput onKeyDown={e => e.key === 'Enter' && handleSave()} />
<NumberFieldDecrementTrigger />
<NumberFieldIncrementTrigger />
</NumberFieldGroup>
</NumberField>
<div class="flex gap-2 justify-end">
<Button variant="ghost" size="sm" onClick={handleClear}>
{t('documents.custom-properties.clear')}
</Button>
<Button size="sm" onClick={handleSave}>
{t('documents.custom-properties.save')}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
};
const DatePropertyEditor: Component<{
value: Date | null;
onSave: (value: Date | null) => void;
isPending: boolean;
}> = (props) => {
const { t, formatDate } = useI18n();
const [open, setOpen] = createSignal(false);
const handleSave = (date: Date | null) => {
props.onSave(date);
setOpen(false);
};
return (
<Popover open={open()} onOpenChange={setOpen} placement="bottom-start">
<PopoverTrigger
as={Button}
variant="ghost"
class="flex items-center gap-2 group bg-transparent! p-0 h-auto text-left"
disabled={props.isPending}
>
<Show when={props.value} keyed fallback={<span class="text-muted-foreground">{t('documents.custom-properties.no-value')}</span>}>
{d => formatDate(d, { dateStyle: 'medium' })}
</Show>
<div class="i-tabler-pencil size-4 text-muted-foreground group-hover:text-foreground transition-colors flex-shrink-0" />
</PopoverTrigger>
<PopoverContent class="w-auto p-3">
<Calendar
mode="single"
value={props.value ?? null}
onValueChange={handleSave}
fixedWeeks
>
{() => (
<div class="flex">
<div class="flex flex-col gap-2">
<CalendarMonthYearHeader />
<CalendarGrid />
</div>
<div class="flex flex-col gap-1 min-w-28 ml-2 border-l pl-2">
<Button
variant="ghost"
size="sm"
class="justify-start text-sm"
onClick={() => handleSave(new Date())}
disabled={props.isPending}
>
<div class="i-tabler-calendar-event size-4 mr-2 text-muted-foreground" />
{t('documents.info.today')}
</Button>
<Button
variant="ghost"
size="sm"
class="justify-start text-sm"
onClick={() => handleSave(null)}
disabled={props.isPending}
>
<div class="i-tabler-x size-4 mr-2 text-muted-foreground" />
{t('documents.custom-properties.clear')}
</Button>
</div>
</div>
)}
</Calendar>
</PopoverContent>
</Popover>
);
};
const BooleanPropertyEditor: Component<{
value: boolean | null;
onSave: (value: boolean) => void;
isPending: boolean;
}> = (props) => {
return (
<Switch
checked={props.value ?? false}
onChange={checked => props.onSave(checked)}
disabled={props.isPending}
>
<SwitchControl>
<SwitchThumb />
</SwitchControl>
</Switch>
);
};
const SelectPropertyEditor: Component<{
value: SelectOption | null;
options: { id: string; name: string }[];
onSave: (value: string | null) => void;
isPending: boolean;
}> = (props) => {
const { t } = useI18n();
const [open, setOpen] = createSignal(false);
const handleSelect = (optionId: string) => {
props.onSave(optionId);
setOpen(false);
};
const handleClear = () => {
props.onSave(null);
setOpen(false);
};
return (
<Popover open={open()} onOpenChange={setOpen} placement="bottom-start">
<PopoverTrigger
as={Button}
variant="ghost"
class="flex items-center gap-2 group bg-transparent! p-0 h-auto text-left"
disabled={props.isPending}
>
<Show when={props.value?.name} keyed fallback={<span class="text-muted-foreground">{t('documents.custom-properties.no-value')}</span>}>
{name => <span>{name}</span>}
</Show>
<div class="i-tabler-pencil size-4 text-muted-foreground group-hover:text-foreground transition-colors flex-shrink-0" />
</PopoverTrigger>
<PopoverContent class="w-48 p-2">
<div class="flex flex-col gap-1">
<For each={props.options}>
{option => (
<button
type="button"
class={`flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-accent transition-colors text-left w-full${props.value?.optionId === option.id ? ' bg-accent' : ''}`}
onClick={() => handleSelect(option.id)}
>
{option.name}
</button>
)}
</For>
<Show when={props.value !== null}>
<Separator class="my-1" />
<button
type="button"
class="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-accent transition-colors text-left w-full text-muted-foreground"
onClick={handleClear}
>
<div class="i-tabler-x size-4" />
{t('documents.custom-properties.clear')}
</button>
</Show>
</div>
</PopoverContent>
</Popover>
);
};
const MultiSelectPropertyEditor: Component<{
value: SelectOption[];
options: { id: string; name: string }[];
onSave: (value: string[]) => void;
isPending: boolean;
}> = (props) => {
const { t } = useI18n();
const [open, setOpen] = createSignal(false);
const selectedIds = createMemo(() => (props.value ?? []).map(v => v.optionId));
const displayText = createMemo(() => {
const selected = props.value ?? [];
return selected.length === 0 ? null : selected.map(v => v.name ?? v.optionId).join(', ');
});
const toggleOption = (optionId: string) => {
const current = selectedIds();
const next = current.includes(optionId)
? current.filter(id => id !== optionId)
: [...current, optionId];
props.onSave(next);
};
const handleClear = () => {
props.onSave([]);
setOpen(false);
};
return (
<Popover open={open()} onOpenChange={setOpen} placement="bottom-start">
<PopoverTrigger
as={Button}
variant="ghost"
class="flex items-center gap-2 group bg-transparent! p-0 h-auto text-left"
disabled={props.isPending}
>
<Show when={displayText()} keyed fallback={<span class="text-muted-foreground">{t('documents.custom-properties.no-value')}</span>}>
{text => <span class="max-w-40 truncate">{text}</span>}
</Show>
<div class="i-tabler-pencil size-4 text-muted-foreground group-hover:text-foreground transition-colors flex-shrink-0" />
</PopoverTrigger>
<PopoverContent class="w-52 p-2">
<div class="flex flex-col gap-1">
<For each={props.options}>
{(option) => {
const isSelected = () => selectedIds().includes(option.id);
return (
<button
type="button"
class="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-accent transition-colors text-left w-full"
onClick={() => toggleOption(option.id)}
>
<div class={`size-4 rounded border flex-shrink-0 flex items-center justify-center ${isSelected() ? 'bg-primary border-primary' : 'border-input'}`}>
<Show when={isSelected()}>
<div class="i-tabler-check size-3 text-primary-foreground" />
</Show>
</div>
{option.name}
</button>
);
}}
</For>
<Show when={selectedIds().length > 0}>
<Separator class="my-1" />
<button
type="button"
class="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-accent transition-colors text-left w-full text-muted-foreground"
onClick={handleClear}
>
<div class="i-tabler-x size-4" />
{t('documents.custom-properties.clear')}
</button>
</Show>
</div>
</PopoverContent>
</Popover>
);
};
type UserRelationValue = { userId: string; name: string | null; email: string };
const UserRelationPropertyEditor: Component<{
value: UserRelationValue[];
organizationId: string;
onSave: (value: string[]) => void;
isPending: boolean;
}> = (props) => {
const { t } = useI18n();
const [open, setOpen] = createSignal(false);
const membersQuery = useQuery(() => ({
queryKey: ['organizations', props.organizationId, 'members'],
queryFn: () => fetchOrganizationMembers({ organizationId: props.organizationId }),
enabled: open(),
}));
const members = () => membersQuery.data?.members ?? [];
const selectedUserIds = createMemo(() => props.value.map(u => u.userId));
const toggleUser = (userId: string) => {
const current = selectedUserIds();
const next = current.includes(userId)
? current.filter(id => id !== userId)
: [...current, userId];
props.onSave(next);
};
const handleClear = () => {
props.onSave([]);
setOpen(false);
};
return (
<Popover open={open()} onOpenChange={setOpen} placement="bottom-start">
<div class="flex flex-col gap-0.5 min-w-0">
<Show when={props.value.length > 0}>
<For each={props.value}>
{user => (
<span class="text-sm truncate">
{user.name ?? user.email}
</span>
)}
</For>
</Show>
<PopoverTrigger
as={Button}
variant="ghost"
class="flex items-center gap-1 group bg-transparent! p-0 h-auto text-left w-fit"
disabled={props.isPending}
>
<Show
when={props.value.length === 0}
fallback={<span class="text-xs text-muted-foreground group-hover:text-foreground transition-colors">{t('documents.custom-properties.user-relation-manage')}</span>}
>
<span class="text-muted-foreground">{t('documents.custom-properties.no-value')}</span>
</Show>
<div class="i-tabler-pencil size-3.5 text-muted-foreground group-hover:text-foreground transition-colors flex-shrink-0" />
</PopoverTrigger>
</div>
<PopoverContent class="w-64 p-2">
<Suspense>
<div class="flex flex-col gap-1">
<For each={members()}>
{(member) => {
const userId = member.user?.id ?? '';
const isSelected = () => selectedUserIds().includes(userId);
const displayName = member.user?.name ?? member.user?.email ?? userId;
return (
<button
type="button"
class="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-accent transition-colors text-left w-full"
onClick={() => toggleUser(userId)}
>
<div class={`size-4 rounded border flex-shrink-0 flex items-center justify-center ${isSelected() ? 'bg-primary border-primary' : 'border-input'}`}>
<Show when={isSelected()}>
<div class="i-tabler-check size-3 text-primary-foreground" />
</Show>
</div>
<div class="flex flex-col min-w-0">
<span class="truncate">{displayName}</span>
<Show when={member.user?.name}>
<span class="text-xs text-muted-foreground truncate">{member.user?.email}</span>
</Show>
</div>
</button>
);
}}
</For>
<Show when={selectedUserIds().length > 0}>
<Separator class="my-1" />
<button
type="button"
class="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-accent transition-colors text-left w-full text-muted-foreground"
onClick={handleClear}
>
<div class="i-tabler-x size-4" />
{t('documents.custom-properties.clear')}
</button>
</Show>
</div>
</Suspense>
</PopoverContent>
</Popover>
);
};
type RelatedDocumentValue = { documentId: string; name: string };
const DocumentRelationPropertyEditor: Component<{
value: RelatedDocumentValue[];
organizationId: string;
onSave: (value: string[]) => void;
isPending: boolean;
}> = (props) => {
const { t } = useI18n();
const [open, setOpen] = createSignal(false);
const [search, setSearch] = createSignal('');
const debouncedSearch = useDebounce(search, 300);
const isSearchActive = () => debouncedSearch().length > 0;
const documentsQuery = useQuery(() => ({
queryKey: ['organizations', props.organizationId, 'documents', { searchQuery: debouncedSearch() }],
queryFn: () => fetchOrganizationDocuments({ organizationId: props.organizationId, searchQuery: debouncedSearch(), pageIndex: 0, pageSize: 10 }),
enabled: open() && isSearchActive(),
}));
const searchResults = () => documentsQuery.data?.documents ?? [];
const selectedDocumentIds = createMemo(() => props.value.map(d => d.documentId));
const toggleDocument = (documentId: string) => {
const current = selectedDocumentIds();
const next = current.includes(documentId)
? current.filter(id => id !== documentId)
: [...current, documentId];
props.onSave(next);
};
const handleClear = () => {
props.onSave([]);
setOpen(false);
};
return (
<Popover
open={open()}
onOpenChange={(isOpen) => {
setOpen(isOpen);
if (!isOpen) {
setSearch('');
}
}}
placement="bottom-start"
>
<div class="flex flex-col gap-0.5 min-w-0">
<Show when={props.value.length > 0}>
<For each={props.value}>
{doc => (
<A
href={`/organizations/${props.organizationId}/documents/${doc.documentId}`}
class="text-sm hover:underline truncate"
>
{doc.name}
</A>
)}
</For>
</Show>
<PopoverTrigger
as={Button}
variant="ghost"
class="flex items-center gap-1 group bg-transparent! p-0 h-auto text-left w-fit"
disabled={props.isPending}
>
<Show
when={props.value.length === 0}
fallback={<span class="text-xs text-muted-foreground group-hover:text-foreground transition-colors">{t('documents.custom-properties.document-relation-manage')}</span>}
>
<span class="text-muted-foreground">{t('documents.custom-properties.no-value')}</span>
</Show>
<div class="i-tabler-pencil size-3.5 text-muted-foreground group-hover:text-foreground transition-colors flex-shrink-0" />
</PopoverTrigger>
</div>
<PopoverContent class="w-72 p-2">
<div class="flex flex-col gap-1">
<TextFieldRoot class="mb-1">
<TextField
value={search()}
onInput={e => setSearch(e.currentTarget.value)}
placeholder={t('documents.custom-properties.document-relation-search-placeholder')}
/>
</TextFieldRoot>
<Suspense>
<Show
when={isSearchActive()}
fallback={(
<Show
when={props.value.length > 0}
fallback={<span class="text-sm text-muted-foreground px-2 py-1.5">{t('documents.custom-properties.no-results')}</span>}
>
<For each={props.value}>
{doc => (
<button
type="button"
class="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-accent transition-colors text-left w-full"
onClick={() => toggleDocument(doc.documentId)}
>
<div class="size-4 rounded border flex-shrink-0 flex items-center justify-center bg-primary border-primary">
<div class="i-tabler-check size-3 text-primary-foreground" />
</div>
<span class="truncate">{doc.name}</span>
</button>
)}
</For>
</Show>
)}
>
<Show
when={searchResults().length > 0}
fallback={<span class="text-sm text-muted-foreground px-2 py-1.5">{t('documents.custom-properties.no-results')}</span>}
>
<For each={searchResults()}>
{(doc) => {
const isSelected = () => selectedDocumentIds().includes(doc.id);
return (
<button
type="button"
class="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-accent transition-colors text-left w-full"
onClick={() => toggleDocument(doc.id)}
>
<div class={`size-4 rounded border flex-shrink-0 flex items-center justify-center ${isSelected() ? 'bg-primary border-primary' : 'border-input'}`}>
<Show when={isSelected()}>
<div class="i-tabler-check size-3 text-primary-foreground" />
</Show>
</div>
<span class="truncate">{doc.name}</span>
</button>
);
}}
</For>
</Show>
</Show>
<Show when={selectedDocumentIds().length > 0}>
<Separator class="my-1" />
<button
type="button"
class="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-accent transition-colors text-left w-full text-muted-foreground"
onClick={handleClear}
>
<div class="i-tabler-x size-4" />
{t('documents.custom-properties.clear')}
</button>
</Show>
</Suspense>
</div>
</PopoverContent>
</Popover>
);
};
const PropertyValueEditor: Component<{
definition: CustomPropertyDefinition;
rawValue: unknown;
documentId: string;
organizationId: string;
}> = (props) => {
const { getErrorMessage } = useI18nApiErrors();
const mutation = useMutation(() => ({
mutationFn: (value: string | number | boolean | string[] | null) => {
if (value === null) {
return deleteDocumentCustomPropertyValue({
organizationId: props.organizationId,
documentId: props.documentId,
propertyDefinitionId: props.definition.id,
});
}
return setDocumentCustomPropertyValue({
organizationId: props.organizationId,
documentId: props.documentId,
propertyDefinitionId: props.definition.id,
value,
});
},
onSuccess: () => queryClient.invalidateQueries({
queryKey: ['organizations', props.organizationId, 'documents', props.documentId],
}),
onError: (error: unknown) => {
createToast({ message: getErrorMessage({ error }), type: 'error' });
},
}));
const save = (value: string | number | boolean | string[] | null) => {
mutation.mutate(value);
};
return (
<SolidSwitch>
<Match when={props.definition.type === 'text'}>
<TextPropertyEditor
value={typeof props.rawValue === 'string' ? props.rawValue : null}
onSave={save}
isPending={mutation.isPending}
/>
</Match>
<Match when={props.definition.type === 'number'}>
<NumberPropertyEditor
value={typeof props.rawValue === 'number' ? props.rawValue : null}
onSave={save}
isPending={mutation.isPending}
/>
</Match>
<Match when={props.definition.type === 'date'}>
<DatePropertyEditor
value={getDateValue(props.rawValue)}
onSave={date => save(date ? date.toISOString() : null)}
isPending={mutation.isPending}
/>
</Match>
<Match when={props.definition.type === 'boolean'}>
<BooleanPropertyEditor
value={typeof props.rawValue === 'boolean' ? props.rawValue : null}
onSave={save}
isPending={mutation.isPending}
/>
</Match>
<Match when={props.definition.type === 'select'}>
<SelectPropertyEditor
value={rawPropertyValueAsOption(props.rawValue)}
options={props.definition.options}
onSave={optionId => save(optionId)}
isPending={mutation.isPending}
/>
</Match>
<Match when={props.definition.type === 'multi_select'}>
<MultiSelectPropertyEditor
value={rawPropertyValueAsOptionArray(props.rawValue)}
options={props.definition.options}
onSave={ids => save(ids)}
isPending={mutation.isPending}
/>
</Match>
<Match when={props.definition.type === 'user_relation'}>
<UserRelationPropertyEditor
value={rawPropertyValueAsUserArray(props.rawValue)}
organizationId={props.organizationId}
onSave={ids => save(ids)}
isPending={mutation.isPending}
/>
</Match>
<Match when={props.definition.type === 'document_relation'}>
<DocumentRelationPropertyEditor
value={rawPropertyValueAsRelatedDocumentArray(props.rawValue)}
organizationId={props.organizationId}
onSave={ids => save(ids)}
isPending={mutation.isPending}
/>
</Match>
</SolidSwitch>
);
};
export const DocumentCustomPropertiesPanel: Component<{
document: Document;
organizationId: string;
propertyDefinitions: CustomPropertyDefinition[];
}> = (props) => {
const { t } = useI18n();
const definitions = createMemo(() => props.propertyDefinitions.toSorted((a, b) => a.displayOrder - b.displayOrder));
const getPropertyValueByKey = createMemo(() => Object.fromEntries(props.document.customProperties?.map(p => [p.key, p.value]) ?? []));
const getPropertyValue = (key: string) => getPropertyValueByKey()[key] ?? null;
return (
<Show when={definitions().length > 0}>
<>
<div class="col-span-2 mt-4">
<Separator class="mb-3" />
<p class="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wide">
{t('documents.custom-properties.section-title')}
</p>
</div>
<For each={definitions()}>
{definition => (
<>
<div class="py-1 pr-2 text-sm text-muted-foreground flex items-start">
<div class="flex items-center gap-2 whitespace-nowrap">
<div class="i-tabler-tag size-4" />
{definition.name}
</div>
</div>
<div class="py-1 pl-2 text-sm">
<PropertyValueEditor
definition={definition}
rawValue={getPropertyValue(definition.key)}
documentId={props.document.id}
organizationId={props.organizationId}
/>
</div>
</>
)}
</For>
</>
</Show>
);
};

View file

@ -0,0 +1,13 @@
import type { TranslationsDictionary } from '../i18n/locales.types';
import type { CustomPropertyType } from './custom-properties.types';
export const PROPERTY_TYPE_LABEL_I18N_KEYS: Record<CustomPropertyType, keyof TranslationsDictionary> = {
text: 'custom-properties.types.text',
number: 'custom-properties.types.number',
date: 'custom-properties.types.date',
boolean: 'custom-properties.types.boolean',
select: 'custom-properties.types.select',
multi_select: 'custom-properties.types.multi_select',
user_relation: 'custom-properties.types.user_relation',
document_relation: 'custom-properties.types.document_relation',
} as const;

View file

@ -0,0 +1,50 @@
export function rawPropertyValueAsOption(value: unknown): { optionId: string; name: string } | null {
if (
typeof value === 'object'
&& value !== null
&& 'optionId' in value
&& typeof value.optionId === 'string'
&& 'name' in value
&& typeof value.name === 'string'
) {
return { optionId: value.optionId, name: value.name };
}
return null;
}
export function rawPropertyValueAsOptionArray(value: unknown): { optionId: string; name: string }[] {
if (Array.isArray(value)) {
return value.map(rawPropertyValueAsOption).filter(v => v !== null);
}
return [];
}
export function rawPropertyValueAsUserArray(value: unknown): { userId: string; name: string | null; email: string }[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter(
(v): v is { userId: string; name: string | null; email: string } =>
typeof v === 'object'
&& v !== null
&& 'userId' in v
&& typeof v.userId === 'string'
&& 'email' in v
&& typeof v.email === 'string',
);
}
export function rawPropertyValueAsRelatedDocumentArray(value: unknown): { documentId: string; name: string }[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter(
(v): v is { documentId: string; name: string } =>
typeof v === 'object'
&& v !== null
&& 'documentId' in v
&& typeof v.documentId === 'string'
&& 'name' in v
&& typeof v.name === 'string',
);
}

View file

@ -0,0 +1,119 @@
import type { AsDto } from '../shared/http/http-client.types';
import type { CustomPropertyDefinition, CustomPropertyType } from './custom-properties.types';
import { apiClient } from '../shared/http/api-client';
import { coerceDates } from '../shared/http/http-client.models';
export async function fetchCustomPropertyDefinitions({ organizationId }: { organizationId: string }) {
const { propertyDefinitions } = await apiClient<{ propertyDefinitions: AsDto<CustomPropertyDefinition>[] }>({
path: `/api/organizations/${organizationId}/custom-properties`,
method: 'GET',
});
return {
propertyDefinitions: propertyDefinitions.map(coerceDates),
};
}
export async function fetchCustomPropertyDefinition({ organizationId, propertyDefinitionId }: { organizationId: string; propertyDefinitionId: string }) {
const { definition } = await apiClient<{ definition: AsDto<CustomPropertyDefinition> }>({
path: `/api/organizations/${organizationId}/custom-properties/${propertyDefinitionId}`,
method: 'GET',
});
return {
definition: coerceDates(definition),
};
}
export async function createCustomPropertyDefinition({
organizationId,
propertyDefinition,
}: {
organizationId: string;
propertyDefinition: {
name: string;
description?: string;
type: CustomPropertyType;
options?: { name: string }[];
};
}) {
const { propertyDefinition: created } = await apiClient<{ propertyDefinition: AsDto<CustomPropertyDefinition> }>({
path: `/api/organizations/${organizationId}/custom-properties`,
method: 'POST',
body: propertyDefinition,
});
return {
propertyDefinition: coerceDates(created),
};
}
export async function updateCustomPropertyDefinition({
organizationId,
propertyDefinitionId,
propertyDefinition,
}: {
organizationId: string;
propertyDefinitionId: string;
propertyDefinition: {
name?: string;
description?: string;
options?: { id?: string; name: string }[];
};
}) {
const { propertyDefinition: updated } = await apiClient<{ propertyDefinition: AsDto<CustomPropertyDefinition> }>({
path: `/api/organizations/${organizationId}/custom-properties/${propertyDefinitionId}`,
method: 'PUT',
body: propertyDefinition,
});
return {
propertyDefinition: coerceDates(updated),
};
}
export async function deleteCustomPropertyDefinition({
organizationId,
propertyDefinitionId,
}: {
organizationId: string;
propertyDefinitionId: string;
}) {
await apiClient({
path: `/api/organizations/${organizationId}/custom-properties/${propertyDefinitionId}`,
method: 'DELETE',
});
}
export async function setDocumentCustomPropertyValue({
organizationId,
documentId,
propertyDefinitionId,
value,
}: {
organizationId: string;
documentId: string;
propertyDefinitionId: string;
value: string | number | boolean | string[];
}) {
await apiClient({
path: `/api/organizations/${organizationId}/documents/${documentId}/custom-properties/${propertyDefinitionId}`,
method: 'PUT',
body: { value },
});
}
export async function deleteDocumentCustomPropertyValue({
organizationId,
documentId,
propertyDefinitionId,
}: {
organizationId: string;
documentId: string;
propertyDefinitionId: string;
}) {
await apiClient({
path: `/api/organizations/${organizationId}/documents/${documentId}/custom-properties/${propertyDefinitionId}`,
method: 'DELETE',
});
}

View file

@ -0,0 +1,29 @@
export type CustomPropertyType = 'text' | 'number' | 'date' | 'boolean' | 'select' | 'multi_select' | 'user_relation' | 'document_relation';
export type DocumentCustomProperty = {
key: string;
name: string;
type: CustomPropertyType;
displayOrder: number;
value: unknown;
};
export type CustomPropertySelectOption = {
id: string;
name: string;
key: string;
displayOrder: number;
};
export type CustomPropertyDefinition = {
id: string;
organizationId: string;
name: string;
key: string;
description?: string | null;
type: CustomPropertyType;
displayOrder: number;
options: CustomPropertySelectOption[];
createdAt: Date;
updatedAt: Date;
};

View file

@ -0,0 +1,57 @@
import type { Component } from 'solid-js';
import type { PropertyDefinitionDraft } from '../components/custom-property-definition-form.component';
import { useNavigate, useParams } from '@solidjs/router';
import { useMutation } from '@tanstack/solid-query';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { queryClient } from '@/modules/shared/query/query-client';
import { Button } from '@/modules/ui/components/button';
import { createToast } from '@/modules/ui/components/sonner';
import { CustomPropertyDefinitionForm } from '../components/custom-property-definition-form.component';
import { createCustomPropertyDefinition } from '../custom-properties.services';
export const CreateCustomPropertyPage: Component = () => {
const params = useParams();
const navigate = useNavigate();
const { t } = useI18n();
const createMutation = useMutation(() => ({
mutationFn: async ({ propertyDefinition }: { propertyDefinition: PropertyDefinitionDraft }) => {
await createCustomPropertyDefinition({ organizationId: params.organizationId, propertyDefinition });
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['organizations', params.organizationId, 'custom-properties'] });
createToast({
message: t('custom-properties.create.success'),
type: 'success',
});
navigate(`/organizations/${params.organizationId}/custom-properties`);
},
onError: () => {
createToast({
message: t('custom-properties.create.error'),
type: 'error',
});
},
}));
return (
<div class="p-6 max-w-screen-md mx-auto mt-4">
<div class="border-b mb-6 pb-4">
<h1 class="text-xl font-bold">
{t('custom-properties.create.title')}
</h1>
</div>
<CustomPropertyDefinitionForm
organizationId={params.organizationId}
onSubmit={({ propertyDefinition }) => createMutation.mutateAsync({ propertyDefinition })}
submitButton={(
<Button type="submit" isLoading={createMutation.isPending}>
{t('custom-properties.create.submit')}
</Button>
)}
/>
</div>
);
};

View file

@ -0,0 +1,260 @@
import type { Component } from 'solid-js';
import type { CustomPropertyType } from '../custom-properties.types';
import { A, useParams } from '@solidjs/router';
import { useMutation, useQuery } from '@tanstack/solid-query';
import { createSolidTable, flexRender, getCoreRowModel, getSortedRowModel } from '@tanstack/solid-table';
import { For, Show, Suspense } from 'solid-js';
import { RelativeTime } from '@/modules/i18n/components/RelativeTime';
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/modules/ui/components/table';
import { PROPERTY_TYPE_LABEL_I18N_KEYS } from '../custom-properties.constants';
import {
deleteCustomPropertyDefinition,
fetchCustomPropertyDefinitions,
} from '../custom-properties.services';
const TYPE_ICON: Record<CustomPropertyType, string> = {
text: 'i-tabler-text-size',
number: 'i-tabler-123',
date: 'i-tabler-calendar',
boolean: 'i-tabler-toggle-left',
select: 'i-tabler-list',
multi_select: 'i-tabler-list-check',
user_relation: 'i-tabler-user',
document_relation: 'i-tabler-file-symlink',
};
export const DeleteCustomPropertyButton: Component<{ organizationId: string; propertyDefinitionId: string; propertyDefinitionName: string }> = (props) => {
const { confirm } = useConfirmModal();
const { t } = useI18n();
const deleteMutation = useMutation(() => ({
mutationFn: async () => {
await deleteCustomPropertyDefinition({ organizationId: props.organizationId, propertyDefinitionId: props.propertyDefinitionId });
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['organizations', props.organizationId, 'custom-properties'] });
createToast({
message: t('custom-properties.list.delete.success'),
type: 'success',
});
},
onError: () => {
createToast({
message: t('custom-properties.list.delete.error'),
type: 'error',
});
},
}));
const handleDelete = async () => {
const isConfirmed = await confirm({
title: t('custom-properties.list.delete.confirm-title'),
message: t('custom-properties.list.delete.confirm-message', { name: props.propertyDefinitionName }),
confirmButton: {
text: t('custom-properties.list.delete.confirm-button'),
variant: 'destructive',
},
});
if (isConfirmed) {
deleteMutation.mutate();
}
};
return (
<Button size="icon" variant="outline" class="size-7 text-red" onClick={handleDelete} disabled={deleteMutation.isPending} aria-label={`Delete custom property ${props.propertyDefinitionName}`}>
<div class="i-tabler-trash size-4" />
</Button>
);
};
export const CustomPropertiesPage: Component = () => {
const params = useParams();
const { t } = useI18n();
const query = useQuery(() => ({
queryKey: ['organizations', params.organizationId, 'custom-properties'],
queryFn: () => fetchCustomPropertyDefinitions({ organizationId: params.organizationId }),
}));
const table = createSolidTable({
get data() {
return query.data?.propertyDefinitions ?? [];
},
columns: [
{
header: () => t('custom-properties.list.table.name'),
accessorKey: 'name',
sortingFn: 'alphanumeric',
cell: data => (
<A href={`/organizations/${params.organizationId}/custom-properties/${data.row.original.id}`} class="font-medium hover:underline">{data.getValue<string>()}</A>
),
},
{
header: () => t('custom-properties.list.table.type'),
accessorKey: 'type',
sortingFn: 'alphanumeric',
cell: (data) => {
const type = data.getValue<CustomPropertyType>();
return (
<div class="flex items-center gap-1.5 w-fit px-2 py-0.5 rounded bg-muted text-xs font-medium">
<div class={`${TYPE_ICON[type]} size-3.5 text-muted-foreground`} />
{t(PROPERTY_TYPE_LABEL_I18N_KEYS[type])}
</div>
);
},
},
{
header: () => t('custom-properties.list.table.description'),
accessorKey: 'description',
sortingFn: 'alphanumeric',
cell: data => (
<span class="text-wrap">
{data.getValue<string | null>() || <span class="text-muted-foreground">{t('custom-properties.list.table.no-description')}</span>}
</span>
),
},
{
header: () => t('custom-properties.list.table.created'),
accessorKey: 'createdAt',
sortingFn: 'datetime',
cell: data => (
<RelativeTime class="text-muted-foreground" date={data.getValue<Date>()} />
),
},
{
id: 'actions',
header: () => <span class="text-right">{t('custom-properties.list.table.actions')}</span>,
cell: data => (
<div class="flex gap-2 justify-end">
<Button
as={A}
href={`/organizations/${params.organizationId}/custom-properties/${data.row.original.id}`}
size="icon"
variant="outline"
class="size-7"
aria-label={`Edit custom property ${data.row.original.name}`}
>
<div class="i-tabler-pencil size-4" />
</Button>
<DeleteCustomPropertyButton
organizationId={params.organizationId}
propertyDefinitionId={data.row.original.id}
propertyDefinitionName={data.row.original.name}
/>
</div>
),
},
],
initialState: {
sorting: [{ id: 'name', desc: false }],
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
return (
<div class="p-6 mt-4 pb-32 mx-auto max-w-5xl">
<Suspense>
<Show when={query.data?.propertyDefinitions}>
{getPropertyDefinitions => (
<Show
when={getPropertyDefinitions().length > 0}
fallback={(
<EmptyState
title={t('custom-properties.list.empty.title')}
icon="i-tabler-forms"
description={t('custom-properties.list.empty.description')}
cta={(
<Button as={A} href={`/organizations/${params.organizationId}/custom-properties/create`}>
<div class="i-tabler-plus size-4 mr-2" />
{t('custom-properties.list.create-button')}
</Button>
)}
/>
)}
>
<div class="flex justify-between sm:items-center pb-6 gap-4 flex-col sm:flex-row">
<div>
<h2 class="text-xl font-bold">
{t('custom-properties.list.title')}
</h2>
<p class="text-muted-foreground mt-1">
{t('custom-properties.list.description')}
</p>
</div>
<div class="flex-shrink-0">
<Button as={A} href={`/organizations/${params.organizationId}/custom-properties/create`} class="w-full">
<div class="i-tabler-plus size-4 mr-2" />
{t('custom-properties.list.create-button')}
</Button>
</div>
</div>
<Table>
<TableHeader>
<For each={table.getHeaderGroups()}>
{headerGroup => (
<TableRow>
<For each={headerGroup.headers}>
{header => (
<TableHead>
<Show
when={header.column.getCanSort()}
fallback={flexRender(header.column.columnDef.header, header.getContext())}
>
<button
class="flex items-center gap-1 cursor-pointer select-none"
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(header.column.columnDef.header, header.getContext())}
<Show when={header.column.getIsSorted() === 'asc'}>
<div class="i-tabler-arrow-down size-3.5" />
</Show>
<Show when={header.column.getIsSorted() === 'desc'}>
<div class="i-tabler-arrow-up size-3.5" />
</Show>
<Show when={!header.column.getIsSorted()}>
<div class="i-tabler-arrows-sort size-3.5 opacity-40" />
</Show>
</button>
</Show>
</TableHead>
)}
</For>
</TableRow>
)}
</For>
</TableHeader>
<TableBody>
<For each={table.getRowModel().rows}>
{row => (
<TableRow>
<For each={row.getVisibleCells()}>
{cell => (
<TableCell>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
)}
</For>
</TableRow>
)}
</For>
</TableBody>
</Table>
</Show>
)}
</Show>
</Suspense>
</div>
);
};

View file

@ -0,0 +1,75 @@
import type { Component } from 'solid-js';
import type { PropertyDefinitionDraft } from '../components/custom-property-definition-form.component';
import { useNavigate, useParams } from '@solidjs/router';
import { useMutation, useQuery } from '@tanstack/solid-query';
import { Show } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { queryClient } from '@/modules/shared/query/query-client';
import { Button } from '@/modules/ui/components/button';
import { createToast } from '@/modules/ui/components/sonner';
import { CustomPropertyDefinitionForm } from '../components/custom-property-definition-form.component';
import { fetchCustomPropertyDefinition, updateCustomPropertyDefinition } from '../custom-properties.services';
export const UpdateCustomPropertyPage: Component = () => {
const params = useParams();
const navigate = useNavigate();
const { t } = useI18n();
const query = useQuery(() => ({
queryKey: ['organizations', params.organizationId, 'custom-properties', params.propertyDefinitionId],
queryFn: () => fetchCustomPropertyDefinition({ organizationId: params.organizationId, propertyDefinitionId: params.propertyDefinitionId }),
}));
const updateMutation = useMutation(() => ({
mutationFn: async ({ propertyDefinition }: { propertyDefinition: PropertyDefinitionDraft }) => {
// Cannot update the type
const { type: _, ...definition } = propertyDefinition;
await updateCustomPropertyDefinition({
organizationId: params.organizationId,
propertyDefinitionId: params.propertyDefinitionId,
propertyDefinition: definition,
});
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['organizations', params.organizationId, 'custom-properties'] });
createToast({
message: t('custom-properties.update.success'),
type: 'success',
});
navigate(`/organizations/${params.organizationId}/custom-properties`);
},
onError: () => {
createToast({
message: t('custom-properties.update.error'),
type: 'error',
});
},
}));
return (
<div class="p-6 max-w-screen-md mx-auto mt-4">
<div class="border-b mb-6 pb-4">
<h1 class="text-xl font-bold">
{t('custom-properties.update.title')}
</h1>
</div>
<Show when={query.data?.definition}>
{getDefinition => (
<CustomPropertyDefinitionForm
organizationId={params.organizationId}
propertyDefinition={getDefinition()}
onSubmit={({ propertyDefinition }) => updateMutation.mutateAsync({ propertyDefinition })}
submitButton={(
<Button type="submit" isLoading={updateMutation.isPending}>
{t('custom-properties.update.submit')}
</Button>
)}
/>
)}
</Show>
</div>
);
};

View file

@ -1,7 +1,9 @@
import type { ApiKey } from '../api-keys/api-keys.types'; import type { ApiKey } from '../api-keys/api-keys.types';
import type { CustomPropertyDefinition } from '../custom-properties/custom-properties.types';
import type { Document } from '../documents/documents.types'; import type { Document } from '../documents/documents.types';
import type { Webhook } from '../webhooks/webhooks.types'; import type { Webhook } from '../webhooks/webhooks.types';
import type { import type {
DocumentCustomPropertyValueStorage,
DocumentFile, DocumentFile,
} from './demo.storage'; } from './demo.storage';
import { FetchError } from 'ofetch'; import { FetchError } from 'ofetch';
@ -11,6 +13,8 @@ import { defineHandler } from './demo-api-mock.models';
import { createId, randomString } from './demo.models'; import { createId, randomString } from './demo.models';
import { import {
apiKeyStorage, apiKeyStorage,
customPropertyDefinitionStorage,
documentCustomPropertyValueStorage,
documentFileStorage, documentFileStorage,
documentStorage, documentStorage,
organizationStorage, organizationStorage,
@ -20,7 +24,7 @@ import {
webhooksStorage, webhooksStorage,
} from './demo.storage'; } from './demo.storage';
import { findMany, getValues } from './demo.storage.models'; import { findMany, getValues } from './demo.storage.models';
import { searchDemoDocuments } from './search/demo.search.services'; import { generatePropertyKey, searchDemoDocuments } from './search/demo.search.services';
import { demoUser } from './seed/users.fixtures'; import { demoUser } from './seed/users.fixtures';
function assert(condition: unknown, { message = 'Error', status }: { message?: string; status?: number } = {}): asserts condition { function assert(condition: unknown, { message = 'Error', status }: { message?: string; status?: number } = {}): asserts condition {
@ -65,6 +69,61 @@ async function deserializeFile(storageInfo: DocumentFile): Promise<File> {
return new File([await fromBase64(base64Content)], name, { type }); return new File([await fromBase64(base64Content)], name, { type });
} }
function hydratePropertyValue({ value, definition }: { value: unknown; definition: CustomPropertyDefinition }): unknown {
if (value == null) {
return null;
}
if (definition.type === 'select') {
const optionId = String(value);
const option = definition.options.find(o => o.id === optionId || o.key === optionId);
if (!option) {
return null;
}
return { optionId: option.id, name: option.name };
}
if (definition.type === 'multi_select') {
const ids = Array.isArray(value) ? value.map(String) : [String(value)];
return ids
.map((id) => {
const option = definition.options.find(o => o.id === id || o.key === id);
return option ? { optionId: option.id, name: option.name } : null;
})
.filter(v => v !== null);
}
return value;
}
function buildCustomPropertiesResponse({
definitions,
storedValues,
documentId,
}: {
definitions: CustomPropertyDefinition[];
storedValues: DocumentCustomPropertyValueStorage[];
documentId: string;
}) {
const documentValues = storedValues.filter(v => v.documentId === documentId);
const valuesByDefinitionId = Object.fromEntries(documentValues.map(v => [v.propertyDefinitionId, v.value]));
return definitions
.toSorted((a, b) => a.displayOrder - b.displayOrder)
.map(def => ({
propertyDefinitionId: def.id,
key: def.key,
name: def.name,
type: def.type,
displayOrder: def.displayOrder,
value: hydratePropertyValue({ value: valuesByDefinitionId[def.id] ?? null, definition: def }),
}));
}
const inMemoryApiMock: Record<string, { handler: any }> = { const inMemoryApiMock: Record<string, { handler: any }> = {
...defineHandler({ ...defineHandler({
path: '/api/config', path: '/api/config',
@ -90,43 +149,6 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
}), }),
}), }),
...defineHandler({
path: '/api/organizations/:organizationId/documents',
method: 'GET',
handler: async ({ params: { organizationId }, query }) => {
const organization = await organizationStorage.getItem(organizationId);
assert(organization, { status: 403 });
const documents = await findMany(documentStorage, document => document.organizationId === organizationId && !document.deletedAt);
const filteredDocuments = await Promise.all(
documents.map(async (document) => {
const tagDocuments = await findMany(tagDocumentStorage, tagDocument => tagDocument?.documentId === document?.id);
const allTags = await getValues(tagStorage);
const tags = allTags.filter(tag => tagDocuments.some(tagDocument => tagDocument?.tagId === tag?.id));
return {
...document,
tags,
};
}),
);
const {
pageIndex = 0,
pageSize = 10,
} = query ?? {};
return {
documents: filteredDocuments
.toSorted((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize),
documentsCount: filteredDocuments.length,
};
},
}),
...defineHandler({ ...defineHandler({
path: '/api/organizations/:organizationId/documents', path: '/api/organizations/:organizationId/documents',
method: 'POST', method: 'POST',
@ -190,7 +212,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
}), }),
...defineHandler({ ...defineHandler({
path: '/api/organizations/:organizationId/documents/search', path: '/api/organizations/:organizationId/documents',
method: 'GET', method: 'GET',
handler: async ({ params: { organizationId }, query }) => { handler: async ({ params: { organizationId }, query }) => {
const { const {
@ -203,23 +225,32 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
assert(organization, { status: 403 }); assert(organization, { status: 403 });
const searchQuery = rawSearchQuery.trim(); const searchQuery = rawSearchQuery.trim();
const [organizationDocuments, allTags, tagDocuments] = await Promise.all([ const [organizationDocuments, allTags, tagDocuments, allDefinitions, allPropertyValues] = await Promise.all([
findMany(documentStorage, document => document?.organizationId === organizationId && !document?.deletedAt), findMany(documentStorage, document => document?.organizationId === organizationId && !document?.deletedAt),
getValues(tagStorage), getValues(tagStorage),
getValues(tagDocumentStorage), getValues(tagDocumentStorage),
findMany(customPropertyDefinitionStorage, def => def.organizationId === organizationId),
getValues(documentCustomPropertyValueStorage),
]); ]);
const documentsWithTags = organizationDocuments.map((document) => { const documentsWithTagsAndProperties = organizationDocuments.map((document) => {
const documentTagDocuments = tagDocuments.filter(tagDocument => tagDocument?.documentId === document?.id); const documentTagDocuments = tagDocuments.filter(tagDocument => tagDocument?.documentId === document?.id);
const tags = allTags.filter(tag => documentTagDocuments.some(tagDocument => tagDocument?.tagId === tag?.id)); const tags = allTags.filter(tag => documentTagDocuments.some(tagDocument => tagDocument?.tagId === tag?.id));
const customProperties = buildCustomPropertiesResponse({
definitions: allDefinitions,
storedValues: allPropertyValues,
documentId: document.id,
});
return { return {
...document, ...document,
tags, tags,
customProperties,
}; };
}); });
const filteredDocuments = searchDemoDocuments({ query: searchQuery, documents: documentsWithTags as Document[] }); const filteredDocuments = searchDemoDocuments({ query: searchQuery, documents: documentsWithTagsAndProperties as unknown as Document[] });
const paginatedDocuments = filteredDocuments const paginatedDocuments = filteredDocuments
.toSorted((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) .toSorted((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
@ -227,7 +258,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
return { return {
documents: paginatedDocuments, documents: paginatedDocuments,
totalCount: filteredDocuments.length, documentsCount: filteredDocuments.length,
}; };
}, },
}), }),
@ -260,13 +291,25 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
assert(document, { status: 404 }); assert(document, { status: 404 });
const tagDocuments = await findMany(tagDocumentStorage, tagDocument => tagDocument.documentId === documentId); const [tagDocuments, allDefinitions, allPropertyValues] = await Promise.all([
findMany(tagDocumentStorage, tagDocument => tagDocument.documentId === documentId),
findMany(customPropertyDefinitionStorage, def => def.organizationId === organizationId),
findMany(documentCustomPropertyValueStorage, v => v.documentId === documentId),
]);
const tags = await findMany(tagStorage, tag => tagDocuments.some(tagDocument => tagDocument.tagId === tag.id)); const tags = await findMany(tagStorage, tag => tagDocuments.some(tagDocument => tagDocument.tagId === tag.id));
const customProperties = buildCustomPropertiesResponse({
definitions: allDefinitions,
storedValues: allPropertyValues,
documentId,
});
return { return {
document: { document: {
...document, ...document,
tags, tags,
customProperties,
}, },
}; };
}, },
@ -332,11 +375,11 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
assert(organization, { status: 403 }); assert(organization, { status: 403 });
const tags = await findMany(tagStorage, tag => tag.organizationId === organizationId); const tags = await findMany(tagStorage, tag => tag.organizationId === organizationId);
const documents = await findMany(documentStorage, document => document.organizationId === organizationId); const tagDocuments = await getValues(tagDocumentStorage);
const tagsWithDocumentsCount = tags.map(tag => ({ const tagsWithDocumentsCount = tags.map(tag => ({
...tag, ...tag,
documentsCount: documents.filter(document => document.tags.some(t => t.id === tag.id)).length, documentsCount: tagDocuments.filter(tagDocument => tagDocument.tagId === tag.id).length,
})); }));
return { return {
@ -353,10 +396,20 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
assert(organization, { status: 403 }); assert(organization, { status: 403 });
const name = get(body, ['name']) as string;
const existingTagsWithSameName = await findMany(tagStorage, tag => tag.organizationId === organizationId && tag.name.toLowerCase() === name.toLowerCase());
if (existingTagsWithSameName.length > 0) {
throw Object.assign(new FetchError('Tag already exists'), {
status: 400,
data: { error: { code: 'tags.already_exists' } },
});
}
const tag = { const tag = {
id: createId({ prefix: 'tag' }), id: createId({ prefix: 'tag' }),
organizationId, organizationId,
name: get(body, ['name']) as string, name,
color: get(body, ['color']) as string, color: get(body, ['color']) as string,
description: (get(body, ['description']) ?? null) as string | null, description: (get(body, ['description']) ?? null) as string | null,
createdAt: new Date(), createdAt: new Date(),
@ -381,6 +434,24 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
assert(tag, { status: 404 }); assert(tag, { status: 404 });
const newName = get(body, ['name']) as string | undefined;
if (newName) {
const existingTagsWithSameName = await findMany(
tagStorage,
t =>
t.organizationId === organizationId
&& t.id !== tagId
&& t.name.toLowerCase() === newName.toLowerCase(),
);
if (existingTagsWithSameName.length > 0) {
throw Object.assign(new FetchError('Tag already exists'), {
status: 400,
data: { error: { code: 'tags.already_exists' } },
});
}
}
await tagStorage.setItem(tagId, Object.assign(tag, body, { updatedAt: new Date() })); await tagStorage.setItem(tagId, Object.assign(tag, body, { updatedAt: new Date() }));
return { tag }; return { tag };
@ -758,16 +829,17 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
assert(document, { status: 404 }); assert(document, { status: 404 });
const { name, content } = body as { name?: string; content?: string }; const { name, content, documentDate } = body as { name?: string; content?: string; documentDate?: string };
const newDocument = { const newDocument = {
...document, ...document,
...(name !== undefined && { name }), ...(name !== undefined && { name }),
...(content !== undefined && { content }), ...(content !== undefined && { content }),
...(documentDate !== undefined && { documentDate: documentDate === null ? null : new Date(documentDate) }),
updatedAt: new Date(), updatedAt: new Date(),
}; };
await documentStorage.setItem(`${organizationId}:${documentId}`, newDocument); await documentStorage.setItem(`${organizationId}:${documentId}`, newDocument as Document); // TODO: introduce a storage/serialized type
return { document: newDocument }; return { document: newDocument };
}, },
@ -870,6 +942,173 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
}; };
}, },
}), }),
...defineHandler({
path: '/api/organizations/:organizationId/custom-properties',
method: 'GET',
handler: async ({ params: { organizationId } }) => {
const organization = await organizationStorage.getItem(organizationId);
assert(organization, { status: 403 });
const propertyDefinitions = await findMany(customPropertyDefinitionStorage, def => def.organizationId === organizationId);
return { propertyDefinitions };
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/custom-properties',
method: 'POST',
handler: async ({ params: { organizationId }, body }) => {
const organization = await organizationStorage.getItem(organizationId);
assert(organization, { status: 403 });
const existingDefinitions = await findMany(customPropertyDefinitionStorage, def => def.organizationId === organizationId);
const propertyDefinition = {
id: createId({ prefix: 'cpd' }),
organizationId,
name: get(body, ['name']) as string,
key: generatePropertyKey({ name: get(body, ['name']) as string }),
description: (get(body, ['description']) ?? null) as string | null,
type: get(body, ['type']) as string,
displayOrder: existingDefinitions.length,
options: (get(body, ['options']) as { name: string }[] ?? []).map((option, index) => ({
id: createId({ prefix: 'opt' }),
name: option.name,
key: generatePropertyKey({ name: option.name }),
displayOrder: index,
})),
createdAt: new Date(),
updatedAt: new Date(),
};
await customPropertyDefinitionStorage.setItem(propertyDefinition.id, propertyDefinition as any);
return { propertyDefinition };
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/custom-properties/:propertyDefinitionId',
method: 'GET',
handler: async ({ params: { organizationId, propertyDefinitionId } }) => {
const organization = await organizationStorage.getItem(organizationId);
assert(organization, { status: 403 });
const definition = await customPropertyDefinitionStorage.getItem(propertyDefinitionId);
assert(definition, { status: 404 });
return { definition };
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/custom-properties/:propertyDefinitionId',
method: 'PUT',
handler: async ({ params: { organizationId, propertyDefinitionId }, body }) => {
const organization = await organizationStorage.getItem(organizationId);
assert(organization, { status: 403 });
const propertyDefinition = await customPropertyDefinitionStorage.getItem(propertyDefinitionId);
assert(propertyDefinition, { status: 404 });
const updatedDefinition = Object.assign(propertyDefinition, body, { updatedAt: new Date() });
await customPropertyDefinitionStorage.setItem(propertyDefinitionId, updatedDefinition);
return { propertyDefinition: updatedDefinition };
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/custom-properties/:propertyDefinitionId',
method: 'DELETE',
handler: async ({ params: { organizationId, propertyDefinitionId } }) => {
const organization = await organizationStorage.getItem(organizationId);
assert(organization, { status: 403 });
await customPropertyDefinitionStorage.removeItem(propertyDefinitionId);
// Remove all values associated with this definition
const values = await findMany(documentCustomPropertyValueStorage, v => v.propertyDefinitionId === propertyDefinitionId);
await Promise.all(values.map(v => documentCustomPropertyValueStorage.removeItem(`${v.documentId}:${propertyDefinitionId}`)));
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/documents/:documentId/custom-properties',
method: 'GET',
handler: async ({ params: { organizationId, documentId } }) => {
const key = `${organizationId}:${documentId}`;
const document = await documentStorage.getItem(key);
assert(document, { status: 404 });
const [allDefinitions, allValues] = await Promise.all([
findMany(customPropertyDefinitionStorage, def => def.organizationId === organizationId),
findMany(documentCustomPropertyValueStorage, v => v.documentId === documentId),
]);
const customProperties = buildCustomPropertiesResponse({
definitions: allDefinitions,
storedValues: allValues,
documentId,
});
return { customProperties };
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/documents/:documentId/custom-properties/:propertyDefinitionId',
method: 'PUT',
handler: async ({ params: { organizationId, documentId, propertyDefinitionId }, body }) => {
const docKey = `${organizationId}:${documentId}`;
const document = await documentStorage.getItem(docKey);
assert(document, { status: 404 });
const definition = await customPropertyDefinitionStorage.getItem(propertyDefinitionId);
assert(definition, { status: 404 });
const valueKey = `${documentId}:${propertyDefinitionId}`;
const existing = await documentCustomPropertyValueStorage.getItem(valueKey);
const value = get(body, ['value']);
await documentCustomPropertyValueStorage.setItem(valueKey, {
id: existing?.id ?? createId({ prefix: 'dcpv' }),
documentId,
propertyDefinitionId,
value,
});
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/documents/:documentId/custom-properties/:propertyDefinitionId',
method: 'DELETE',
handler: async ({ params: { organizationId, documentId, propertyDefinitionId } }) => {
const docKey = `${organizationId}:${documentId}`;
const document = await documentStorage.getItem(docKey);
assert(document, { status: 404 });
const valueKey = `${documentId}:${propertyDefinitionId}`;
await documentCustomPropertyValueStorage.removeItem(valueKey);
},
}),
}; };
export const router = createRouter({ routes: inMemoryApiMock, strictTrailingSlash: false }); export const router = createRouter({ routes: inMemoryApiMock, strictTrailingSlash: false });

View file

@ -1,13 +1,13 @@
import type { Component } from 'solid-js'; import type { Component } from 'solid-js';
import { A } from '@solidjs/router'; import { A } from '@solidjs/router';
import { createSignal } from 'solid-js'; import { createSignal, Match, Switch } from 'solid-js';
import { Portal } from 'solid-js/web'; import { Portal } from 'solid-js/web';
import { useI18n } from '../i18n/i18n.provider'; import { useI18n } from '../i18n/i18n.provider';
import { Button } from '../ui/components/button'; import { Button } from '../ui/components/button';
import { clearDemoStorage } from './demo.storage'; import { clearDemoStorage } from './demo.storage';
export const DemoIndicator: Component = () => { export const DemoIndicator: Component = () => {
const [getIsMinified, setIsMinified] = createSignal(false); const [getPopupState, setPopupState] = createSignal<'minified' | 'expanded' | 'hidden'>('expanded');
const { t, te } = useI18n(); const { t, te } = useI18n();
const clearDemo = async () => { const clearDemo = async () => {
@ -16,35 +16,46 @@ export const DemoIndicator: Component = () => {
window.location.href = '/'; window.location.href = '/';
}; };
const switchToStateUnlessCtrl = (state: 'minified' | 'expanded') => (e: MouseEvent) => {
if (e.ctrlKey) {
setPopupState('hidden');
return;
}
setPopupState(state);
};
return ( return (
<Portal> <Portal>
{getIsMinified() <Switch>
? ( <Match when={getPopupState() === 'minified'}>
<div class="fixed bottom-4 right-4 z-50 rounded-xl max-w-280px"> <div class="fixed bottom-4 right-4 z-50 rounded-xl max-w-280px">
<Button onClick={() => setIsMinified(false)} size="icon"> <Button onClick={switchToStateUnlessCtrl('expanded')} size="icon">
<div class="i-tabler-info-circle size-5.5" /> <div class="i-tabler-info-circle size-5.5" />
</Button>
</div>
</Match>
<Match when={getPopupState() === 'expanded'}>
<div class="fixed bottom-4 right-4 z-50 bg-primary text-primary-foreground p-5 py-4 rounded-xl shadow-md max-w-300px">
<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')}
</Button>
<Button onClick={switchToStateUnlessCtrl('minified')} class="bg-transparent hover:text-primary" variant="outline" size="sm">
{t('demo.popup.hide')}
</Button> </Button>
</div> </div>
) </div>
: ( </Match>
<div class="fixed bottom-4 right-4 z-50 bg-primary text-primary-foreground p-5 py-4 rounded-xl shadow-md max-w-300px"> </Switch>
<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')}
</Button>
<Button onClick={() => setIsMinified(true)} class="bg-transparent hover:text-primary" variant="outline" size="sm">
{t('demo.popup.hide')}
</Button>
</div>
</div>
)}
</Portal> </Portal>
); );
}; };

View file

@ -1,4 +1,5 @@
import type { ApiKey } from '../api-keys/api-keys.types'; import type { ApiKey } from '../api-keys/api-keys.types';
import type { CustomPropertyDefinition } from '../custom-properties/custom-properties.types';
import type { Document } from '../documents/documents.types'; import type { Document } from '../documents/documents.types';
import type { Organization } from '../organizations/organizations.types'; import type { Organization } from '../organizations/organizations.types';
import type { TaggingRule } from '../tagging-rules/tagging-rules.types'; import type { TaggingRule } from '../tagging-rules/tagging-rules.types';
@ -9,6 +10,7 @@ import localStorageDriver from 'unstorage/drivers/localstorage';
import { trackingServices } from '../tracking/tracking.services'; import { trackingServices } from '../tracking/tracking.services';
import { DEMO_IS_SEEDED_KEY } from './demo.constants'; import { DEMO_IS_SEEDED_KEY } from './demo.constants';
import { createId } from './demo.models'; import { createId } from './demo.models';
import { customPropertyDefinitionsFixtures } from './seed/custom-property-definitions.fixtures';
import { documentFixtures } from './seed/documents.fixtures'; import { documentFixtures } from './seed/documents.fixtures';
import { tagsFixtures } from './seed/tags.fixtures'; import { tagsFixtures } from './seed/tags.fixtures';
@ -20,6 +22,13 @@ export type DocumentFileStoredFile = { name: string; size: number; type: string;
export type DocumentFileRemoteFile = { name: string; path: string }; export type DocumentFileRemoteFile = { name: string; path: string };
export type DocumentFile = DocumentFileStoredFile | DocumentFileRemoteFile; export type DocumentFile = DocumentFileStoredFile | DocumentFileRemoteFile;
export type DocumentCustomPropertyValueStorage = {
id: string;
documentId: string;
propertyDefinitionId: string;
value: unknown;
};
export const organizationStorage = prefixStorage<Organization>(storage, 'organizations'); export const organizationStorage = prefixStorage<Organization>(storage, 'organizations');
export const documentStorage = prefixStorage<Document>(storage, 'documents'); export const documentStorage = prefixStorage<Document>(storage, 'documents');
export const documentFileStorage = prefixStorage<DocumentFile>(storage, 'documentFiles'); export const documentFileStorage = prefixStorage<DocumentFile>(storage, 'documentFiles');
@ -28,6 +37,8 @@ export const tagDocumentStorage = prefixStorage<{ documentId: string; tagId: str
export const taggingRuleStorage = prefixStorage<TaggingRule>(storage, 'taggingRules'); export const taggingRuleStorage = prefixStorage<TaggingRule>(storage, 'taggingRules');
export const apiKeyStorage = prefixStorage<ApiKey>(storage, 'apiKeys'); export const apiKeyStorage = prefixStorage<ApiKey>(storage, 'apiKeys');
export const webhooksStorage = prefixStorage<Webhook>(storage, 'webhooks'); export const webhooksStorage = prefixStorage<Webhook>(storage, 'webhooks');
export const customPropertyDefinitionStorage = prefixStorage<CustomPropertyDefinition>(storage, 'customPropertyDefinitions');
export const documentCustomPropertyValueStorage = prefixStorage<DocumentCustomPropertyValueStorage>(storage, 'documentCustomPropertyValues');
export async function clearDemoStorage() { export async function clearDemoStorage() {
await storage.clear(); await storage.clear();
@ -90,6 +101,24 @@ export async function seedDemoStorage() {
const tagsPromises = tagStorage.setItems(tags.map(tag => ({ key: tag.id, value: tag }))); const tagsPromises = tagStorage.setItems(tags.map(tag => ({ key: tag.id, value: tag })));
// Create custom property definitions
const customPropertyDefinitions = customPropertyDefinitionsFixtures.map((fixture, index) => ({
id: createId({ prefix: 'cpd' }),
organizationId,
name: fixture.name,
key: fixture.key,
description: fixture.description ?? null,
type: fixture.type,
displayOrder: index,
options: fixture.options ?? [],
createdAt: lastMonth,
updatedAt: lastMonth,
}));
const customPropertyDefinitionsPromises = customPropertyDefinitionStorage.setItems(
customPropertyDefinitions.map(def => ({ key: def.id, value: def })),
);
const documentsPromises = documentFixtures.flatMap((fixture) => { const documentsPromises = documentFixtures.flatMap((fixture) => {
const documentId = createId({ prefix: 'doc' }); const documentId = createId({ prefix: 'doc' });
@ -126,11 +155,30 @@ export async function seedDemoStorage() {
}; };
})); }));
return [documentPromise, documentFilePromise, tagDocumentPromise]; const customPropertyValuePromises = (fixture.customProperties ?? []).map((prop) => {
const definition = customPropertyDefinitions.find(def => def.key === prop.key);
if (!definition) {
return Promise.resolve();
}
const id = createId({ prefix: 'dcpv' });
const valueKey = `${documentId}:${definition.id}`;
return documentCustomPropertyValueStorage.setItem(valueKey, {
id,
documentId,
propertyDefinitionId: definition.id,
value: prop.value,
});
});
return [documentPromise, documentFilePromise, tagDocumentPromise, ...customPropertyValuePromises];
}); });
await Promise.all([ await Promise.all([
tagsPromises, tagsPromises,
customPropertyDefinitionsPromises,
...documentsPromises, ...documentsPromises,
]); ]);
} }

View file

@ -1,7 +1,7 @@
import type { Document } from '@/modules/documents/documents.types'; import type { Document } from '@/modules/documents/documents.types';
import type { Tag } from '@/modules/tags/tags.types'; import type { Tag } from '@/modules/tags/tags.types';
import { describe, expect, test } from 'vitest'; import { describe, expect, test } from 'vitest';
import { searchDemoDocuments } from './demo.search.services'; import { searchDemoDocuments, someCorpusTokenStartsWith } from './demo.search.services';
describe('demo search services', () => { describe('demo search services', () => {
describe('searchDemoDocuments', () => { describe('searchDemoDocuments', () => {
@ -18,18 +18,27 @@ describe('demo search services', () => {
{ id: 'doc_4', name: 'Grocery List', content: 'Eggs, milk, bread, and butter.', tags: [tags.cooking, tags.personal], createdAt: new Date('2023-04-05') }, { id: 'doc_4', name: 'Grocery List', content: 'Eggs, milk, bread, and butter.', tags: [tags.cooking, tags.personal], createdAt: new Date('2023-04-05') },
{ id: 'doc_5', name: 'Project Plan', content: 'Outline project goals and milestones.', tags: [tags.work], createdAt: new Date('2023-05-20') }, { id: 'doc_5', name: 'Project Plan', content: 'Outline project goals and milestones.', tags: [tags.work], createdAt: new Date('2023-05-20') },
{ id: 'doc_6', name: 'Vacation Ideas', content: 'Consider visiting the beach or mountains.', tags: [], createdAt: new Date('2023-06-15') }, { id: 'doc_6', name: 'Vacation Ideas', content: 'Consider visiting the beach or mountains.', tags: [], createdAt: new Date('2023-06-15') },
{ id: 'doc_7', name: 'Invoice 001', content: 'Invoice for services.', tags: [tags.work], createdAt: new Date('2023-07-01'), customProperties: [{ key: 'invoicenumber', type: 'text', value: 'INV-001' }] },
] as unknown as Document[]; ] as unknown as Document[];
const queries = [ const queries = [
{ query: 'panca', expectedIds: ['doc_1'] },
{ query: 'pancakes', expectedIds: ['doc_1'] }, { query: 'pancakes', expectedIds: ['doc_1'] },
{ query: 'pancakes flour', expectedIds: ['doc_1'] },
{ query: 'tag:cooking', expectedIds: ['doc_1', 'doc_4'] }, { query: 'tag:cooking', expectedIds: ['doc_1', 'doc_4'] },
{ query: 'tag:cooking butter', expectedIds: ['doc_4'] }, { query: 'tag:cooking butter', expectedIds: ['doc_4'] },
{ query: 'tag:work created:>2023-03-01', expectedIds: ['doc_5'] }, { query: 'tag:work created:>2023-03-01', expectedIds: ['doc_5', 'doc_7'] },
{ query: '-tag:work', expectedIds: ['doc_1', 'doc_3', 'doc_4', 'doc_6'] }, { query: '-tag:work', expectedIds: ['doc_1', 'doc_3', 'doc_4', 'doc_6'] },
{ query: 'has:tags', expectedIds: ['doc_1', 'doc_2', 'doc_3', 'doc_4', 'doc_5'] }, { query: 'has:tags', expectedIds: ['doc_1', 'doc_2', 'doc_3', 'doc_4', 'doc_5', 'doc_7'] },
{ query: '-has:tags', expectedIds: ['doc_6'] }, { query: '-has:tags', expectedIds: ['doc_6'] },
{ query: 'NOT has:tags', expectedIds: ['doc_6'] }, { query: 'NOT has:tags', expectedIds: ['doc_6'] },
{ query: '-has:tags OR tag:personal', expectedIds: ['doc_3', 'doc_4', 'doc_6'] }, { query: '-has:tags OR tag:personal', expectedIds: ['doc_3', 'doc_4', 'doc_6'] },
{ query: 'ncakes', expectedIds: [] },
{ query: 'name:ncakes', expectedIds: [] },
{ query: 'content:ncakes', expectedIds: [] },
{ query: 'InvoiceNumber:INV', expectedIds: ['doc_7'] },
{ query: 'invoicenumber:INV', expectedIds: ['doc_7'] },
{ query: 'INVOICENUMBER:INV', expectedIds: ['doc_7'] },
]; ];
for (const { query, expectedIds } of queries) { for (const { query, expectedIds } of queries) {
@ -42,4 +51,19 @@ describe('demo search services', () => {
}); });
} }
}); });
describe('someCorpusTokenStartsWith', () => {
test('simulate FTS search behavior by checking if a word starts with the search text, like `name:"foo"*`', () => {
expect(someCorpusTokenStartsWith({ corpus: 'The quick brown fox', prefix: 'qu' })).toBe(true);
expect(someCorpusTokenStartsWith({ corpus: 'The quick brown fox', prefix: 'ick' })).toBe(false);
});
test('works with punctuation', () => {
expect(someCorpusTokenStartsWith({ corpus: 'Hello, world! This is a test.', prefix: 'wo' })).toBe(true);
expect(someCorpusTokenStartsWith({ corpus: 'Hello, world! This is a test.', prefix: 'is' })).toBe(true);
expect(someCorpusTokenStartsWith({ corpus: 'Hello, world! This is a test.', prefix: 'te' })).toBe(true);
expect(someCorpusTokenStartsWith({ corpus: 'Hello, world! This is a test.', prefix: 'lo' })).toBe(false);
expect(someCorpusTokenStartsWith({ corpus: 'Hello-world', prefix: 'worl' })).toBe(true);
});
});
}); });

View file

@ -2,14 +2,34 @@ import type { AndExpression, Expression, FilterExpression, NotExpression, OrExpr
import type { Document } from '../../documents/documents.types'; import type { Document } from '../../documents/documents.types';
import { parseSearchQuery } from '@papra/search-parser'; import { parseSearchQuery } from '@papra/search-parser';
export function generatePropertyKey({ name }: { name: string }): string {
return name
.replace(/[^\p{L}\p{N}]/gu, '')
.toLowerCase();
}
type DocumentCondition = (params: { document: Document }) => boolean; type DocumentCondition = (params: { document: Document }) => boolean;
const falseCondition: DocumentCondition = () => false; const falseCondition: DocumentCondition = () => false;
const trueCondition: DocumentCondition = () => true;
export function someCorpusTokenStartsWith({ corpus, prefix }: { corpus: string | string []; prefix: string }): boolean {
const lowerPrefix = prefix.toLowerCase();
const corpusString = Array.isArray(corpus) ? corpus.join(' ') : corpus;
const prefixLength = lowerPrefix.length;
return corpusString
.split(/[\W_]+/)
.some(token =>
token.length >= prefixLength // early exit for faster checks
&& token.toLowerCase().startsWith(lowerPrefix),
);
}
function buildTextCondition({ expression }: { expression: TextExpression }): DocumentCondition { function buildTextCondition({ expression }: { expression: TextExpression }): DocumentCondition {
const searchText = expression.value.trim().toLowerCase(); const searchText = expression.value.trim().toLowerCase();
return ({ document }) => [document.name, document.content].join(' ').toLowerCase().includes(searchText); return ({ document }) => someCorpusTokenStartsWith({ corpus: [document.name, document.content], prefix: searchText });
} }
function buildAndCondition({ expression }: { expression: AndExpression }): DocumentCondition { function buildAndCondition({ expression }: { expression: AndExpression }): DocumentCondition {
@ -37,7 +57,7 @@ function buildTagFilterCondition({ expression }: { expression: FilterExpression
return falseCondition; return falseCondition;
} }
return ({ document }) => document.tags.find(tag => tag.name === value || tag.id === value) !== undefined; return ({ document }) => document.tags.find(tag => tag.name.toLowerCase() === value.toLowerCase() || tag.id === value) !== undefined;
} }
function buildNameFilterCondition({ expression }: { expression: FilterExpression }): DocumentCondition { function buildNameFilterCondition({ expression }: { expression: FilterExpression }): DocumentCondition {
@ -47,7 +67,7 @@ function buildNameFilterCondition({ expression }: { expression: FilterExpression
return falseCondition; return falseCondition;
} }
return ({ document }) => document.name.toLowerCase().includes(value.toLowerCase()); return ({ document }) => someCorpusTokenStartsWith({ corpus: document.name, prefix: value });
} }
function buildContentFilterCondition({ expression }: { expression: FilterExpression }): DocumentCondition { function buildContentFilterCondition({ expression }: { expression: FilterExpression }): DocumentCondition {
@ -57,12 +77,12 @@ function buildContentFilterCondition({ expression }: { expression: FilterExpress
return falseCondition; return falseCondition;
} }
return ({ document }) => document.content.toLowerCase().includes(value.toLowerCase()); return ({ document }) => someCorpusTokenStartsWith({ corpus: document.content, prefix: value });
} }
function buildCreatedFilterCondition({ expression }: { expression: FilterExpression }): DocumentCondition { function buildCreatedFilterCondition({ expression }: { expression: FilterExpression }): DocumentCondition {
const { value, operator } = expression; const { value, operator } = expression;
const dateValue = new Date(value); const dateValue = getDateValue({ value });
if (Number.isNaN(dateValue.getTime())) { if (Number.isNaN(dateValue.getTime())) {
return () => false; return () => false;
@ -92,6 +112,160 @@ function buildHasTagsFilter({ expression }: { expression: FilterExpression }): D
return ({ document }) => document.tags.length > 0; return ({ document }) => document.tags.length > 0;
} }
function getDateValue({ value, now = new Date() }: { value: string; now?: Date }): Date {
if (value === 'now') {
return now;
}
return new Date(value);
}
function buildDateFilterCondition({ expression }: { expression: FilterExpression }): DocumentCondition {
const { value, operator } = expression;
const dateValue = getDateValue({ value });
if (Number.isNaN(dateValue.getTime())) {
return () => false;
}
return ({ document }) => {
if (!document.documentDate) {
return false;
}
const docDate = new Date(document.documentDate);
switch (operator) {
case '=': return docDate.toDateString() === dateValue.toDateString();
case '<': return docDate < dateValue;
case '<=': return docDate <= dateValue;
case '>': return docDate > dateValue;
case '>=': return docDate >= dateValue;
default: return false;
}
};
}
function buildHasDateFilter({ expression }: { expression: FilterExpression }): DocumentCondition {
const { operator } = expression;
if (operator !== '=') {
return falseCondition;
}
return ({ document }) => document.documentDate != null;
}
function buildHasCustomPropertyFilter({ propertyKey }: { propertyKey: string }): DocumentCondition {
const normalizedKey = generatePropertyKey({ name: propertyKey });
return ({ document }) => {
const prop = document.customProperties?.find(p => p.key === normalizedKey);
return prop !== undefined && prop.value != null;
};
}
function buildCustomPropertyFilterCondition({ field, expression }: { field: string; expression: FilterExpression }): DocumentCondition {
const { value, operator } = expression;
const normalizedField = generatePropertyKey({ name: field });
return ({ document }) => {
const prop = document.customProperties?.find(p => p.key === normalizedField);
if (!prop || prop.value == null) {
return false;
}
switch (prop.type) {
case 'boolean': {
if (operator !== '=') {
return false;
}
const boolValue = ['true', 'yes', '1', 'on', 'enabled'].includes(value.toLowerCase());
return prop.value === boolValue;
}
case 'number': {
const numValue = Number(value);
if (Number.isNaN(numValue)) {
return false;
}
const propNum = Number(prop.value);
switch (operator) {
case '=': return propNum === numValue;
case '<': return propNum < numValue;
case '<=': return propNum <= numValue;
case '>': return propNum > numValue;
case '>=': return propNum >= numValue;
default: return false;
}
}
case 'date': {
const dateValue = getDateValue({ value });
if (Number.isNaN(dateValue.getTime())) {
return false;
}
const propDate = new Date(prop.value as string);
switch (operator) {
case '=': return propDate.toDateString() === dateValue.toDateString();
case '<': return propDate < dateValue;
case '<=': return propDate <= dateValue;
case '>': return propDate > dateValue;
case '>=': return propDate >= dateValue;
default: return false;
}
}
case 'text': {
if (operator !== '=') {
return false;
}
return someCorpusTokenStartsWith({ corpus: String(prop.value), prefix: value });
}
case 'select': {
if (operator !== '=') {
return false;
}
const propValue = prop.value as { key: string; name: string } | string;
const propKey = typeof propValue === 'object' ? propValue.key : propValue;
return propKey.toLowerCase() === value.toLowerCase();
}
case 'multi_select': {
if (operator !== '=') {
return false;
}
const propValues = prop.value as Array<{ key: string; name: string } | string>;
return propValues.some((v) => {
const optKey = typeof v === 'object' ? v.key : v;
return optKey.toLowerCase() === value.toLowerCase();
});
}
default: {
if (operator !== '=') {
return false;
}
return String(prop.value).toLowerCase() === value.toLowerCase();
}
}
};
}
const KNOWN_FILTER_FIELDS = new Set(['tag', 'name', 'content', 'created', 'date', 'has']);
function buildExpressionCondition({ expression }: { expression: Expression }): DocumentCondition { function buildExpressionCondition({ expression }: { expression: Expression }): DocumentCondition {
switch (expression.type) { switch (expression.type) {
case 'text': return buildTextCondition({ expression }); case 'text': return buildTextCondition({ expression });
@ -104,14 +278,24 @@ function buildExpressionCondition({ expression }: { expression: Expression }): D
case 'name': return buildNameFilterCondition({ expression }); case 'name': return buildNameFilterCondition({ expression });
case 'content': return buildContentFilterCondition({ expression }); case 'content': return buildContentFilterCondition({ expression });
case 'created': return buildCreatedFilterCondition({ expression }); case 'created': return buildCreatedFilterCondition({ expression });
case 'date': return buildDateFilterCondition({ expression });
case 'has': case 'has':
switch (expression.value) { switch (expression.value) {
case 'tags': return buildHasTagsFilter({ expression }); case 'tags': return buildHasTagsFilter({ expression });
default: return falseCondition; case 'date': return buildHasDateFilter({ expression });
default:
// has:<customPropertyKey> — check if document has a non-null value for this property
return buildHasCustomPropertyFilter({ propertyKey: expression.value });
} }
default: return falseCondition; default:
// Unknown field — treat as a custom property key filter
if (!KNOWN_FILTER_FIELDS.has(expression.field)) {
return buildCustomPropertyFilterCondition({ field: expression.field, expression });
}
return falseCondition;
} }
case 'empty': return falseCondition; case 'empty': return trueCondition;
default: return falseCondition; default: return falseCondition;
} }
} }

View file

@ -0,0 +1,53 @@
import type { CustomPropertySelectOption, CustomPropertyType } from '../../custom-properties/custom-properties.types';
export type DemoCustomPropertyDefinitionFixture = {
name: string;
key: string;
description?: string;
type: CustomPropertyType;
options?: CustomPropertySelectOption[];
};
export const customPropertyDefinitionsFixtures: DemoCustomPropertyDefinitionFixture[] = [
{
name: 'Status',
key: 'status',
description: 'Current processing status of the document',
type: 'select',
options: [
{ id: 'opt_status_pending', key: 'pending', name: 'Pending', displayOrder: 0 },
{ id: 'opt_status_reviewed', key: 'reviewed', name: 'Reviewed', displayOrder: 1 },
{ id: 'opt_status_archived', key: 'archived', name: 'Archived', displayOrder: 2 },
],
},
{
name: 'Priority',
key: 'priority',
description: 'Investigation priority level',
type: 'select',
options: [
{ id: 'opt_priority_low', key: 'low', name: 'Low', displayOrder: 0 },
{ id: 'opt_priority_medium', key: 'medium', name: 'Medium', displayOrder: 1 },
{ id: 'opt_priority_high', key: 'high', name: 'High', displayOrder: 2 },
{ id: 'opt_priority_critical', key: 'critical', name: 'Critical', displayOrder: 3 },
],
},
{
name: 'Amount',
key: 'amount',
description: 'Monetary amount in GBP',
type: 'number',
},
{
name: 'Confidential',
key: 'confidential',
description: 'Whether this document is confidential',
type: 'boolean',
},
{
name: 'Reference',
key: 'reference',
description: 'External reference number or identifier',
type: 'text',
},
] as const satisfies DemoCustomPropertyDefinitionFixture[];

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