Compare commits

...

12 Commits

Author SHA1 Message Date
Corentin Thomasset
13889c1c42 wip 2025-06-29 15:28:17 +02:00
Corentin Thomasset
6cedc30716 chore(deps): updated dependencies (#379) 2025-06-24 20:52:15 +02:00
Corentin Thomasset
f1e1b4037b feat(tags): add color picker and swatches for tag creation (#378) 2025-06-24 20:27:58 +02:00
Corentin Thomasset
205c6cfd46 feat(preview): improved document preview for text-like files (#377) 2025-06-24 00:11:40 +02:00
Alex
c54a71d2c5 fix(tags): allow for uppercase tag color code (#346)
* Update tags.page.tsx

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

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

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

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

---------

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

* fix: Added changeset entry

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

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

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

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2025-06-15 21:51:13 +02:00
Corentin Thomasset
585c53cd9d chore(changesets): added /llms.txt announcement changesets (#357) 2025-06-14 19:16:28 +02:00
Corentin Thomasset
f035458e16 feat(docs): added descriptions in docs-navigation.json (#354) 2025-06-14 00:37:47 +02:00
Corentin Thomasset
556fd8b167 feat(docs): added navigation json export (#341) 2025-06-10 21:30:56 +02:00
46 changed files with 2347 additions and 808 deletions

View File

@@ -0,0 +1,5 @@
---
"@papra/app-client": patch
---
Improve file preview for text-like files (.env, yaml, extension-less text files,...)

View File

@@ -0,0 +1,5 @@
---
"@papra/app-client": patch
---
Fixes 400 error when submitting tags with uppercase hex colour codes.

View 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

View File

@@ -0,0 +1,5 @@
---
"@papra/app-client": patch
---
Added tag color swatches and picker

View File

@@ -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

View File

@@ -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",

View File

@@ -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';

View File

@@ -1,7 +1,7 @@
---
title: Configuration
slug: self-hosting/configuration
description: Configure your self-hosted Papra instance.
---
import { mdSections, fullDotEnv } from '../../../config.data.ts';

View File

@@ -1,6 +1,6 @@
---
title: Papra documentation
description: Papra documentation.
description: Documentation for Papra, the minimalistic document archiving platform.
hero:
title: Papra Docs
tagline: Documentation for Papra, the minimalistic document archiving platform.

View File

@@ -1,6 +1,6 @@
import type { StarlightUserConfig } from '@astrojs/starlight/types';
export const sidebar: StarlightUserConfig['sidebar'] = [
export const sidebar = [
{
label: 'Getting Started',
items: [
@@ -55,6 +55,7 @@ export const sidebar: StarlightUserConfig['sidebar'] = [
target: '_blank',
},
},
],
},
];
] satisfies StarlightUserConfig['sidebar'];

View File

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

View File

@@ -1,5 +1,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

View File

@@ -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"
}

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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),

View File

@@ -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>
);

View File

@@ -1,4 +1,5 @@
export const locales = [
{ key: 'en', name: 'English' },
{ key: 'fr', name: 'Français' },
{ key: 'de', name: 'Deutsch' },
] as const;

View File

@@ -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';

View File

@@ -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 });
});
});
});

View File

@@ -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 };
}

View File

@@ -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);
});
});
});

View 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;
}

View File

@@ -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,

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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:",

View File

@@ -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);
});

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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 };
}

View File

@@ -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,
});
},
);
}

View File

@@ -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 };
}

View File

@@ -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.

View File

@@ -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' }),
});

View File

@@ -0,0 +1 @@
export type DocumentsRequestAccessLevel = 'organization_members' | 'authenticated_users' | 'public';

View File

@@ -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 };
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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

File diff suppressed because it is too large Load Diff