mirror of
https://github.com/papra-hq/papra.git
synced 2025-12-19 03:51:28 -06:00
Compare commits
20 Commits
@papra/doc
...
documents-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13889c1c42 | ||
|
|
6cedc30716 | ||
|
|
f1e1b4037b | ||
|
|
205c6cfd46 | ||
|
|
c54a71d2c5 | ||
|
|
62b7f0382c | ||
|
|
57c6a26657 | ||
|
|
b8c2bd70e3 | ||
|
|
0c2cf698d1 | ||
|
|
585c53cd9d | ||
|
|
f035458e16 | ||
|
|
556fd8b167 | ||
|
|
81e85295ba | ||
|
|
1c574b8305 | ||
|
|
ff830c234a | ||
|
|
451564f354 | ||
|
|
ecd6af45c8 | ||
|
|
cb652c7166 | ||
|
|
17ca8f8f81 | ||
|
|
f54b8e162a |
5
.changeset/big-walls-tell.md
Normal file
5
.changeset/big-walls-tell.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@papra/app-client": patch
|
||||
---
|
||||
|
||||
Improve file preview for text-like files (.env, yaml, extension-less text files,...)
|
||||
5
.changeset/few-toes-ask.md
Normal file
5
.changeset/few-toes-ask.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@papra/app-client": patch
|
||||
---
|
||||
|
||||
Fixes 400 error when submitting tags with uppercase hex colour codes.
|
||||
10
.changeset/polite-apples-begin.md
Normal file
10
.changeset/polite-apples-begin.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
"@papra/app-client": patch
|
||||
"@papra/app-server": patch
|
||||
"@papra/webhooks": patch
|
||||
"@papra/api-sdk": patch
|
||||
"@papra/cli": patch
|
||||
"@papra/docs": patch
|
||||
---
|
||||
|
||||
Updated dependencies
|
||||
5
.changeset/wet-emus-grin.md
Normal file
5
.changeset/wet-emus-grin.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@papra/app-client": patch
|
||||
---
|
||||
|
||||
Added tag color swatches and picker
|
||||
15
README.md
15
README.md
@@ -39,12 +39,9 @@ A live demo of the platform is available at [demo.papra.app](https://demo.papra.
|
||||
|
||||
## Project Status
|
||||
|
||||
Papra is currently in **beta**. The core functionality is stable and usable, but you may encounter occasional bugs or limitations. The project is actively developed, with new features being added regularly.
|
||||
Papra is under active development, the core functionalities are stable and usable. With lots of features and improvements added regularly.
|
||||
|
||||
- ✅ Core document management features are stable
|
||||
- ✅ Self-hosting is fully supported
|
||||
- 🚧 Some advanced features are still in development
|
||||
- 📝 Feedback and bug reports are highly appreciated
|
||||
Feedback and bug reports are highly appreciated to help us improve the platform.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -63,11 +60,17 @@ Papra is currently in **beta**. The core functionality is stable and usable, but
|
||||
- **Folder ingestion**: Automatically import documents from a folder.
|
||||
- **CLI**: Manage your documents from the command line.
|
||||
- **API, SDK and webhooks**: Build your own applications on top of Papra.
|
||||
- *In progress:* **i18n**: Support for multiple languages.
|
||||
- **i18n**: Support for multiple languages.
|
||||
- *Coming soon:* **Document sharing**: Share documents with others.
|
||||
- *Coming soon:* **Document requests**: Generate upload links for people to add documents.
|
||||
- *Coming maybe one day:* **Mobile app**: Access and upload documents on the go.
|
||||
- *Coming maybe one day:* **Desktop app**: Access and upload documents from your computer.
|
||||
- *Coming maybe one day:* **Browser extension**: Upload documents from your browser.
|
||||
- *Coming maybe one day:* **AI**: Use AI to help you manage or tag your documents.
|
||||
|
||||
## Sponsors
|
||||
|
||||
Papra is a free and open-source project, but it is not free to run and develop. If you want to support the project, you can become a sponsor on [GitHub Sponsors](https://github.com/sponsors/corentinth) or [Buy me a coffee](https://buymeacoffee.com/cthmsst). If you are a company, you can also contact me to discuss a sponsorship.
|
||||
|
||||
## Self-hosting
|
||||
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# @papra/docs
|
||||
|
||||
## 0.5.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#357](https://github.com/papra-hq/papra/pull/357) [`585c53c`](https://github.com/papra-hq/papra/commit/585c53cd9d0d7dbd517dbb1adddfd9e7b70f9fe5) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added a /llms.txt on main website
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#337](https://github.com/papra-hq/papra/pull/337) [`1c574b8`](https://github.com/papra-hq/papra/commit/1c574b8305eb7bde4f1b75ac38a610ca0120a613) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added troubleshooting page
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#337](https://github.com/papra-hq/papra/pull/337) [`1c574b8`](https://github.com/papra-hq/papra/commit/1c574b8305eb7bde4f1b75ac38a610ca0120a613) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added `docker compose up` command in dc generator
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#322](https://github.com/papra-hq/papra/pull/322) [`f54b8e1`](https://github.com/papra-hq/papra/commit/f54b8e162acd6cfe92241aaa649847fc03ca5852) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Auto computes urls from the provided port
|
||||
|
||||
## 0.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "@papra/docs",
|
||||
"type": "module",
|
||||
"version": "0.4.1",
|
||||
"version": "0.5.1",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.9.0",
|
||||
"packageManager": "pnpm@10.12.3",
|
||||
"description": "Papra documentation website",
|
||||
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
title: Using Docker Compose
|
||||
slug: self-hosting/using-docker-compose
|
||||
description: Self-host Papra using Docker Compose.
|
||||
---
|
||||
|
||||
import { Steps } from '@astrojs/starlight/components';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Configuration
|
||||
slug: self-hosting/configuration
|
||||
|
||||
description: Configure your self-hosted Papra instance.
|
||||
---
|
||||
|
||||
import { mdSections, fullDotEnv } from '../../../config.data.ts';
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
title: Troubleshooting
|
||||
description: Troubleshooting guide for Papra
|
||||
slug: resources/troubleshooting
|
||||
---
|
||||
|
||||
You can find here some common issues and how to fix them. If you encounter an issue that is not listed here, please [open an issue](https://github.com/papra-hq/papra/issues/new/choose) or [join our Discord](https://papra.app/discord).
|
||||
|
||||
## Failed to ensure that the database directory exists
|
||||
|
||||
Upon starting the server or a script, you may encounter this error
|
||||
|
||||
```
|
||||
Failed to ensure that the database directory exists, error while creating the directory
|
||||
Error: EACCES: permission denied, mkdir './app-data/db'
|
||||
|
||||
```
|
||||
|
||||
Before accessing the DB sqlite file, the server will try to ensure that the database directory exists, and if it doesn't, it try will create it. But in case of insufficient permissions, it will fail.
|
||||
|
||||
To fix this, you can either:
|
||||
|
||||
- Create the directory manually `mkdir -p <your-app-data-dir>/db`
|
||||
- Ensure that the directory is owned by the user running the container
|
||||
- Run the server as root (not recommended)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Papra documentation
|
||||
description: Papra documentation.
|
||||
description: Documentation for Papra, the minimalistic document archiving platform.
|
||||
hero:
|
||||
title: Papra Docs
|
||||
tagline: Documentation for Papra, the minimalistic document archiving platform.
|
||||
@@ -55,7 +55,7 @@ In today's digital world, managing countless important documents efficiently and
|
||||
- **Folder ingestion**: Automatically import documents from a folder.
|
||||
- **API, SDK and webhooks**: Build your own applications on top of Papra.
|
||||
- **CLI**: Manage your documents from the command line.
|
||||
- *In progress:* **i18n**: Support for multiple languages.
|
||||
- **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.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { StarlightUserConfig } from '@astrojs/starlight/types';
|
||||
|
||||
export const sidebar: StarlightUserConfig['sidebar'] = [
|
||||
export const sidebar = [
|
||||
{
|
||||
label: 'Getting Started',
|
||||
items: [
|
||||
@@ -40,6 +40,10 @@ export const sidebar: StarlightUserConfig['sidebar'] = [
|
||||
{
|
||||
label: 'Resources',
|
||||
items: [
|
||||
{
|
||||
label: 'Troubleshooting',
|
||||
slug: 'resources/troubleshooting',
|
||||
},
|
||||
{
|
||||
label: 'CLI Documentation',
|
||||
slug: 'resources/cli',
|
||||
@@ -51,6 +55,7 @@ export const sidebar: StarlightUserConfig['sidebar'] = [
|
||||
target: '_blank',
|
||||
},
|
||||
},
|
||||
|
||||
],
|
||||
},
|
||||
];
|
||||
] satisfies StarlightUserConfig['sidebar'];
|
||||
|
||||
@@ -24,9 +24,9 @@ services:
|
||||
`.trim();
|
||||
|
||||
const dcHtml = await codeToHtml(defaultDockerCompose, { theme: 'vitesse-black', lang: 'yaml' });
|
||||
const defaultCommand = `mkdir -p ./app-data/{db,documents} && docker compose up -d`;
|
||||
---
|
||||
|
||||
<p>This tool will help you generate a custom docker-compose.yml file for Papra, tailored to your needs. You can personalize the service name, the port, the auth secret, and the source image.</p>
|
||||
|
||||
<h2 class="mt-8 mb-2">General settings</h2>
|
||||
|
||||
@@ -125,7 +125,7 @@ const dcHtml = await codeToHtml(defaultDockerCompose, { theme: 'vitesse-black',
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<label for="intake-email-owlrelay-webhook-url" class="min-w-32">Webhook URL</label>
|
||||
<input id="intake-email-owlrelay-webhook-url" class="input-field" type="text" placeholder="https://your-instance.com/api/intake-emails/ingest" />
|
||||
<input id="intake-email-owlrelay-webhook-url" class="input-field" type="text" placeholder="https://your-instance.com/api/intake-emails/ingest" value="https://localhost:1221/api/intake-emails/ingest" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -146,9 +146,12 @@ const dcHtml = await codeToHtml(defaultDockerCompose, { theme: 'vitesse-black',
|
||||
|
||||
<div id="docker-compose-output" class="mt-12" set:html={dcHtml} />
|
||||
|
||||
<pre id="command-output" class="bg-card p-4 rounded-md text-muted-foreground text-sm font-mono overflow-x-auto">{defaultCommand}</pre>
|
||||
|
||||
<div class="flex items-center gap-2 mt-4">
|
||||
<button class="btn bg-muted mt-0" id="download-button">Download docker-compose.yml</button>
|
||||
<button class="btn bg-muted mt-0" id="copy-button">Copy to clipboard</button>
|
||||
<button class="btn bg-muted mt-0" id="copy-button">Copy docker compose to clipboard</button>
|
||||
<button class="btn bg-muted mt-0" id="copy-command-button">Copy command</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -179,6 +182,13 @@ const owlrelayWebhookUrlInput = document.getElementById('intake-email-owlrelay-w
|
||||
const cfEmailDomainInput = document.getElementById('intake-email-cf-email-domain') as HTMLInputElement;
|
||||
const webhookSecretInput = document.getElementById('intake-email-webhook-secret') as HTMLInputElement;
|
||||
const refreshWebhookSecretButton = document.getElementById('refresh-webhook-secret');
|
||||
const commandOutput = document.getElementById('command-output');
|
||||
const copyCommandButton = document.getElementById('copy-command-button');
|
||||
|
||||
// Track whether the app base URL has been customized by the user
|
||||
let isAppBaseUrlCustomized = false;
|
||||
// Track whether the webhook URL has been customized by the user
|
||||
let isWebhookUrlCustomized = false;
|
||||
|
||||
function getRandomString() {
|
||||
const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
@@ -189,6 +199,76 @@ function copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
function isDefaultAppBaseUrl(url: string, port: string): boolean {
|
||||
return url === `http://localhost:${port}`;
|
||||
}
|
||||
|
||||
function generateDefaultWebhookUrl(baseUrl: string): string {
|
||||
// Remove trailing slash if present
|
||||
const cleanBaseUrl = baseUrl.replace(/\/$/, '');
|
||||
return `${cleanBaseUrl}/api/intake-emails/ingest`;
|
||||
}
|
||||
|
||||
function isDefaultWebhookUrl(webhookUrl: string, baseUrl: string): boolean {
|
||||
return webhookUrl === generateDefaultWebhookUrl(baseUrl);
|
||||
}
|
||||
|
||||
function refreshIsWebhookUrlCustomized() {
|
||||
const currentBaseUrl = appBaseUrlInput.value.trim();
|
||||
const currentWebhookUrl = owlrelayWebhookUrlInput.value.trim();
|
||||
|
||||
if (isDefaultWebhookUrl(currentWebhookUrl, currentBaseUrl)) {
|
||||
isWebhookUrlCustomized = false;
|
||||
} else {
|
||||
isWebhookUrlCustomized = true;
|
||||
}
|
||||
}
|
||||
|
||||
function refreshIsAppBaseUrlCustomized() {
|
||||
const currentPort = portInput.value;
|
||||
const currentUrl = appBaseUrlInput.value.trim();
|
||||
|
||||
if (isDefaultAppBaseUrl(currentUrl, currentPort)) {
|
||||
isAppBaseUrlCustomized = false;
|
||||
} else {
|
||||
isAppBaseUrlCustomized = true;
|
||||
}
|
||||
}
|
||||
|
||||
function updateWebhookUrlFromBaseUrl() {
|
||||
if (!isWebhookUrlCustomized) {
|
||||
const baseUrl = appBaseUrlInput.value.trim();
|
||||
if (baseUrl) {
|
||||
owlrelayWebhookUrlInput.value = generateDefaultWebhookUrl(baseUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateAppBaseUrlFromPort() {
|
||||
if (!isAppBaseUrlCustomized) {
|
||||
const port = portInput.value;
|
||||
appBaseUrlInput.value = `http://localhost:${port}`;
|
||||
// Also update webhook URL when app base URL changes
|
||||
updateWebhookUrlFromBaseUrl();
|
||||
}
|
||||
}
|
||||
|
||||
function handlePortChange() {
|
||||
updateAppBaseUrlFromPort();
|
||||
updateDockerCompose();
|
||||
}
|
||||
|
||||
function handleAppBaseUrlChange() {
|
||||
refreshIsAppBaseUrlCustomized();
|
||||
updateWebhookUrlFromBaseUrl();
|
||||
updateDockerCompose();
|
||||
}
|
||||
|
||||
function handleWebhookUrlChange() {
|
||||
refreshIsWebhookUrlCustomized();
|
||||
updateDockerCompose();
|
||||
}
|
||||
|
||||
function getDockerComposeYml() {
|
||||
const serviceName = serviceNameInput.value;
|
||||
const isRootless = privilegedModeSelect.value === 'false';
|
||||
@@ -247,14 +327,31 @@ function getDockerComposeYml() {
|
||||
return stringify(dc);
|
||||
}
|
||||
|
||||
function getStartCommand() {
|
||||
const volumePath = volumePathInput.value;
|
||||
const volumePathNormalized = volumePath.replace(/\/$/, '');
|
||||
const volumeWithSubdirs = `${volumePathNormalized}/{db,documents}`;
|
||||
|
||||
const mkdirCommand = `mkdir -p ${volumeWithSubdirs}`;
|
||||
|
||||
const dockerCommand = 'docker compose up -d';
|
||||
|
||||
return `${mkdirCommand} && ${dockerCommand}`;
|
||||
}
|
||||
|
||||
async function updateDockerCompose() {
|
||||
const dockerCompose = getDockerComposeYml();
|
||||
const command = getStartCommand();
|
||||
|
||||
const html = await codeToHtml(dockerCompose, { theme: 'vitesse-black', lang: 'yaml' });
|
||||
|
||||
if (dockerComposeOutput) {
|
||||
dockerComposeOutput.innerHTML = html;
|
||||
}
|
||||
|
||||
if (commandOutput) {
|
||||
commandOutput.textContent = command;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopy() {
|
||||
@@ -346,12 +443,28 @@ function handleRefreshWebhookSecret() {
|
||||
updateDockerCompose();
|
||||
}
|
||||
|
||||
function handleCopyCommand() {
|
||||
const command = getStartCommand();
|
||||
|
||||
copyToClipboard(command);
|
||||
|
||||
if (copyCommandButton) {
|
||||
copyCommandButton.textContent = 'Copied!';
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (copyCommandButton) {
|
||||
copyCommandButton.textContent = 'Copy command';
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Add event listeners
|
||||
portInput.addEventListener('input', updateDockerCompose);
|
||||
portInput.addEventListener('input', handlePortChange);
|
||||
sourceSelect.addEventListener('change', updateDockerCompose);
|
||||
serviceNameInput.addEventListener('input', updateDockerCompose);
|
||||
authSecretInput.addEventListener('input', updateDockerCompose);
|
||||
appBaseUrlInput.addEventListener('input', updateDockerCompose);
|
||||
appBaseUrlInput.addEventListener('input', handleAppBaseUrlChange);
|
||||
refreshSecretButton?.addEventListener('click', handleRefreshSecret);
|
||||
copyButton?.addEventListener('click', handleCopy);
|
||||
downloadButton?.addEventListener('click', handleDownload);
|
||||
@@ -362,10 +475,11 @@ ingestionPathInput.addEventListener('input', updateDockerCompose);
|
||||
intakeEmailEnabledSelect.addEventListener('change', handleIntakeEmailEnabledChange);
|
||||
intakeDriverSelect.addEventListener('change', handleIntakeDriverChange);
|
||||
owlrelayApiKeyInput.addEventListener('input', updateDockerCompose);
|
||||
owlrelayWebhookUrlInput.addEventListener('input', updateDockerCompose);
|
||||
owlrelayWebhookUrlInput.addEventListener('input', handleWebhookUrlChange);
|
||||
cfEmailDomainInput.addEventListener('input', updateDockerCompose);
|
||||
webhookSecretInput.addEventListener('input', updateDockerCompose);
|
||||
refreshWebhookSecretButton?.addEventListener('click', handleRefreshWebhookSecret);
|
||||
copyCommandButton?.addEventListener('click', handleCopyCommand);
|
||||
|
||||
authSecretInput.value = getRandomString();
|
||||
|
||||
|
||||
@@ -8,8 +8,9 @@ import DockerComposeGeneratorComp from '../docker-compose-generator/dc-generator
|
||||
title: 'Papra docker-compose.yml generator',
|
||||
description: 'Generate a custom docker-compose.yml file for Papra, tailored to your needs.',
|
||||
tableOfContents: false,
|
||||
|
||||
}}
|
||||
>
|
||||
<p>This tool will help you generate a custom docker-compose.yml file for Papra, tailored to your needs. You can personalize the service name, the port, the auth secret, and the source image.</p>
|
||||
<p>For more configuration options, you can use the <a href="/self-hosting/configuration">configuration reference</a>.</p>
|
||||
<DockerComposeGeneratorComp />
|
||||
</StarlightPage>
|
||||
|
||||
28
apps/docs/src/pages/docs-navigation.json.ts
Normal file
28
apps/docs/src/pages/docs-navigation.json.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { sidebar } from '../content/navigation';
|
||||
|
||||
export const GET: APIRoute = async ({ site }) => {
|
||||
const docs = await getCollection('docs');
|
||||
|
||||
const sections = sidebar.map((section) => {
|
||||
return {
|
||||
label: section.label,
|
||||
items: section
|
||||
.items
|
||||
.filter(item => item.slug !== undefined || (item.link && !item.link.startsWith('http')))
|
||||
.map((item) => {
|
||||
const slug = item.slug ?? item.link?.replace(/^\//, '');
|
||||
|
||||
return {
|
||||
label: item.label,
|
||||
slug,
|
||||
url: new URL(slug, site).toString(),
|
||||
description: docs.find(doc => (doc.id === slug || (slug === '' && doc.id === 'index')))?.data.description,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify(sections));
|
||||
};
|
||||
@@ -1,5 +1,21 @@
|
||||
# @papra/app-client
|
||||
|
||||
## 0.6.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#357](https://github.com/papra-hq/papra/pull/357) [`585c53c`](https://github.com/papra-hq/papra/commit/585c53cd9d0d7dbd517dbb1adddfd9e7b70f9fe5) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added a /llms.txt on main website
|
||||
|
||||
- [#359](https://github.com/papra-hq/papra/pull/359) [`0c2cf69`](https://github.com/papra-hq/papra/commit/0c2cf698d1a9e9a3cea023920b10cfcd5d83be14) Thanks [@Mavv3006](https://github.com/Mavv3006)! - Add German translation
|
||||
|
||||
## 0.6.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#333](https://github.com/papra-hq/papra/pull/333) [`ff830c2`](https://github.com/papra-hq/papra/commit/ff830c234a02ddb4cbc480cf77ef49b8de35fbae) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fixed version release link
|
||||
|
||||
## 0.6.1
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "@papra/app-client",
|
||||
"type": "module",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.3",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.9.0",
|
||||
"packageManager": "pnpm@10.12.3",
|
||||
"description": "Papra frontend client",
|
||||
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
@@ -31,13 +31,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@corentinth/chisels": "^1.3.1",
|
||||
"@kobalte/core": "^0.13.9",
|
||||
"@kobalte/core": "^0.13.10",
|
||||
"@kobalte/utils": "^0.9.1",
|
||||
"@modular-forms/solid": "^0.25.1",
|
||||
"@pdfslick/solid": "^2.3.0",
|
||||
"@solid-primitives/storage": "^4.3.2",
|
||||
"@solidjs/router": "^0.14.10",
|
||||
"@tanstack/solid-query": "^5.77.2",
|
||||
"@tanstack/solid-query": "^5.81.2",
|
||||
"@tanstack/solid-table": "^8.21.3",
|
||||
"@unocss/reset": "^0.64.1",
|
||||
"better-auth": "catalog:",
|
||||
@@ -47,7 +47,7 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"ofetch": "^1.4.1",
|
||||
"posthog-js": "^1.246.0",
|
||||
"posthog-js": "^1.255.1",
|
||||
"radix3": "^1.1.2",
|
||||
"solid-js": "^1.9.7",
|
||||
"solid-sonner": "^0.2.8",
|
||||
@@ -59,18 +59,18 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "catalog:",
|
||||
"@iconify-json/tabler": "^1.2.18",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@iconify-json/tabler": "^1.2.19",
|
||||
"@playwright/test": "^1.53.1",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"jsdom": "^25.0.1",
|
||||
"tinyglobby": "^0.2.14",
|
||||
"tsx": "^4.19.4",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "catalog:",
|
||||
"unocss": "0.65.0-beta.2",
|
||||
"vite": "^5.4.19",
|
||||
"vite-plugin-solid": "^2.11.6",
|
||||
"vite-plugin-solid": "^2.11.7",
|
||||
"vitest": "catalog:",
|
||||
"yaml": "^2.8.0"
|
||||
}
|
||||
|
||||
562
apps/papra-client/src/locales/de.yml
Normal file
562
apps/papra-client/src/locales/de.yml
Normal file
@@ -0,0 +1,562 @@
|
||||
# Authentication
|
||||
|
||||
auth.request-password-reset.title: Passwort zurücksetzen
|
||||
auth.request-password-reset.description: Geben Sie Ihre E-Mail-Adresse ein, um Ihr Passwort zurückzusetzen.
|
||||
auth.request-password-reset.requested: Wenn ein Konto mit dieser E-Mail-Adresse existiert, haben wir Ihnen eine E-Mail zum Zurücksetzen Ihres Passworts gesendet.
|
||||
auth.request-password-reset.back-to-login: Zurück zum Login
|
||||
auth.request-password-reset.form.email.label: E-Mail
|
||||
auth.request-password-reset.form.email.placeholder: 'Beispiel: ada@papra.app'
|
||||
auth.request-password-reset.form.email.required: Bitte geben Sie Ihre E-Mail-Adresse ein
|
||||
auth.request-password-reset.form.email.invalid: Diese E-Mail-Adresse ist ungültig
|
||||
auth.request-password-reset.form.submit: Passwort zurücksetzen anfordern
|
||||
|
||||
auth.reset-password.title: Passwort zurücksetzen
|
||||
auth.reset-password.description: Geben Sie Ihr neues Passwort ein, um Ihr Passwort zurückzusetzen.
|
||||
auth.reset-password.reset: Ihr Passwort wurde zurückgesetzt.
|
||||
auth.reset-password.back-to-login: Zurück zum Login
|
||||
auth.reset-password.form.new-password.label: Neues Passwort
|
||||
auth.reset-password.form.new-password.placeholder: 'Beispiel: **********'
|
||||
auth.reset-password.form.new-password.required: Bitte geben Sie Ihr neues Passwort ein
|
||||
auth.reset-password.form.new-password.min-length: Das Passwort muss mindestens {{ minLength }} Zeichen lang sein
|
||||
auth.reset-password.form.new-password.max-length: Das Passwort muss weniger als {{ maxLength }} Zeichen lang sein
|
||||
auth.reset-password.form.submit: Passwort zurücksetzen
|
||||
|
||||
auth.email-provider.open: '{{ provider }} öffnen'
|
||||
|
||||
auth.login.title: Bei Papra anmelden
|
||||
auth.login.description: Geben Sie Ihre E-Mail-Adresse ein oder verwenden Sie die soziale Anmeldung, um auf Ihr Papra-Konto zuzugreifen.
|
||||
auth.login.login-with-provider: Mit {{ provider }} anmelden
|
||||
auth.login.no-account: Sie haben noch kein Konto?
|
||||
auth.login.register: Registrieren
|
||||
auth.login.form.email.label: E-Mail
|
||||
auth.login.form.email.placeholder: 'Beispiel: ada@papra.app'
|
||||
auth.login.form.email.required: Bitte geben Sie Ihre E-Mail-Adresse ein
|
||||
auth.login.form.email.invalid: Diese E-Mail-Adresse ist ungültig
|
||||
auth.login.form.password.label: Passwort
|
||||
auth.login.form.password.placeholder: Passwort festlegen
|
||||
auth.login.form.password.required: Bitte geben Sie Ihr Passwort ein
|
||||
auth.login.form.remember-me.label: Angemeldet bleiben
|
||||
auth.login.form.forgot-password.label: Passwort vergessen?
|
||||
auth.login.form.submit: Anmelden
|
||||
|
||||
auth.register.title: Bei Papra registrieren
|
||||
auth.register.description: Erstellen Sie ein Konto, um Papra zu nutzen.
|
||||
auth.register.register-with-email: Mit E-Mail registrieren
|
||||
auth.register.register-with-provider: Mit {{ provider }} registrieren
|
||||
auth.register.providers.google: Google
|
||||
auth.register.providers.github: GitHub
|
||||
auth.register.have-account: Sie haben bereits ein Konto?
|
||||
auth.register.login: Anmelden
|
||||
auth.register.registration-disabled.title: Registrierung ist deaktiviert
|
||||
auth.register.registration-disabled.description: Die Erstellung neuer Konten ist auf dieser Papra-Instanz derzeit deaktiviert. Nur Benutzer mit bestehenden Konten können sich anmelden. Wenn Sie dies für einen Fehler halten, wenden Sie sich bitte an den Administrator dieser Instanz.
|
||||
auth.register.form.email.label: E-Mail
|
||||
auth.register.form.email.placeholder: 'Beispiel: ada@papra.app'
|
||||
auth.register.form.email.required: Bitte geben Sie Ihre E-Mail-Adresse ein
|
||||
auth.register.form.email.invalid: Diese E-Mail-Adresse ist ungültig
|
||||
auth.register.form.password.label: Passwort
|
||||
auth.register.form.password.placeholder: Passwort festlegen
|
||||
auth.register.form.password.required: Bitte geben Sie Ihr Passwort ein
|
||||
auth.register.form.password.min-length: Das Passwort muss mindestens {{ minLength }} Zeichen lang sein
|
||||
auth.register.form.password.max-length: Das Passwort muss weniger als {{ maxLength }} Zeichen lang sein
|
||||
auth.register.form.name.label: Name
|
||||
auth.register.form.name.placeholder: 'Beispiel: Ada Lovelace'
|
||||
auth.register.form.name.required: Bitte geben Sie Ihren Namen ein
|
||||
auth.register.form.name.max-length: Der Name muss weniger als {{ maxLength }} Zeichen lang sein
|
||||
auth.register.form.submit: Registrieren
|
||||
|
||||
auth.email-validation-required.title: E-Mail verifizieren
|
||||
auth.email-validation-required.description: Eine Verifizierungs-E-Mail wurde an Ihre E-Mail-Adresse gesendet. Bitte verifizieren Sie Ihre E-Mail-Adresse, indem Sie auf den Link in der E-Mail klicken.
|
||||
|
||||
auth.legal-links.description: Indem Sie fortfahren, bestätigen Sie, dass Sie die {{ terms }} und die {{ privacy }} verstanden haben und ihnen zustimmen.
|
||||
auth.legal-links.terms: Nutzungsbedingungen
|
||||
auth.legal-links.privacy: Datenschutzrichtlinie
|
||||
|
||||
# User settings
|
||||
|
||||
user.settings.title: Benutzereinstellungen
|
||||
user.settings.description: Verwalten Sie hier Ihre Kontoeinstellungen.
|
||||
|
||||
user.settings.email.title: E-Mail-Adresse
|
||||
user.settings.email.description: Ihre E-Mail-Adresse kann nicht geändert werden.
|
||||
user.settings.email.label: E-Mail-Adresse
|
||||
|
||||
user.settings.name.title: Vollständiger Name
|
||||
user.settings.name.description: Ihr vollständiger Name wird anderen Organisationsmitgliedern angezeigt.
|
||||
user.settings.name.label: Vollständiger Name
|
||||
user.settings.name.placeholder: Z.B. Max Mustermann
|
||||
user.settings.name.update: Namen aktualisieren
|
||||
user.settings.name.updated: Ihr vollständiger Name wurde aktualisiert
|
||||
|
||||
user.settings.logout.title: Abmelden
|
||||
user.settings.logout.description: Melden Sie sich von Ihrem Konto ab. Sie können sich später wieder anmelden.
|
||||
user.settings.logout.button: Abmelden
|
||||
|
||||
# Organizations
|
||||
|
||||
organizations.list.title: Ihre Organisationen
|
||||
organizations.list.description: Organisationen sind eine Möglichkeit, Ihre Dokumente zu gruppieren und den Zugriff darauf zu verwalten. Sie können mehrere Organisationen erstellen und Ihre Teammitglieder zur Zusammenarbeit einladen.
|
||||
organizations.list.create-new: Neue Organisation erstellen
|
||||
|
||||
organizations.details.no-documents.title: Keine Dokumente
|
||||
organizations.details.no-documents.description: Es sind noch keine Dokumente in dieser Organisation vorhanden. Beginnen Sie mit dem Hochladen von Dokumenten.
|
||||
organizations.details.upload-documents: Dokumente hochladen
|
||||
organizations.details.documents-count: Dokumente insgesamt
|
||||
organizations.details.total-size: Gesamtgröße
|
||||
organizations.details.latest-documents: Neueste importierte Dokumente
|
||||
|
||||
organizations.create.title: Eine neue Organisation erstellen
|
||||
organizations.create.description: Ihre Dokumente werden nach Organisation gruppiert. Sie können mehrere Organisationen erstellen, um Ihre Dokumente zu trennen, z.B. für persönliche und geschäftliche Dokumente.
|
||||
organizations.create.back: Zurück
|
||||
organizations.create.error.max-count-reached: Sie haben die maximale Anzahl an Organisationen erreicht, die Sie erstellen können. Wenn Sie weitere erstellen möchten, kontaktieren Sie bitte den Support.
|
||||
organizations.create.form.name.label: Name der Organisation
|
||||
organizations.create.form.name.placeholder: Z.B. Acme Inc.
|
||||
organizations.create.form.name.required: Bitte geben Sie einen Organisationsnamen ein
|
||||
organizations.create.form.submit: Organisation erstellen
|
||||
organizations.create.success: Organisation erfolgreich erstellt
|
||||
|
||||
organizations.create-first.title: Erstellen Sie Ihre Organisation
|
||||
organizations.create-first.description: Ihre Dokumente werden nach Organisation gruppiert. Sie können mehrere Organisationen erstellen, um Ihre Dokumente zu trennen, z.B. für persönliche und geschäftliche Dokumente.
|
||||
organizations.create-first.default-name: Meine Organisation
|
||||
organizations.create-first.user-name: Organisation von "{{ name }}"
|
||||
|
||||
organization.settings.title: Organisationseinstellungen
|
||||
organization.settings.page.title: Organisationseinstellungen
|
||||
organization.settings.page.description: Verwalten Sie hier Ihre Organisationseinstellungen.
|
||||
organization.settings.name.title: Name der Organisation
|
||||
organization.settings.name.update: Namen aktualisieren
|
||||
organization.settings.name.placeholder: Z.B. Acme Inc.
|
||||
organization.settings.name.updated: Organisationsname aktualisiert
|
||||
organization.settings.subscription.title: Abonnement
|
||||
organization.settings.subscription.description: Verwalten Sie Ihre Abrechnung, Rechnungen und Zahlungsmethoden.
|
||||
organization.settings.subscription.manage: Abonnement verwalten
|
||||
organization.settings.subscription.error: Kundenportal-URL konnte nicht abgerufen werden
|
||||
organization.settings.delete.title: Organisation löschen
|
||||
organization.settings.delete.description: Das Löschen dieser Organisation entfernt dauerhaft alle damit verbundenen Daten.
|
||||
organization.settings.delete.confirm.title: Organisation löschen
|
||||
organization.settings.delete.confirm.message: Sind Sie sicher, dass Sie diese Organisation löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden und alle mit dieser Organisation verbundenen Daten werden dauerhaft entfernt.
|
||||
organization.settings.delete.confirm.confirm-button: Organisation löschen
|
||||
organization.settings.delete.confirm.cancel-button: Abbrechen
|
||||
organization.settings.delete.success: Organisation gelöscht
|
||||
|
||||
organizations.members.title: Mitglieder
|
||||
organizations.members.description: Verwalten Sie Ihre Organisationsmitglieder
|
||||
organizations.members.invite-member: Mitglied einladen
|
||||
organizations.members.invite-member-disabled-tooltip: Nur Administratoren oder Eigentümer können Mitglieder in die Organisation einladen
|
||||
organizations.members.remove-from-organization: Aus Organisation entfernen
|
||||
organizations.members.role: Rolle
|
||||
organizations.members.roles.owner: Eigentümer
|
||||
organizations.members.roles.admin: Administrator
|
||||
organizations.members.roles.member: Mitglied
|
||||
organizations.members.delete.confirm.title: Mitglied entfernen
|
||||
organizations.members.delete.confirm.message: Sind Sie sicher, dass Sie dieses Mitglied aus der Organisation entfernen möchten?
|
||||
organizations.members.delete.confirm.confirm-button: Entfernen
|
||||
organizations.members.delete.confirm.cancel-button: Abbrechen
|
||||
organizations.members.delete.success: Mitglied aus Organisation entfernt
|
||||
organizations.members.update-role.success: Mitgliederrolle aktualisiert
|
||||
organizations.members.table.headers.name: Name
|
||||
organizations.members.table.headers.email: E-Mail
|
||||
organizations.members.table.headers.role: Rolle
|
||||
organizations.members.table.headers.created: Erstellt
|
||||
organizations.members.table.headers.actions: Aktionen
|
||||
|
||||
organizations.invite-member.title: Mitglied einladen
|
||||
organizations.invite-member.description: Laden Sie ein Mitglied in Ihre Organisation ein
|
||||
organizations.invite-member.form.email.label: E-Mail
|
||||
organizations.invite-member.form.email.placeholder: 'Beispiel: ada@papra.app'
|
||||
organizations.invite-member.form.email.required: Bitte geben Sie eine gültige E-Mail-Adresse ein
|
||||
organizations.invite-member.form.role.label: Rolle
|
||||
organizations.invite-member.form.submit: In Organisation einladen
|
||||
organizations.invite-member.success.message: Mitglied eingeladen
|
||||
organizations.invite-member.success.description: Die E-Mail wurde in die Organisation eingeladen.
|
||||
organizations.invite-member.error.message: Mitglied konnte nicht eingeladen werden
|
||||
|
||||
organizations.invitations.title: Einladungen
|
||||
organizations.invitations.description: Verwalten Sie Ihre Organisationseinladungen
|
||||
organizations.invitations.list.cta: Mitglied einladen
|
||||
organizations.invitations.list.empty.title: Keine ausstehenden Einladungen
|
||||
organizations.invitations.list.empty.description: Sie wurden noch nicht zu Organisationen eingeladen.
|
||||
organizations.invitations.status.pending: Ausstehend
|
||||
organizations.invitations.status.accepted: Angenommen
|
||||
organizations.invitations.status.rejected: Abgelehnt
|
||||
organizations.invitations.status.expired: Abgelaufen
|
||||
organizations.invitations.status.cancelled: Abgebrochen
|
||||
organizations.invitations.resend: Einladung erneut senden
|
||||
organizations.invitations.cancel.title: Einladung abbrechen
|
||||
organizations.invitations.cancel.description: Sind Sie sicher, dass Sie diese Einladung abbrechen möchten?
|
||||
organizations.invitations.cancel.confirm: Einladung abbrechen
|
||||
organizations.invitations.cancel.cancel: Abbrechen
|
||||
organizations.invitations.resend.title: Einladung erneut senden
|
||||
organizations.invitations.resend.description: Sind Sie sicher, dass Sie diese Einladung erneut senden möchten? Dadurch wird eine neue E-Mail an den Empfänger gesendet.
|
||||
organizations.invitations.resend.confirm: Einladung erneut senden
|
||||
organizations.invitations.resend.cancel: Abbrechen
|
||||
|
||||
invitations.list.title: Einladungen
|
||||
invitations.list.description: Verwalten Sie Ihre Organisationseinladungen
|
||||
invitations.list.empty.title: Keine ausstehenden Einladungen
|
||||
invitations.list.empty.description: Sie wurden noch nicht zu Organisationen eingeladen.
|
||||
invitations.list.headers.organization: Organisation
|
||||
invitations.list.headers.status: Status
|
||||
invitations.list.headers.created: Erstellt
|
||||
invitations.list.headers.actions: Aktionen
|
||||
invitations.list.actions.accept: Annehmen
|
||||
invitations.list.actions.reject: Ablehnen
|
||||
invitations.list.actions.accept.success.message: Einladung angenommen
|
||||
invitations.list.actions.accept.success.description: Die Einladung wurde angenommen.
|
||||
invitations.list.actions.reject.success.message: Einladung abgelehnt
|
||||
invitations.list.actions.reject.success.description: Die Einladung wurde abgelehnt.
|
||||
|
||||
# Documents
|
||||
|
||||
documents.list.title: Dokumente
|
||||
documents.list.no-documents.title: Keine Dokumente
|
||||
documents.list.no-documents.description: Es sind noch keine Dokumente in dieser Organisation vorhanden. Beginnen Sie mit dem Hochladen von Dokumenten.
|
||||
documents.list.no-results: Keine Dokumente gefunden
|
||||
|
||||
documents.tabs.info: Info
|
||||
documents.tabs.content: Inhalt
|
||||
documents.tabs.activity: Aktivität
|
||||
documents.deleted.message: Dieses Dokument wurde gelöscht und wird in {{ days }} Tagen dauerhaft entfernt.
|
||||
documents.actions.download: Herunterladen
|
||||
documents.actions.open-in-new-tab: In neuem Tab öffnen
|
||||
documents.actions.restore: Wiederherstellen
|
||||
documents.actions.delete: Löschen
|
||||
documents.actions.edit: Bearbeiten
|
||||
documents.actions.cancel: Abbrechen
|
||||
documents.actions.save: Speichern
|
||||
documents.actions.saving: Speichern...
|
||||
documents.content.alert: Der Inhalt des Dokuments wird beim Hochladen automatisch aus dem Dokument extrahiert. Er wird nur für Such- und Indexierungszwecke verwendet.
|
||||
documents.info.id: ID
|
||||
documents.info.name: Name
|
||||
documents.info.type: Typ
|
||||
documents.info.size: Größe
|
||||
documents.info.created-at: Erstellt am
|
||||
documents.info.updated-at: Aktualisiert am
|
||||
documents.info.never: Nie
|
||||
|
||||
documents.rename.title: Dokument umbenennen
|
||||
documents.rename.form.name.label: Name
|
||||
documents.rename.form.name.placeholder: 'Beispiel: Rechnung 2024'
|
||||
documents.rename.form.name.required: Bitte geben Sie einen Namen für das Dokument ein
|
||||
documents.rename.form.name.max-length: Der Name muss weniger als 255 Zeichen lang sein
|
||||
documents.rename.form.submit: Dokument umbenennen
|
||||
documents.rename.success: Dokument erfolgreich umbenannt
|
||||
documents.rename.cancel: Abbrechen
|
||||
|
||||
import-documents.title.error: '{{ count }} Dokumente fehlgeschlagen'
|
||||
import-documents.title.success: '{{ count }} Dokumente importiert'
|
||||
import-documents.title.pending: '{{ count }} / {{ total }} Dokumente importiert'
|
||||
import-documents.title.none: Dokumente importieren
|
||||
import-documents.no-import-in-progress: Kein Dokumentimport im Gange
|
||||
|
||||
documents.deleted.title: Gelöschte Dokumente
|
||||
documents.deleted.empty.title: Keine gelöschten Dokumente
|
||||
documents.deleted.empty.description: Sie haben keine gelöschten Dokumente. Gelöschte Dokumente werden für {{ days }} Tage in den Papierkorb verschoben.
|
||||
documents.deleted.retention-notice: Alle gelöschten Dokumente werden für {{ days }} Tage im Papierkorb gespeichert. Nach Ablauf dieser Frist werden die Dokumente dauerhaft gelöscht und Sie können sie nicht wiederherstellen.
|
||||
documents.deleted.deleted-at: Gelöscht
|
||||
documents.deleted.restoring: Wiederherstellen...
|
||||
documents.deleted.deleting: Löschen...
|
||||
|
||||
documents.preview.unknown-file-type: Kein Vorschau verfügbar für diesen Dateityp
|
||||
documents.preview.binary-file: Dies scheint eine Binärdatei zu sein und kann nicht als Text angezeigt werden
|
||||
|
||||
trash.delete-all.button: Alles löschen
|
||||
trash.delete-all.confirm.title: Alle Dokumente dauerhaft löschen?
|
||||
trash.delete-all.confirm.description: Sind Sie sicher, dass Sie alle Dokumente aus dem Papierkorb dauerhaft löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.
|
||||
trash.delete-all.confirm.label: Löschen
|
||||
trash.delete-all.confirm.cancel: Abbrechen
|
||||
trash.delete.button: Löschen
|
||||
trash.delete.confirm.title: Dokument dauerhaft löschen?
|
||||
trash.delete.confirm.description: Sind Sie sicher, dass Sie dieses Dokument dauerhaft aus dem Papierkorb löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.
|
||||
trash.delete.confirm.label: Löschen
|
||||
trash.delete.confirm.cancel: Abbrechen
|
||||
trash.deleted.success.title: Dokument gelöscht
|
||||
trash.deleted.success.description: Das Dokument wurde dauerhaft gelöscht.
|
||||
|
||||
activity.document.created: Das Dokument wurde erstellt
|
||||
activity.document.updated.single: Das Feld {{ field }} wurde aktualisiert
|
||||
activity.document.updated.multiple: Die Felder {{ fields }} wurden aktualisiert
|
||||
activity.document.updated: Das Dokument wurde aktualisiert
|
||||
activity.document.deleted: Das Dokument wurde gelöscht
|
||||
activity.document.restored: Das Dokument wurde wiederhergestellt
|
||||
activity.document.tagged: Tag {{ tag }} wurde hinzugefügt
|
||||
activity.document.untagged: Tag {{ tag }} wurde entfernt
|
||||
|
||||
activity.document.user.name: von {{ name }}
|
||||
|
||||
activity.load-more: Mehr laden
|
||||
activity.no-more-activities: Keine weiteren Aktivitäten für dieses Dokument
|
||||
|
||||
# Tags
|
||||
|
||||
tags.no-tags.title: Noch keine Tags
|
||||
tags.no-tags.description: Diese Organisation hat noch keine Tags. Tags werden zur Kategorisierung von Dokumenten verwendet. Sie können Ihren Dokumenten Tags hinzufügen, um sie leichter zu finden und zu organisieren.
|
||||
tags.no-tags.create-tag: Tag erstellen
|
||||
|
||||
tags.title: Dokumenten-Tags
|
||||
tags.description: Tags werden zur Kategorisierung von Dokumenten verwendet. Sie können Ihren Dokumenten Tags hinzufügen, um sie leichter zu finden und zu organisieren.
|
||||
tags.create: Tag erstellen
|
||||
tags.update: Tag aktualisieren
|
||||
tags.delete: Tag löschen
|
||||
tags.delete.confirm.title: Tag löschen
|
||||
tags.delete.confirm.message: Sind Sie sicher, dass Sie diesen Tag löschen möchten? Das Löschen eines Tags entfernt ihn von allen Dokumenten.
|
||||
tags.delete.confirm.confirm-button: Löschen
|
||||
tags.delete.confirm.cancel-button: Abbrechen
|
||||
tags.delete.success: Tag erfolgreich gelöscht
|
||||
tags.create.success: Tag "{{ name }}" erfolgreich erstellt.
|
||||
tags.update.success: Tag "{{ name }}" erfolgreich aktualisiert.
|
||||
tags.form.name.label: Name
|
||||
tags.form.name.placeholder: Z.B. Verträge
|
||||
tags.form.name.required: Bitte geben Sie einen Tag-Namen ein
|
||||
tags.form.name.max-length: Tag-Name muss weniger als 64 Zeichen lang sein
|
||||
tags.form.color.label: Farbe
|
||||
tags.form.color.required: Bitte geben Sie eine Farbe ein
|
||||
tags.form.color.invalid: Die Hex-Farbe ist falsch formatiert.
|
||||
tags.form.description.label: Beschreibung
|
||||
tags.form.description.optional: (optional)
|
||||
tags.form.description.placeholder: Z.B. Alle von der Firma unterzeichneten Verträge
|
||||
tags.form.description.max-length: Beschreibung muss weniger als 256 Zeichen lang sein
|
||||
tags.form.no-description: Keine Beschreibung
|
||||
tags.table.headers.tag: Tag
|
||||
tags.table.headers.description: Beschreibung
|
||||
tags.table.headers.documents: Dokumente
|
||||
tags.table.headers.created: Erstellt
|
||||
tags.table.headers.actions: Aktionen
|
||||
|
||||
# Tagging rules
|
||||
|
||||
tagging-rules.field.name: Dokumentenname
|
||||
tagging-rules.field.content: Dokumenteninhalt
|
||||
tagging-rules.operator.equals: ist gleich
|
||||
tagging-rules.operator.not-equals: ist nicht gleich
|
||||
tagging-rules.operator.contains: enthält
|
||||
tagging-rules.operator.not-contains: enthält nicht
|
||||
tagging-rules.operator.starts-with: beginnt mit
|
||||
tagging-rules.operator.ends-with: endet mit
|
||||
tagging-rules.list.title: Tagging-Regeln
|
||||
tagging-rules.list.description: Verwalten Sie die Tagging-Regeln Ihrer Organisation, um Dokumente automatisch basierend auf von Ihnen definierten Bedingungen zu taggen.
|
||||
tagging-rules.list.demo-warning: 'Hinweis: Da dies eine Demo-Umgebung (ohne Server) ist, werden Tagging-Regeln nicht auf neu hinzugefügte Dokumente angewendet.'
|
||||
tagging-rules.list.no-tagging-rules.title: Keine Tagging-Regeln
|
||||
tagging-rules.list.no-tagging-rules.description: Erstellen Sie eine Tagging-Regel, um Ihre hinzugefügten Dokumente automatisch basierend auf von Ihnen definierten Bedingungen zu taggen.
|
||||
tagging-rules.list.no-tagging-rules.create-tagging-rule: Tagging-Regel erstellen
|
||||
tagging-rules.list.card.no-conditions: Keine Bedingungen
|
||||
tagging-rules.list.card.one-condition: 1 Bedingung
|
||||
tagging-rules.list.card.conditions: '{{ count }} Bedingungen'
|
||||
tagging-rules.list.card.delete: Regel löschen
|
||||
tagging-rules.list.card.edit: Regel bearbeiten
|
||||
tagging-rules.create.title: Tagging-Regel erstellen
|
||||
tagging-rules.create.success: Tagging-Regel erfolgreich erstellt
|
||||
tagging-rules.create.error: Tagging-Regel konnte nicht erstellt werden
|
||||
tagging-rules.create.submit: Regel erstellen
|
||||
tagging-rules.form.name.label: Name
|
||||
tagging-rules.form.name.placeholder: 'Beispiel: Rechnungen taggen'
|
||||
tagging-rules.form.name.min-length: Bitte geben Sie einen Namen für die Regel ein
|
||||
tagging-rules.form.name.max-length: Der Name muss weniger als 64 Zeichen lang sein
|
||||
tagging-rules.form.description.label: Beschreibung
|
||||
tagging-rules.form.description.placeholder: "Beispiel: Dokumente mit 'Rechnung' im Namen taggen"
|
||||
tagging-rules.form.description.max-length: Die Beschreibung muss weniger als 256 Zeichen lang sein
|
||||
tagging-rules.form.conditions.label: Bedingungen
|
||||
tagging-rules.form.conditions.description: Definieren Sie die Bedingungen, die erfüllt sein müssen, damit die Regel angewendet wird. Alle Bedingungen müssen erfüllt sein, damit die Regel angewendet wird.
|
||||
tagging-rules.form.conditions.add-condition: Bedingung hinzufügen
|
||||
tagging-rules.form.conditions.no-conditions.title: Keine Bedingungen
|
||||
tagging-rules.form.conditions.no-conditions.description: Sie haben dieser Regel keine Bedingungen hinzugefügt. Diese Regel wendet ihre Tags auf alle Dokumente an.
|
||||
tagging-rules.form.conditions.no-conditions.confirm: Regel ohne Bedingungen anwenden
|
||||
tagging-rules.form.conditions.no-conditions.cancel: Abbrechen
|
||||
tagging-rules.form.conditions.value.placeholder: 'Beispiel: Rechnung'
|
||||
tagging-rules.form.conditions.value.min-length: Bitte geben Sie einen Wert für die Bedingung ein
|
||||
tagging-rules.form.tags.label: Tags
|
||||
tagging-rules.form.tags.description: Wählen Sie die Tags aus, die auf die hinzugefügten Dokumente angewendet werden sollen, die den Bedingungen entsprechen
|
||||
tagging-rules.form.tags.min-length: Es ist mindestens ein anzuwendender Tag erforderlich
|
||||
tagging-rules.form.tags.add-tag: Tag erstellen
|
||||
tagging-rules.form.submit: Regel erstellen
|
||||
tagging-rules.update.title: Tagging-Regel aktualisieren
|
||||
tagging-rules.update.error: Tagging-Regel konnte nicht aktualisiert werden
|
||||
tagging-rules.update.submit: Regel aktualisieren
|
||||
tagging-rules.update.cancel: Abbrechen
|
||||
|
||||
# Intake emails
|
||||
|
||||
intake-emails.title: E-Mail-Eingang
|
||||
intake-emails.description: E-Mail-Eingangsadressen werden verwendet, um E-Mails automatisch in Papra aufzunehmen. Leiten Sie einfach E-Mails an die Eingangsadresse weiter und deren Anhänge werden zu den Dokumenten Ihrer Organisation hinzugefügt.
|
||||
intake-emails.disabled.title: E-Mail-Eingang ist deaktiviert
|
||||
intake-emails.disabled.description: E-Mail-Eingang ist auf dieser Instanz deaktiviert. Bitte kontaktieren Sie Ihren Administrator, um ihn zu aktivieren. Weitere Informationen finden Sie in der {{ documentation }}.
|
||||
intake-emails.disabled.documentation: Dokumentation
|
||||
intake-emails.info: Es werden nur aktivierte E-Mails aus zulässigen Ursprüngen verarbeitet. Sie können eine E-Mail-Eingangsadresse jederzeit aktivieren oder deaktivieren.
|
||||
intake-emails.empty.title: Keine E-Mail-Eingänge
|
||||
intake-emails.empty.description: Generieren Sie eine Eingangsadresse, um E-Mail-Anhänge einfach aufzunehmen.
|
||||
intake-emails.empty.generate: E-Mail-Eingang generieren
|
||||
intake-emails.count: '{{ count }} Eingangse-Mail{{ plural }} für diese Organisation'
|
||||
intake-emails.new: Neue Eingangse-Mail
|
||||
intake-emails.disabled-label: (Deaktiviert)
|
||||
intake-emails.no-origins: Keine zulässigen E-Mail-Ursprünge
|
||||
intake-emails.allowed-origins: Zulässig von {{ count }} Adresse{{ plural }}
|
||||
intake-emails.actions.enable: Aktivieren
|
||||
intake-emails.actions.disable: Deaktivieren
|
||||
intake-emails.actions.manage-origins: Ursprungsadressen verwalten
|
||||
intake-emails.actions.delete: Löschen
|
||||
intake-emails.delete.confirm.title: Eingangse-Mail löschen?
|
||||
intake-emails.delete.confirm.message: Sind Sie sicher, dass Sie diese Eingangse-Mail löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.
|
||||
intake-emails.delete.confirm.confirm-button: Eingangse-Mail löschen
|
||||
intake-emails.delete.confirm.cancel-button: Abbrechen
|
||||
intake-emails.delete.success: Eingangse-Mail gelöscht
|
||||
intake-emails.create.success: Eingangse-Mail erstellt
|
||||
intake-emails.update.success.enabled: Eingangse-Mail aktiviert
|
||||
intake-emails.update.success.disabled: Eingangse-Mail deaktiviert
|
||||
intake-emails.allowed-origins.title: Zulässige Ursprünge
|
||||
intake-emails.allowed-origins.description: Es werden nur E-Mails, die an {{ email }} von diesen Ursprüngen gesendet werden, verarbeitet. Wenn keine Ursprünge angegeben sind, werden alle E-Mails verworfen.
|
||||
intake-emails.allowed-origins.add.label: Zulässige Ursprungs-E-Mail hinzufügen
|
||||
intake-emails.allowed-origins.add.placeholder: Z.B. ada@papra.app
|
||||
intake-emails.allowed-origins.add.button: Hinzufügen
|
||||
intake-emails.allowed-origins.add.error.exists: Diese E-Mail ist bereits in den zulässigen Ursprüngen für diese Eingangse-Mail vorhanden
|
||||
|
||||
# API keys
|
||||
|
||||
api-keys.permissions.documents.title: Dokumente
|
||||
api-keys.permissions.documents.documents:create: Dokumente erstellen
|
||||
api-keys.permissions.documents.documents:read: Dokumente lesen
|
||||
api-keys.permissions.documents.documents:update: Dokumente aktualisieren
|
||||
api-keys.permissions.documents.documents:delete: Dokumente löschen
|
||||
api-keys.permissions.tags.title: Tags
|
||||
api-keys.permissions.tags.tags:create: Tags erstellen
|
||||
api-keys.permissions.tags.tags:read: Tags lesen
|
||||
api-keys.permissions.tags.tags:update: Tags aktualisieren
|
||||
api-keys.permissions.tags.tags:delete: Tags löschen
|
||||
api-keys.create.title: API-Schlüssel erstellen
|
||||
api-keys.create.description: Erstellen Sie einen neuen API-Schlüssel, um auf die Papra API zuzugreifen.
|
||||
api-keys.create.success: Der API-Schlüssel wurde erfolgreich erstellt.
|
||||
api-keys.create.back: Zurück zu den API-Schlüsseln
|
||||
api-keys.create.form.name.label: Name
|
||||
api-keys.create.form.name.placeholder: 'Beispiel: Mein API-Schlüssel'
|
||||
api-keys.create.form.name.required: Bitte geben Sie einen Namen für den API-Schlüssel ein
|
||||
api-keys.create.form.permissions.label: Berechtigungen
|
||||
api-keys.create.form.permissions.required: Bitte wählen Sie mindestens eine Berechtigung aus
|
||||
api-keys.create.form.submit: API-Schlüssel erstellen
|
||||
api-keys.create.created.title: API-Schlüssel erstellt
|
||||
api-keys.create.created.description: Der API-Schlüssel wurde erfolgreich erstellt. Speichern Sie ihn an einem sicheren Ort, da er nicht erneut angezeigt wird.
|
||||
api-keys.list.title: API-Schlüssel
|
||||
api-keys.list.description: Verwalten Sie hier Ihre API-Schlüssel.
|
||||
api-keys.list.create: API-Schlüssel erstellen
|
||||
api-keys.list.empty.title: Keine API-Schlüssel
|
||||
api-keys.list.empty.description: Erstellen Sie einen API-Schlüssel, um auf die Papra API zuzugreifen.
|
||||
api-keys.list.card.last-used: Zuletzt verwendet
|
||||
api-keys.list.card.never: Nie
|
||||
api-keys.list.card.created: Erstellt
|
||||
api-keys.delete.success: Der API-Schlüssel wurde erfolgreich gelöscht
|
||||
api-keys.delete.confirm.title: API-Schlüssel löschen
|
||||
api-keys.delete.confirm.message: Sind Sie sicher, dass Sie diesen API-Schlüssel löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.
|
||||
api-keys.delete.confirm.confirm-button: Löschen
|
||||
api-keys.delete.confirm.cancel-button: Abbrechen
|
||||
|
||||
# Webhooks
|
||||
|
||||
webhooks.list.title: Webhooks
|
||||
webhooks.list.description: Verwalten Sie Ihre Organisations-Webhooks
|
||||
webhooks.list.empty.title: Keine Webhooks
|
||||
webhooks.list.empty.description: Erstellen Sie Ihren ersten Webhook, um Ereignisse zu empfangen
|
||||
webhooks.list.create: Webhook erstellen
|
||||
webhooks.list.card.last-triggered: Zuletzt ausgelöst
|
||||
webhooks.list.card.never: Nie
|
||||
webhooks.list.card.created: Erstellt
|
||||
webhooks.create.title: Webhook erstellen
|
||||
webhooks.create.description: Erstellen Sie einen neuen Webhook, um Ereignisse zu empfangen
|
||||
webhooks.create.success: Webhook erfolgreich erstellt
|
||||
webhooks.create.back: Zurück
|
||||
webhooks.create.form.submit: Webhook erstellen
|
||||
webhooks.create.form.name.label: Webhook-Name
|
||||
webhooks.create.form.name.placeholder: Webhook-Namen eingeben
|
||||
webhooks.create.form.name.required: Name ist erforderlich
|
||||
webhooks.create.form.url.label: Webhook-URL
|
||||
webhooks.create.form.url.placeholder: Webhook-URL eingeben
|
||||
webhooks.create.form.url.required: URL ist erforderlich
|
||||
webhooks.create.form.url.invalid: URL ist ungültig
|
||||
webhooks.create.form.secret.label: Geheimnis
|
||||
webhooks.create.form.secret.placeholder: Webhook-Geheimnis eingeben
|
||||
webhooks.create.form.events.label: Ereignisse
|
||||
webhooks.create.form.events.required: Mindestens ein Ereignis ist erforderlich
|
||||
webhooks.update.title: Webhook bearbeiten
|
||||
webhooks.update.description: Aktualisieren Sie Ihre Webhook-Details
|
||||
webhooks.update.success: Webhook erfolgreich aktualisiert
|
||||
webhooks.update.submit: Webhook aktualisieren
|
||||
webhooks.update.cancel: Abbrechen
|
||||
webhooks.update.form.secret.placeholder: Neues Geheimnis eingeben
|
||||
webhooks.update.form.secret.placeholder-redacted: '[Geheimnis geschwärzt]'
|
||||
webhooks.update.form.rotate-secret.button: Geheimnis rotieren
|
||||
webhooks.delete.success: Webhook erfolgreich gelöscht
|
||||
webhooks.delete.confirm.title: Webhook löschen
|
||||
webhooks.delete.confirm.message: Sind Sie sicher, dass Sie diesen Webhook löschen möchten?
|
||||
webhooks.delete.confirm.confirm-button: Löschen
|
||||
webhooks.delete.confirm.cancel-button: Abbrechen
|
||||
|
||||
webhooks.events.documents.document:created.description: Dokument erstellt
|
||||
webhooks.events.documents.document:deleted.description: Dokument gelöscht
|
||||
|
||||
# Navigation
|
||||
|
||||
layout.menu.home: Startseite
|
||||
layout.menu.documents: Dokumente
|
||||
layout.menu.tags: Tags
|
||||
layout.menu.tagging-rules: Tagging-Regeln
|
||||
layout.menu.deleted-documents: Gelöschte Dokumente
|
||||
layout.menu.organization-settings: Einstellungen
|
||||
layout.menu.api-keys: API-Schlüssel
|
||||
layout.menu.settings: Einstellungen
|
||||
layout.menu.account: Konto
|
||||
layout.menu.general-settings: Allgemeine Einstellungen
|
||||
layout.menu.intake-emails: E-Mail-Eingang
|
||||
layout.menu.webhooks: Webhooks
|
||||
layout.menu.members: Mitglieder
|
||||
layout.menu.invitations: Einladungen
|
||||
|
||||
layout.theme.light: Heller Modus
|
||||
layout.theme.dark: Dunkler Modus
|
||||
layout.theme.system: Systemmodus
|
||||
|
||||
layout.search.placeholder: Suchen...
|
||||
layout.menu.import-document: Dokument importieren
|
||||
|
||||
user-menu.account-settings: Kontoeinstellungen
|
||||
user-menu.api-keys: API-Schlüssel
|
||||
user-menu.invitations: Einladungen
|
||||
user-menu.language: Sprache
|
||||
user-menu.logout: Abmelden
|
||||
|
||||
# Command palette
|
||||
|
||||
command-palette.search.placeholder: Befehle oder Dokumente suchen
|
||||
command-palette.no-results: Keine Ergebnisse gefunden
|
||||
command-palette.sections.documents: Dokumente
|
||||
command-palette.sections.theme: Thema
|
||||
|
||||
# API errors
|
||||
|
||||
api-errors.document.already_exists: Das Dokument existiert bereits
|
||||
api-errors.document.file_too_big: Die Dokumentdatei ist zu groß
|
||||
api-errors.intake_email.limit_reached: Die maximale Anzahl an Eingangse-Mails für diese Organisation wurde erreicht. Bitte aktualisieren Sie Ihren Plan, um weitere Eingangse-Mails zu erstellen.
|
||||
api-errors.user.max_organization_count_reached: Sie haben die maximale Anzahl an Organisationen erreicht, die Sie erstellen können. Wenn Sie weitere erstellen möchten, kontaktieren Sie bitte den Support.
|
||||
api-errors.default: Beim Verarbeiten Ihrer Anfrage ist ein Fehler aufgetreten.
|
||||
api-errors.organization.invitation_already_exists: Eine Einladung für diese E-Mail existiert bereits in dieser Organisation.
|
||||
api-errors.user.already_in_organization: Dieser Benutzer ist bereits in dieser Organisation.
|
||||
api-errors.user.organization_invitation_limit_reached: Die maximale Anzahl an Einladungen für heute wurde erreicht. Bitte versuchen Sie es morgen erneut.
|
||||
api-errors.demo.not_available: Diese Funktion ist in der Demo nicht verfügbar
|
||||
api-errors.tags.already_exists: Ein Tag mit diesem Namen existiert bereits für diese Organisation
|
||||
|
||||
# Not found
|
||||
|
||||
not-found.title: 404 - Seite nicht gefunden
|
||||
not-found.description: Entschuldigung, die gesuchte Seite scheint nicht zu existieren. Bitte überprüfen Sie die URL und versuchen Sie es erneut.
|
||||
not-found.back-to-home: Zurück zur Startseite
|
||||
|
||||
# Demo
|
||||
|
||||
demo.popup.description: Dies ist eine Demo-Umgebung, alle Daten werden im lokalen Speicher Ihres Browsers gespeichert.
|
||||
demo.popup.discord: Treten Sie dem {{ discordLink }} bei, um Support zu erhalten, Funktionen vorzuschlagen oder einfach nur zu chatten.
|
||||
demo.popup.discord-link-label: Discord-Server
|
||||
demo.popup.reset: Demo-Daten zurücksetzen
|
||||
demo.popup.hide: Ausblenden
|
||||
|
||||
# Color picker
|
||||
|
||||
color-picker.hue: Farbton
|
||||
color-picker.saturation: Sättigung
|
||||
color-picker.lightness: Helligkeit
|
||||
color-picker.select-color: Farbe auswählen
|
||||
color-picker.select-a-color: Eine Farbe auswählen
|
||||
@@ -256,6 +256,9 @@ documents.deleted.deleted-at: Deleted
|
||||
documents.deleted.restoring: Restoring...
|
||||
documents.deleted.deleting: Deleting...
|
||||
|
||||
documents.preview.unknown-file-type: No preview available for this file type
|
||||
documents.preview.binary-file: This appears to be a binary file and cannot be displayed as text
|
||||
|
||||
trash.delete-all.button: Delete all
|
||||
trash.delete-all.confirm.title: Permanently delete all documents?
|
||||
trash.delete-all.confirm.description: Are you sure you want to permanently delete all documents from the trash? This action cannot be undone.
|
||||
@@ -306,7 +309,6 @@ tags.form.name.placeholder: Eg. Contracts
|
||||
tags.form.name.required: Please enter a tag name
|
||||
tags.form.name.max-length: Tag name must be less than 64 characters
|
||||
tags.form.color.label: Color
|
||||
tags.form.color.placeholder: 'Eg. #FF0000'
|
||||
tags.form.color.required: Please enter a color
|
||||
tags.form.color.invalid: The hex color is badly formatted.
|
||||
tags.form.description.label: Description
|
||||
@@ -550,3 +552,11 @@ demo.popup.discord: Join the {{ discordLink }} to get support, propose features
|
||||
demo.popup.discord-link-label: Discord server
|
||||
demo.popup.reset: Reset demo data
|
||||
demo.popup.hide: Hide
|
||||
|
||||
# Color picker
|
||||
|
||||
color-picker.hue: Hue
|
||||
color-picker.saturation: Saturation
|
||||
color-picker.lightness: Lightness
|
||||
color-picker.select-color: Select color
|
||||
color-picker.select-a-color: Select a color
|
||||
|
||||
@@ -256,6 +256,9 @@ documents.deleted.deleted-at: Supprimé
|
||||
documents.deleted.restoring: Restauration...
|
||||
documents.deleted.deleting: Suppression...
|
||||
|
||||
documents.preview.unknown-file-type: Aucun aperçu disponible pour ce type de fichier
|
||||
documents.preview.binary-file: Cela semble être un fichier binaire et ne peut pas être affiché en texte
|
||||
|
||||
trash.delete-all.button: Supprimer tous les documents
|
||||
trash.delete-all.confirm.title: Supprimer définitivement tous les documents ?
|
||||
trash.delete-all.confirm.description: Êtes-vous sûr de vouloir supprimer définitivement tous les documents de la corbeille ? Cette action est irréversible.
|
||||
@@ -306,7 +309,6 @@ tags.form.name.placeholder: 'Exemple: Contrats'
|
||||
tags.form.name.required: Veuillez entrer un nom pour le tag
|
||||
tags.form.name.max-length: Le nom du tag doit contenir moins de 64 caractères
|
||||
tags.form.color.label: Couleur
|
||||
tags.form.color.placeholder: 'Exemple: #FF0000'
|
||||
tags.form.color.required: Veuillez entrer une couleur
|
||||
tags.form.color.invalid: La couleur hexadécimale est mal formatée.
|
||||
tags.form.description.label: Description
|
||||
@@ -550,3 +552,11 @@ demo.popup.discord: Rejoignez le {{ discordLink }} pour obtenir de l'aide, propo
|
||||
demo.popup.discord-link-label: Serveur Discord
|
||||
demo.popup.reset: Réinitialiser la démo
|
||||
demo.popup.hide: Masquer
|
||||
|
||||
# Color picker
|
||||
|
||||
color-picker.hue: Teinte
|
||||
color-picker.saturation: Saturation
|
||||
color-picker.lightness: Luminosité
|
||||
color-picker.select-color: Sélectionner la couleur
|
||||
color-picker.select-a-color: Sélectionner une couleur
|
||||
|
||||
@@ -5,7 +5,7 @@ const asString = <T extends string | undefined>(value: string | undefined, defau
|
||||
const asNumber = <T extends number | undefined>(value: string | undefined, defaultValue?: T): T extends undefined ? number | undefined : number => (value === undefined ? defaultValue : Number(value)) as T extends undefined ? number | undefined : number;
|
||||
|
||||
export const buildTimeConfig = {
|
||||
papraVersion: asString(import.meta.env.VITE_PAPRA_VERSION),
|
||||
papraVersion: asString(import.meta.env.VITE_PAPRA_VERSION, '0.0.0'),
|
||||
baseUrl: asString(import.meta.env.VITE_BASE_URL, window.location.origin),
|
||||
baseApiUrl: asString(import.meta.env.VITE_BASE_API_URL, window.location.origin),
|
||||
vitrineBaseUrl: asString(import.meta.env.VITE_VITRINE_BASE_URL, 'http://localhost:3000/'),
|
||||
|
||||
@@ -193,7 +193,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
const {
|
||||
pageIndex = 0,
|
||||
pageSize = 5,
|
||||
searchQuery = '',
|
||||
searchQuery: rawSearchQuery = '',
|
||||
} = query ?? {};
|
||||
|
||||
const organization = organizationStorage.getItem(organizationId);
|
||||
@@ -201,7 +201,9 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
|
||||
const documents = await findMany(documentStorage, document => document?.organizationId === organizationId);
|
||||
|
||||
const filteredDocuments = documents.filter(document => document?.name.includes(searchQuery) && !document?.deletedAt);
|
||||
const searchQuery = rawSearchQuery.trim().toLowerCase();
|
||||
|
||||
const filteredDocuments = documents.filter(document => document?.name.toLowerCase().includes(searchQuery) && !document?.deletedAt);
|
||||
|
||||
return {
|
||||
documents: filteredDocuments.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize),
|
||||
|
||||
@@ -2,13 +2,14 @@ import type { Component } from 'solid-js';
|
||||
import type { Document } from '../documents.types';
|
||||
import { useQuery } from '@tanstack/solid-query';
|
||||
import { createResource, Match, Suspense, Switch } from 'solid-js';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { Card } from '@/modules/ui/components/card';
|
||||
import { fetchDocumentFile } from '../documents.services';
|
||||
import { PdfViewer } from './pdf-viewer.component';
|
||||
|
||||
const imageMimeType = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
const pdfMimeType = ['application/pdf'];
|
||||
const txtLikeMimeType = ['text/plain', 'text/markdown', 'text/csv', 'text/html'];
|
||||
const txtLikeMimeType = ['application/x-yaml', 'application/json', 'application/xml'];
|
||||
|
||||
function blobToString(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -19,6 +20,83 @@ function blobToString(blob: Blob): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: IA generated code, add some tests
|
||||
* Detects if a blob can be safely displayed as text by checking for valid UTF-8 encoding
|
||||
* and common text patterns (low ratio of control characters, presence of readable text)
|
||||
*/
|
||||
async function isBlobTextSafe(blob: Blob): Promise<boolean> {
|
||||
try {
|
||||
const text = await blobToString(blob);
|
||||
|
||||
// Check if the text contains mostly printable characters
|
||||
const totalChars = text.length;
|
||||
if (totalChars === 0) {
|
||||
return true;
|
||||
} // Empty files are considered text-safe
|
||||
|
||||
// Count control characters (excluding common whitespace and newlines)
|
||||
// Use a simpler approach to avoid linter issues with Unicode escapes
|
||||
let controlCharCount = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const charCode = text.charCodeAt(i);
|
||||
// Check for control characters (0-31, 127-159) excluding common whitespace
|
||||
if ((charCode >= 0 && charCode <= 31 && ![9, 10, 13, 12, 11].includes(charCode))
|
||||
|| (charCode >= 127 && charCode <= 159)) {
|
||||
controlCharCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// If more than 10% of characters are control characters, it's likely binary
|
||||
const controlCharRatio = controlCharCount / totalChars;
|
||||
if (controlCharRatio > 0.1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for common binary file signatures in the first few bytes
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
|
||||
// Common binary file signatures to check
|
||||
const binarySignatures = [
|
||||
[0xFF, 0xD8, 0xFF], // JPEG
|
||||
[0x89, 0x50, 0x4E, 0x47], // PNG
|
||||
[0x47, 0x49, 0x46], // GIF
|
||||
[0x25, 0x50, 0x44, 0x46], // PDF
|
||||
[0x50, 0x4B, 0x03, 0x04], // ZIP/DOCX/XLSX
|
||||
[0x7F, 0x45, 0x4C, 0x46], // ELF executable
|
||||
[0x4D, 0x5A], // Windows executable
|
||||
];
|
||||
|
||||
for (const signature of binarySignatures) {
|
||||
if (uint8Array.length >= signature.length) {
|
||||
const matches = signature.every((byte, index) => uint8Array[index] === byte);
|
||||
if (matches) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the text contains mostly ASCII printable characters
|
||||
let asciiPrintableCount = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const charCode = text.charCodeAt(i);
|
||||
// ASCII printable characters (32-126) excluding common whitespace
|
||||
if (charCode >= 32 && charCode <= 126 && ![9, 10, 13, 12, 11].includes(charCode)) {
|
||||
asciiPrintableCount++;
|
||||
}
|
||||
}
|
||||
|
||||
const asciiRatio = asciiPrintableCount / totalChars;
|
||||
|
||||
// If less than 70% are ASCII printable, it's likely binary
|
||||
return asciiRatio > 0.7;
|
||||
} catch {
|
||||
// If we can't read as text, it's definitely not text-safe
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const TextFromBlob: Component<{ blob: Blob }> = (props) => {
|
||||
const [txt] = createResource(() => blobToString(props.blob));
|
||||
|
||||
@@ -34,12 +112,25 @@ const TextFromBlob: Component<{ blob: Blob }> = (props) => {
|
||||
export const DocumentPreview: Component<{ document: Document }> = (props) => {
|
||||
const getIsImage = () => imageMimeType.includes(props.document.mimeType);
|
||||
const getIsPdf = () => pdfMimeType.includes(props.document.mimeType);
|
||||
const getIsTxtLike = () => txtLikeMimeType.includes(props.document.mimeType) || props.document.mimeType.startsWith('text/');
|
||||
const { t } = useI18n();
|
||||
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['organizations', props.document.organizationId, 'documents', props.document.id, 'file'],
|
||||
queryFn: () => fetchDocumentFile({ documentId: props.document.id, organizationId: props.document.organizationId }),
|
||||
}));
|
||||
|
||||
// Create a resource to check if octet-stream blob is text-safe
|
||||
const [isOctetStreamTextSafe] = createResource(
|
||||
() => query.data && props.document.mimeType === 'application/octet-stream' ? query.data : null,
|
||||
async (blob) => {
|
||||
if (!blob) {
|
||||
return false;
|
||||
}
|
||||
return await isBlobTextSafe(blob);
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<Switch>
|
||||
@@ -48,12 +139,30 @@ export const DocumentPreview: Component<{ document: Document }> = (props) => {
|
||||
<img src={URL.createObjectURL(query.data!)} class="w-full h-full object-contain" />
|
||||
</div>
|
||||
</Match>
|
||||
|
||||
<Match when={getIsPdf() && query.data}>
|
||||
<PdfViewer url={URL.createObjectURL(query.data!)} />
|
||||
</Match>
|
||||
<Match when={txtLikeMimeType.includes(props.document.mimeType) && query.data}>
|
||||
|
||||
<Match when={getIsTxtLike() && query.data}>
|
||||
<TextFromBlob blob={query.data!} />
|
||||
</Match>
|
||||
|
||||
<Match when={props.document.mimeType === 'application/octet-stream' && query.data && isOctetStreamTextSafe()}>
|
||||
<TextFromBlob blob={query.data!} />
|
||||
</Match>
|
||||
|
||||
<Match when={props.document.mimeType === 'application/octet-stream' && query.data && !isOctetStreamTextSafe()}>
|
||||
<Card class="px-6 py-12 text-center text-sm text-muted-foreground">
|
||||
<p>{t('documents.preview.binary-file')}</p>
|
||||
</Card>
|
||||
</Match>
|
||||
|
||||
<Match when={query.data}>
|
||||
<Card class="px-6 py-12 text-center text-sm text-muted-foreground">
|
||||
<p>{t('documents.preview.unknown-file-type')}</p>
|
||||
</Card>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const locales = [
|
||||
{ key: 'en', name: 'English' },
|
||||
{ key: 'fr', name: 'Français' },
|
||||
{ key: 'de', name: 'Deutsch' },
|
||||
] as const;
|
||||
|
||||
@@ -229,6 +229,8 @@ export type LocaleKeys =
|
||||
| 'documents.deleted.deleted-at'
|
||||
| 'documents.deleted.restoring'
|
||||
| 'documents.deleted.deleting'
|
||||
| 'documents.preview.unknown-file-type'
|
||||
| 'documents.preview.binary-file'
|
||||
| 'trash.delete-all.button'
|
||||
| 'trash.delete-all.confirm.title'
|
||||
| 'trash.delete-all.confirm.description'
|
||||
@@ -272,7 +274,6 @@ export type LocaleKeys =
|
||||
| 'tags.form.name.required'
|
||||
| 'tags.form.name.max-length'
|
||||
| 'tags.form.color.label'
|
||||
| 'tags.form.color.placeholder'
|
||||
| 'tags.form.color.required'
|
||||
| 'tags.form.color.invalid'
|
||||
| 'tags.form.description.label'
|
||||
@@ -484,4 +485,9 @@ export type LocaleKeys =
|
||||
| 'demo.popup.discord'
|
||||
| 'demo.popup.discord-link-label'
|
||||
| 'demo.popup.reset'
|
||||
| 'demo.popup.hide';
|
||||
| 'demo.popup.hide'
|
||||
| 'color-picker.hue'
|
||||
| 'color-picker.saturation'
|
||||
| 'color-picker.lightness'
|
||||
| 'color-picker.select-color'
|
||||
| 'color-picker.select-a-color';
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { getRgbChannelsFromHex } from './color-formats';
|
||||
|
||||
describe('color-formats', () => {
|
||||
describe('getRgbChannelsFromHex', () => {
|
||||
test('extracts the rgb channels values from a hex color', () => {
|
||||
expect(getRgbChannelsFromHex('#000000')).toEqual({ r: 0, g: 0, b: 0 });
|
||||
expect(getRgbChannelsFromHex('#FFFFFF')).toEqual({ r: 255, g: 255, b: 255 });
|
||||
expect(getRgbChannelsFromHex('#FF0000')).toEqual({ r: 255, g: 0, b: 0 });
|
||||
expect(getRgbChannelsFromHex('#00FF00')).toEqual({ r: 0, g: 255, b: 0 });
|
||||
expect(getRgbChannelsFromHex('#0000FF')).toEqual({ r: 0, g: 0, b: 255 });
|
||||
expect(getRgbChannelsFromHex('#0000FF')).toEqual({ r: 0, g: 0, b: 255 });
|
||||
});
|
||||
|
||||
test('is case insensitive', () => {
|
||||
expect(getRgbChannelsFromHex('#ff0000')).toEqual({ r: 255, g: 0, b: 0 });
|
||||
expect(getRgbChannelsFromHex('#00ff00')).toEqual({ r: 0, g: 255, b: 0 });
|
||||
expect(getRgbChannelsFromHex('#0000ff')).toEqual({ r: 0, g: 0, b: 255 });
|
||||
});
|
||||
|
||||
test('returns 0, 0, 0 for invalid colors', () => {
|
||||
expect(getRgbChannelsFromHex('lorem')).toEqual({ r: 0, g: 0, b: 0 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export function getRgbChannelsFromHex(color: string) {
|
||||
const [r, g, b] = color.match(/^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i)?.slice(1).map(c => Number.parseInt(c, 16)) ?? [0, 0, 0];
|
||||
|
||||
return { r, g, b };
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { getLuminance } from './luminance';
|
||||
|
||||
describe('luminance', () => {
|
||||
describe('getLuminance', () => {
|
||||
test(`the relative luminance of a color is the relative brightness of any point in a color space, normalized to 0 for darkest black and 1 for lightest white
|
||||
the formula is: 0.2126 * R + 0.7152 * G + 0.0722 * B
|
||||
where R, G, B are the red, green, and blue channels of the color, normalized to 0-1 and gamma corrected (sRGB):
|
||||
if the channel value is less than 0.03928, it is divided by 12.92, otherwise it is raised to the power of 2.4
|
||||
|
||||
Source: https://www.w3.org/TR/WCAG20/#relativeluminancedef
|
||||
`, () => {
|
||||
expect(getLuminance('#000000')).toBe(0);
|
||||
expect(getLuminance('#FFFFFF')).toBe(1);
|
||||
expect(getLuminance('#FF0000')).toBeCloseTo(0.2126, 4);
|
||||
expect(getLuminance('#00FF00')).toBeCloseTo(0.7152, 4);
|
||||
expect(getLuminance('#0000FF')).toBeCloseTo(0.0722, 4);
|
||||
});
|
||||
});
|
||||
});
|
||||
17
apps/papra-client/src/modules/shared/colors/luminance.ts
Normal file
17
apps/papra-client/src/modules/shared/colors/luminance.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { getRgbChannelsFromHex } from './color-formats';
|
||||
|
||||
// https://www.w3.org/TR/WCAG20/#relativeluminancedef
|
||||
export function getLuminance(color: string) {
|
||||
const { r, g, b } = getRgbChannelsFromHex(color);
|
||||
|
||||
const toLinear = (channelValue: number) => {
|
||||
const normalized = channelValue / 255;
|
||||
return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
|
||||
const R = toLinear(r);
|
||||
const G = toLinear(g);
|
||||
const B = toLinear(b);
|
||||
|
||||
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import type { DialogTriggerProps } from '@kobalte/core/dialog';
|
||||
import type { Component, JSX } from 'solid-js';
|
||||
import type { Tag as TagType } from '../tags.types';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { getValues } from '@modular-forms/solid';
|
||||
import { getValues, setValue } from '@modular-forms/solid';
|
||||
import { A, useParams } from '@solidjs/router';
|
||||
import { useQuery } from '@tanstack/solid-query';
|
||||
import { createSignal, For, Show, Suspense } from 'solid-js';
|
||||
@@ -14,6 +14,7 @@ import { createForm } from '@/modules/shared/form/form';
|
||||
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
|
||||
import { queryClient } from '@/modules/shared/query/query-client';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { ColorSwatchPicker } from '@/modules/ui/components/color-swatch-picker';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/modules/ui/components/dialog';
|
||||
import { EmptyState } from '@/modules/ui/components/empty';
|
||||
import { createToast } from '@/modules/ui/components/sonner';
|
||||
@@ -23,6 +24,26 @@ import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/component
|
||||
import { Tag } from '../components/tag.component';
|
||||
import { createTag, deleteTag, fetchTags, updateTag } from '../tags.services';
|
||||
|
||||
// To keep, useful for generating swatches
|
||||
// function generateSwatches(count = 9, saturation = 100, lightness = 74) {
|
||||
// const colors = [];
|
||||
// for (let i = 0; i < count; i++) {
|
||||
// const hue = Math.round((78 + i * 360 / count) % 360);
|
||||
// const hsl = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||
// colors.push(parseColor(hsl).toString('hex').toUpperCase());
|
||||
// }
|
||||
// return colors;
|
||||
// }
|
||||
|
||||
const defaultColors = ['#D8FF75', '#7FFF7A', '#7AFFCE', '#7AD7FF', '#7A7FFF', '#CE7AFF', '#FF7AD7', '#FF7A7F', '#FFCE7A', '#FFFFFF'];
|
||||
|
||||
const TagColorPicker: Component<{
|
||||
color: string;
|
||||
onChange: (color: string) => void;
|
||||
}> = (props) => {
|
||||
return <ColorSwatchPicker value={props.color} onChange={props.onChange} colors={defaultColors} />;
|
||||
};
|
||||
|
||||
const TagForm: Component<{
|
||||
onSubmit: (values: { name: string; color: string; description: string }) => Promise<void>;
|
||||
initialValues?: { name?: string; color?: string; description?: string | null };
|
||||
@@ -71,10 +92,10 @@ const TagForm: Component<{
|
||||
</Field>
|
||||
|
||||
<Field name="color">
|
||||
{(field, inputProps) => (
|
||||
{field => (
|
||||
<TextFieldRoot class="flex flex-col gap-1 mb-4">
|
||||
<TextFieldLabel for="color">{t('tags.form.color.label')}</TextFieldLabel>
|
||||
<TextField id="color" {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} placeholder={t('tags.form.color.placeholder')} />
|
||||
<TagColorPicker color={field.value ?? ''} onChange={color => setValue(form, 'color', color)} />
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
@@ -119,7 +140,7 @@ export const CreateTagModal: Component<{
|
||||
const onSubmit = async ({ name, color, description }: { name: string; color: string; description: string }) => {
|
||||
const [,error] = await safely(createTag({
|
||||
name,
|
||||
color,
|
||||
color: color.toLowerCase(),
|
||||
description,
|
||||
organizationId: props.organizationId,
|
||||
}));
|
||||
@@ -153,7 +174,7 @@ export const CreateTagModal: Component<{
|
||||
<DialogTitle>{t('tags.create')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<TagForm onSubmit={onSubmit} initialValues={{ color: '#d8ff75' }} />
|
||||
<TagForm onSubmit={onSubmit} initialValues={{ color: '#D8FF75' }} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -170,7 +191,7 @@ const UpdateTagModal: Component<{
|
||||
const onSubmit = async ({ name, color, description }: { name: string; color: string; description: string }) => {
|
||||
await updateTag({
|
||||
name,
|
||||
color,
|
||||
color: color.toLowerCase(),
|
||||
description,
|
||||
organizationId: props.organizationId,
|
||||
tagId: props.tag.id,
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { Color } from '@kobalte/core/colors';
|
||||
import type { VariantProps } from 'class-variance-authority';
|
||||
import type { Component, ParentProps } from 'solid-js';
|
||||
import { ColorSlider } from '@kobalte/core/color-slider';
|
||||
import { parseColor } from '@kobalte/core/colors';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { createSignal, For, splitProps } from 'solid-js';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { getLuminance } from '@/modules/shared/colors/luminance';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { Button } from './button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||
import { TextField, TextFieldRoot } from './textfield';
|
||||
|
||||
const Slider: Component<{
|
||||
channel: 'hue' | 'saturation' | 'lightness';
|
||||
label: string;
|
||||
value: Color;
|
||||
onChange?: (value: Color) => void;
|
||||
}> = (props) => {
|
||||
return (
|
||||
<ColorSlider channel={props.channel} class="relative flex flex-col gap-0.5 w-full" value={props.value} onChange={props.onChange}>
|
||||
<div class="flex items-center justify-between text-xs font-medium text-muted-foreground">
|
||||
<ColorSlider.Label>{props.label}</ColorSlider.Label>
|
||||
<ColorSlider.ValueLabel />
|
||||
</div>
|
||||
<ColorSlider.Track class="w-full h-24px rounded relative ">
|
||||
<ColorSlider.Thumb class="w-4 h-4 top-4px rounded-full bg-[var(--kb-color-current)] border-2 border-#0a0a0a">
|
||||
<ColorSlider.Input />
|
||||
</ColorSlider.Thumb>
|
||||
</ColorSlider.Track>
|
||||
</ColorSlider>
|
||||
);
|
||||
};
|
||||
|
||||
const ColorPicker: Component<{
|
||||
color: string;
|
||||
onChange?: (color: string) => void;
|
||||
}> = (props) => {
|
||||
const { t } = useI18n();
|
||||
const [color, setColor] = createSignal<Color>(parseColor(props.color).toFormat('hsl'));
|
||||
|
||||
const onUpdateColor = (color: Color) => {
|
||||
setColor(color.toFormat('hsl'));
|
||||
props.onChange?.(color.toString('hex').toUpperCase());
|
||||
};
|
||||
|
||||
const onInputColorChange = (e: Event) => {
|
||||
const color = (e.target as HTMLInputElement).value;
|
||||
|
||||
try {
|
||||
const parsedColor = parseColor(color);
|
||||
onUpdateColor(parsedColor);
|
||||
} catch (_error) {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-2">
|
||||
<Slider channel="hue" label={t('color-picker.hue')} value={color()} onChange={onUpdateColor} />
|
||||
<Slider channel="saturation" label={t('color-picker.saturation')} value={color()} onChange={onUpdateColor} />
|
||||
<Slider channel="lightness" label={t('color-picker.lightness')} value={color()} onChange={onUpdateColor} />
|
||||
|
||||
<TextFieldRoot>
|
||||
<TextField value={color().toString('hex').toUpperCase()} onInput={onInputColorChange} placeholder="#000000" />
|
||||
</TextFieldRoot>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const colorSwatchVariants = cva(
|
||||
'rounded-lg border-2 border-background shadow-sm transition-all hover:scale-110 focus-visible:(outline-none ring-1.5 ring-ring ring-offset-1)',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'h-6 w-6',
|
||||
md: 'h-8 w-8',
|
||||
lg: 'h-10 w-10',
|
||||
},
|
||||
selected: {
|
||||
true: 'ring-1.5 ring-primary! ring-offset-1',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
selected: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
type ColorSwatchPickerProps = ParentProps<{
|
||||
value?: string;
|
||||
onChange?: (color: string) => void;
|
||||
colors?: string[];
|
||||
size?: VariantProps<typeof colorSwatchVariants>['size'];
|
||||
class?: string;
|
||||
disabled?: boolean;
|
||||
}>;
|
||||
|
||||
export function ColorSwatchPicker(props: ColorSwatchPickerProps) {
|
||||
const { t } = useI18n();
|
||||
const [local, rest] = splitProps(props, [
|
||||
'value',
|
||||
'onChange',
|
||||
'colors',
|
||||
'size',
|
||||
'class',
|
||||
'disabled',
|
||||
'children',
|
||||
]);
|
||||
|
||||
const colors = () => local.colors ?? [];
|
||||
const selectedColor = () => local.value ?? colors()[0];
|
||||
|
||||
const handleColorSelect = (color: string) => {
|
||||
if (!local.disabled && local.onChange) {
|
||||
local.onChange(color);
|
||||
}
|
||||
};
|
||||
|
||||
const getIsNotInSwatch = (color?: string) => color && !colors().includes(color);
|
||||
|
||||
function getContrastTextColor(color: string) {
|
||||
const luminance = getLuminance(color);
|
||||
// 0.179 is the threshold for WCAG 2.0 level AA
|
||||
return luminance > 0.179 ? 'black' : 'white';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class={cn(
|
||||
'inline-flex items-center gap-1 flex-wrap',
|
||||
local.disabled && 'opacity-50 cursor-not-allowed',
|
||||
local.class,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<For each={colors()}>
|
||||
{color => (
|
||||
<button
|
||||
type="button"
|
||||
class={cn(
|
||||
colorSwatchVariants({
|
||||
size: local.size,
|
||||
selected: selectedColor() === color,
|
||||
}),
|
||||
)}
|
||||
style={{ 'background-color': color }}
|
||||
onClick={() => handleColorSelect(color)}
|
||||
disabled={local.disabled}
|
||||
aria-label={`${t('color-picker.select-color')} ${color}`}
|
||||
title={color}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
as={Button}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class={cn(getIsNotInSwatch(local.value) && 'ring-1.5 ring-primary! ring-offset-1')}
|
||||
style={{ 'background-color': getIsNotInSwatch(local.value) ? local.value : '' }}
|
||||
aria-label={t('color-picker.select-a-color')}
|
||||
>
|
||||
<div class="i-tabler-plus size-4" style={{ color: getIsNotInSwatch(local.value) ? getContrastTextColor(local.value ?? '') : undefined }}></div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<p class="text-sm font-medium mb-4">{t('color-picker.select-a-color')}</p>
|
||||
|
||||
<ColorPicker color={local.value ?? ''} onChange={local?.onChange} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,12 @@ const MenuItemButton: Component<MenuItem> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
function getReleaseUrl({ version, packageName = '@papra/app-server' }: { version: string; packageName?: string }) {
|
||||
const encodedVersion = encodeURIComponent(`${packageName}@${version}`);
|
||||
|
||||
return `https://github.com/papra-hq/papra/releases/tag/${encodedVersion}`;
|
||||
}
|
||||
|
||||
export const SideNav: Component<{
|
||||
mainMenu?: MenuItem[];
|
||||
footerMenu?: MenuItem[];
|
||||
@@ -95,7 +101,7 @@ export const SideNav: Component<{
|
||||
))}
|
||||
</div>
|
||||
|
||||
<a class="text-xs text-muted-foreground text-center mt-auto transition-colors hover:(text-primary underline)" href={`https://github.com/papra-hq/papra/releases/tag/${version}`} target="_blank" rel="noopener noreferrer">
|
||||
<a class="text-xs text-muted-foreground text-center mt-auto transition-colors hover:(text-primary underline)" href={getReleaseUrl({ version: config.papraVersion })} target="_blank" rel="noopener noreferrer">
|
||||
{version}
|
||||
</a>
|
||||
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# @papra/app-server
|
||||
|
||||
## 0.6.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#357](https://github.com/papra-hq/papra/pull/357) [`585c53c`](https://github.com/papra-hq/papra/commit/585c53cd9d0d7dbd517dbb1adddfd9e7b70f9fe5) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added a /llms.txt on main website
|
||||
|
||||
- [#366](https://github.com/papra-hq/papra/pull/366) [`b8c2bd7`](https://github.com/papra-hq/papra/commit/b8c2bd70e3d0c215da34efcdcdf1b75da1ed96a1) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Allow for adding/removing tags to document using api keys
|
||||
|
||||
## 0.6.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#337](https://github.com/papra-hq/papra/pull/337) [`1c574b8`](https://github.com/papra-hq/papra/commit/1c574b8305eb7bde4f1b75ac38a610ca0120a613) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Ensure database directory exists when running scripts (like migrations)
|
||||
|
||||
## 0.6.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#326](https://github.com/papra-hq/papra/pull/326) [`17ca8f8`](https://github.com/papra-hq/papra/commit/17ca8f8f8110c3ffb550f67bfba817872370171c) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix content disposition header to support non-ascii filenames
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "@papra/app-server",
|
||||
"type": "module",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.3",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.9.0",
|
||||
"packageManager": "pnpm@10.12.3",
|
||||
"description": "Papra app server",
|
||||
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
@@ -30,21 +30,21 @@
|
||||
"stripe:webhook": "stripe listen --forward-to localhost:1221/api/stripe/webhook"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.817.0",
|
||||
"@aws-sdk/lib-storage": "^3.817.0",
|
||||
"@aws-sdk/client-s3": "^3.835.0",
|
||||
"@aws-sdk/lib-storage": "^3.835.0",
|
||||
"@azure/storage-blob": "^12.27.0",
|
||||
"@corentinth/chisels": "^1.3.1",
|
||||
"@corentinth/friendly-ids": "^0.0.1",
|
||||
"@crowlog/async-context-plugin": "^1.2.1",
|
||||
"@crowlog/logger": "^1.2.1",
|
||||
"@hono/node-server": "^1.14.3",
|
||||
"@hono/node-server": "^1.14.4",
|
||||
"@libsql/client": "^0.14.0",
|
||||
"@owlrelay/api-sdk": "^0.0.2",
|
||||
"@owlrelay/webhook": "^0.0.3",
|
||||
"@papra/lecture": "^0.0.4",
|
||||
"@papra/webhooks": "workspace:*",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"backblaze-b2": "^1.7.0",
|
||||
"backblaze-b2": "^1.7.1",
|
||||
"better-auth": "catalog:",
|
||||
"c12": "^3.0.4",
|
||||
"chokidar": "^4.0.3",
|
||||
@@ -52,7 +52,7 @@
|
||||
"drizzle-kit": "^0.30.6",
|
||||
"drizzle-orm": "^0.38.4",
|
||||
"figue": "^2.2.3",
|
||||
"hono": "^4.7.10",
|
||||
"hono": "^4.8.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
"mime-types": "^3.0.1",
|
||||
"nanoid": "^5.1.5",
|
||||
@@ -61,12 +61,12 @@
|
||||
"p-limit": "^6.2.0",
|
||||
"p-queue": "^8.1.0",
|
||||
"picomatch": "^4.0.2",
|
||||
"posthog-node": "^4.17.2",
|
||||
"resend": "^4.5.1",
|
||||
"posthog-node": "^4.18.0",
|
||||
"resend": "^4.6.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"stripe": "^17.7.0",
|
||||
"tsx": "^4.19.4",
|
||||
"zod": "^3.25.28"
|
||||
"tsx": "^4.20.3",
|
||||
"zod": "^3.25.67"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "catalog:",
|
||||
|
||||
@@ -105,7 +105,7 @@ describe('auth models', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('when the auth type is api-key, at least one permission must match', () => {
|
||||
test('when the auth type is api-key, all permissions must match', () => {
|
||||
expect(isAuthenticationValid({
|
||||
authType: 'api-key',
|
||||
apiKey: {
|
||||
@@ -136,6 +136,22 @@ describe('auth models', () => {
|
||||
permissions: ['documents:create'],
|
||||
} as ApiKey,
|
||||
requiredApiKeyPermissions: ['documents:create', 'documents:read'],
|
||||
})).to.eql(false);
|
||||
|
||||
expect(isAuthenticationValid({
|
||||
authType: 'api-key',
|
||||
apiKey: {
|
||||
permissions: ['documents:create', 'documents:read'],
|
||||
} as ApiKey,
|
||||
requiredApiKeyPermissions: ['documents:create', 'documents:read'],
|
||||
})).to.eql(true);
|
||||
|
||||
expect(isAuthenticationValid({
|
||||
authType: 'api-key',
|
||||
apiKey: {
|
||||
permissions: ['documents:create', 'documents:read', 'documents:update'],
|
||||
} as ApiKey,
|
||||
requiredApiKeyPermissions: ['documents:create', 'documents:read'],
|
||||
})).to.eql(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -71,9 +71,9 @@ export function isAuthenticationValid({
|
||||
return false;
|
||||
}
|
||||
|
||||
const atLeastOnePermissionMatches = apiKey.permissions.some(permission => requiredApiKeyPermissions.includes(permission));
|
||||
const allPermissionsMatch = requiredApiKeyPermissions.every(permission => apiKey.permissions.includes(permission));
|
||||
|
||||
return atLeastOnePermissionMatches;
|
||||
return allPermissionsMatch;
|
||||
}
|
||||
|
||||
if (authType === 'session' && session) {
|
||||
|
||||
@@ -31,6 +31,6 @@ export async function ensureLocalDatabaseDirectoryExists({ config }: { config: C
|
||||
logger.info({ dbUrl: url, dbDir, dbPath }, 'Database directory missing, created it');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error, dbDir, dbPath }, 'Failed to ensure that the database directory exists, error while creating the directory');
|
||||
logger.error({ error, dbDir, dbPath }, 'Failed to ensure that the database directory exists, error while creating the directory. Please see https://docs.papra.app/resources/troubleshooting/#failed-to-ensure-that-the-database-directory-exists for more information.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export const DOCUMENTS_REQUESTS_ID_PREFIX = 'dr';
|
||||
export const DOCUMENTS_REQUESTS_FILES_ID_PREFIX = 'dr_files';
|
||||
export const DOCUMENTS_REQUESTS_FILE_TAGS_ID_PREFIX = 'dr_file_tags';
|
||||
export const DOCUMENTS_REQUESTS_TOKEN_LENGTH = 32;
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Database } from '../app/database/database.types';
|
||||
|
||||
import type { DocumentsRequestAccessLevel } from './documents-requests.types';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { injectArguments } from '@corentinth/chisels';
|
||||
import { generateId } from '../shared/random/ids';
|
||||
import { DOCUMENTS_REQUESTS_FILES_ID_PREFIX, DOCUMENTS_REQUESTS_ID_PREFIX } from './documents-requests.constants';
|
||||
import { documentsRequestsFilesTable, documentsRequestsFileTagsTable, documentsRequestsTable } from './documents-requests.tables';
|
||||
|
||||
export function createDocumentsRequestsRepository({ db }: { db: Database }) {
|
||||
return injectArguments(
|
||||
{
|
||||
createDocumentsRequest,
|
||||
},
|
||||
{
|
||||
db,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function createDocumentsRequest({
|
||||
documentsRequest,
|
||||
files,
|
||||
db,
|
||||
}: {
|
||||
documentsRequest: {
|
||||
token: string;
|
||||
organizationId: string;
|
||||
createdBy: string | null;
|
||||
title: string;
|
||||
description?: string;
|
||||
useLimit?: number;
|
||||
expiresAt?: Date;
|
||||
accessLevel: DocumentsRequestAccessLevel;
|
||||
isEnabled?: boolean;
|
||||
};
|
||||
files: {
|
||||
title: string;
|
||||
description?: string;
|
||||
allowedMimeTypes: string[];
|
||||
sizeLimit?: number;
|
||||
tags: string[];
|
||||
}[];
|
||||
db: Database;
|
||||
}) {
|
||||
|
||||
const [createdDocumentsRequest] = await db
|
||||
.insert(documentsRequestsTable)
|
||||
.values(documentsRequest)
|
||||
.returning();
|
||||
|
||||
for (const file of files) {
|
||||
const [createdFile] = await db
|
||||
.insert(documentsRequestsFilesTable)
|
||||
.values({
|
||||
documentsRequestId: createdDocumentsRequest.id,
|
||||
title: file.title,
|
||||
description: file.description,
|
||||
allowedMimeTypes: file.allowedMimeTypes,
|
||||
sizeLimit: file.sizeLimit,
|
||||
})
|
||||
.returning();
|
||||
|
||||
for (const tag of file.tags) {
|
||||
await db
|
||||
.insert(documentsRequestsFileTagsTable)
|
||||
.values({
|
||||
documentsRequestId: createdDocumentsRequest.id,
|
||||
fileId: createdFile.id,
|
||||
tagId: tag,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { documentsRequest: createdDocumentsRequest };
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { RouteDefinitionContext } from '../app/server.types';
|
||||
import type { DocumentsRequestAccessLevel } from './documents-requests.types';
|
||||
import { z } from 'zod';
|
||||
import { requireAuthentication } from '../app/auth/auth.middleware';
|
||||
import { getUser } from '../app/auth/auth.models';
|
||||
import { organizationIdSchema } from '../organizations/organization.schemas';
|
||||
import { createOrganizationsRepository } from '../organizations/organizations.repository';
|
||||
import { ensureUserIsInOrganization } from '../organizations/organizations.usecases';
|
||||
import { validateJsonBody, validateParams } from '../shared/validation/validation';
|
||||
import { tagIdSchema } from '../tags/tags.schemas';
|
||||
import { createDocumentsRequestsRepository } from './documents-requests.repository';
|
||||
import { createDocumentsRequest } from './documents-requests.usecases';
|
||||
|
||||
export function registerDocumentsRequestsRoutes(context: RouteDefinitionContext) {
|
||||
setupCreateDocumentsRequestRoute(context);
|
||||
}
|
||||
|
||||
function setupCreateDocumentsRequestRoute({ app, db }: RouteDefinitionContext) {
|
||||
app.post(
|
||||
'/api/organizations/:organizationId/documents-requests',
|
||||
requireAuthentication(),
|
||||
validateParams(z.object({
|
||||
organizationId: organizationIdSchema,
|
||||
})),
|
||||
validateJsonBody(z.object({
|
||||
title: z.string().min(1).max(100),
|
||||
description: z.string().max(512).optional(),
|
||||
useLimit: z.number().positive().optional(),
|
||||
expiresAt: z.date().optional(),
|
||||
accessLevel: z.enum(['organization_members', 'authenticated_users', 'public'] as const),
|
||||
isEnabled: z.boolean().optional().default(true),
|
||||
files: z.array(z.object({
|
||||
title: z.string().min(1).max(100),
|
||||
description: z.string().max(512).optional(),
|
||||
allowedMimeTypes: z.array(z.string()).optional().default(['*/*']),
|
||||
sizeLimit: z.number().positive().optional(),
|
||||
tags: z.array(tagIdSchema).optional().default([]),
|
||||
})).min(1).max(32),
|
||||
})),
|
||||
async (context) => {
|
||||
const { userId } = getUser({ context });
|
||||
const { organizationId } = context.req.valid('param');
|
||||
const { title, description, useLimit, expiresAt, accessLevel, isEnabled, files } = context.req.valid('json');
|
||||
|
||||
const organizationsRepository = createOrganizationsRepository({ db });
|
||||
const documentsRequestsRepository = createDocumentsRequestsRepository({ db });
|
||||
|
||||
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
|
||||
|
||||
const { documentsRequest } = await createDocumentsRequest({
|
||||
organizationId,
|
||||
createdBy: userId,
|
||||
title,
|
||||
description,
|
||||
useLimit,
|
||||
expiresAt,
|
||||
accessLevel: accessLevel as DocumentsRequestAccessLevel,
|
||||
isEnabled,
|
||||
documentsRequestsRepository,
|
||||
files,
|
||||
});
|
||||
|
||||
return context.json({
|
||||
documentsRequest,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { generateToken } from '../shared/random/random.services';
|
||||
import { DOCUMENTS_REQUESTS_TOKEN_LENGTH } from './documents-requests.constants';
|
||||
|
||||
export function generateDocumentsRequestToken() {
|
||||
const { token } = generateToken({ length: DOCUMENTS_REQUESTS_TOKEN_LENGTH });
|
||||
|
||||
return { token };
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
Here's the complete specification for implementing the **Document Request Feature** in Papra, including the required database schema adjustments:
|
||||
|
||||
## Feature Overview
|
||||
|
||||
The **Document Request Feature** allows Papra users to create links through which others can upload documents directly into an organization's document archive. These links can be configured for multiple specific file types, restricted to one-time or multiple uses, pre-assigned tags per file type, and configured access levels (organization members only, authenticated users, or public access).
|
||||
|
||||
---
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
### 1. Creating a Document Request
|
||||
|
||||
Users should be able to configure the following when creating a request:
|
||||
|
||||
* **Title**: Descriptive title for the request (e.g., "Quarterly Reports Submission").
|
||||
* **Description** (optional): Brief context/instructions.
|
||||
* **File Types Configuration**: Define multiple specific file types that can be uploaded:
|
||||
* **File Title**: Descriptive name for each file type (e.g., "Financial Report", "Supporting Documents").
|
||||
* **File Description** (optional): Specific instructions for each file type.
|
||||
* **Allowed MIME Types**: Specify accepted file formats (e.g., `['application/pdf', 'image/jpeg']` or `['*/*']` for all types).
|
||||
* **Size Limit** (optional): Maximum file size in bytes for each file type.
|
||||
* **Predefined Tags**: Tags automatically applied to uploaded documents of this specific file type.
|
||||
* **Use Limit**:
|
||||
* Single-use: Link is valid for only one submission.
|
||||
* Multi-use: Link allows multiple submissions.
|
||||
* Unlimited submissions option (toggle on/off).
|
||||
* **Expiration Date** (optional): Request becomes invalid after a specific date.
|
||||
* **Access Restrictions**:
|
||||
* **Org Members Only**: Only current organization members can submit.
|
||||
* **Authenticated Users**: Any logged-in user can submit.
|
||||
* **Public Access**: Anyone with the link can submit.
|
||||
|
||||
### 2. Document Upload via Request Link
|
||||
|
||||
When a recipient accesses the link:
|
||||
|
||||
* They see the request details (title, description, required file types).
|
||||
* If access restricted, validation occurs based on the specified type.
|
||||
* User uploads documents for each configured file type:
|
||||
* Each file type shows its specific title, description, and requirements.
|
||||
* Files are validated against the configured MIME types and size limits.
|
||||
* Users can see which tags will be automatically applied to each file type.
|
||||
* Documents are tagged automatically based on predefined tags for each file type.
|
||||
|
||||
### 3. Managing Requests
|
||||
|
||||
* Creator can view active/inactive requests.
|
||||
* Creator can disable, edit, or delete a request (deletion/archive keeps submitted docs intact).
|
||||
* Creator can modify file type configurations, including adding/removing file types.
|
||||
|
||||
### 4. Notifications & Tracking
|
||||
|
||||
* Optional notifications via email or app notifications upon document upload.
|
||||
* Request creator receives updates about submissions.
|
||||
|
||||
|
||||
## Conclusion
|
||||
|
||||
This spec outlines a robust document request feature with multi-file type support, integrating smoothly with Papra's existing architecture, providing flexibility, security, and ease-of-use, fulfilling both individual and organizational needs effectively.
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { DocumentsRequestAccessLevel } from './documents-requests.types';
|
||||
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
import { organizationsTable } from '../organizations/organizations.table';
|
||||
import { createPrimaryKeyField, createTimestampColumns } from '../shared/db/columns.helpers';
|
||||
import { tagsTable } from '../tags/tags.table';
|
||||
import { usersTable } from '../users/users.table';
|
||||
import { DOCUMENTS_REQUESTS_FILE_TAGS_ID_PREFIX, DOCUMENTS_REQUESTS_FILES_ID_PREFIX, DOCUMENTS_REQUESTS_ID_PREFIX } from './documents-requests.constants';
|
||||
|
||||
export const documentsRequestsTable = sqliteTable('documents_requests', {
|
||||
...createPrimaryKeyField({ prefix: DOCUMENTS_REQUESTS_ID_PREFIX }),
|
||||
...createTimestampColumns(),
|
||||
|
||||
token: text('token').notNull().unique(),
|
||||
organizationId: text('organization_id').notNull().references(() => organizationsTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
createdBy: text('created_by').references(() => usersTable.id, { onDelete: 'set null', onUpdate: 'cascade' }),
|
||||
title: text('title').notNull(),
|
||||
description: text('description'),
|
||||
|
||||
useLimit: integer('use_limit').default(1), // null means unlimited
|
||||
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }),
|
||||
accessLevel: text('access_level').notNull().$type<DocumentsRequestAccessLevel>().default('organization_members'),
|
||||
isEnabled: integer('is_enabled', { mode: 'boolean' }).notNull().default(true),
|
||||
});
|
||||
|
||||
// To store the files that are allowed to be uploaded to the documents request
|
||||
export const documentsRequestsFilesTable = sqliteTable('documents_requests_files', {
|
||||
...createPrimaryKeyField({ prefix: DOCUMENTS_REQUESTS_FILES_ID_PREFIX }),
|
||||
...createTimestampColumns(),
|
||||
|
||||
documentsRequestId: text('documents_request_id').notNull().references(() => documentsRequestsTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
title: text('title').notNull(),
|
||||
description: text('description'),
|
||||
allowedMimeTypes: text('allowed_mime_types', { mode: 'json' }).notNull().$type<string[]>().default(['*/*']),
|
||||
sizeLimit: integer('size_limit'), // null for no limit
|
||||
});
|
||||
|
||||
export const documentsRequestsFileTagsTable = sqliteTable('documents_requests_file_tags', {
|
||||
...createPrimaryKeyField({ prefix: DOCUMENTS_REQUESTS_FILE_TAGS_ID_PREFIX }),
|
||||
...createTimestampColumns(),
|
||||
|
||||
documentsRequestId: text('documents_request_id').notNull().references(() => documentsRequestsTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
fileId: text('file_id').notNull().references(() => documentsRequestsFilesTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
tagId: text('tag_id').notNull().references(() => tagsTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
});
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export type DocumentsRequestAccessLevel = 'organization_members' | 'authenticated_users' | 'public';
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { DocumentsRequestAccessLevel } from './documents-requests.types';
|
||||
import { generateDocumentsRequestToken } from './documents-requests.services';
|
||||
|
||||
export type DocumentsRequestsRepository = ReturnType<typeof import('./documents-requests.repository').createDocumentsRequestsRepository>;
|
||||
|
||||
export async function createDocumentsRequest({
|
||||
organizationId,
|
||||
createdBy,
|
||||
title,
|
||||
description,
|
||||
useLimit,
|
||||
expiresAt,
|
||||
accessLevel,
|
||||
isEnabled,
|
||||
documentsRequestsRepository,
|
||||
files,
|
||||
generateToken = generateDocumentsRequestToken,
|
||||
}: {
|
||||
organizationId: string;
|
||||
createdBy: string | null;
|
||||
title: string;
|
||||
description?: string;
|
||||
useLimit?: number;
|
||||
expiresAt?: Date;
|
||||
accessLevel: DocumentsRequestAccessLevel;
|
||||
isEnabled?: boolean;
|
||||
documentsRequestsRepository: DocumentsRequestsRepository;
|
||||
files: {
|
||||
title: string;
|
||||
description?: string;
|
||||
allowedMimeTypes: string[];
|
||||
sizeLimit?: number;
|
||||
tags: string[];
|
||||
}[];
|
||||
generateToken?: () => { token: string };
|
||||
}) {
|
||||
const { token } = generateToken();
|
||||
|
||||
const { documentsRequest } = await documentsRequestsRepository.createDocumentsRequest({
|
||||
documentsRequest: {
|
||||
token,
|
||||
organizationId,
|
||||
createdBy,
|
||||
title,
|
||||
description,
|
||||
useLimit,
|
||||
expiresAt,
|
||||
accessLevel,
|
||||
isEnabled,
|
||||
},
|
||||
files,
|
||||
});
|
||||
|
||||
return { documentsRequest };
|
||||
}
|
||||
@@ -329,7 +329,7 @@ function setupGetDocumentFileRoute({ app, config, db }: RouteDefinitionContext)
|
||||
200,
|
||||
{
|
||||
'Content-Type': document.mimeType,
|
||||
'Content-Disposition': `inline; filename="${document.name}"`,
|
||||
'Content-Disposition': `inline; filename*=UTF-8''${encodeURIComponent(document.name)}`,
|
||||
'Content-Length': String(document.originalSize),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RouteDefinitionContext } from '../app/server.types';
|
||||
import { z } from 'zod';
|
||||
import { API_KEY_PERMISSIONS } from '../api-keys/api-keys.constants';
|
||||
import { requireAuthentication } from '../app/auth/auth.middleware';
|
||||
import { getUser } from '../app/auth/auth.models';
|
||||
import { createDocumentActivityRepository } from '../documents/document-activity/document-activity.repository';
|
||||
@@ -142,7 +143,7 @@ function setupDeleteTagRoute({ app, db }: RouteDefinitionContext) {
|
||||
function setupAddTagToDocumentRoute({ app, db }: RouteDefinitionContext) {
|
||||
app.post(
|
||||
'/api/organizations/:organizationId/documents/:documentId/tags',
|
||||
requireAuthentication(),
|
||||
requireAuthentication({ apiKeyPermissions: [API_KEY_PERMISSIONS.DOCUMENTS.UPDATE, API_KEY_PERMISSIONS.TAGS.READ] }),
|
||||
validateParams(z.object({
|
||||
organizationId: organizationIdSchema,
|
||||
documentId: documentIdSchema,
|
||||
@@ -182,7 +183,7 @@ function setupAddTagToDocumentRoute({ app, db }: RouteDefinitionContext) {
|
||||
function setupRemoveTagFromDocumentRoute({ app, db }: RouteDefinitionContext) {
|
||||
app.delete(
|
||||
'/api/organizations/:organizationId/documents/:documentId/tags/:tagId',
|
||||
requireAuthentication(),
|
||||
requireAuthentication({ apiKeyPermissions: [API_KEY_PERMISSIONS.DOCUMENTS.UPDATE, API_KEY_PERMISSIONS.TAGS.READ] }),
|
||||
validateParams(z.object({
|
||||
organizationId: organizationIdSchema,
|
||||
documentId: documentIdSchema,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Config } from '../../modules/config/config.types';
|
||||
import type { Logger } from '../../modules/shared/logger/logger';
|
||||
import process from 'node:process';
|
||||
import { setupDatabase } from '../../modules/app/database/database';
|
||||
import { ensureLocalDatabaseDirectoryExists } from '../../modules/app/database/database.services';
|
||||
import { parseConfig } from '../../modules/config/config';
|
||||
import { createLogger, wrapWithLoggerContext } from '../../modules/shared/logger/logger';
|
||||
|
||||
@@ -23,6 +24,7 @@ async function runScript(
|
||||
const logger = createLogger({ namespace: 'scripts' });
|
||||
|
||||
const { config } = await parseConfig({ env: process.env });
|
||||
await ensureLocalDatabaseDirectoryExists({ config });
|
||||
const { db, client } = setupDatabase({ ...config.database });
|
||||
|
||||
try {
|
||||
|
||||
@@ -4,7 +4,7 @@ ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN npm install -g corepack@latest
|
||||
RUN corepack enable
|
||||
RUN corepack prepare pnpm@10.9.0 --activate
|
||||
RUN corepack prepare pnpm@10.12.3 --activate
|
||||
|
||||
# Build stage
|
||||
FROM base AS build
|
||||
|
||||
@@ -4,7 +4,7 @@ ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN npm install -g corepack@latest
|
||||
RUN corepack enable
|
||||
RUN corepack prepare pnpm@10.9.0 --activate
|
||||
RUN corepack prepare pnpm@10.12.3 --activate
|
||||
|
||||
# Build stage
|
||||
FROM base AS build
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@papra/root",
|
||||
"version": "0.3.0",
|
||||
"packageManager": "pnpm@10.9.0",
|
||||
"packageManager": "pnpm@10.12.3",
|
||||
"description": "Papra document management monorepo root",
|
||||
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@papra/api-sdk",
|
||||
"type": "module",
|
||||
"version": "1.0.1",
|
||||
"packageManager": "pnpm@10.9.0",
|
||||
"packageManager": "pnpm@10.12.3",
|
||||
"description": "Api SDK for Papra, the document archiving platform.",
|
||||
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@papra/cli",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"packageManager": "pnpm@10.9.0",
|
||||
"packageManager": "pnpm@10.12.3",
|
||||
"description": "Command line interface for Papra, the document archiving platform.",
|
||||
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@papra/webhooks",
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"packageManager": "pnpm@10.9.0",
|
||||
"packageManager": "pnpm@10.12.3",
|
||||
"description": "Webhooks helper library for Papra, the document archiving platform.",
|
||||
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
|
||||
1677
pnpm-lock.yaml
generated
1677
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user