mirror of
https://github.com/papra-hq/papra
synced 2026-04-21 13:37:23 +00:00
Compare commits
160 commits
@papra/app
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7854f6b1a1 | ||
|
|
2b0c258f8a | ||
|
|
988383ea57 | ||
|
|
a574fb7206 | ||
|
|
6a8ade711b | ||
|
|
e05e6e86f0 | ||
|
|
fb8cc381cc | ||
|
|
4f25ecd9d2 | ||
|
|
ad3fabf564 | ||
|
|
123b7c1784 | ||
|
|
4fe1731323 | ||
|
|
ff2ae64abe | ||
|
|
791d08c486 | ||
|
|
612fcb7563 | ||
|
|
488997f580 | ||
|
|
9ad1c16b45 | ||
|
|
c5ccac53c2 | ||
|
|
5d55e41c3b | ||
|
|
401efe2fa4 | ||
|
|
366e4eead1 | ||
|
|
6c1e45acf1 | ||
|
|
b154d2f363 | ||
|
|
5f8e2f6375 | ||
|
|
9c6985b51f | ||
|
|
2fcbe0bc89 | ||
|
|
5b3a85795c | ||
|
|
133d235ccd | ||
|
|
5bdf0dab1f | ||
|
|
015bb53498 | ||
|
|
07d7109a46 | ||
|
|
f0b346760d | ||
|
|
8984179a76 | ||
|
|
1548535f1e | ||
|
|
ad5e42d445 | ||
|
|
cee14d96a5 | ||
|
|
4a10ace601 | ||
|
|
927c7d6b31 | ||
|
|
59a62cbf6a | ||
|
|
5b0d747d29 | ||
|
|
db988e990d | ||
|
|
ef89ea6751 | ||
|
|
da86b93439 | ||
|
|
e11fb5352a | ||
|
|
f713a9801a | ||
|
|
fe724fbb06 | ||
|
|
ac11aa50a4 | ||
|
|
f248b283dc | ||
|
|
b29396a197 | ||
|
|
c252a5945f | ||
|
|
7d22a31511 | ||
|
|
327eda0001 | ||
|
|
17b501a996 | ||
|
|
9ee776f1c5 | ||
|
|
884d470410 | ||
|
|
9039b4806e | ||
|
|
4b77e41242 | ||
|
|
00cfbf88df | ||
|
|
8fe222bb8f | ||
|
|
7d34241bbf | ||
|
|
f336b842c6 | ||
|
|
5555934847 | ||
|
|
2ca5634166 | ||
|
|
882fee6e73 | ||
|
|
31e0813429 | ||
|
|
1c1e904ba1 | ||
|
|
ce1e891ec1 | ||
|
|
30bd0778b3 | ||
|
|
595b5a9149 | ||
|
|
5900674083 | ||
|
|
74828e8ad6 | ||
|
|
812e7c317e | ||
|
|
b900a1d947 | ||
|
|
d47d6b29a6 | ||
|
|
87d80af2ac | ||
|
|
7c2b2d27cd | ||
|
|
db6badbc3c | ||
|
|
ec740ed168 | ||
|
|
725eaff4b0 | ||
|
|
27fe78501d | ||
|
|
c7089610ac | ||
|
|
31e27d5e1e | ||
|
|
3bde35bb07 | ||
|
|
a7b18cec6b | ||
|
|
e1314a42f2 | ||
|
|
305921e779 | ||
|
|
87a94ab567 | ||
|
|
f11c2b9064 | ||
|
|
69ab83c504 | ||
|
|
62e9e66638 | ||
|
|
69f8f0e13b | ||
|
|
c4645eae9c | ||
|
|
6be6beae90 | ||
|
|
41e9f33b06 | ||
|
|
01e4540927 | ||
|
|
077e4294fb | ||
|
|
f29ebc5de7 | ||
|
|
105353ff00 | ||
|
|
65c2bea4c3 | ||
|
|
1a0a900fd9 | ||
|
|
aca98084ca | ||
|
|
548608be39 | ||
|
|
21e5c313a6 | ||
|
|
a096360eeb | ||
|
|
2143728157 | ||
|
|
1228c1c36d | ||
|
|
ad5393aeb3 | ||
|
|
d13c74f0db | ||
|
|
f978fdd314 | ||
|
|
9058f9e08c | ||
|
|
d1eae05dd3 | ||
|
|
fdd955e20c | ||
|
|
77186da42c | ||
|
|
0c62716e5d | ||
|
|
1c1d273fbd | ||
|
|
86805aeae0 | ||
|
|
1624efc9d9 | ||
|
|
fb323cdeb3 | ||
|
|
1b7d79408c | ||
|
|
b766d1ec1a | ||
|
|
63ddecf489 | ||
|
|
4c7da4b674 | ||
|
|
4f5b29b7ed | ||
|
|
bd57373d60 | ||
|
|
e2caea9e92 | ||
|
|
5285796233 | ||
|
|
685d3a4041 | ||
|
|
4c772105f1 | ||
|
|
de9ca03915 | ||
|
|
69633fb9ea | ||
|
|
7629055daa | ||
|
|
77f8511627 | ||
|
|
faebe93594 | ||
|
|
85e1c862de | ||
|
|
afcfcf75cb | ||
|
|
3f4ca07a5d | ||
|
|
ac78626607 | ||
|
|
cb8b4bb521 | ||
|
|
316a8c2f9c | ||
|
|
71872db367 | ||
|
|
c9d1d64b91 | ||
|
|
53b179260d | ||
|
|
fe284fc3d7 | ||
|
|
5250d20e26 | ||
|
|
a754c68a11 | ||
|
|
2d44c2b043 | ||
|
|
f7b03368d6 | ||
|
|
f3a9dca1a0 | ||
|
|
0a0b7ed9f2 | ||
|
|
393a15593f | ||
|
|
ca2ef2866b | ||
|
|
494aa5b882 | ||
|
|
7c581fa343 | ||
|
|
2ac51dc066 | ||
|
|
b6951ea05a | ||
|
|
3fa398c928 | ||
|
|
46d8d2d45e | ||
|
|
3945c7924e | ||
|
|
1d5ada8522 | ||
|
|
1eeb3df4a2 | ||
|
|
b458a22d85 |
531 changed files with 23616 additions and 26760 deletions
5
.changeset/eight-papayas-exist.md
Normal file
5
.changeset/eight-papayas-exist.md
Normal 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.
|
||||
5
.changeset/free-lemur-yummy.md
Normal file
5
.changeset/free-lemur-yummy.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@papra/app": patch
|
||||
---
|
||||
|
||||
Synchronized the document pagination of the home page in query params to permit sharing and navigation.
|
||||
5
.changeset/happy-donkeys-prove.md
Normal file
5
.changeset/happy-donkeys-prove.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@papra/app": patch
|
||||
---
|
||||
|
||||
Added content preview for yaml files
|
||||
5
.changeset/hip-corners-rhyme.md
Normal file
5
.changeset/hip-corners-rhyme.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@papra/app": patch
|
||||
---
|
||||
|
||||
Removed weird shadows on ui components in light mode
|
||||
9
.changeset/new-dolls-wink.md
Normal file
9
.changeset/new-dolls-wink.md
Normal 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.
|
||||
5
.changeset/pink-baths-worry.md
Normal file
5
.changeset/pink-baths-worry.md
Normal 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.
|
||||
5
.changeset/proud-things-rest.md
Normal file
5
.changeset/proud-things-rest.md
Normal 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.
|
||||
5
.changeset/reserved-jaguar-cry.md
Normal file
5
.changeset/reserved-jaguar-cry.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@papra/app": patch
|
||||
---
|
||||
|
||||
Removed native clear button of search bar in safari.
|
||||
5
.changeset/smooth-peaches-wash.md
Normal file
5
.changeset/smooth-peaches-wash.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@papra/app": patch
|
||||
---
|
||||
|
||||
Updated pnpm to 10.33.0.
|
||||
5
.changeset/tiny-hairs-drive.md
Normal file
5
.changeset/tiny-hairs-drive.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@papra/app": patch
|
||||
---
|
||||
|
||||
Increased the sidebar collapsing breakpoint to improve the UX on tablets and small laptops.
|
||||
5
.changeset/versatile-sparrow-clean.md
Normal file
5
.changeset/versatile-sparrow-clean.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@papra/app": patch
|
||||
---
|
||||
|
||||
Removed useless close button in the small-screen sidebar sheet.
|
||||
5
.changeset/violet-geese-sit.md
Normal file
5
.changeset/violet-geese-sit.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@papra/app": patch
|
||||
---
|
||||
|
||||
Prevented the users and organizations tables from forcing horizontal scrolling in the admin panels.
|
||||
5
.changeset/whole-socks-drop.md
Normal file
5
.changeset/whole-socks-drop.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@papra/app": patch
|
||||
---
|
||||
|
||||
Prevented long documents name from pushing the right columns out of the container.
|
||||
|
|
@ -1 +0,0 @@
|
|||
packages/docker/.dockerignore
|
||||
45
.dockerignore
Normal file
45
.dockerignore
Normal 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
3
.gitignore
vendored
|
|
@ -45,4 +45,5 @@ ingestion
|
|||
|
||||
.eslintcache
|
||||
.claude
|
||||
.opencode
|
||||
.opencode
|
||||
AGENTS.md
|
||||
24
README.md
24
README.md
|
|
@ -39,7 +39,7 @@ A live demo of the platform is available at [demo.papra.app](https://demo.papra.
|
|||
|
||||
## 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.
|
||||
|
||||
|
|
@ -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.
|
||||
- **API, SDK and webhooks**: Build your own applications on top of Papra.
|
||||
- **i18n**: Support for multiple languages.
|
||||
- *Coming soon:* **Document sharing**: Share documents with others.
|
||||
- *Coming soon:* **Document requests**: Generate upload links for people to add documents.
|
||||
- *Coming maybe one day:* **Mobile app**: Access and upload documents on the go.
|
||||
- *Coming maybe one day:* **Desktop app**: Access and upload documents from your computer.
|
||||
- *Coming maybe one day:* **Browser extension**: Upload documents from your browser.
|
||||
- *Coming maybe one day:* **AI**: Use AI to help you manage or tag your documents.
|
||||
- _Coming soon:_ **Document sharing**: Share documents with others.
|
||||
- _Coming soon:_ **Document requests**: Generate upload links for people to add documents.
|
||||
- _Coming maybe one day:_ **Mobile app**: Access and upload documents on the go.
|
||||
- _Coming maybe one day:_ **Desktop app**: Access and upload documents from your computer.
|
||||
- _Coming maybe one day:_ **Browser extension**: Upload documents from your browser.
|
||||
- _Coming maybe one day:_ **AI**: Use AI to help you manage or tag your documents.
|
||||
|
||||
## Sponsors
|
||||
## 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
|
||||
|
||||
|
|
@ -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.
|
||||
|
||||
## Contact Information
|
||||
## Sponsors
|
||||
|
||||
Please use the issue tracker on GitHub for any questions or feedback.
|
||||
Shout-out to our current sponsors:
|
||||

|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { sidebar } from './src/content/navigation';
|
|||
import posthogRawScript from './src/scripts/posthog.script.js?raw';
|
||||
|
||||
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 posthogScript = posthogRawScript.replace('[POSTHOG-API-KEY]', posthogApiKey ?? '').replace('[POSTHOG-API-HOST]', posthogApiHost);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export default antfu({
|
|||
],
|
||||
|
||||
rules: {
|
||||
'pnpm/json-enforce-catalog': 'off',
|
||||
// To allow export on top of files
|
||||
'ts/no-use-before-define': ['error', { allowNamedExports: true, functions: false }],
|
||||
'curly': ['error', 'all'],
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
"dependencies": {
|
||||
"@astrojs/solid-js": "^5.1.0",
|
||||
"@astrojs/starlight": "^0.34.3",
|
||||
"@valibot/to-json-schema": "^1.6.0",
|
||||
"astro": "catalog:",
|
||||
"sharp": "^0.32.5",
|
||||
"shiki": "^3.4.2",
|
||||
|
|
@ -26,19 +27,16 @@
|
|||
"starlight-theme-rapide": "^0.5.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"unocss-preset-animations": "catalog:",
|
||||
"yaml": "^2.8.0",
|
||||
"zod": "^3.25.67",
|
||||
"zod-to-json-schema": "^3.24.5"
|
||||
"valibot": "catalog:",
|
||||
"yaml": "^2.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "catalog:",
|
||||
"@iconify-json/tabler": "catalog:",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@unocss/reset": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"eslint-plugin-astro": "^1.3.1",
|
||||
"figue": "^3.1.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"figue": "catalog:",
|
||||
"marked": "^15.0.6",
|
||||
"typescript": "catalog:",
|
||||
"unocss": "catalog:",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type { ConfigDefinition, ConfigDefinitionElement } from 'figue';
|
||||
import { castArray, isArray, isEmpty, isNil } from 'lodash-es';
|
||||
|
||||
import { configDefinition } from '../../papra-server/src/modules/config/config';
|
||||
import { renderMarkdown } from './markdown';
|
||||
|
|
@ -28,10 +27,14 @@ function formatDoc(doc: string | undefined): string {
|
|||
return `${coerced}.`;
|
||||
}
|
||||
|
||||
function getIsEmptyDefaultValue(defaultValue: unknown): boolean {
|
||||
return defaultValue === undefined || defaultValue === null || defaultValue === '' || (Array.isArray(defaultValue) && defaultValue.length === 0);
|
||||
}
|
||||
|
||||
const rows = configDetails
|
||||
.filter(({ path }) => path[0] !== 'env')
|
||||
.filter(({ showInDocumentation }) => showInDocumentation !== false)
|
||||
.map(({ doc, default: defaultValue, env, path }) => {
|
||||
const isEmptyDefaultValue = isNil(defaultValue) || (isArray(defaultValue) && isEmpty(defaultValue)) || defaultValue === '';
|
||||
const isEmptyDefaultValue = getIsEmptyDefaultValue(defaultValue);
|
||||
|
||||
const rawDocumentation = formatDoc(doc);
|
||||
|
||||
|
|
@ -47,7 +50,7 @@ const rows = configDetails
|
|||
});
|
||||
|
||||
const mdSections = rows.map(({ documentation, env, path, defaultValue }) => {
|
||||
const envs = castArray(env);
|
||||
const envs = Array.isArray(env) ? env : [env];
|
||||
const [firstEnv, ...restEnvs] = envs;
|
||||
|
||||
return `
|
||||
|
|
@ -84,8 +87,8 @@ function wrapText(text: string, maxLength = 75) {
|
|||
}
|
||||
|
||||
const fullDotEnv = rows.map(({ env, defaultValue, documentation }) => {
|
||||
const isEmptyDefaultValue = isNil(defaultValue) || (isArray(defaultValue) && isEmpty(defaultValue)) || defaultValue === '';
|
||||
const envs = castArray(env);
|
||||
const isEmptyDefaultValue = getIsEmptyDefaultValue(defaultValue);
|
||||
const envs = Array.isArray(env) ? env : [env];
|
||||
const [firstEnv] = envs;
|
||||
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# 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
|
||||
# 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
|
||||
# set the same as the `WEBHOOK_SECRET` variable in the Email Worker
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import EncryptionKeyGenerator from '../../../components/encryption-key-generator
|
|||
Document encryption is available in Papra v0.9.0 and above.
|
||||
</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
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,9 @@ Use filters to search specific document properties. Current supported filters:
|
|||
- `content:` - Search in document content
|
||||
- `tag:` - Search by tag name or ID
|
||||
- `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: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
|
||||
|
||||
|
|
@ -69,6 +71,8 @@ The `has:` filter checks whether documents have certain properties:
|
|||
|
||||
- `has:tags` - Documents with at least one tag
|
||||
- `-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
|
||||
|
||||
|
|
@ -84,11 +88,17 @@ has:tags invoice
|
|||
|
||||
# Find tagged documents that are not 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
|
||||
|
||||
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
|
||||
- `>` - After the date
|
||||
|
|
@ -127,6 +137,13 @@ created:<=2024-06-30
|
|||
|
||||
# Finds invoices created only in 2024
|
||||
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
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
|
|
|||
255
apps/docs/src/content/docs/03-guides/09-storage-key-patterns.mdx
Normal file
255
apps/docs/src/content/docs/03-guides/09-storage-key-patterns.mdx
Normal 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)
|
||||
|
|
@ -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`.
|
||||
|
|
@ -155,7 +155,7 @@ List all documents in the organization.
|
|||
- Query parameters
|
||||
- `pageIndex`: (optional, default: 0) The page index to start from.
|
||||
- `pageSize`: (optional, default: 100) The number of documents to return.
|
||||
- `tags`: (optional) The tags IDs to filter by.
|
||||
- `searchQuery`: (optional) Search query, can reference [Filters](https://docs.papra.app/guides/advanced-search/#filters) for a more defined search
|
||||
- Response (JSON)
|
||||
- `documents`: The list 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.
|
||||
|
||||
- Required API key permissions: `documents:update`
|
||||
- Body (form-data)
|
||||
- Body (JSON)
|
||||
- `name`: (optional) The document name.
|
||||
- `content`: (optional) The document content.
|
||||
- Response (JSON)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -41,6 +41,10 @@ export const sidebar = [
|
|||
label: 'Document Encryption',
|
||||
slug: 'guides/document-encryption',
|
||||
},
|
||||
{
|
||||
label: 'Migrate Document Storage',
|
||||
slug: 'guides/migrate-document-storage',
|
||||
},
|
||||
{
|
||||
label: 'Tagging Rules',
|
||||
slug: 'guides/tagging-rules',
|
||||
|
|
@ -53,6 +57,10 @@ export const sidebar = [
|
|||
label: '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',
|
||||
slug: 'resources/api-endpoints',
|
||||
},
|
||||
{
|
||||
label: 'Versioning & Tagging',
|
||||
slug: 'resources/versioning-and-tagging',
|
||||
},
|
||||
],
|
||||
},
|
||||
] satisfies StarlightUserConfig['sidebar'];
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<select class="input-field mt-0" id="intake-email-driver">
|
||||
<option value="owlrelay" class="bg-background">OwlRelay</option>
|
||||
<option value="random-username" class="bg-background">Cloudflare Email Worker</option>
|
||||
<option value="catch-all" class="bg-background">Cloudflare Email Worker</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -294,7 +294,7 @@ function getDockerComposeYml() {
|
|||
intakeEmailEnabled && `INTAKE_EMAILS_WEBHOOK_SECRET=${webhookSecret}`,
|
||||
intakeEmailEnabled && intakeDriver === 'owlrelay' && owlrelayApiKeyInput.value && `OWLRELAY_API_KEY=${owlrelayApiKeyInput.value}`,
|
||||
intakeEmailEnabled && intakeDriver === 'owlrelay' && owlrelayWebhookUrlInput.value && `OWLRELAY_WEBHOOK_URL=${owlrelayWebhookUrlInput.value}`,
|
||||
intakeEmailEnabled && intakeDriver === 'random-username' && cfEmailDomainInput.value && `INTAKE_EMAILS_EMAIL_GENERATION_DOMAIN=${cfEmailDomainInput.value}`,
|
||||
intakeEmailEnabled && intakeDriver === 'catch-all' && cfEmailDomainInput.value && `INTAKE_EMAILS_CATCH_ALL_DOMAIN=${cfEmailDomainInput.value}`,
|
||||
].flat().filter(Boolean);
|
||||
|
||||
const volumes = [
|
||||
|
|
@ -426,7 +426,7 @@ function handleIntakeDriverChange() {
|
|||
owlrelayConfig.style.display = driver === 'owlrelay' ? 'block' : 'none';
|
||||
}
|
||||
if (cfWorkerConfig) {
|
||||
cfWorkerConfig.style.display = driver === 'random-username' ? 'block' : 'none';
|
||||
cfWorkerConfig.style.display = driver === 'catch-all' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
updateDockerCompose();
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ const changelog = parseChangelog(rawChangelog);
|
|||
>
|
||||
<p>
|
||||
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>
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,46 +1,32 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
import type { ConfigDefinition } from 'figue';
|
||||
import { mapValues } from 'lodash-es';
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import { toJsonSchema } from '@valibot/to-json-schema';
|
||||
import * as v from 'valibot';
|
||||
import { configDefinition } from '../../../papra-server/src/modules/config/config';
|
||||
|
||||
function buildConfigSchema({ configDefinition }: { configDefinition: ConfigDefinition }) {
|
||||
const schema: any = mapValues(configDefinition, (config) => {
|
||||
if (typeof config === 'object' && config !== null && 'schema' in config && 'doc' in config) {
|
||||
return config.schema;
|
||||
function buildConfigSchema({ configDefinition }: { configDefinition: ConfigDefinition }): v.GenericSchema {
|
||||
const entries: Record<string, v.GenericSchema> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(configDefinition)) {
|
||||
if ('schema' in value) {
|
||||
entries[key] = v.optional(value.schema as v.GenericSchema);
|
||||
} else {
|
||||
return buildConfigSchema({
|
||||
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]);
|
||||
entries[key] = v.optional(buildConfigSchema({ configDefinition: value }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addSchema(schema: any) {
|
||||
schema.properties.$schema = {
|
||||
type: 'string',
|
||||
description: 'The schema of the configuration file, to be used by IDEs to provide autocompletion and validation',
|
||||
};
|
||||
return v.object(entries);
|
||||
}
|
||||
|
||||
function getConfigSchema() {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export default antfu({
|
|||
},
|
||||
|
||||
rules: {
|
||||
'pnpm/json-enforce-catalog': 'off',
|
||||
// To allow export on top of files
|
||||
'ts/no-use-before-define': ['error', { allowNamedExports: true, functions: false }],
|
||||
'curly': ['error', 'all'],
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { FetchOptions, ResponseType } from 'ofetch';
|
||||
import { ofetch } from 'ofetch';
|
||||
import type { FetchOptions } from 'ofetch';
|
||||
import { FetchError, ofetch, ResponseType } from 'ofetch';
|
||||
|
||||
export { ResponseType };
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
export function isHttpClientError(error: unknown): error is FetchError {
|
||||
return error instanceof FetchError;
|
||||
}
|
||||
|
|
|
|||
3
apps/mobile/src/modules/app/app.constants.ts
Normal file
3
apps/mobile/src/modules/app/app.constants.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import Constants from 'expo-constants';
|
||||
|
||||
export const APP_SCHEME = String(Constants.expoConfig?.scheme ?? 'papra');
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { expoClient } from '@better-auth/expo/client';
|
||||
import { createAuthClient as createBetterAuthClient } from 'better-auth/react';
|
||||
import Constants from 'expo-constants';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { Platform } from 'react-native';
|
||||
import { APP_SCHEME } from '../app/app.constants';
|
||||
|
||||
export type AuthClient = ReturnType<typeof createAuthClient>;
|
||||
|
||||
|
|
@ -11,8 +11,8 @@ export function createAuthClient({ baseUrl}: { baseUrl: string }) {
|
|||
baseURL: baseUrl,
|
||||
plugins: [
|
||||
expoClient({
|
||||
scheme: String(Constants.expoConfig?.scheme ?? 'papra'),
|
||||
storagePrefix: String(Constants.expoConfig?.scheme ?? 'papra'),
|
||||
scheme: APP_SCHEME,
|
||||
storagePrefix: APP_SCHEME,
|
||||
storage: Platform.OS === 'web'
|
||||
? localStorage
|
||||
: SecureStore,
|
||||
|
|
|
|||
76
apps/mobile/src/modules/auth/auth.models.test.ts
Normal file
76
apps/mobile/src/modules/auth/auth.models.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
15
apps/mobile/src/modules/auth/auth.models.ts
Normal file
15
apps/mobile/src/modules/auth/auth.models.ts
Normal 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,
|
||||
];
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ import { useAuthClient } from '@/modules/api/providers/api.provider';
|
|||
import { useAlert } from '@/modules/ui/providers/alert-provider';
|
||||
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
|
||||
import { useServerConfig } from '../../config/hooks/use-server-config';
|
||||
import { getEnabledOAuthProviders } from '../auth.models';
|
||||
import { BackToServerSelectionButton } from '../components/back-to-server-selection';
|
||||
|
||||
const loginSchema = v.object({
|
||||
|
|
@ -34,7 +35,7 @@ export function LoginScreen() {
|
|||
const insets = useSafeAreaInsets();
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { data: serverConfig, isLoading: isLoadingConfig } = useServerConfig();
|
||||
const { serverConfig, isLoading: isConfigLoading } = useServerConfig();
|
||||
|
||||
const form = useForm({
|
||||
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 isGoogleEnabled = authConfig?.providers?.google?.isEnabled ?? false;
|
||||
const isGithubEnabled = authConfig?.providers?.github?.isEnabled ?? false;
|
||||
const customProviders = authConfig?.providers?.customs ?? [];
|
||||
|
||||
const oauthProviders = getEnabledOAuthProviders({ serverConfig });
|
||||
|
||||
const styles = createStyles({ themeColors });
|
||||
|
||||
if (isLoadingConfig) {
|
||||
if (isConfigLoading) {
|
||||
return (
|
||||
<View style={[styles.container, styles.centerContent]}>
|
||||
<ActivityIndicator size="large" color={themeColors.primary} />
|
||||
|
|
@ -159,7 +159,7 @@ export function LoginScreen() {
|
|||
>
|
||||
{isSubmitting
|
||||
? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
<ActivityIndicator color={themeColors.primaryForeground} />
|
||||
)
|
||||
: (
|
||||
<Text style={styles.buttonText}>Sign In</Text>
|
||||
|
|
@ -168,7 +168,7 @@ export function LoginScreen() {
|
|||
</View>
|
||||
)}
|
||||
|
||||
{(isGoogleEnabled || isGithubEnabled || customProviders.length > 0) && (
|
||||
{(oauthProviders.length > 0) && (
|
||||
<>
|
||||
{isEmailEnabled && (
|
||||
<View style={styles.divider}>
|
||||
|
|
@ -179,25 +179,7 @@ export function LoginScreen() {
|
|||
)}
|
||||
|
||||
<View style={styles.socialButtons}>
|
||||
{isGoogleEnabled && (
|
||||
<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 => (
|
||||
{oauthProviders.map(provider => (
|
||||
<TouchableOpacity
|
||||
key={provider.providerId}
|
||||
style={styles.socialButton}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export function SignupScreen() {
|
|||
const insets = useSafeAreaInsets();
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { data: serverConfig, isLoading: isLoadingConfig } = useServerConfig();
|
||||
const { serverConfig, isLoading: isConfigLoading } = useServerConfig();
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
|
|
@ -53,7 +53,7 @@ export function SignupScreen() {
|
|||
|
||||
await authClient.signUp.email({ name, email, password });
|
||||
|
||||
const isEmailVerificationRequired = serverConfig?.config?.auth?.isEmailVerificationRequired ?? false;
|
||||
const isEmailVerificationRequired = serverConfig?.auth?.isEmailVerificationRequired ?? false;
|
||||
|
||||
if (isEmailVerificationRequired) {
|
||||
showAlert({
|
||||
|
|
@ -75,12 +75,12 @@ export function SignupScreen() {
|
|||
},
|
||||
});
|
||||
|
||||
const authConfig = serverConfig?.config?.auth;
|
||||
const authConfig = serverConfig?.auth;
|
||||
const isRegistrationEnabled = authConfig?.isRegistrationEnabled ?? false;
|
||||
|
||||
const styles = createStyles({ themeColors });
|
||||
|
||||
if (isLoadingConfig) {
|
||||
if (isConfigLoading) {
|
||||
return (
|
||||
<View style={[styles.container, styles.centerContent]}>
|
||||
<ActivityIndicator size="large" color={themeColors.primary} />
|
||||
|
|
@ -182,7 +182,7 @@ export function SignupScreen() {
|
|||
>
|
||||
{isSubmitting
|
||||
? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
<ActivityIndicator color={themeColors.primaryForeground} />
|
||||
)
|
||||
: (
|
||||
<Text style={styles.buttonText}>Sign Up</Text>
|
||||
|
|
|
|||
37
apps/mobile/src/modules/config/config.models.test.ts
Normal file
37
apps/mobile/src/modules/config/config.models.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
12
apps/mobile/src/modules/config/config.models.ts
Normal file
12
apps/mobile/src/modules/config/config.models.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -1,31 +1,10 @@
|
|||
import type { ApiClient } from '../api/api.client';
|
||||
import type { ServerConfig } from './config.types';
|
||||
import { httpClient } from '../api/http.client';
|
||||
|
||||
export async function fetchServerConfig({ apiClient}: { apiClient: ApiClient }) {
|
||||
return apiClient<{
|
||||
config: {
|
||||
auth: {
|
||||
isEmailVerificationRequired: boolean;
|
||||
isPasswordResetEnabled: boolean;
|
||||
isRegistrationEnabled: boolean;
|
||||
showLegalLinksOnAuthPage: boolean;
|
||||
providers: {
|
||||
email: {
|
||||
isEnabled: boolean;
|
||||
};
|
||||
github: {
|
||||
isEnabled: boolean;
|
||||
};
|
||||
google: {
|
||||
isEnabled: boolean;
|
||||
};
|
||||
customs: {
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
config: ServerConfig;
|
||||
}>({
|
||||
path: '/api/config',
|
||||
});
|
||||
|
|
|
|||
23
apps/mobile/src/modules/config/config.types.ts
Normal file
23
apps/mobile/src/modules/config/config.types.ts
Normal 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;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
@ -5,8 +5,13 @@ import { fetchServerConfig } from '../config.services';
|
|||
export function useServerConfig() {
|
||||
const apiClient = useApiClient();
|
||||
|
||||
return useQuery({
|
||||
const query = useQuery({
|
||||
queryKey: ['server', 'config'],
|
||||
queryFn: async () => fetchServerConfig({ apiClient }),
|
||||
});
|
||||
|
||||
return {
|
||||
...query,
|
||||
serverConfig: query.data?.config,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { ThemeColors } from '@/modules/ui/theme.constants';
|
||||
import { safelySync } from '@corentinth/chisels';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
|
|
@ -17,6 +18,7 @@ import { useAlert } from '@/modules/ui/providers/alert-provider';
|
|||
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
|
||||
import { MANAGED_SERVER_URL } from '../config.constants';
|
||||
import { configLocalStorage } from '../config.local-storage';
|
||||
import { validateServerUrl } from '../config.models';
|
||||
import { pingServer } from '../config.services';
|
||||
|
||||
function getDefaultCustomServerUrl() {
|
||||
|
|
@ -38,8 +40,20 @@ export function ServerSelectionScreen() {
|
|||
const [customUrl, setCustomUrl] = useState(getDefaultCustomServerUrl());
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
|
||||
const handleValidateCustomUrl = async ({ url}: { url: string }) => {
|
||||
const handleValidateCustomUrl = async ({ url: rawUrl }: { url: string }) => {
|
||||
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 {
|
||||
await pingServer({ url });
|
||||
await configLocalStorage.setApiServerBaseUrl({ apiServerBaseUrl: url });
|
||||
|
|
@ -108,7 +122,7 @@ export function ServerSelectionScreen() {
|
|||
>
|
||||
{isValidating
|
||||
? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
<ActivityIndicator color={themeColors.primaryForeground} />
|
||||
)
|
||||
: (
|
||||
<Text style={styles.buttonText}>Continue with Managed</Text>
|
||||
|
|
@ -137,7 +151,7 @@ export function ServerSelectionScreen() {
|
|||
>
|
||||
{isValidating
|
||||
? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
<ActivityIndicator color={themeColors.primaryForeground} />
|
||||
)
|
||||
: (
|
||||
<Text style={styles.buttonText}>Connect</Text>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import type { Document } from '../documents.types';
|
||||
import type { CoerceDates } from '@/modules/api/api.models';
|
||||
import type { ThemeColors } from '@/modules/ui/theme.constants';
|
||||
import { formatBytes } from '@corentinth/chisels';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { router } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
|
|
@ -14,7 +16,7 @@ import {
|
|||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
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 { OrganizationPickerDrawer } from '@/modules/organizations/components/organization-picker-drawer';
|
||||
import { useOrganizations } from '@/modules/organizations/organizations.provider';
|
||||
|
|
@ -57,16 +59,6 @@ export function DocumentsListScreen() {
|
|||
}).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 () => {
|
||||
await documentsQuery.refetch();
|
||||
if (currentOrganizationId != null) {
|
||||
|
|
@ -107,17 +99,27 @@ export function DocumentsListScreen() {
|
|||
data={documentsQuery.data?.documents ?? []}
|
||||
keyExtractor={item => item.id}
|
||||
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={{ backgroundColor: themeColors.muted, padding: 10, borderRadius: 6, marginRight: 12 }}>
|
||||
<Icon name="file-text" size={24} color={themeColors.primary} />
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.documentTitle} numberOfLines={2}>
|
||||
<View style={styles.documentContent}>
|
||||
<Text style={styles.documentTitle} numberOfLines={1} ellipsizeMode="tail">
|
||||
{item.name}
|
||||
</Text>
|
||||
<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.metaText}>{formatDate(item.createdAt)}</Text>
|
||||
{item.localUri !== undefined && (
|
||||
|
|
@ -144,6 +146,16 @@ export function DocumentsListScreen() {
|
|||
)}
|
||||
</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>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
|
@ -222,12 +234,17 @@ function createStyles({ themeColors }: { themeColors: ThemeColors }) {
|
|||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
documentTitle: {
|
||||
documentContent: {
|
||||
flex: 1,
|
||||
marginRight: 8,
|
||||
},
|
||||
documentTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: themeColors.foreground,
|
||||
marginRight: 12,
|
||||
},
|
||||
moreButton: {
|
||||
padding: 8,
|
||||
},
|
||||
documentMeta: {
|
||||
flexDirection: 'row',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { Organization } from '@/modules/organizations/organizations.types';
|
|||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { isHttpClientError } from '../api/http.client';
|
||||
import { useApiClient } from '../api/providers/api.provider';
|
||||
import { organizationsLocalStorage } from './organizations.local-storage';
|
||||
import { fetchOrganizations } from './organizations.services';
|
||||
|
|
@ -49,12 +50,23 @@ export function OrganizationsProvider({ children }: OrganizationsProviderProps)
|
|||
setCurrentOrganizationIdState(organizationId);
|
||||
};
|
||||
|
||||
// Redirect to organization selection if no organizations or no current org set
|
||||
useEffect(() => {
|
||||
if (!isInitialized || organizationsQuery.isLoading) {
|
||||
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 ?? [];
|
||||
|
||||
if (organizations.length === 0) {
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ export function OrganizationCreateScreen() {
|
|||
>
|
||||
{createMutation.isPending
|
||||
? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
<ActivityIndicator color={themeColors.primaryForeground} />
|
||||
)
|
||||
: (
|
||||
<Text style={styles.buttonText}>Create Organization</Text>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { Feather } from '@expo/vector-icons';
|
||||
|
||||
export const Icon = Feather;
|
||||
export type IconName = React.ComponentProps<typeof Feather>['name'];
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { defineConfig } from 'vitest/config';
|
|||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
isolate: false,
|
||||
env: {
|
||||
TZ: 'UTC',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export default antfu({
|
|||
],
|
||||
|
||||
rules: {
|
||||
'pnpm/json-enforce-catalog': 'off',
|
||||
// To allow export on top of files
|
||||
'ts/no-use-before-define': ['error', { allowNamedExports: true, functions: false }],
|
||||
'curly': ['error', 'all'],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
<!-- Prevent flash of wrong theme on load -->
|
||||
<!-- Prevent FOUC, default to dark theme -->
|
||||
<script>
|
||||
(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', 'dark');
|
||||
}
|
||||
document.documentElement.setAttribute('data-kb-theme', isLight ? 'light' : 'dark');
|
||||
} catch {}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
"dependencies": {
|
||||
"@branchlet/core": "^1.0.0",
|
||||
"@corentinth/chisels": "catalog:",
|
||||
"@corvu/calendar": "^0.1.2",
|
||||
"@corvu/otp-field": "^0.1.4",
|
||||
"@kobalte/core": "^0.13.10",
|
||||
"@kobalte/utils": "^0.9.1",
|
||||
|
|
|
|||
|
|
@ -72,4 +72,7 @@
|
|||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
input[type="search"]::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
/* @refresh reload */
|
||||
|
||||
import type { ConfigColorMode } from '@kobalte/core/color-mode';
|
||||
import { ColorModeProvider, createLocalStorageManager } from '@kobalte/core/color-mode';
|
||||
import { Router } from '@solidjs/router';
|
||||
import { QueryClientProvider } from '@tanstack/solid-query';
|
||||
|
||||
|
|
@ -12,8 +10,10 @@ import { isDemoMode } from './modules/config/config';
|
|||
import { ConfigProvider } from './modules/config/config.provider';
|
||||
import { RenameDocumentDialogProvider } from './modules/documents/components/rename-document-button.component';
|
||||
import { I18nProvider } from './modules/i18n/i18n.provider';
|
||||
import { AboutDialogProvider } from './modules/shared/components/about-dialog';
|
||||
import { ConfirmModalProvider } from './modules/shared/confirm';
|
||||
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 { PageViewTracker } from './modules/tracking/components/pageview-tracker.component';
|
||||
import { Toaster } from './modules/ui/components/sonner';
|
||||
|
|
@ -27,10 +27,6 @@ const DemoIndicator = isDemoMode
|
|||
|
||||
render(
|
||||
() => {
|
||||
const initialColorMode: ConfigColorMode = 'dark';
|
||||
const colorModeStorageKey = 'papra_color_mode';
|
||||
const localStorageManager = createLocalStorageManager(colorModeStorageKey);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Router
|
||||
|
|
@ -41,23 +37,22 @@ render(
|
|||
<IdentifyUser />
|
||||
<I18nProvider>
|
||||
<ConfirmModalProvider>
|
||||
<ColorModeProvider
|
||||
initialColorMode={initialColorMode}
|
||||
storageManager={localStorageManager}
|
||||
>
|
||||
<ThemeProvider>
|
||||
<CommandPaletteProvider>
|
||||
<ConfigProvider>
|
||||
<RenameDocumentDialogProvider>
|
||||
<div class="min-h-screen font-sans text-sm font-400">
|
||||
{props.children}
|
||||
</div>
|
||||
</RenameDocumentDialogProvider>
|
||||
{DemoIndicator && <DemoIndicator />}
|
||||
<AboutDialogProvider>
|
||||
<RenameDocumentDialogProvider>
|
||||
<div class="min-h-screen font-sans text-sm font-400">
|
||||
{props.children}
|
||||
</div>
|
||||
</RenameDocumentDialogProvider>
|
||||
{DemoIndicator && <DemoIndicator />}
|
||||
</AboutDialogProvider>
|
||||
</ConfigProvider>
|
||||
|
||||
<Toaster />
|
||||
</CommandPaletteProvider>
|
||||
</ColorModeProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
</ConfirmModalProvider>
|
||||
</I18nProvider>
|
||||
|
|
|
|||
|
|
@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'documents.info.created-at': 'Erstellt am',
|
||||
'documents.info.updated-at': 'Aktualisiert am',
|
||||
'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.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.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.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.',
|
||||
|
|
@ -443,6 +577,10 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'tags.table.headers.documents': 'Dokumente',
|
||||
'tags.table.headers.created': 'Erstellt',
|
||||
'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
|
||||
|
||||
|
|
@ -605,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'webhooks.create.form.name.label': 'Webhook-Name',
|
||||
'webhooks.create.form.name.placeholder': 'Webhook-Namen eingeben',
|
||||
'webhooks.create.form.name.required': 'Name ist erforderlich',
|
||||
'webhooks.create.form.name.max-length': 'Der Name darf maximal 128 Zeichen lang sein',
|
||||
'webhooks.create.form.url.label': 'Webhook-URL',
|
||||
'webhooks.create.form.url.placeholder': 'Webhook-URL eingeben',
|
||||
'webhooks.create.form.url.required': 'URL ist erforderlich',
|
||||
|
|
@ -639,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'layout.menu.home': 'Startseite',
|
||||
'layout.menu.documents': 'Dokumente',
|
||||
'layout.menu.tags': 'Tags',
|
||||
'layout.menu.custom-properties': 'Benutzerdefinierte Eigenschaften',
|
||||
'layout.menu.tagging-rules': 'Tagging-Regeln',
|
||||
'layout.menu.deleted-documents': 'Gelöschte Dokumente',
|
||||
'layout.menu.organization-settings': 'Einstellungen',
|
||||
|
|
@ -668,6 +808,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'user-menu.api-keys': 'API-Schlüssel',
|
||||
'user-menu.invitations': 'Einladungen',
|
||||
'user-menu.language': 'Sprache',
|
||||
'user-menu.theme': 'Design',
|
||||
'user-menu.about': 'Über Papra',
|
||||
'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.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.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.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.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
|
||||
'api-errors.USER_NOT_FOUND': 'Benutzer nicht gefunden',
|
||||
'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.select-color': 'Farbe auswählen',
|
||||
'color-picker.select-a-color': 'Eine Farbe auswählen',
|
||||
'color-picker.random-color': 'Zufällige Farbe',
|
||||
|
||||
// Subscriptions
|
||||
|
||||
|
|
|
|||
|
|
@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'documents.info.created-at': 'Ημερομηνία δημιουργίας',
|
||||
'documents.info.updated-at': 'Ημερομηνία ενημέρωσης',
|
||||
'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.form.name.label': 'Όνομα',
|
||||
|
|
@ -381,6 +450,71 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'documents.preview.unknown-file-type': 'Δεν υπάρχει διαθέσιμη προεπισκόπηση για αυτόν τον τύπο αρχείου',
|
||||
'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.confirm.title': 'Οριστική διαγραφή όλων των εγγράφων;',
|
||||
'trash.delete-all.confirm.description': 'Είστε βέβαιοι ότι θέλετε να τα διαγράψετε οριστικά; Η ενέργεια δεν μπορεί να αναιρεθεί.',
|
||||
|
|
@ -443,6 +577,10 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'tags.table.headers.documents': 'Έγγραφα',
|
||||
'tags.table.headers.created': 'Δημιουργήθηκε',
|
||||
'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
|
||||
|
||||
|
|
@ -605,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'webhooks.create.form.name.label': 'Όνομα webhook',
|
||||
'webhooks.create.form.name.placeholder': 'Εισαγάγετε όνομα',
|
||||
'webhooks.create.form.name.required': 'Το όνομα είναι υποχρεωτικό',
|
||||
'webhooks.create.form.name.max-length': 'Το όνομα πρέπει να έχει το πολύ 128 χαρακτήρες',
|
||||
'webhooks.create.form.url.label': 'URL Webhook',
|
||||
'webhooks.create.form.url.placeholder': 'Εισαγάγετε URL',
|
||||
'webhooks.create.form.url.required': 'Το URL είναι υποχρεωτικό',
|
||||
|
|
@ -639,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'layout.menu.home': 'Αρχική',
|
||||
'layout.menu.documents': 'Έγγραφα',
|
||||
'layout.menu.tags': 'Ετικέτες',
|
||||
'layout.menu.custom-properties': 'Προσαρμοσμένες ιδιότητες',
|
||||
'layout.menu.tagging-rules': 'Κανόνες ετικετοποίησης',
|
||||
'layout.menu.deleted-documents': 'Διαγεγραμμένα έγγραφα',
|
||||
'layout.menu.organization-settings': 'Ρυθμίσεις',
|
||||
|
|
@ -668,6 +808,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'user-menu.api-keys': 'API keys',
|
||||
'user-menu.invitations': 'Προσκλήσεις',
|
||||
'user-menu.language': 'Γλώσσα',
|
||||
'user-menu.theme': 'Θέμα',
|
||||
'user-menu.about': 'Σχετικά με το Papra',
|
||||
'user-menu.logout': 'Αποσύνδεση',
|
||||
|
||||
|
|
@ -693,10 +834,12 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'api-errors.user.organization_invitation_limit_reached': 'Φτάσατε το ημερήσιο όριο προσκλήσεων. Δοκιμάστε αύριο.',
|
||||
'api-errors.demo.not_available': 'Η δυνατότητα δεν είναι διαθέσιμη στο demo',
|
||||
'api-errors.tags.already_exists': 'Υπάρχει ήδη ετικέτα με αυτό το όνομα',
|
||||
'api-errors.tags.organization_limit_reached': 'Συμπληρώθηκε ο μέγιστος αριθμός ετικετών για αυτόν τον οργανισμό.',
|
||||
'api-errors.internal.error': 'Προέκυψε σφάλμα. Δοκιμάστε αργότερα.',
|
||||
'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.has_active_subscription': 'Δεν είναι δυνατή η διαγραφή με ενεργή συνδρομή. Ακυρώστε πρώτα μέσω "Διαχείριση συνδρομής".',
|
||||
'api-errors.webhooks.ssrf_unsafe_url': 'Η παρεχόμενη URL δεν επιτρέπεται. Οι URL webhook δεν πρέπει να δείχνουν σε ιδιωτικές ή δεσμευμένες διευθύνσεις IP.',
|
||||
// Better auth api errors
|
||||
'api-errors.USER_NOT_FOUND': 'Ο χρήστης δεν βρέθηκε',
|
||||
'api-errors.FAILED_TO_CREATE_USER': 'Αποτυχία δημιουργίας χρήστη',
|
||||
|
|
@ -752,6 +895,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'color-picker.lightness': 'Φωτεινότητα',
|
||||
'color-picker.select-color': 'Επιλογή χρώματος',
|
||||
'color-picker.select-a-color': 'Επιλέξτε χρώμα',
|
||||
'color-picker.random-color': 'Τυχαίο χρώμα',
|
||||
|
||||
// Subscriptions
|
||||
|
||||
|
|
|
|||
|
|
@ -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-results': 'No documents found',
|
||||
'documents.list.table.headers.file-name': 'File name',
|
||||
'documents.list.table.headers.created': 'Created at',
|
||||
'documents.list.table.headers.deleted': 'Deleted at',
|
||||
'documents.list.table.headers.created': 'Created',
|
||||
'documents.list.table.headers.deleted': 'Deleted',
|
||||
'documents.list.table.headers.actions': 'Actions',
|
||||
'documents.list.table.headers.tags': 'Tags',
|
||||
'documents.list.search.placeholder': 'Search documents...',
|
||||
|
|
@ -349,9 +349,78 @@ export const translations = {
|
|||
'documents.info.name': 'Name',
|
||||
'documents.info.type': 'Type',
|
||||
'documents.info.size': 'Size',
|
||||
'documents.info.created-at': 'Created At',
|
||||
'documents.info.updated-at': 'Updated At',
|
||||
'documents.info.created-at': 'Created',
|
||||
'documents.info.updated-at': 'Updated',
|
||||
'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.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.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.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.',
|
||||
|
|
@ -441,6 +575,10 @@ export const translations = {
|
|||
'tags.table.headers.documents': 'Documents',
|
||||
'tags.table.headers.created': 'Created',
|
||||
'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
|
||||
|
||||
|
|
@ -603,6 +741,7 @@ export const translations = {
|
|||
'webhooks.create.form.name.label': 'Webhook name',
|
||||
'webhooks.create.form.name.placeholder': 'Enter webhook name',
|
||||
'webhooks.create.form.name.required': 'Name is required',
|
||||
'webhooks.create.form.name.max-length': 'Name must be at most 128 characters',
|
||||
'webhooks.create.form.url.label': 'Webhook URL',
|
||||
'webhooks.create.form.url.placeholder': 'Enter webhook URL',
|
||||
'webhooks.create.form.url.required': 'URL is required',
|
||||
|
|
@ -637,6 +776,7 @@ export const translations = {
|
|||
'layout.menu.home': 'Home',
|
||||
'layout.menu.documents': 'Documents',
|
||||
'layout.menu.tags': 'Tags',
|
||||
'layout.menu.custom-properties': 'Custom Properties',
|
||||
'layout.menu.tagging-rules': 'Tagging rules',
|
||||
'layout.menu.deleted-documents': 'Deleted documents',
|
||||
'layout.menu.organization-settings': 'Settings',
|
||||
|
|
@ -666,6 +806,7 @@ export const translations = {
|
|||
'user-menu.api-keys': 'API keys',
|
||||
'user-menu.invitations': 'Invitations',
|
||||
'user-menu.language': 'Language',
|
||||
'user-menu.theme': 'Theme',
|
||||
'user-menu.about': 'About Papra',
|
||||
'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.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.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.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.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
|
||||
'api-errors.USER_NOT_FOUND': 'User not found',
|
||||
'api-errors.FAILED_TO_CREATE_USER': 'Failed to create user',
|
||||
|
|
@ -750,6 +893,7 @@ export const translations = {
|
|||
'color-picker.lightness': 'Lightness',
|
||||
'color-picker.select-color': 'Select color',
|
||||
'color-picker.select-a-color': 'Select a color',
|
||||
'color-picker.random-color': 'Random color',
|
||||
|
||||
// Subscriptions
|
||||
|
||||
|
|
|
|||
|
|
@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'documents.info.created-at': 'Creado el',
|
||||
'documents.info.updated-at': 'Actualizado el',
|
||||
'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.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.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.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.',
|
||||
|
|
@ -443,6 +577,10 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'tags.table.headers.documents': 'Documentos',
|
||||
'tags.table.headers.created': 'Creado',
|
||||
'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
|
||||
|
||||
|
|
@ -605,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'webhooks.create.form.name.label': 'Nombre del webhook',
|
||||
'webhooks.create.form.name.placeholder': 'Ingresa el nombre del webhook',
|
||||
'webhooks.create.form.name.required': 'El nombre es obligatorio',
|
||||
'webhooks.create.form.name.max-length': 'El nombre debe tener como máximo 128 caracteres',
|
||||
'webhooks.create.form.url.label': 'URL del webhook',
|
||||
'webhooks.create.form.url.placeholder': 'Ingresa la URL del webhook',
|
||||
'webhooks.create.form.url.required': 'La URL es obligatoria',
|
||||
|
|
@ -639,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'layout.menu.home': 'Inicio',
|
||||
'layout.menu.documents': 'Documentos',
|
||||
'layout.menu.tags': 'Etiquetas',
|
||||
'layout.menu.custom-properties': 'Propiedades personalizadas',
|
||||
'layout.menu.tagging-rules': 'Reglas de etiquetado',
|
||||
'layout.menu.deleted-documents': 'Documentos eliminados',
|
||||
'layout.menu.organization-settings': 'Configuración',
|
||||
|
|
@ -668,6 +808,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'user-menu.api-keys': 'Claves API',
|
||||
'user-menu.invitations': 'Invitaciones',
|
||||
'user-menu.language': 'Idioma',
|
||||
'user-menu.theme': 'Tema',
|
||||
'user-menu.about': 'Acerca de Papra',
|
||||
'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.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.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.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.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
|
||||
'api-errors.USER_NOT_FOUND': 'Usuario no encontrado',
|
||||
'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.select-color': 'Seleccionar color',
|
||||
'color-picker.select-a-color': 'Selecciona un color',
|
||||
'color-picker.random-color': 'Color aleatorio',
|
||||
|
||||
// Subscriptions
|
||||
|
||||
|
|
|
|||
|
|
@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'documents.info.created-at': 'Créé le',
|
||||
'documents.info.updated-at': 'Mis à jour le',
|
||||
'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.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.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.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.',
|
||||
|
|
@ -443,6 +577,10 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'tags.table.headers.documents': 'Documents',
|
||||
'tags.table.headers.created': 'Date de création',
|
||||
'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
|
||||
|
||||
|
|
@ -605,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'webhooks.create.form.name.label': 'Nom du webhook',
|
||||
'webhooks.create.form.name.placeholder': 'Entrez le nom du webhook',
|
||||
'webhooks.create.form.name.required': 'Le nom est requis',
|
||||
'webhooks.create.form.name.max-length': 'Le nom doit contenir au maximum 128 caractères',
|
||||
'webhooks.create.form.url.label': 'URL du webhook',
|
||||
'webhooks.create.form.url.placeholder': 'Entrez l\'URL du webhook',
|
||||
'webhooks.create.form.url.required': 'L\'URL est requise',
|
||||
|
|
@ -639,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'layout.menu.home': 'Accueil',
|
||||
'layout.menu.documents': 'Documents',
|
||||
'layout.menu.tags': 'Tags',
|
||||
'layout.menu.custom-properties': 'Propriétés personnalisées',
|
||||
'layout.menu.tagging-rules': 'Règles de catégorisation',
|
||||
'layout.menu.deleted-documents': 'Documents supprimés',
|
||||
'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.invitations': 'Invitations',
|
||||
'user-menu.language': 'Langue',
|
||||
'user-menu.theme': 'Thème',
|
||||
'user-menu.about': 'À propos de Papra',
|
||||
'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.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.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.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.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
|
||||
'api-errors.USER_NOT_FOUND': 'Utilisateur introuvable',
|
||||
'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.select-color': 'Sélectionner la couleur',
|
||||
'color-picker.select-a-color': 'Sélectionner une couleur',
|
||||
'color-picker.random-color': 'Couleur aléatoire',
|
||||
|
||||
// Subscriptions
|
||||
|
||||
|
|
|
|||
|
|
@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'documents.info.created-at': 'Creato il',
|
||||
'documents.info.updated-at': 'Aggiornato il',
|
||||
'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.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.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.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.',
|
||||
|
|
@ -443,6 +577,10 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'tags.table.headers.documents': 'Documenti',
|
||||
'tags.table.headers.created': 'Creato',
|
||||
'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
|
||||
|
||||
|
|
@ -605,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'webhooks.create.form.name.label': 'Nome webhook',
|
||||
'webhooks.create.form.name.placeholder': 'Inserisci nome webhook',
|
||||
'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.placeholder': 'Inserisci URL webhook',
|
||||
'webhooks.create.form.url.required': 'L\'URL è obbligatorio',
|
||||
|
|
@ -639,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'layout.menu.home': 'Home',
|
||||
'layout.menu.documents': 'Documenti',
|
||||
'layout.menu.tags': 'Tag',
|
||||
'layout.menu.custom-properties': 'Proprietà personalizzate',
|
||||
'layout.menu.tagging-rules': 'Regole di tagging',
|
||||
'layout.menu.deleted-documents': 'Documenti eliminati',
|
||||
'layout.menu.organization-settings': 'Impostazioni',
|
||||
|
|
@ -668,6 +808,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'user-menu.api-keys': 'Chiavi API',
|
||||
'user-menu.invitations': 'Inviti',
|
||||
'user-menu.language': 'Lingua',
|
||||
'user-menu.theme': 'Tema',
|
||||
'user-menu.about': 'Informazioni su Papra',
|
||||
'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.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.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.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.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
|
||||
'api-errors.USER_NOT_FOUND': 'Utente non trovato',
|
||||
'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.select-color': 'Seleziona colore',
|
||||
'color-picker.select-a-color': 'Seleziona un colore',
|
||||
'color-picker.random-color': 'Colore casuale',
|
||||
|
||||
// Subscriptions
|
||||
|
||||
|
|
|
|||
|
|
@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'documents.info.created-at': 'Aangemaakt op',
|
||||
'documents.info.updated-at': 'Bijgewerkt op',
|
||||
'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.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.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.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.',
|
||||
|
|
@ -443,6 +577,10 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'tags.table.headers.documents': 'Documenten',
|
||||
'tags.table.headers.created': 'Aangemaakt',
|
||||
'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
|
||||
|
||||
|
|
@ -605,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'webhooks.create.form.name.label': 'Webhooknaam',
|
||||
'webhooks.create.form.name.placeholder': 'Voer een webhooknaam in',
|
||||
'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.placeholder': 'Voer een webhook-URL in',
|
||||
'webhooks.create.form.url.required': 'URL is verplicht',
|
||||
|
|
@ -639,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'layout.menu.home': 'Start',
|
||||
'layout.menu.documents': 'Documenten',
|
||||
'layout.menu.tags': 'Labels',
|
||||
'layout.menu.custom-properties': 'Aangepaste eigenschappen',
|
||||
'layout.menu.tagging-rules': 'Labelregels',
|
||||
'layout.menu.deleted-documents': 'Verwijderde documenten',
|
||||
'layout.menu.organization-settings': 'Instellingen',
|
||||
|
|
@ -668,6 +808,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'user-menu.api-keys': 'API-sleutels',
|
||||
'user-menu.invitations': 'Uitnodigingen',
|
||||
'user-menu.language': 'Taal',
|
||||
'user-menu.theme': 'Thema',
|
||||
'user-menu.about': 'Over Papra',
|
||||
'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.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.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.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.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
|
||||
'api-errors.USER_NOT_FOUND': 'Gebruiker niet gevonden',
|
||||
'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.select-color': 'Kleur selecteren',
|
||||
'color-picker.select-a-color': 'Selecteer een kleur',
|
||||
'color-picker.random-color': 'Willekeurige kleur',
|
||||
|
||||
// Subscriptions
|
||||
|
||||
|
|
|
|||
|
|
@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'documents.info.created-at': 'Utworzono',
|
||||
'documents.info.updated-at': 'Zaktualizowano',
|
||||
'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.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.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.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.',
|
||||
|
|
@ -443,6 +577,10 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'tags.table.headers.documents': 'Dokumenty',
|
||||
'tags.table.headers.created': 'Utworzono',
|
||||
'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
|
||||
|
||||
|
|
@ -605,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'webhooks.create.form.name.label': 'Nazwa webhooka',
|
||||
'webhooks.create.form.name.placeholder': 'Wprowadź nazwę webhooka',
|
||||
'webhooks.create.form.name.required': 'Nazwa jest wymagana',
|
||||
'webhooks.create.form.name.max-length': 'Nazwa może mieć maksymalnie 128 znaków',
|
||||
'webhooks.create.form.url.label': 'URL webhooka',
|
||||
'webhooks.create.form.url.placeholder': 'Wprowadź URL webhooka',
|
||||
'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.documents': 'Dokumenty',
|
||||
'layout.menu.tags': 'Tagi',
|
||||
'layout.menu.custom-properties': 'Właściwości niestandardowe',
|
||||
'layout.menu.tagging-rules': 'Zasady tagowania',
|
||||
'layout.menu.deleted-documents': 'Usunięte dokumenty',
|
||||
'layout.menu.organization-settings': 'Ustawienia',
|
||||
|
|
@ -668,6 +808,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'user-menu.api-keys': 'Klucze API',
|
||||
'user-menu.invitations': 'Zaproszenia',
|
||||
'user-menu.language': 'Język',
|
||||
'user-menu.theme': 'Motyw',
|
||||
'user-menu.about': 'O Papra',
|
||||
'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.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.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.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.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
|
||||
'api-errors.USER_NOT_FOUND': 'Nie znaleziono 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.select-color': 'Wybierz kolor',
|
||||
'color-picker.select-a-color': 'Wybierz kolor',
|
||||
'color-picker.random-color': 'Losowy kolor',
|
||||
|
||||
// Subscriptions
|
||||
|
||||
|
|
|
|||
|
|
@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'documents.info.created-at': 'Criado em',
|
||||
'documents.info.updated-at': 'Atualizado em',
|
||||
'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.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.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.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.',
|
||||
|
|
@ -443,6 +577,10 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'tags.table.headers.documents': 'Documentos',
|
||||
'tags.table.headers.created': 'Criado em',
|
||||
'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
|
||||
|
||||
|
|
@ -605,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'webhooks.create.form.name.label': 'Nome do webhook',
|
||||
'webhooks.create.form.name.placeholder': 'Insira o nome do webhook',
|
||||
'webhooks.create.form.name.required': 'O nome é obrigatório',
|
||||
'webhooks.create.form.name.max-length': 'O nome deve ter no máximo 128 caracteres',
|
||||
'webhooks.create.form.url.label': 'URL do Webhook',
|
||||
'webhooks.create.form.url.placeholder': 'Insira a URL do webhook',
|
||||
'webhooks.create.form.url.required': 'A URL é obrigatória',
|
||||
|
|
@ -639,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'layout.menu.home': 'Início',
|
||||
'layout.menu.documents': 'Documentos',
|
||||
'layout.menu.tags': 'Tags',
|
||||
'layout.menu.custom-properties': 'Propriedades personalizadas',
|
||||
'layout.menu.tagging-rules': 'Regras de marcação',
|
||||
'layout.menu.deleted-documents': 'Documentos excluídos',
|
||||
'layout.menu.organization-settings': 'Configurações',
|
||||
|
|
@ -668,6 +808,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'user-menu.api-keys': 'Chaves de API',
|
||||
'user-menu.invitations': 'Convites',
|
||||
'user-menu.language': 'Idioma',
|
||||
'user-menu.theme': 'Tema',
|
||||
'user-menu.about': 'Sobre o Papra',
|
||||
'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.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.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.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.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
|
||||
'api-errors.USER_NOT_FOUND': 'Usuário não encontrado',
|
||||
'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.select-color': 'Selecionar cor',
|
||||
'color-picker.select-a-color': 'Selecione uma cor',
|
||||
'color-picker.random-color': 'Cor aleatória',
|
||||
|
||||
// Subscriptions
|
||||
|
||||
|
|
|
|||
|
|
@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'documents.info.created-at': 'Criado em',
|
||||
'documents.info.updated-at': 'Atualizado em',
|
||||
'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.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.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.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.',
|
||||
|
|
@ -443,6 +577,10 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'tags.table.headers.documents': 'Documentos',
|
||||
'tags.table.headers.created': 'Criado',
|
||||
'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
|
||||
|
||||
|
|
@ -605,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'webhooks.create.form.name.label': 'Nome do webhook',
|
||||
'webhooks.create.form.name.placeholder': 'Insira o nome do webhook',
|
||||
'webhooks.create.form.name.required': 'O nome é obrigatório',
|
||||
'webhooks.create.form.name.max-length': 'O nome deve ter no máximo 128 caracteres',
|
||||
'webhooks.create.form.url.label': 'URL do Webhook',
|
||||
'webhooks.create.form.url.placeholder': 'Insira o URL do webhook',
|
||||
'webhooks.create.form.url.required': 'O URL é obrigatória',
|
||||
|
|
@ -639,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'layout.menu.home': 'Início',
|
||||
'layout.menu.documents': 'Documentos',
|
||||
'layout.menu.tags': 'Tags',
|
||||
'layout.menu.custom-properties': 'Propriedades personalizadas',
|
||||
'layout.menu.tagging-rules': 'Regras de etiquetagem',
|
||||
'layout.menu.deleted-documents': 'Documentos eliminados',
|
||||
'layout.menu.organization-settings': 'Definições',
|
||||
|
|
@ -668,6 +808,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'user-menu.api-keys': 'Chaves API',
|
||||
'user-menu.invitations': 'Convites',
|
||||
'user-menu.language': 'Linguagem',
|
||||
'user-menu.theme': 'Tema',
|
||||
'user-menu.about': 'Acerca do Papra',
|
||||
'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.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.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.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.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
|
||||
'api-errors.USER_NOT_FOUND': 'Utilizador não encontrado',
|
||||
'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.select-color': 'Selecionar cor',
|
||||
'color-picker.select-a-color': 'Selecione uma cor',
|
||||
'color-picker.random-color': 'Cor aleatória',
|
||||
|
||||
// Subscriptions
|
||||
|
||||
|
|
|
|||
|
|
@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'documents.info.created-at': 'Creat la',
|
||||
'documents.info.updated-at': 'Actualizat la',
|
||||
'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.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.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.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ă.',
|
||||
|
|
@ -443,6 +577,10 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'tags.table.headers.documents': 'Documente',
|
||||
'tags.table.headers.created': 'Creat la',
|
||||
'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
|
||||
|
||||
|
|
@ -605,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'webhooks.create.form.name.label': 'Nume webhook',
|
||||
'webhooks.create.form.name.placeholder': 'Introdu numele webhook-ului',
|
||||
'webhooks.create.form.name.required': 'Numele este obligatoriu',
|
||||
'webhooks.create.form.name.max-length': 'Numele trebuie să aibă cel mult 128 de caractere',
|
||||
'webhooks.create.form.url.label': 'URL webhook',
|
||||
'webhooks.create.form.url.placeholder': 'Introdu URL-ul webhook-ului',
|
||||
'webhooks.create.form.url.required': 'URL-ul este obligatoriu',
|
||||
|
|
@ -639,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'layout.menu.home': 'Acasă',
|
||||
'layout.menu.documents': 'Documente',
|
||||
'layout.menu.tags': 'Etichete',
|
||||
'layout.menu.custom-properties': 'Proprietăți personalizate',
|
||||
'layout.menu.tagging-rules': 'Reguli de etichetare',
|
||||
'layout.menu.deleted-documents': 'Documente șterse',
|
||||
'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.invitations': 'Invitații',
|
||||
'user-menu.language': 'Limbă',
|
||||
'user-menu.theme': 'Temă',
|
||||
'user-menu.about': 'Despre Papra',
|
||||
'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.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.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.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.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
|
||||
'api-errors.USER_NOT_FOUND': 'Utilizatorul nu a fost găsit',
|
||||
'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.select-color': 'Selectează culoarea',
|
||||
'color-picker.select-a-color': 'Selectează o culoare',
|
||||
'color-picker.random-color': 'Culoare aleatorie',
|
||||
|
||||
// Subscriptions
|
||||
|
||||
|
|
|
|||
|
|
@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'documents.info.created-at': 'Создан',
|
||||
'documents.info.updated-at': 'Обновлён',
|
||||
'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.form.name.label': 'Имя',
|
||||
|
|
@ -381,6 +450,71 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'documents.preview.unknown-file-type': 'Предпросмотр для этого типа файлов недоступен',
|
||||
'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.confirm.title': 'Окончательно удалить все документы?',
|
||||
'trash.delete-all.confirm.description': 'Вы уверены, что хотите окончательно удалить все документы из корзины? Это действие нельзя отменить.',
|
||||
|
|
@ -443,6 +577,10 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'tags.table.headers.documents': 'Документы',
|
||||
'tags.table.headers.created': 'Создан',
|
||||
'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
|
||||
|
||||
|
|
@ -605,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'webhooks.create.form.name.label': 'Название вебхука',
|
||||
'webhooks.create.form.name.placeholder': 'Введите название вебхука',
|
||||
'webhooks.create.form.name.required': 'Название обязательно',
|
||||
'webhooks.create.form.name.max-length': 'Название должно содержать не более 128 символов',
|
||||
'webhooks.create.form.url.label': 'URL вебхука',
|
||||
'webhooks.create.form.url.placeholder': 'Введите URL вебхука',
|
||||
'webhooks.create.form.url.required': 'URL обязателен',
|
||||
|
|
@ -639,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'layout.menu.home': 'Главная',
|
||||
'layout.menu.documents': 'Документы',
|
||||
'layout.menu.tags': 'Теги',
|
||||
'layout.menu.custom-properties': 'Пользовательские свойства',
|
||||
'layout.menu.tagging-rules': 'Правила тегирования',
|
||||
'layout.menu.deleted-documents': 'Удалённые документы',
|
||||
'layout.menu.organization-settings': 'Настройки',
|
||||
|
|
@ -668,6 +808,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'user-menu.api-keys': 'API ключи',
|
||||
'user-menu.invitations': 'Приглашения',
|
||||
'user-menu.language': 'Язык',
|
||||
'user-menu.theme': 'Тема',
|
||||
'user-menu.about': 'Информация',
|
||||
'user-menu.logout': 'Выйти',
|
||||
|
||||
|
|
@ -693,10 +834,12 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'api-errors.user.organization_invitation_limit_reached': 'Достигнуто максимальное количество приглашений на сегодня. Пожалуйста, попробуйте снова завтра.',
|
||||
'api-errors.demo.not_available': 'Эта функция недоступна в демо',
|
||||
'api-errors.tags.already_exists': 'Тег с таким именем уже существует в этой организации',
|
||||
'api-errors.tags.organization_limit_reached': 'Достигнуто максимальное количество тегов для этой организации.',
|
||||
'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.organization.max_members_count_reached': 'Достигнуто максимальное количество участников и ожидающих приглашений для этой организации. Пожалуйста, обновите ваш план, чтобы добавить больше участников.',
|
||||
'api-errors.organization.has_active_subscription': 'Невозможно удалить организацию с активной подпиской. Пожалуйста, сначала отмените подписку, используя кнопку "Управление подпиской" выше.',
|
||||
'api-errors.webhooks.ssrf_unsafe_url': 'Указанный URL не разрешён. URL-адреса вебхуков не должны указывать на частные или зарезервированные IP-адреса.',
|
||||
// Better auth api errors
|
||||
'api-errors.USER_NOT_FOUND': 'Пользователь не найден',
|
||||
'api-errors.FAILED_TO_CREATE_USER': 'Не удалось создать пользователя',
|
||||
|
|
@ -752,6 +895,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'color-picker.lightness': 'Яркость',
|
||||
'color-picker.select-color': 'Выбрать цвет',
|
||||
'color-picker.select-a-color': 'Выберите цвет',
|
||||
'color-picker.random-color': 'Случайный цвет',
|
||||
|
||||
// Subscriptions
|
||||
|
||||
|
|
|
|||
1092
apps/papra-client/src/locales/sv.dictionary.ts
Normal file
1092
apps/papra-client/src/locales/sv.dictionary.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'documents.info.created-at': '创建时间',
|
||||
'documents.info.updated-at': '更新时间',
|
||||
'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.form.name.label': '名称',
|
||||
|
|
@ -381,6 +450,71 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'documents.preview.unknown-file-type': '此文件类型暂无预览',
|
||||
'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.confirm.title': '永久删除所有文档?',
|
||||
'trash.delete-all.confirm.description': '您确定要永久删除回收站中的所有文档吗?此操作无法撤销。',
|
||||
|
|
@ -443,6 +577,10 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'tags.table.headers.documents': '文档',
|
||||
'tags.table.headers.created': '创建时间',
|
||||
'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
|
||||
|
||||
|
|
@ -605,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'webhooks.create.form.name.label': 'Webhook 名称',
|
||||
'webhooks.create.form.name.placeholder': '请输入 Webhook 名称',
|
||||
'webhooks.create.form.name.required': '名称为必填项',
|
||||
'webhooks.create.form.name.max-length': '名称最多不能超过128个字符',
|
||||
'webhooks.create.form.url.label': 'Webhook URL',
|
||||
'webhooks.create.form.url.placeholder': '请输入 Webhook URL',
|
||||
'webhooks.create.form.url.required': 'URL 为必填项',
|
||||
|
|
@ -639,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'layout.menu.home': '首页',
|
||||
'layout.menu.documents': '文档',
|
||||
'layout.menu.tags': '标签',
|
||||
'layout.menu.custom-properties': '自定义属性',
|
||||
'layout.menu.tagging-rules': '标签规则',
|
||||
'layout.menu.deleted-documents': '已删除文档',
|
||||
'layout.menu.organization-settings': '设置',
|
||||
|
|
@ -668,6 +808,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'user-menu.api-keys': 'API 密钥',
|
||||
'user-menu.invitations': '邀请',
|
||||
'user-menu.language': '语言',
|
||||
'user-menu.theme': '主题',
|
||||
'user-menu.about': '关于 Papra',
|
||||
'user-menu.logout': '登出',
|
||||
|
||||
|
|
@ -693,10 +834,12 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'api-errors.user.organization_invitation_limit_reached': '今日邀请次数已达上限,请明天再试。',
|
||||
'api-errors.demo.not_available': '此功能在演示环境中不可用',
|
||||
'api-errors.tags.already_exists': '该组织已存在同名标签',
|
||||
'api-errors.tags.organization_limit_reached': '该组织的标签数量已达上限。',
|
||||
'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.organization.max_members_count_reached': '该组织的成员和待处理邀请数量已达上限。请升级方案以添加更多成员。',
|
||||
'api-errors.organization.has_active_subscription': '无法删除有有效订阅的组织。请先通过上方“管理订阅”取消订阅。',
|
||||
'api-errors.webhooks.ssrf_unsafe_url': '提供的 URL 不被允许。Webhook URL 不能指向私有或保留的 IP 地址。',
|
||||
// Better auth api errors
|
||||
'api-errors.USER_NOT_FOUND': '未找到用户',
|
||||
'api-errors.FAILED_TO_CREATE_USER': '创建用户失败',
|
||||
|
|
@ -752,6 +895,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'color-picker.lightness': '亮度',
|
||||
'color-picker.select-color': '选择颜色',
|
||||
'color-picker.select-a-color': '选择一个颜色',
|
||||
'color-picker.random-color': '随机颜色',
|
||||
|
||||
// Subscriptions
|
||||
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ const AdminLayout: ParentComponent = (props) => {
|
|||
<div class="w-280px flex-shrink-0 hidden md:block">
|
||||
{sidenav()}
|
||||
</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">
|
||||
<Sheet>
|
||||
<SheetTrigger>
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ export const AdminListOrganizationsPage: Component = () => {
|
|||
</Table>
|
||||
</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">
|
||||
{t('admin.organizations.pagination.info', {
|
||||
start: table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1,
|
||||
|
|
|
|||
|
|
@ -161,7 +161,7 @@ export const AdminListUsersPage: Component = () => {
|
|||
</Table>
|
||||
</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">
|
||||
{t('admin.users.pagination.info', {
|
||||
start: table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1,
|
||||
|
|
|
|||
|
|
@ -5,15 +5,15 @@ import { coerceDates } from '../shared/http/http-client.models';
|
|||
export async function createApiKey({
|
||||
name,
|
||||
permissions,
|
||||
organizationIds,
|
||||
allOrganizations,
|
||||
expiresAt,
|
||||
// organizationIds,
|
||||
// allOrganizations,
|
||||
// expiresAt,
|
||||
}: {
|
||||
name: string;
|
||||
permissions: string[];
|
||||
organizationIds: string[];
|
||||
allOrganizations: boolean;
|
||||
expiresAt?: Date;
|
||||
// organizationIds: string[];
|
||||
// allOrganizations: boolean;
|
||||
// expiresAt?: Date;
|
||||
}) {
|
||||
const { apiKey, token } = await apiClient<{
|
||||
apiKey: ApiKey;
|
||||
|
|
@ -24,9 +24,9 @@ export async function createApiKey({
|
|||
body: {
|
||||
name,
|
||||
permissions,
|
||||
organizationIds,
|
||||
allOrganizations,
|
||||
expiresAt,
|
||||
// organizationIds,
|
||||
// allOrganizations,
|
||||
// expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ export const CreateApiKeyPage: Component = () => {
|
|||
const { token } = await createApiKey({
|
||||
name,
|
||||
permissions,
|
||||
organizationIds: [],
|
||||
allOrganizations: false,
|
||||
// organizationIds: [],
|
||||
// allOrganizations: false,
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ['api-keys'] });
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@ import type { createAuthClient } from './auth.services';
|
|||
|
||||
export function createDemoAuthClient() {
|
||||
const baseClient = {
|
||||
useSession: () => () => ({
|
||||
isPending: false,
|
||||
getSession: () => ({
|
||||
data: {
|
||||
user: {
|
||||
id: '1',
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ export function createAuthClient() {
|
|||
requestPasswordReset: client.requestPasswordReset,
|
||||
resetPassword: client.resetPassword,
|
||||
sendVerificationEmail: client.sendVerificationEmail,
|
||||
useSession: client.useSession,
|
||||
twoFactor: client.twoFactor,
|
||||
getSession: client.getSession,
|
||||
signOut: async () => {
|
||||
trackingServices.capture({ event: 'User logged out' });
|
||||
const result = await client.signOut();
|
||||
|
|
@ -39,7 +39,7 @@ export function createAuthClient() {
|
|||
}
|
||||
|
||||
export const {
|
||||
useSession,
|
||||
getSession,
|
||||
signIn,
|
||||
signUp,
|
||||
signOut,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,26 +1,19 @@
|
|||
import type { Component } from 'solid-js';
|
||||
import type { ParentComponent } from 'solid-js';
|
||||
import { Navigate } from '@solidjs/router';
|
||||
import { Match, Suspense, Switch } from 'solid-js';
|
||||
import { Dynamic } from 'solid-js/web';
|
||||
import { useSession } from '../auth.services';
|
||||
import { Match, Switch } from 'solid-js';
|
||||
import { useSession } from '../composables/use-session.composable';
|
||||
|
||||
export function createProtectedPage({ authType, component }: { authType: 'public' | 'private' | 'public-only' | 'admin'; component: Component }) {
|
||||
return () => {
|
||||
const session = useSession();
|
||||
export const PublicOnlyPage: ParentComponent = (props) => {
|
||||
const { getIsAuthenticated, sessionQuery } = useSession();
|
||||
|
||||
const getIsAuthenticated = () => Boolean(session()?.data?.user);
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<Switch fallback={<Dynamic component={component} />}>
|
||||
<Match when={authType === 'private' && !getIsAuthenticated()}>
|
||||
<Navigate href="/login" />
|
||||
</Match>
|
||||
<Match when={authType === 'public-only' && getIsAuthenticated()}>
|
||||
<Navigate href="/" />
|
||||
</Match>
|
||||
</Switch>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
}
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={!sessionQuery.isLoading && getIsAuthenticated()}>
|
||||
<Navigate href="/" />
|
||||
</Match>
|
||||
<Match when={!sessionQuery.isLoading && !getIsAuthenticated()}>
|
||||
{props.children}
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,11 +3,12 @@ import { safely } from '@corentinth/chisels';
|
|||
import { useNavigate, useParams } from '@solidjs/router';
|
||||
import { createContext, createEffect, createSignal, For, on, onCleanup, onMount, Show, useContext } from 'solid-js';
|
||||
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 { cn } from '../shared/style/cn';
|
||||
import { toArrayIf } from '../shared/utils/array';
|
||||
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';
|
||||
|
||||
const CommandPaletteContext = createContext<{
|
||||
|
|
@ -51,14 +52,25 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
|
|||
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 { setColorMode } = useThemeStore();
|
||||
const { setThemePreference } = useTheme();
|
||||
|
||||
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 ?? []);
|
||||
setMatchingDocumentsTotalCount(result?.totalCount ?? 0);
|
||||
setMatchingDocumentsTotalCount(result?.documentsCount ?? 0);
|
||||
setIsLoading(false);
|
||||
}, 300);
|
||||
|
||||
|
|
@ -74,16 +86,6 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
|
|||
},
|
||||
));
|
||||
|
||||
createEffect(on(
|
||||
getIsCommandPaletteOpen,
|
||||
(isCommandPaletteOpen) => {
|
||||
if (isCommandPaletteOpen) {
|
||||
setMatchingDocuments([]);
|
||||
setMatchingDocumentsTotalCount(0);
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
const getCommandData = (): {
|
||||
label: string;
|
||||
forceMatch?: boolean;
|
||||
|
|
@ -100,14 +102,15 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
|
|||
forceMatch: true,
|
||||
})),
|
||||
|
||||
...(getMatchingDocumentsTotalCount() > getMatchingDocuments().length
|
||||
? [{
|
||||
label: t('command-palette.show-more-results', { count: getMatchingDocumentsTotalCount() - getMatchingDocuments().length, query: getSearchQuery() }),
|
||||
icon: 'i-tabler-search',
|
||||
action: () => navigate(makeDocumentSearchPermalink({ organizationId: params.organizationId, search: { query: getSearchQuery() } })),
|
||||
forceMatch: true,
|
||||
}]
|
||||
: []),
|
||||
...toArrayIf(
|
||||
getMatchingDocumentsTotalCount() > getMatchingDocuments().length,
|
||||
{
|
||||
label: t('command-palette.show-more-results', { count: getMatchingDocumentsTotalCount() - getMatchingDocuments().length, query: getSearchQuery() }),
|
||||
icon: 'i-tabler-search',
|
||||
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'),
|
||||
icon: 'i-tabler-sun',
|
||||
action: () => setColorMode({ mode: 'light' }),
|
||||
action: () => setThemePreference('light'),
|
||||
},
|
||||
{
|
||||
label: t('layout.theme.dark'),
|
||||
icon: 'i-tabler-moon',
|
||||
action: () => setColorMode({ mode: 'dark' }),
|
||||
action: () => setThemePreference('dark'),
|
||||
},
|
||||
{
|
||||
label: t('layout.theme.system'),
|
||||
icon: 'i-tabler-device-laptop',
|
||||
action: () => setColorMode({ mode: 'system' }),
|
||||
action: () => setThemePreference('system'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -151,7 +154,12 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
|
|||
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>
|
||||
<Show when={getIsLoading()}>
|
||||
<CommandLoading>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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',
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
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 { Webhook } from '../webhooks/webhooks.types';
|
||||
import type {
|
||||
DocumentCustomPropertyValueStorage,
|
||||
DocumentFile,
|
||||
} from './demo.storage';
|
||||
import { FetchError } from 'ofetch';
|
||||
|
|
@ -11,6 +13,8 @@ import { defineHandler } from './demo-api-mock.models';
|
|||
import { createId, randomString } from './demo.models';
|
||||
import {
|
||||
apiKeyStorage,
|
||||
customPropertyDefinitionStorage,
|
||||
documentCustomPropertyValueStorage,
|
||||
documentFileStorage,
|
||||
documentStorage,
|
||||
organizationStorage,
|
||||
|
|
@ -20,7 +24,7 @@ import {
|
|||
webhooksStorage,
|
||||
} from './demo.storage';
|
||||
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';
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
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 }> = {
|
||||
...defineHandler({
|
||||
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({
|
||||
path: '/api/organizations/:organizationId/documents',
|
||||
method: 'POST',
|
||||
|
|
@ -190,7 +212,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
|||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/documents/search',
|
||||
path: '/api/organizations/:organizationId/documents',
|
||||
method: 'GET',
|
||||
handler: async ({ params: { organizationId }, query }) => {
|
||||
const {
|
||||
|
|
@ -203,23 +225,32 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
|||
assert(organization, { status: 403 });
|
||||
|
||||
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),
|
||||
getValues(tagStorage),
|
||||
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 tags = allTags.filter(tag => documentTagDocuments.some(tagDocument => tagDocument?.tagId === tag?.id));
|
||||
|
||||
const customProperties = buildCustomPropertiesResponse({
|
||||
definitions: allDefinitions,
|
||||
storedValues: allPropertyValues,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
return {
|
||||
...document,
|
||||
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
|
||||
.toSorted((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
|
|
@ -227,7 +258,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
|||
|
||||
return {
|
||||
documents: paginatedDocuments,
|
||||
totalCount: filteredDocuments.length,
|
||||
documentsCount: filteredDocuments.length,
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
|
@ -260,13 +291,25 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
|||
|
||||
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 customProperties = buildCustomPropertiesResponse({
|
||||
definitions: allDefinitions,
|
||||
storedValues: allPropertyValues,
|
||||
documentId,
|
||||
});
|
||||
|
||||
return {
|
||||
document: {
|
||||
...document,
|
||||
tags,
|
||||
customProperties,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
|
@ -332,11 +375,11 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
|||
assert(organization, { status: 403 });
|
||||
|
||||
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 => ({
|
||||
...tag,
|
||||
documentsCount: documents.filter(document => document.tags.some(t => t.id === tag.id)).length,
|
||||
documentsCount: tagDocuments.filter(tagDocument => tagDocument.tagId === tag.id).length,
|
||||
}));
|
||||
|
||||
return {
|
||||
|
|
@ -353,10 +396,20 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
|||
|
||||
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 = {
|
||||
id: createId({ prefix: 'tag' }),
|
||||
organizationId,
|
||||
name: get(body, ['name']) as string,
|
||||
name,
|
||||
color: get(body, ['color']) as string,
|
||||
description: (get(body, ['description']) ?? null) as string | null,
|
||||
createdAt: new Date(),
|
||||
|
|
@ -381,6 +434,24 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
|||
|
||||
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() }));
|
||||
|
||||
return { tag };
|
||||
|
|
@ -758,16 +829,17 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
|||
|
||||
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 = {
|
||||
...document,
|
||||
...(name !== undefined && { name }),
|
||||
...(content !== undefined && { content }),
|
||||
...(documentDate !== undefined && { documentDate: documentDate === null ? null : new Date(documentDate) }),
|
||||
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 };
|
||||
},
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import type { Component } from 'solid-js';
|
||||
import { A } from '@solidjs/router';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { createSignal, Match, Switch } from 'solid-js';
|
||||
import { Portal } from 'solid-js/web';
|
||||
import { useI18n } from '../i18n/i18n.provider';
|
||||
import { Button } from '../ui/components/button';
|
||||
import { clearDemoStorage } from './demo.storage';
|
||||
|
||||
export const DemoIndicator: Component = () => {
|
||||
const [getIsMinified, setIsMinified] = createSignal(false);
|
||||
const [getPopupState, setPopupState] = createSignal<'minified' | 'expanded' | 'hidden'>('expanded');
|
||||
const { t, te } = useI18n();
|
||||
|
||||
const clearDemo = async () => {
|
||||
|
|
@ -16,35 +16,46 @@ export const DemoIndicator: Component = () => {
|
|||
window.location.href = '/';
|
||||
};
|
||||
|
||||
const switchToStateUnlessCtrl = (state: 'minified' | 'expanded') => (e: MouseEvent) => {
|
||||
if (e.ctrlKey) {
|
||||
setPopupState('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
setPopupState(state);
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
{getIsMinified()
|
||||
? (
|
||||
<div class="fixed bottom-4 right-4 z-50 rounded-xl max-w-280px">
|
||||
<Button onClick={() => setIsMinified(false)} size="icon">
|
||||
<div class="i-tabler-info-circle size-5.5" />
|
||||
<Switch>
|
||||
<Match when={getPopupState() === 'minified'}>
|
||||
<div class="fixed bottom-4 right-4 z-50 rounded-xl max-w-280px">
|
||||
<Button onClick={switchToStateUnlessCtrl('expanded')} size="icon">
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<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={() => setIsMinified(true)} class="bg-transparent hover:text-primary" variant="outline" size="sm">
|
||||
{t('demo.popup.hide')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
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 { Organization } from '../organizations/organizations.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 { DEMO_IS_SEEDED_KEY } from './demo.constants';
|
||||
import { createId } from './demo.models';
|
||||
import { customPropertyDefinitionsFixtures } from './seed/custom-property-definitions.fixtures';
|
||||
import { documentFixtures } from './seed/documents.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 DocumentFile = DocumentFileStoredFile | DocumentFileRemoteFile;
|
||||
|
||||
export type DocumentCustomPropertyValueStorage = {
|
||||
id: string;
|
||||
documentId: string;
|
||||
propertyDefinitionId: string;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
export const organizationStorage = prefixStorage<Organization>(storage, 'organizations');
|
||||
export const documentStorage = prefixStorage<Document>(storage, 'documents');
|
||||
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 apiKeyStorage = prefixStorage<ApiKey>(storage, 'apiKeys');
|
||||
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() {
|
||||
await storage.clear();
|
||||
|
|
@ -90,6 +101,24 @@ export async function seedDemoStorage() {
|
|||
|
||||
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 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([
|
||||
tagsPromises,
|
||||
customPropertyDefinitionsPromises,
|
||||
...documentsPromises,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { Document } from '@/modules/documents/documents.types';
|
||||
import type { Tag } from '@/modules/tags/tags.types';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { searchDemoDocuments } from './demo.search.services';
|
||||
import { searchDemoDocuments, someCorpusTokenStartsWith } from './demo.search.services';
|
||||
|
||||
describe('demo search services', () => {
|
||||
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_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_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[];
|
||||
|
||||
const queries = [
|
||||
{ query: 'panca', 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 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: '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: 'NOT has:tags', expectedIds: ['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) {
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,14 +2,34 @@ import type { AndExpression, Expression, FilterExpression, NotExpression, OrExpr
|
|||
import type { Document } from '../../documents/documents.types';
|
||||
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;
|
||||
|
||||
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 {
|
||||
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 {
|
||||
|
|
@ -37,7 +57,7 @@ function buildTagFilterCondition({ expression }: { expression: FilterExpression
|
|||
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 {
|
||||
|
|
@ -47,7 +67,7 @@ function buildNameFilterCondition({ expression }: { expression: FilterExpression
|
|||
return falseCondition;
|
||||
}
|
||||
|
||||
return ({ document }) => document.name.toLowerCase().includes(value.toLowerCase());
|
||||
return ({ document }) => someCorpusTokenStartsWith({ corpus: document.name, prefix: value });
|
||||
}
|
||||
|
||||
function buildContentFilterCondition({ expression }: { expression: FilterExpression }): DocumentCondition {
|
||||
|
|
@ -57,12 +77,12 @@ function buildContentFilterCondition({ expression }: { expression: FilterExpress
|
|||
return falseCondition;
|
||||
}
|
||||
|
||||
return ({ document }) => document.content.toLowerCase().includes(value.toLowerCase());
|
||||
return ({ document }) => someCorpusTokenStartsWith({ corpus: document.content, prefix: value });
|
||||
}
|
||||
|
||||
function buildCreatedFilterCondition({ expression }: { expression: FilterExpression }): DocumentCondition {
|
||||
const { value, operator } = expression;
|
||||
const dateValue = new Date(value);
|
||||
const dateValue = getDateValue({ value });
|
||||
|
||||
if (Number.isNaN(dateValue.getTime())) {
|
||||
return () => false;
|
||||
|
|
@ -92,6 +112,160 @@ function buildHasTagsFilter({ expression }: { expression: FilterExpression }): D
|
|||
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 {
|
||||
switch (expression.type) {
|
||||
case 'text': return buildTextCondition({ expression });
|
||||
|
|
@ -104,14 +278,24 @@ function buildExpressionCondition({ expression }: { expression: Expression }): D
|
|||
case 'name': return buildNameFilterCondition({ expression });
|
||||
case 'content': return buildContentFilterCondition({ expression });
|
||||
case 'created': return buildCreatedFilterCondition({ expression });
|
||||
case 'date': return buildDateFilterCondition({ expression });
|
||||
case 'has':
|
||||
switch (expression.value) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue