mirror of
https://github.com/papra-hq/papra.git
synced 2025-12-17 03:51:45 -06:00
Compare commits
5 Commits
@papra/app
...
documents-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13889c1c42 | ||
|
|
6cedc30716 | ||
|
|
f1e1b4037b | ||
|
|
205c6cfd46 | ||
|
|
c54a71d2c5 |
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
|
||||
@@ -3,7 +3,7 @@
|
||||
"type": "module",
|
||||
"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",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"type": "module",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -256,6 +256,9 @@ 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.
|
||||
@@ -306,7 +309,6 @@ 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.placeholder: 'Z.B. #FF0000'
|
||||
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
|
||||
@@ -550,3 +552,11 @@ demo.popup.discord: Treten Sie dem {{ discordLink }} bei, um Support zu erhalten
|
||||
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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"type": "module",
|
||||
"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:",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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