mirror of
https://github.com/papra-hq/papra.git
synced 2025-12-17 03:51:45 -06:00
Compare commits
12 Commits
@papra/app
...
documents-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13889c1c42 | ||
|
|
6cedc30716 | ||
|
|
f1e1b4037b | ||
|
|
205c6cfd46 | ||
|
|
c54a71d2c5 | ||
|
|
62b7f0382c | ||
|
|
57c6a26657 | ||
|
|
b8c2bd70e3 | ||
|
|
0c2cf698d1 | ||
|
|
585c53cd9d | ||
|
|
f035458e16 | ||
|
|
556fd8b167 |
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
|
||||
@@ -1,5 +1,11 @@
|
||||
# @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
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "@papra/docs",
|
||||
"type": "module",
|
||||
"version": "0.5.0",
|
||||
"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';
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { StarlightUserConfig } from '@astrojs/starlight/types';
|
||||
|
||||
export const sidebar: StarlightUserConfig['sidebar'] = [
|
||||
export const sidebar = [
|
||||
{
|
||||
label: 'Getting Started',
|
||||
items: [
|
||||
@@ -55,6 +55,7 @@ export const sidebar: StarlightUserConfig['sidebar'] = [
|
||||
target: '_blank',
|
||||
},
|
||||
},
|
||||
|
||||
],
|
||||
},
|
||||
];
|
||||
] satisfies StarlightUserConfig['sidebar'];
|
||||
|
||||
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,13 @@
|
||||
# @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
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "@papra/app-client",
|
||||
"type": "module",
|
||||
"version": "0.6.2",
|
||||
"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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
# @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
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "@papra/app-server",
|
||||
"type": "module",
|
||||
"version": "0.6.2",
|
||||
"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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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