Compare commits

...

198 Commits

Author SHA1 Message Date
Corentin Thomasset
6b435bba79 chore(release): update versions (#305)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-04 00:09:45 +02:00
Corentin Thomasset
8ccdb74834 refactor(docker): added base url override in docker (#320) 2025-06-03 22:04:15 +00:00
Corentin Thomasset
60059c895c feat(invitations): add invitations management page (#319) 2025-06-03 22:13:21 +02:00
Corentin Thomasset
6e22a93dff feat(locales): add fr translations for document activity logging (#318) 2025-05-30 13:58:00 +02:00
Corentin Thomasset
79c1d3206b feat(documents): added document activity logging (#317) 2025-05-30 13:45:25 +02:00
Corentin Thomasset
48a953a584 refactor(client): migrated tanstack createQuery and createMutation to useQuery and useMutation (#316) 2025-05-28 21:51:30 +02:00
Corentin Thomasset
fdb90fa164 feat(tags): add error handling for existing tags (#315) 2025-05-27 21:09:46 +02:00
Corentin Thomasset
e9a205c0a3 feat(documents): added document renaming (#314) 2025-05-27 20:11:05 +02:00
Corentin Thomasset
278db63fc8 chore(deps): updated some dependencies version (#313) 2025-05-27 13:46:43 +02:00
Corentin Thomasset
e5ef40f36c chore(version): added missing changeset for password reset fix (#312) 2025-05-26 20:30:24 +00:00
Corentin Thomasset
27c9e39422 fix(auth): fix deprecated better-auth database id generation conf (#311) 2025-05-26 20:27:32 +00:00
Corentin Thomasset
91d2e236d0 fix(auth): corrected password reset navigation guard (#310) 2025-05-26 22:19:33 +02:00
Corentin Thomasset
d4f72e889a refactor(client): hide manage subscription section (#309) 2025-05-26 21:42:19 +02:00
Corentin Thomasset
759a3ff713 feat(i18n): extracted hard coded text for i18n (#308) 2025-05-26 01:14:43 +02:00
Corentin Thomasset
34862991fb chore(cf): added security headers in docs and papra-client (#307) 2025-05-25 12:05:38 +00:00
Corentin Thomasset
f0876fdc63 feat(server): added smtp client support for emailing (#306) 2025-05-25 11:47:12 +02:00
Corentin Thomasset
cb38d66485 refactor(emails): restructure emails service to support multiple drivers (#304) 2025-05-25 01:26:28 +02:00
Corentin Thomasset
c28af1407f chore(release): update versions (#303)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-24 19:58:09 +02:00
Corentin Thomasset
b62ddf2bc4 chore(docker): add EMAILS_DRY_RUN environment variable to Dockerfiles (#302) 2025-05-24 17:55:59 +00:00
Corentin Thomasset
fa7909c62d chore(release): add 'actions' permission to changeset workflow (#301) 2025-05-24 17:38:19 +00:00
Corentin Thomasset
1996b51b4d chore(release): update versions (#292)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-24 18:17:41 +02:00
Corentin Thomasset
734027f00c feat(docs): updated feature list statuses (#300) 2025-05-24 18:08:32 +02:00
Corentin Thomasset
557cde940c feat(organizations): added member role update functionality (#299) 2025-05-24 17:13:32 +02:00
Corentin Thomasset
26a83052bd fix(intake-emails): enhance disabled intake-email state (#298) 2025-05-24 12:27:09 +00:00
Corentin Thomasset
5aac3f7ba6 fix(demo): added missing routes in demo (#297) 2025-05-24 12:16:05 +00:00
Corentin Thomasset
0ddc2340f0 fix(locales): update registration page description (#296) 2025-05-24 11:24:44 +00:00
Corentin Thomasset
438a31171c feat(auth): added support for custom oauth2 providers (#295) 2025-05-24 03:12:39 +02:00
Corentin Thomasset
53bf93f128 feat(doc): added a papra docker compose generator (#293) 2025-05-23 21:24:08 +00:00
Corentin Thomasset
b400b3f18d feat(database): ensure local database directory en boot (#294) 2025-05-23 22:21:33 +02:00
Corentin Thomasset
0627ec25a4 feat(organizations): add permission check for invitation (#291) 2025-05-21 23:06:43 +02:00
Corentin Thomasset
72e5a9a4de feat(invitations): added organizations invitations and multi-user (#289) 2025-05-21 21:53:56 +02:00
Corentin Thomasset
268ac8e358 chore(release): update Docker release workflow to use version input parameter (#286) 2025-05-14 13:11:19 +02:00
Corentin Thomasset
249b3bcfd2 chore(release): update versions (#285)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-05-13 22:44:37 +02:00
Corentin Thomasset
d7838b5d57 chore(release): remove commitMode from release job configuration (#284) 2025-05-13 20:14:08 +00:00
Corentin Thomasset
f170ddd817 chore(release): use PAT for release PR creation (#283) 2025-05-13 20:05:57 +00:00
Corentin Thomasset
4f53c70854 chore(release): update permissions for release job (#281) 2025-05-13 16:44:47 +00:00
Corentin Thomasset
85fa5c4342 chore(version): added changeset for versioning (#280) 2025-05-13 13:48:55 +02:00
Corentin Thomasset
c5d984a3a0 refactor(docker): build transitive dependencies (#277) 2025-05-08 20:47:57 +02:00
Corentin Thomasset
565bd8d7fd feat(webhooks): added webhook management and logic (#276) 2025-05-08 18:52:11 +02:00
Corentin Thomasset
9b72aa886c feat(cli): added cli documentation (#275) 2025-05-02 23:30:33 +02:00
Corentin Thomasset
7410455093 feat(cli): setup base cli (#274) 2025-05-02 00:20:57 +02:00
riskpoint-per
dd8f194fd0 feat(server): add azure blob storage support (#261)
* add azure blob storage support

* set stream to nodejs.readable

* Update apps/papra-server/src/modules/documents/storage/drivers/az-blob/az-blob.storage-driver.ts

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

* fix lock file

* bugfixes

* fix lint issues

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2025-04-28 09:34:28 +02:00
Corentin Thomasset
803c39cbc8 chore(packages): added api sdk package (#270) 2025-04-27 22:08:59 +02:00
Joshua Anderson
096331a4ee feat(server): add support for b2 object storage type (#232)
* feat(b2): add support for b2 object storage type

* feat(b2): fix order of tsconfig entries

* feat(b2): fix accidental responseType change

* fix(b2): remove unnecessary try-catches

* refactor(b2): use error factories
2025-04-27 21:35:29 +02:00
Corentin Thomasset
59ba9465f6 docs(features): marked tagging rules and folder ingestion as available features (#268) 2025-04-27 13:57:11 +00:00
Corentin Thomasset
a1056702af feat(docs): fixed broken links with auto check (#267) 2025-04-27 13:20:34 +00:00
Corentin Thomasset
fd44897bca fix(documents): hard delete file in storage driver (#266) 2025-04-27 14:52:05 +02:00
Corentin Thomasset
332d836d11 fix(ingestion-folders): added schema validation coercion in config (#265) 2025-04-27 12:11:05 +00:00
Corentin Thomasset
f613198cbd refactor(storage): replace generic error messages with specific file not found errors (#264) 2025-04-27 13:52:47 +02:00
Corentin Thomasset
80491a5a58 chore(deps): update eslint and and eslint config (#260) 2025-04-27 13:43:17 +02:00
Corentin Thomasset
605e21a410 chore(deps): updated pnpm to 10.9.0 in all package.json files (#258) 2025-04-25 13:15:32 +00:00
Corentin Thomasset
dec589b6ed fix(documents): remove incorrect default tab value (#259) 2025-04-25 13:08:39 +00:00
Corentin Thomasset
c0bd6e2ae4 refactor(i18n): flattened keys directly in yaml (#255)
* refactor(i18n): flattened keys directly in yaml

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

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-04-24 21:17:37 +00:00
Corentin Thomasset
6287aaa973 feat(i18n): auto generate i18n type in dev mode (#254) 2025-04-24 20:03:21 +00:00
Corentin Thomasset
cc2edc59b0 feat(server): added api-keys (#248) 2025-04-24 21:13:56 +02:00
Corentin Thomasset
9cba84e38b refactor(client, services): updated API responses to use AsDto and date coercion (#250) 2025-04-22 22:35:48 +02:00
Corentin Thomasset
5fe401778d feat(documents): enhance document page with alert for content extraction details (#249) 2025-04-22 19:26:48 +00:00
Joshua Anderson
38aa1ea7f1 feat(documents): add document searchable content view and edit (#230)
* feat(documents): add tab for viewing content

* feat(documents): allow editing document content

* fix(demo): add content to demo document api

* refactor(documents): fix api structure for updating documents

* refactor(documents): return updated document after change

* refactor(documents): use correct api validation

* refactor(documents): move update to repository

* refactor(documents): limit height of content view

* refactor(documents): use new validation schemas
2025-04-22 21:14:57 +02:00
Corentin Thomasset
ab98c1b255 refactor(validation): mutualized resources ids for route validation (#241) 2025-04-19 16:27:51 +02:00
Corentin Thomasset
265f06f8b7 docs(CONTRIBUTING): updated guidelines for PRs (#240) 2025-04-18 21:47:59 +00:00
Corentin Thomasset
a787b7915c chore(github): added CODEOWNERS file (#239) 2025-04-18 21:40:37 +00:00
Joshua Anderson
0ba6a09923 fix(documents): fix padding when tags input wraps (#227)
* fix(documents): fix padding when tags input wraps

* fix(documents): use padding for y not just b
2025-04-18 22:58:50 +02:00
Joshua Anderson
6880bfd41c feat(documents): wrap txt document preview (#231)
* feat(documents): wrap txt document preview

* Update apps/papra-client/src/modules/documents/components/document-preview.component.tsx

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2025-04-18 22:43:48 +02:00
Corentin Thomasset
21a2c95e56 fix(tags): exclude deleted documents from tags docs count (#234) 2025-04-18 08:43:32 +00:00
Joshua Anderson
19e2083a71 feat(documents): add create tag button on document page (#220)
* feat(documents): fix tag selection input width

* feat(documents): add create tag button on document page

also added i18n just for the tag button, to be used for future localizing
2025-04-17 23:09:47 +02:00
Corentin Thomasset
e6b2d9fb2d refactor(documents): add functions to retrieve document name and extension (#217) 2025-04-16 23:07:54 +02:00
Corentin Thomasset
5140a64c40 chore: release v0.3.0 2025-04-16 20:00:09 +02:00
Corentin Thomasset
9ddb7d545d feat(ingestion): added folder ingestion support (#215) 2025-04-16 00:46:04 +02:00
Corentin Thomasset
2a73551ca4 feat(documents): restore document in trash when same file is uploaded (#213) 2025-04-11 22:08:55 +02:00
Corentin Thomasset
7be56455b0 feat(documents): implement document upload context with status (#212) 2025-04-11 18:41:38 +02:00
Corentin Thomasset
1085bf079c feat(documents): delete documents from the trash (#211) 2025-04-10 20:24:47 +00:00
Corentin Thomasset
b13986e1e3 refactor(config): remove support for wildcard '*' in trustedOrigins (#210) 2025-04-10 18:56:18 +00:00
Corentin Thomasset
d4462f942b refactor(auth, i18n): extracted hard coded text for i18n (#205)
* refactor(auth, i18n): extracted hard coded text for i18n

* Update apps/papra-client/src/modules/auth/pages/register.page.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-04-10 20:55:26 +02:00
Corentin Thomasset
2f2ad90fd3 feat(documents): made file upload limit disableable (#209) 2025-04-10 18:47:22 +00:00
Corentin Thomasset
2bbb68aa17 feat(tagging-rules): added documents auto tagging rules (#200) 2025-04-09 23:51:23 +02:00
Corentin Thomasset
2b2827cdb3 feat(demo): added Discord support link in demo popup (#203)
* feat(demo): added Discord support link in demo popup

* Update apps/papra-client/src/modules/demo/demo.provider.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-04-07 20:34:19 +00:00
Corentin Thomasset
4b4621e4d0 chore(issues): added issue template configuration with Discord community link (#202) 2025-04-06 20:57:23 +00:00
Corentin Thomasset
fd0f79feb0 docs(docker): improved docker and docker-compose instructions for Papra deployment (#201) 2025-04-06 19:25:02 +00:00
Corentin Thomasset
b9c2448805 feat(docs): implement text wrapping for documentation in .env configuration display (#199) 2025-04-06 07:37:50 +00:00
Corentin Thomasset
542225fabc feat(docs): add full .env configuration display in self-hosting guide (#198) 2025-04-06 09:19:07 +02:00
Corentin Thomasset
e4af2653ea chore: release v0.2.1 2025-04-05 19:31:39 +02:00
Corentin Thomasset
4dd15527c0 feat(config): add trustedOrigins configuration (#195) 2025-04-05 19:30:58 +02:00
Corentin Thomasset
ae0f69043d fix(docs): update Discord invitation links (#193) 2025-04-05 13:39:59 +02:00
Corentin Thomasset
79eafdb3ee feat(intake-emails): when deleting intake email in organization, delete in OwlRelay too (#192)
* feat(intake-emails): delete email in owlrelay too

* Update apps/papra-server/src/modules/intake-emails/drivers/random-username/random-username.intake-email-driver.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-04-04 16:23:55 +02:00
Corentin Thomasset
979df5dad8 docs(readme): updated Papra screenshot (#189) 2025-04-03 16:26:01 +00:00
Corentin Thomasset
76c50dce6c chore: release v0.2.0 2025-04-03 16:30:26 +02:00
Corentin Thomasset
8acd7de79e docs(readme): enhance self-hosting description (#188) 2025-04-03 13:42:59 +02:00
Corentin Thomasset
a3bd2024c6 docs(readme): correct typo and add project status section (#187) 2025-04-03 09:54:25 +00:00
Corentin Thomasset
25c26e8dc0 docs(readme): update i18n status and add document requests feature (#186) 2025-04-03 09:52:24 +00:00
Corentin Thomasset
07563dce5d feat(events): integrated posthog-node for user event (#184)
* feat(events): integrated posthog-node for user event

* Update apps/papra-server/src/modules/documents/documents.usecases.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* feat(events): integrated posthog-node for user event

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-04-03 00:35:49 +02:00
Corentin Thomasset
a0797beb14 refactor(config): introduce booleanishSchema for boolean coercion in config (#183) 2025-04-02 23:32:54 +02:00
Corentin Thomasset
0701a84973 refactor(config): replace async config loading with synchronous dry config loading (#182)
* refactor(config): replace async config loading with synchronous dry config loading

* Update apps/papra-server/src/modules/config/config.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-04-02 20:57:06 +00:00
Corentin Thomasset
0789ad3e9a docs(readme): remove self-hosting warning from README (#181) 2025-04-02 20:33:44 +00:00
Corentin Thomasset
cb3c9c3194 docs(index): added hero section on doc home page (#180) 2025-04-02 20:31:27 +00:00
Corentin Thomasset
f9b02c4439 style(docs): updated some dark theme colors (#179) 2025-04-02 19:16:06 +00:00
Corentin Thomasset
9afca3fd84 docs(readme): add self-hosting section to README (#178) 2025-04-02 18:34:52 +00:00
Corentin Thomasset
faca409604 feat(config): added support for file based configuration (#177) 2025-04-02 18:56:36 +02:00
Corentin Thomasset
fc973d20fe test(intake-emails): added e2e happy path test for email ingestion (#176) 2025-04-02 12:57:50 +02:00
Corentin Thomasset
400541f0ce refactor(intake-emails): remove unused legacy webhook secret validation code (#175) 2025-04-01 23:04:41 +00:00
Corentin Thomasset
f98b810bd4 feat(demo): add customer portal endpoint for demo mode (#174) 2025-04-01 22:29:11 +00:00
Corentin Thomasset
091bd26fbc fix(intake-emails): mutualized intake email ingestion endpoint path (#173) 2025-04-01 22:07:14 +00:00
Corentin Thomasset
6541a83e72 chore: release v0.1.2 2025-04-01 15:08:27 +02:00
Corentin Thomasset
77cf75a08b chore(package): remove logging formatter from migrate:up script (#171) 2025-04-01 15:03:32 +02:00
Corentin Thomasset
c0785e5e7a chore: release v0.1.1 2025-04-01 14:35:59 +02:00
Corentin Thomasset
14489457b2 feat(docker): release rootless as :latest (#170) 2025-04-01 12:32:36 +00:00
Corentin Thomasset
feb8378227 refactor(server): merged migrations (#169) 2025-04-01 14:03:23 +02:00
Corentin Thomasset
8622751c22 feat(organizations): add organization subscription management (#166) 2025-03-27 22:02:44 +01:00
Corentin Thomasset
b17f93b5e3 feat(server): implement organization subscription and limits base (#164) 2025-03-18 21:01:54 +01:00
Corentin Thomasset
51109c39f8 refactor(server): unify route handler dependencies management (#162) 2025-03-17 21:24:40 +01:00
Corentin Thomasset
3a1410f554 chore(dependencies): updated better-auth (#161) 2025-03-16 17:14:19 +01:00
Corentin Thomasset
180a9c234f chore(dependencies): remove unused deps (#160) 2025-03-16 16:15:32 +01:00
Corentin Thomasset
25379b5be5 refactor(organizations): rollback organization management to in-house API (#159) 2025-03-16 16:04:55 +01:00
Corentin Thomasset
300e8918d6 fix(documents): add endpoint and service for organization documents statistics (#158) 2025-03-16 11:17:43 +01:00
Corentin Thomasset
6f5ea9f9de feat(docs): update dark theme colors and refined docs (#157) 2025-03-16 01:05:50 +01:00
Corentin Thomasset
7b6c37fd4c feat(legals): add security policy (#156) 2025-03-15 16:06:25 +01:00
Corentin Thomasset
0f9f7831c9 fix(auth): update privacy policy link in legal links (#155) 2025-03-15 13:06:07 +00:00
Corentin Thomasset
51228dc157 refactor(client, config): update PostHog isEnabled flag to use dedicated env (#154) 2025-03-15 00:16:39 +01:00
Corentin Thomasset
0786b81e75 feat(client, tracking): prevent identify on demo mode (#153) 2025-03-15 00:00:45 +01:00
Corentin Thomasset
e92456bc6b fix(client, auth): explicitly return client methods instead of spreading proxy object (#152)
* fix(client, auth): explicitly return client methods instead of spreading proxy object

* Update apps/papra-client/src/modules/auth/auth.services.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-03-14 22:47:21 +00:00
Corentin Thomasset
3e7b4ea2db feat(client): added posthog analytics (#151) 2025-03-14 23:18:52 +01:00
Corentin Thomasset
2fd681b8a4 feat(analytics): integrate PostHog for analytics 2025-03-14 22:30:45 +01:00
Corentin Thomasset
73788ceb58 refactor(organizations): migrated to better-auth organization system (#150) 2025-03-14 20:57:55 +01:00
Corentin Thomasset
24b80eb785 feat(documents): add document filtering by tags (#146) 2025-03-06 22:31:40 +01:00
Corentin Thomasset
ae69eb2b33 feat(emails): add email service integration (#141) 2025-03-01 16:53:26 +01:00
Corentin Thomasset
f78d42ca25 feat(documents): prevent duplicate document uploads within an organization (#140)
* feat(documents): prevent duplicate document uploads within an organization

* Update apps/papra-server/src/modules/documents/documents.usecases.test.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-02-28 23:41:49 +01:00
Corentin Thomasset
4be13d0742 feat(intake-emails): added owlrelay driver integration (#138) 2025-02-28 13:21:36 +01:00
Corentin Thomasset
416b9d50b6 refactor(intake-email): mutualized intake-emails creation in drivers (#137) 2025-02-26 20:53:55 +01:00
Corentin Thomasset
84d2af5df5 refactor(organization): split home and documents pages (#136) 2025-02-26 00:24:53 +01:00
Corentin Thomasset
0d1be0d3a5 refactor(server,logger): migrate to crowlog logger ecosystem (#135) 2025-02-09 20:57:52 +01:00
Corentin Thomasset
c1f8507891 feat(organizations): added organization statistics (#134) 2025-02-06 08:41:43 +01:00
Corentin Thomasset
5422d3e2f6 chore(deps): update vitest to version 3.0.5 whole workspace 2025-02-05 22:19:14 +01:00
Corentin Thomasset
b16b557733 feat(documents): include organizationId in document repository crud operations 2025-02-04 23:48:33 +01:00
Corentin Thomasset
e981785a03 feat(auth): add indexes for auth_sessions and auth_verifications tables (#127) 2025-02-04 22:15:49 +00:00
Corentin Thomasset
9395df746e feat(logger): improved logger with transports interfaces 2025-02-04 21:44:19 +01:00
Corentin Thomasset
998dd2c667 test(errors): added test for standard error management 2025-02-04 21:44:19 +01:00
Corentin Thomasset
b99d72d23b refactor(static): mutualized static assets routes 2025-02-04 21:44:19 +01:00
Corentin Thomasset
b2d1848226 feat(docs): updates README files (#125) 2025-02-02 22:41:56 +00:00
Corentin Thomasset
d51f19d5e3 feat(dev): added proxy in client for default backend redirection (#121) 2025-02-02 21:57:50 +00:00
Corentin Thomasset
b0d9cfc67a refactor(ci): remove corepack config from setup-node workflows (#122) 2025-02-02 21:57:32 +00:00
Corentin Thomasset
e77b49832f refactor(ci): replace corepack with pnpm setup in workflow files (#124) 2025-02-02 22:56:00 +01:00
Corentin Thomasset
947c09eff9 feat(docs): added Discord community invitation (#120) 2025-02-01 23:12:21 +01:00
Corentin Thomasset
85cbd547f5 refactor(docs): updated dark theme colors (#119) 2025-02-01 21:17:25 +00:00
Corentin Thomasset
2484932f96 feat(docs): add guide for setting up email intake (#118) 2025-02-01 15:40:51 +00:00
Corentin Thomasset
97d835f240 refactor(i18n): add email validation and legal links translations (#117) 2025-01-31 23:46:35 +00:00
Corentin Thomasset
72414d8122 refactor(auth): added theme and language picker in auth layout (#115) 2025-01-31 22:17:00 +00:00
Corentin Thomasset
7f4fb3b0e7 chore: release v0.1.0 2025-01-31 21:45:26 +01:00
Corentin Thomasset
918ae55ebc refactor(config): improved default config for self hosting 2025-01-31 21:39:44 +01:00
Corentin Thomasset
6a0dd40e1e refactor(components): replace map with For for reactivity (#113) 2025-01-31 16:12:18 +01:00
Corentin Thomasset
e5d95e3ffe docs(contributing): clarify guidelines for submitting pull requests (#112) 2025-01-31 14:20:58 +00:00
Corentin Thomasset
08886fd754 fix(scripts): update dev and migrate:up scripts to use env-file-if-exists option (#111) 2025-01-31 14:15:01 +00:00
Corentin Thomasset
5a8d30a34f chore(deps): update pnpm version to 9.15.4 in package.json files (#110) 2025-01-31 14:57:32 +01:00
Corentin Thomasset
ee81d2e6c1 feat(database): added db encryption capability (#109) 2025-01-31 14:53:21 +01:00
Alexis Ablain
d84ad00e95 docs(readme): fix introduction demo link (#108) 2025-01-27 09:48:59 +01:00
Corentin THOMASSET
7c1aecd5fa feat(i18n): added internationalization support (#107) 2025-01-27 00:45:00 +01:00
Corentin THOMASSET
e412507f30 git c iudocs(readme): updated content extraction section feature (#106) 2025-01-26 17:06:40 +00:00
Corentin THOMASSET
5521d67a68 chore(github): added contribution guidelines (#105) 2025-01-26 17:39:44 +01:00
Corentin THOMASSET
4962215093 fix(documents): added missing Content-Length header (#104) 2025-01-26 16:41:25 +01:00
Corentin THOMASSET
274fb7d72e feat(intake-emails): added intake emails (#103) 2025-01-26 01:11:24 +01:00
Corentin THOMASSET
a491987c1b refactor(documents): externalized document text extraction (#102) 2025-01-22 14:38:52 +00:00
Corentin THOMASSET
f3466e4bfd feat(documents): integrated PDF text extraction (#101) 2025-01-22 01:45:57 +01:00
Corentin THOMASSET
c2dc8bfdfb feat(client): added unreachable server display (#100) 2025-01-21 23:57:34 +00:00
Corentin THOMASSET
510e8622b5 refactor(demo-api-mock): simplify document and tag retrieval using utility functions (#99) 2025-01-21 21:44:36 +01:00
Corentin THOMASSET
7860ea49a0 docs(README): update tags section to remove 'coming soon' phrasing (#98) 2025-01-21 19:37:00 +00:00
Corentin THOMASSET
b319a86934 feat(tags): added documents tags (#97) 2025-01-21 20:17:01 +01:00
Corentin THOMASSET
b15bc2a087 feat(documents): added document restoration and deletion hooks (#96) 2025-01-18 21:59:34 +00:00
Corentin THOMASSET
0c811e3fc4 fix(plausible): adjust url pattern for 404 routes (#95) 2025-01-18 15:13:24 +00:00
Corentin THOMASSET
8b3372a2bd feat(client): integrated plausible analytics (#94) 2025-01-18 14:39:47 +00:00
Corentin THOMASSET
753a07a008 fix(config): update Plausible analytics script to use 'node:process' env (#93) 2025-01-18 13:49:59 +01:00
Corentin THOMASSET
c4943f8de7 feat(docs): add Plausible analytics script configuration (#92) 2025-01-18 13:42:52 +01:00
Corentin THOMASSET
538b490583 fix(config): added Google SSO configuration in public (#91) 2025-01-18 10:37:09 +01:00
Corentin THOMASSET
79542bab7b refactor(config): remove unused configuration (#90) 2025-01-18 10:30:22 +01:00
Corentin THOMASSET
5cae1fdf7e chore(github): added funding configuration (#88) 2025-01-18 00:29:17 +01:00
Corentin THOMASSET
9452c4be92 feat(docs): configuration documentation (#86) 2025-01-18 00:21:31 +01:00
Corentin THOMASSET
cd5b609427 feat(auth): add Google as an SSO provider (#85) 2025-01-17 23:57:12 +01:00
Corentin THOMASSET
d0a9842e7d style(sidenav): enhance button icon with rotation effect on hover (#84) 2025-01-17 23:57:00 +01:00
Corentin THOMASSET
82ecba25e0 feat(auth): disable registration form when signup is disabled (#83) 2025-01-17 23:56:47 +01:00
Corentin THOMASSET
904f2c091a feat(tasks): added document deletion recuring task (#81) 2025-01-17 23:56:34 +01:00
Corentin THOMASSET
77eb6dc476 refactor(og): improved og image (#82) 2025-01-17 22:09:32 +01:00
Corentin THOMASSET
1fc6182a09 fix(demo): added mock endpoint for configuration retrieval (#80) 2025-01-17 11:51:13 +01:00
Corentin THOMASSET
3e1bae897e feat(config): implement runtime server-provided config (#79) 2025-01-17 11:33:39 +01:00
Corentin THOMASSET
a049479fb5 refactor(auth): use root auth instance (#78) 2025-01-15 22:18:43 +01:00
Corentin THOMASSET
c8cae4842e feat(auth): added email / password support (#77) 2025-01-15 22:06:51 +01:00
Corentin THOMASSET
181e59ac87 docs(readme): updated README to include self-hosting section and better auth library (#76) 2025-01-14 23:12:57 +01:00
Corentin Thomasset
f6960eafea chore: release v0.0.1 2025-01-14 23:03:13 +01:00
Corentin THOMASSET
e1ab9481e0 refactor(style): updated light mode primary color (#74) 2025-01-14 22:46:37 +01:00
Corentin THOMASSET
2a4731c0d7 feat(auth): enable demo mode authentication with createDemoAuthClient (#73) 2025-01-14 22:31:44 +01:00
Corentin THOMASSET
32564fe5ee refactor(auth): remove isPending state from protected page middleware (#70) 2025-01-14 21:24:41 +01:00
Corentin THOMASSET
02b7f70393 refactor(auth): integrated better-auth (#69) 2025-01-14 20:58:08 +01:00
Corentin THOMASSET
1ff8902bd0 feat(routes): add health check route to server (#68) 2025-01-13 20:09:23 +01:00
Corentin THOMASSET
912daeaea8 chore(dependencies): updated and cleaned some deps (#67) 2025-01-13 13:59:10 +01:00
Corentin THOMASSET
81b0cd74d4 feat(upload): added global drag-drop on organization (#66) 2025-01-12 23:54:42 +01:00
Corentin THOMASSET
36cb2b1829 refactor(documents): added confirmation modal on document deletion (#64) 2025-01-12 22:07:49 +01:00
Corentin THOMASSET
bba6cba60e refactor(documents): loading state for restore document button (#63) 2025-01-12 21:37:24 +01:00
Corentin THOMASSET
0f20b9fd16 feat(demo): added deletion/restoration demo routes (#62) 2025-01-12 20:55:01 +01:00
Corentin THOMASSET
cad6ff4e51 feat(documents): added delete documents restoration (#61) 2025-01-12 20:41:13 +01:00
Corentin Thomasset
5c875b3e6f chore: release v0.0.1-beta.2 2025-01-10 21:28:22 +01:00
Corentin THOMASSET
68d88b460e fix(docker: update docker images names for ghcr (#60) 2025-01-10 21:25:16 +01:00
Corentin THOMASSET
5f044e281d refactor(docs): using starlight theme (#59) 2025-01-10 20:31:50 +01:00
568 changed files with 47548 additions and 8435 deletions

8
.changeset/README.md Normal file
View File

@@ -0,0 +1,8 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)

19
.changeset/config.json Normal file
View File

@@ -0,0 +1,19 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
"changelog": [
"@changesets/changelog-github",
{ "repo": "papra-hq/papra"}
],
"commit": false,
"fixed": [
["@papra/app-client", "@papra/app-server"]
],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": [],
"privatePackages": {
"tag": true,
"version": true
}
}

View File

@@ -4,7 +4,8 @@ node_modules
*.log
dist
*.local
.env
.git
db.sqlite
local-documents
local-documents
.env
**/.env

4
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,4 @@
github:
- CorentinTh
buy_me_a_coffee: cthmsst

48
.github/ISSUE_TEMPLATE/bug-report.yml vendored Normal file
View File

@@ -0,0 +1,48 @@
name: 🐞 Bug Report
description: File a bug report.
labels: ['bug', 'triage']
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: textarea
id: bug-description
attributes:
label: Describe the bug
description: A clear and concise description of what the bug is. If you intend to submit a PR for this issue, tell us in the description. Thanks!
placeholder: Bug description
validations:
required: true
- type: textarea
id: what-happened
attributes:
label: What happened?
description: Also tell us, what did you expect to happen? If you have a screenshot, you can paste it here.
placeholder: Tell us what you see!
value: 'A bug happened!'
validations:
required: true
- type: textarea
id: version
attributes:
label: System information
description: What is you environment? You can use the `npx envinfo --system --browsers` command to get this information.
validations:
required: true
- type: dropdown
id: app-type
attributes:
label: Where did you encounter the bug?
options:
- Demo app (demo.papra.app)
- Public app (dashboard.papra.app
- Documentation website (docs.papra.ap)
- A self hosted instance
- Other (installations, docker, etc.)
validations:
required: true

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: 💬 Discord Community
url: https://papra.app/discord
about: Join the Papra Discord community to get help, share your feedback, and stay updated on the project.

View File

@@ -0,0 +1,52 @@
name: 🚀 New feature proposal
description: Propose a new feature/enhancement
labels: ['enhancement', 'triage']
body:
- type: markdown
attributes:
value: |
Thanks for your interest in the project and taking the time to fill out this feature report!
- type: dropdown
id: request-type
attributes:
label: What type of request is this?
options:
- New feature idea
- Enhancement of an existing feature
- Deployment or CI/CD improvement
- Hosting or Self-hosting improvement
- Related to documentation
- Related to the community
- Other
validations:
required: true
- type: textarea
id: feature-description
attributes:
label: Clear and concise description of the feature you are proposing
description: A clear and concise description of what the feature is.
placeholder: 'Example: I would like to see...'
validations:
required: true
- type: textarea
id: additional-context
attributes:
label: Additional context
description: Any other context or screenshots about the feature request here.
- type: checkboxes
id: checkboxes
attributes:
label: Validations
description: Before submitting the issue, please make sure you do the following
options:
- label: Check the feature is not already implemented in the project.
required: true
- label: Check that there isn't already an issue that request the same feature to avoid creating a duplicate.
required: true
- label: Check that the feature is technically feasible and aligns with the project's goals.
required: true

BIN
.github/papra-screenshot.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

View File

@@ -16,11 +16,13 @@ jobs:
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- run: corepack enable
- name: Install pnpm
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
corepack: true
cache: 'pnpm'
- name: Install dependencies

View File

@@ -16,11 +16,13 @@ jobs:
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- run: corepack enable
- name: Install pnpm
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
corepack: true
cache: 'pnpm'
- name: Install dependencies
@@ -36,5 +38,12 @@ jobs:
- name: Run unit test
run: pnpm test
# Ensure locales types are up to date, must be run before building the app
- name: Check locales types
run: |
pnpm script:generate-i18n-types
git diff --exit-code -- src/modules/i18n/locales.types.ts > /dev/null || (echo "Locales types are outdated, please run 'pnpm script:generate-i18n-types' and commit the changes." && exit 1)
- name: Build the app
run: pnpm build
run: pnpm build

View File

@@ -16,15 +16,19 @@ jobs:
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- run: corepack enable
- name: Install pnpm
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
corepack: true
cache: 'pnpm'
- name: Install dependencies
run: pnpm i --frozen-lockfile
run: |
pnpm i --frozen-lockfile
pnpm --filter "@papra/app-server^..." build
- name: Run linters
run: pnpm lint

View File

@@ -0,0 +1,41 @@
name: CI - Api SDK
on:
pull_request:
push:
branches:
- main
jobs:
ci-packages-api-sdk:
name: CI - Api SDK
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/api-sdk
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm i
- name: Run linters
run: pnpm lint
- name: Type check
run: pnpm typecheck
# - name: Run unit test
# run: pnpm test
- name: Build the app
run: pnpm build

44
.github/workflows/ci-packages-cli.yaml vendored Normal file
View File

@@ -0,0 +1,44 @@
name: CI - CLI
on:
pull_request:
push:
branches:
- main
jobs:
ci-packages-cli:
name: CI - CLI
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/cli
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm i
- name: Build related packages
run: cd ../api-sdk && pnpm build
- name: Run linters
run: pnpm lint
- name: Type check
run: pnpm typecheck
# - name: Run unit test
# run: pnpm test
- name: Build the app
run: pnpm build

View File

@@ -0,0 +1,41 @@
name: CI - Webhooks
on:
pull_request:
push:
branches:
- main
jobs:
ci-packages-webhooks:
name: CI - Webhooks
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/webhooks
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm i
- name: Run linters
run: pnpm lint
- name: Type check
run: pnpm typecheck
- name: Run unit test
run: pnpm test
- name: Build the app
run: pnpm build

View File

@@ -1,9 +1,12 @@
name: Release new versions
on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
version:
description: 'Version to release'
required: true
type: string
permissions:
contents: read
@@ -14,14 +17,6 @@ jobs:
name: Release Docker images
runs-on: ubuntu-latest
steps:
- name: Get release version from tag
if: ${{ github.event_name == 'push' }}
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
- name: Get release version from input
if: ${{ github.event_name == 'workflow_dispatch' }}
run: echo "RELEASE_VERSION=${{ github.event.inputs.release_version }}" >> $GITHUB_ENV
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -53,9 +48,9 @@ jobs:
push: true
tags: |
corentinth/papra:latest-root
corentinth/papra:${{ env.RELEASE_VERSION }}-root
ghcr.io/corentinth/papra:latest-root
ghcr.io/corentinth/papra:${{ env.RELEASE_VERSION }}-root
corentinth/papra:${{ inputs.version }}-root
ghcr.io/papra-hq/papra:latest-root
ghcr.io/papra-hq/papra:${{ inputs.version }}-root
- name: Build and push rootless Docker image
uses: docker/build-push-action@v6
@@ -65,7 +60,9 @@ jobs:
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
tags: |
corentinth/papra:latest
corentinth/papra:latest-rootless
corentinth/papra:${{ env.RELEASE_VERSION }}-rootless
ghcr.io/corentinth/papra:latest-rootless
ghcr.io/corentinth/papra:${{ env.RELEASE_VERSION }}-rootless
corentinth/papra:${{ inputs.version }}-rootless
ghcr.io/papra-hq/papra:latest
ghcr.io/papra-hq/papra:latest-rootless
ghcr.io/papra-hq/papra:${{ inputs.version }}-rootless

53
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,53 @@
name: Release
on:
push:
branches:
- main
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
release:
name: Release
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
id-token: write
actions: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm i
- name: Create Release Pull Request
id: changesets
uses: changesets/action@v1
with:
# Note: pnpm install after versioning is necessary to refresh lockfile
version: pnpm run version
publish: pnpm exec changeset publish
commit: "chore(release): update versions"
title: "chore(release): update versions"
env:
GITHUB_TOKEN: ${{ secrets.CHANGESET_GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Trigger Docker build
if: steps.changesets.outputs.published == 'true' && contains(fromJson(steps.changesets.outputs.publishedPackages).*.name, '@papra/app-server')
run: |
VERSION=$(echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[] | select(.name=="@papra/app-server") | .version')
echo "VERSION: $VERSION"
gh workflow run release-docker.yaml -f version="$VERSION"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

4
.gitignore vendored
View File

@@ -37,4 +37,6 @@ cache
*.sqlite
local-documents
.cursorrules
ingestion
.cursorrules
*.traineddata

1
CODEOWNERS Normal file
View File

@@ -0,0 +1 @@
* @papra-hq/papra-maintainers

128
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
<corentinth@proton.me>.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
<https://www.contributor-covenant.org/version/2/0/code_of_conduct.html>.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
<https://www.contributor-covenant.org/faq>. Translations are available at
<https://www.contributor-covenant.org/translations>.

157
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,157 @@
# Contributing to Papra
First off, thanks for taking the time to contribute to Papra! We welcome contributions of all types and encourage you to help make this project better for everyone.
## Code of Conduct
This project adheres to the [Contributor Covenant](https://www.contributor-covenant.org/). By participating, you are expected to uphold this code. Please report unacceptable behavior to <corentinth@proton.me>
## How Can I Contribute?
### Reporting Issues
If you find a bug, have a feature request, or need help, feel free to open an issue in the [GitHub Issue Tracker](https://github.com/papra-hq/papra/issues). You're also welcome to comment on existing issues.
### Submitting Pull Requests
Please refrain from submitting pull requests that implement new features or fix bugs without first opening an issue. This will help us avoid duplicate work and ensure that your contribution is in line with the project's goals and prevents wasted effort on your part.
We follow a **GitHub Flow** model where all PRs should target the `main` branch, which is continuously deployed to production.
**Guidelines for submitting PRs:**
- Each PR should be small and atomic. Please avoid solving multiple unrelated issues in a single PR.
- Ensure that the **CI is green** before submitting. Some of the following checks are automatically run for each package: linting, type checking, testing, and building.
- If your PR fixes an issue, please reference the issue number in the PR description.
- If your PR adds a new feature, please include tests and update the documentation if necessary.
- Be prepared to address feedback and iterate on your PR.
- Resolving merge conflicts is part of the PR author's responsibility.
- Draft PRs are welcome to get feedback early on your work but only when requested, they'll not be reviewed.
### Branching
- **Main branch**: This is the production branch. All pull requests must target this branch.
- **Feature branches**: Create a new branch for your feature (e.g., `my-new-feature`), make your changes, and then open a PR targeting `main`.
### Commit Guidelines
We use **[Conventional Commits](https://www.conventionalcommits.org/)** to keep commit messages consistent and meaningful. Please follow these guidelines when writing commit messages. While you can structure commits however you like, PRs will be squashed on merge.
## i18n
We welcome contributions to improve and expand the app's internationalization (i18n) support. Below are the guidelines for adding a new language or updating an existing translation.
### Adding a New Language
1. **Create a Language File**: To add a new language, create a YAML file named with the appropriate [ISO language code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) (e.g., `fr.yml` for French) in the [`apps/papra-client/src/locales`](./apps/papra-client/src/locales) directory.
2. **Use the Reference File**: Refer to the [`en.yml`](./apps/papra-client/src/locales/en.yml) file, which contains all keys used in the app. Use it as a base to ensure consistency when creating your new language file. And act as a fallback if a key is missing in the new language file.
3. **Update the Locale List**: After adding the new language file, include the language code in the `locales` array found in the [`apps/papra-client/src/modules/i18n/i18n.constants.ts`](./apps/papra-client/src/modules/i18n/i18n.constants.ts) file.
4. **Submit a Pull Request**: Once you've added the file and updated `i18n.constants.ts`, create a pull request (PR) with your changes. Ensure that your PR is clearly titled with the language being added (e.g., "Add French translations").
### Updating an Existing Language
If you want to update an existing language file, you can do so directly in the corresponding JSON file in the [`apps/papra-client/src/locales`](./apps/papra-client/src/locales) directory. If you're adding or removing keys in the default language file ([`en.yml`](./apps/papra-client/src/locales/en.yml)), please run the following command to update the types (used for type checking the translations keys in the app):
```bash
pnpm script:generate-i18n-types
```
- This command will update the file [`locales.types.ts`](./apps/papra-client/src/modules/i18n/locale.types.ts) with the new/removed keys.
- When developing in papra-client (using `pnpm dev`), **the i18n types definition will automatically update** when you touch the [`en.yml`](./apps/papra-client/src/locales/en.yml) file, so no need to run the command above.
> [!TIP]
> You can use the command `pnpm script:sync-i18n-key-order` to sync the order of the keys in the i18n files, it'll also add the missing keys as comments.
## Development Setup
### Local Environment Setup
We recommend running the app locally for development. Follow these steps:
1. Clone the repository and navigate inside the project directory.
```bash
git clone https://github.com/papra-hq/papra.git
cd papra
```
2. Install dependencies:
```bash
pnpm install
```
3. Start the development server for the backend:
```bash
cd apps/papra-server
# Run the migration script to create the database schema
pnpm migrate:up
# Start the server
pnpm dev
```
4. Start the frontend:
```bash
cd apps/papra-client
# Start the client
pnpm dev
```
5. Open your browser and navigate to `http://localhost:3000`.
### Testing
We use **Vitest** for testing. Each package comes with its own testing commands.
- To run the tests for any package:
```bash
pnpm test
```
- To run tests in watch mode:
```bash
pnpm test:watch
```
All new features must be covered by unit or integration tests. Be sure to use business-oriented test names (avoid vague descriptions like `it('should return true')`).
## Writing Documentation
If your code changes affect the documentation, you must update the docs. The documentation is powered by [**Astro Starlight**](https://starlight.astro.build/).
To start the documentation server for local development:
1. Navigate to the `packages/docs` directory:
```bash
cd apps/docs
```
2. Start the documentation server:
```bash
pnpm dev
```
3. Open your browser and navigate to `http://localhost:4321`.
## Coding Style
- Use functional programming where possible.
- Focus on clarity and maintainability over performance.
- Choose meaningful, relevant names for variables, functions, and components.
## Issue Labels
Look out for issues tagged as [**good first issue**](https://github.com/papra-hq/papra/issues?q=sort%3Aupdated-desc%20is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22) or [**PR welcome**](https://github.com/papra-hq/papra/issues?q=sort%3Aupdated-desc+is%3Aissue+state%3Aopen+label%3A%22PR+welcome%22) for tasks that are well-suited for new contributors. Feel free to comment on existing issues or create new ones.
## License
By contributing, you agree that your contributions will be licensed under the [AGPL3](./LICENSE), the same as the project itself.

View File

@@ -17,24 +17,34 @@
<a href="https://demo.papra.app">Demo</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://docs.papra.app">Docs</a>
<!-- <span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://docs.papra.app/self-hosting/docker">Self-hosting</a> -->
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://docs.papra.app/self-hosting/using-docker">Self-hosting</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://github.com/orgs/papra-hq/projects/2">Roadmap</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://dashboard.papra.app">Managed instance</a>
<a href="https://papra.app/discord">Discord</a>
<!-- <span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="https://dashboard.papra.app">Managed instance</a> -->
</p>
## Introduction
> [!IMPORTANT]
> **Papra** is currently in active development and is not yet ready for production use or self-hosting.
**Papra** is a minimalistic document management and archiving platform. It is designed to be simple to use and accessible to everyone. Papra is a plateform for long-term document storage and management, like a digital archive for your documents.
**Papra** is a minimalistic document management and archiving platform. It is designed to be simple to use and accessible to everyone. Papra is a platform for long-term document storage and management, like a digital archive for your documents.
Forget about that receipt of that gift you bought for your friend last year, or that warranty for your new phone. With Papra, you can easily store, forget, and retrieve your documents whenever you need them.
A live demo of the platform is available at [demo.papra.cc](https://demo.papra.cc) (no backend, client-side local storage only).
A live demo of the platform is available at [demo.papra.app](https://demo.papra.app) (no backend, client-side local storage only).
[![Papra](./.github/papra-screenshot.png)](https://demo.papra.app)
## Project Status
Papra is currently in **beta**. The core functionality is stable and usable, but you may encounter occasional bugs or limitations. The project is actively developed, with new features being added regularly.
- ✅ Core document management features are stable
- ✅ Self-hosting is fully supported
- 🚧 Some advanced features are still in development
- 📝 Feedback and bug reports are highly appreciated
## Features
@@ -45,28 +55,45 @@ A live demo of the platform is available at [demo.papra.cc](https://demo.papra.c
- **Dark Mode**: A dark theme for those late-night document management sessions.
- **Responsive Design**: Works on all devices, from desktops to mobile phones.
- **Open Source**: The project is open-source and free to use.
- *Coming soon:* **Self-hosting**: Host your own instance of Papra using Docker or other methods.
- *Coming soon:* **Tags**: Organize your documents with tags.
- *Coming soon:* **Tagging Rules**: Automatically tag documents based on custom rules.
- *Coming soon:* **OCR**: Automatically extract text from images or scanned documents for search.
- *Coming soon:* **i18n**: Support for multiple languages.
- *Coming soon:* **Email ingestion**: Forward emails to automatically import documents.
- *Coming soon:* **SDK and API**: Build your own applications on top of Papra.
- *Coming soon:* **CLI**: Manage your documents from the command line.
- **Self-hosting**: Host your own instance of Papra using Docker or other methods.
- **Tags**: Organize your documents with tags.
- **Email ingestion**: Send/forward emails to a generated address to automatically import documents.
- **Content extraction**: Automatically extract text from images or scanned documents for search.
- **Tagging Rules**: Automatically tag documents based on custom rules.
- **Folder ingestion**: Automatically import documents from a folder.
- **CLI**: Manage your documents from the command line.
- **API, SDK and webhooks**: Build your own applications on top of Papra.
- *In progress:* **i18n**: Support for multiple languages.
- *Coming soon:* **Document sharing**: Share documents with others.
- *Coming soon:* **Folder ingestion**: Automatically import documents from a folder.
- *Coming soon:* **Document requests**: Generate upload links for people to add documents.
- *Coming maybe one day:* **Mobile app**: Access and upload documents on the go.
- *Coming maybe one day:* **Desktop app**: Access and upload documents from your computer.
## Self-hosting
Papra is dedicated to providing a simple yet highly configurable self-hosting experience. Our lightweight Docker image (<200MB) is compatible with multiple architectures including x86, ARM64, and ARMv7.
For a quick start, simply run the following command:
```bash
docker run -d --name papra -p 1221:1221 ghcr.io/papra-hq/papra:latest
```
Please refer to the [self-hosting documentation](https://docs.papra.app/self-hosting/using-docker) for more information and configuration options.
## Contributing
*Coming soon*
Currently, the project is in heavy development and is not yet ready for contributions as changes are frequent and the architecture is not yet finalized. However, you can star the project to follow its progress.
Contributions are welcome! Please refer to the [`CONTRIBUTING.md`](./CONTRIBUTING.md) file for guidelines on how to get started, report issues, and submit pull requests.
You can find easy-to-pick-up tasks with the [`good first issue`](https://github.com/papra-hq/papra/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20label%3A%22good%20first%20issue%22) or [`PR welcome`](https://github.com/papra-hq/papra/issues?q=sort%3Aupdated-desc%20is%3Aissue%20is%3Aopen%20label%3A%22good%20first%20issue%22) labels.
## License
This project is licensed under the AGPL-3.0 License - see the [LICENSE](./LICENSE) file for details.
## Community
Join the community on [Papra's Discord server](https://papra.app/discord) to discuss the project, ask questions, or get help.
## Credits
This project is crafted with ❤️ by [Corentin Thomasset](https://corentin.tech).
@@ -76,7 +103,7 @@ If you find this project helpful, please consider [supporting my work](https://b
### Stack
Enclosed would not have been possible without the following open-source projects:
Papra would not have been possible without the following open-source projects:
- **Frontend**
- **[SolidJS](https://www.solidjs.com)**: A declarative JavaScript library for building user interfaces.
@@ -87,7 +114,19 @@ Enclosed would not have been possible without the following open-source projects
- **Backend**
- **[HonoJS](https://hono.dev/)**: A small, fast, and lightweight web framework for building APIs.
- **[Drizzle](https://orm.drizzle.team/)**: A simple and lightweight ORM for Node.js.
- **[Better Auth](https://better-auth.com/)**: A simple and lightweight authentication library for Node.js.
- And other dependencies listed in the **[server package.json](./apps/papra-server/package.json)**
- **Documentation**
- **[Astro](https://astro.build)**: A great static site generator.
- **[Starlight](https://starlight.astro.build)**: A module for Astro that provides a starting point for building documentation websites.
- **[HiDeoo/starlight-theme-rapide](https://github.com/HiDeoo/starlight-theme-rapide)**: A theme for Starlight.
- **Project**
- **[PNPM Workspaces](https://pnpm.io/workspaces)**: A monorepo management tool.
- **[Github Actions](https://github.com/features/actions)**: For CI/CD.
- **Infrastructure**
- **[Cloudflare Pages](https://pages.cloudflare.com/)**: For static site hosting.
- **[Render](https://render.com/)**: For backend hosting.
- **[Turso](https://turso.tech/)**: For production database.
### Inspiration

35
SECURITY.md Normal file
View File

@@ -0,0 +1,35 @@
# Security Policy
Security is critically important to Papra. We actively welcome responsible disclosure of any vulnerabilities found in our platform.
## Reporting a Vulnerability
If you discover a security issue within Papra, please email us directly at **security@papra.app** with the following details:
- Clear description of the vulnerability.
- Steps or proof-of-concept to reproduce the vulnerability.
- Potential impact or implications of the vulnerability.
We ask you **not to publicly disclose the vulnerability** until we have had a reasonable opportunity to address it.
## Response and Communication
We will:
- Acknowledge receipt of your report within **48 hours**.
- Investigate and provide initial feedback within **5 business days**.
- Work diligently to fix validated vulnerabilities.
- Keep you updated throughout the process until the issue is resolved.
## Security Practices at Papra
Papra follows industry-standard security practices:
- Secure hosting infrastructure provided by trusted services (Render, Cloudflare, Turso).
- Regular security and dependency updates.
- Strict access controls to production environments.
- Encryption of data in transit and at rest.
## Acknowledgments
We greatly appreciate and acknowledge all researchers who responsibly report vulnerabilities, helping us keep Papra secure.

View File

@@ -1,6 +1,5 @@
# build output
dist/
# generated types
.astro/
@@ -13,12 +12,10 @@ yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/

21
apps/docs/CHANGELOG.md Normal file
View File

@@ -0,0 +1,21 @@
# @papra/docs
## 0.4.1
### Patch Changes
- [#320](https://github.com/papra-hq/papra/pull/320) [`8ccdb74`](https://github.com/papra-hq/papra/commit/8ccdb748349a3cacf38f032fd4d3beebce202487) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added base url configuration in docker compose generator
## 0.4.0
### Minor Changes
- [#295](https://github.com/papra-hq/papra/pull/295) [`438a311`](https://github.com/papra-hq/papra/commit/438a31171c606138c4b7fa299fdd58dcbeaaf298) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added support for custom oauth2 providers
- [#293](https://github.com/papra-hq/papra/pull/293) [`53bf93f`](https://github.com/papra-hq/papra/commit/53bf93f128b54ad1d3553e18680c87ab23155f8d) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added a papra docker-compose.yml generator
## 0.3.1
### Patch Changes
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix broken lint and added auto link check

View File

@@ -1,16 +1,22 @@
# Papra docs
# Papra - Docs website
## Introduction
This is the documentation website for [Papra](https://papra.app).
This Papra documentation website. It is built with Astro, Unocss, solidjs and shadcn-solid.
This website is built using [Astro](https://astro.build), and based on the [Starlight](https://starlight.astro.build) module styled with [HiDeoo/starlight-theme-rapide](https://github.com/HiDeoo/starlight-theme-rapide) theme.
## Getting started
## Development
To get started, you can clone the repository and run the development server.
To start the development server, run:
```bash
git clone https://github.com/papra-hq/papra.git
# Navigate to the docs directory
cd apps/docs
pnpm i
# Install dependencies
pnpm install
# Start the development server
pnpm dev
```
The development server will start at [http://localhost:4321](http://localhost:4321).

View File

@@ -1,24 +1,65 @@
import sitemap from '@astrojs/sitemap';
import { env } from 'node:process';
import starlight from '@astrojs/starlight';
import { defineConfig } from 'astro/config';
import starlightLinksValidator from 'starlight-links-validator';
import starlightThemeRapide from 'starlight-theme-rapide';
import UnoCSS from 'unocss/astro';
import { sidebar } from './src/content/navigation';
import posthogRawScript from './src/scripts/posthog.script.js?raw';
const posthogApiKey = env.POSTHOG_API_KEY;
const posthogApiHost = env.POSTHOG_API_HOST ?? 'https://eu.i.posthog.com';
const isPosthogEnabled = Boolean(posthogApiKey);
const posthogScript = posthogRawScript.replace('[POSTHOG-API-KEY]', posthogApiKey ?? '').replace('[POSTHOG-API-HOST]', posthogApiHost);
// https://astro.build/config
export default defineConfig({
site: 'https://docs.papra.app',
integrations: [
UnoCSS({
injectReset: true,
}),
sitemap(),
],
markdown: {
shikiConfig: {
themes: {
light: 'vitesse-light',
dark: 'vitesse-dark',
UnoCSS(),
starlight({
plugins: [starlightThemeRapide(), starlightLinksValidator({ exclude: ['http://localhost:1221'] })],
title: 'Papra Docs',
logo: {
dark: './src/assets/logo-dark.svg',
light: './src/assets/logo-light.svg',
alt: 'Papra Logo',
},
},
},
social: [
{ href: 'https://github.com/papra-hq/papra', icon: 'github', label: 'GitHub' },
{ href: 'https://bsky.app/profile/papra.app', icon: 'blueSky', label: 'BlueSky' },
{ href: 'https://papra.app/discord', icon: 'discord', label: 'Discord' },
],
expressiveCode: {
themes: ['vitesse-black', 'vitesse-light'],
},
editLink: {
baseUrl: 'https://github.com/papra-hq/papra/edit/main/apps/docs/',
},
sidebar,
favicon: '/favicon.svg',
head: [
// Add ICO favicon fallback for Safari.
{
tag: 'link',
attrs: {
rel: 'icon',
href: '/favicon.ico',
sizes: '32x32',
},
},
...(isPosthogEnabled
? [
{
tag: 'script',
content: posthogScript,
} as const,
]
: []),
],
customCss: ['./src/assets/app.css'],
}),
],
});

View File

@@ -7,6 +7,10 @@ export default antfu({
semi: true,
},
ignores: [
'src/scripts/posthog.script.js',
],
rules: {
// To allow export on top of files
'ts/no-use-before-define': ['error', { allowNamedExports: true, functions: false }],

1382
apps/docs/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,15 @@
{
"name": "@papra/docs",
"type": "module",
"version": "0.0.1-beta.1",
"version": "0.4.1",
"private": true,
"packageManager": "pnpm@10.9.0",
"description": "Papra documentation website",
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
"license": "AGPL-3.0-or-later",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
@@ -12,23 +18,29 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@astrojs/sitemap": "^3.2.1",
"astro": "^5.1.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"markdown-to-txt": "^2.0.1",
"minisearch": "^7.1.1",
"@astrojs/solid-js": "^5.1.0",
"@astrojs/starlight": "^0.34.3",
"astro": "^5.8.0",
"sharp": "^0.32.5",
"shiki": "^3.4.2",
"starlight-links-validator": "^0.16.0",
"starlight-theme-rapide": "^0.5.0",
"tailwind-merge": "^2.6.0",
"unstorage": "^1.14.4"
"unocss-preset-animations": "^1.2.1",
"yaml": "^2.8.0",
"zod-to-json-schema": "^3.24.5"
},
"devDependencies": {
"@antfu/eslint-config": "^3.12.2",
"@antfu/eslint-config": "^3.13.0",
"@iconify-json/tabler": "^1.1.120",
"@types/lodash-es": "^4.17.12",
"@unocss/reset": "^0.64.0",
"eslint": "^9.17.0",
"eslint-plugin-astro": "^1.3.1",
"typescript": "^5.7.2",
"unocss": "0.65.0-beta.2",
"unocss-preset-animations": "^1.1.0"
"figue": "^2.2.2",
"lodash-es": "^4.17.21",
"marked": "^15.0.6",
"typescript": "^5.7.3",
"unocss": "0.65.0-beta.2"
}
}

1342
apps/docs/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
/*
X-Frame-Options: DENY
X-Content-Type-Options: nosniff

View File

@@ -0,0 +1,96 @@
:root[data-theme='dark'] {
--background: 240 4% 10%;
--foreground: 0 0% 98%;
--card: 240 4% 8%;
--card-foreground: 0 0% 98%;
--popover: 240 4% 8%;
--popover-foreground: 0 0% 98%;
--primary: 77 100% 74%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--warning: 31 98% 50%;
--warning-foreground: 0 0% 98%;
--border: 345 4% 17%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
--background-color: #0c0d0f!important;
--accent-color: #fff!important;
--foreground-color: #9ea3a2!important;
--sl-color-white: #f3f7f6!important;
--surface: #0a0b0d!important;
--sl-color-text: var(--foreground-color)!important;
--sl-color-text-accent: var(--accent-color)!important;
--sl-color-accent-high: var(--accent-color)!important;
--sl-color-bg: var(--background-color)!important;
--sl-rapide-ui-header-bg-color: var(--background-color)!important;
--sl-color-bg-sidebar: var(--background-color)!important;
}
.sl-link-card {
background-color: var(--surface)!important;
}
.hero .sl-link-button {
background-color: var(--sl-color-text)!important;
border-color: transparent!important;
color: var(--sl-color-bg)!important;
border-radius: 0.8rem!important;
transition: opacity 0.2s ease-in-out!important;
font-weight: 500!important;
}
:root[data-theme='dark'] .hero .sl-link-button {
background-color: var(--accent-color)!important;
color: var(--background-color)!important;
}
.hero .sl-link-button:hover {
opacity: 0.8!important;
}
#_top {
padding-top: 10px!important;
padding-bottom: 30px!important;
}
.site-title {
color:inherit !important;
gap: 0.5rem !important;
}
:root[data-theme='dark'] .site-title {
color:#fff !important;
}
.site-title img {
width: 1.8rem !important;
}
pre.shiki {
border-radius: 0.5rem!important;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><g fill="none" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2zM9 9h1m-1 4h6m-6 4h6"/></g></svg>

After

Width:  |  Height:  |  Size: 318 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><g fill="none" stroke="#403a3a" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2zM9 9h1m-1 4h6m-6 4h6"/></g></svg>

After

Width:  |  Height:  |  Size: 318 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View File

@@ -1,41 +0,0 @@
---
import type { HTMLAttributes } from 'astro/types';
import { cn } from '@/utils/cn';
import { cva, type VariantProps } from 'class-variance-authority';
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium focus-visible:(outline-none ring-1.5 ring-ring) disabled:(pointer-events-none opacity-50) bg-inherit',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:(bg-accent text-accent-foreground)',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 px-3 text-xs',
lg: 'h-10 px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export type Props<T extends 'a' | 'button' = 'button'> = { as?: T } & VariantProps<typeof buttonVariants> & HTMLAttributes<T>;
const { as: Element = 'button', class: className, variant, size, ...props } = Astro.props;
---
<Element class={cn(buttonVariants({ variant, size }), className)} {...props}>
<slot />
</Element>

View File

@@ -1,40 +0,0 @@
---
import { createStorage } from 'unstorage';
import fsLiteDriver from 'unstorage/drivers/fs-lite';
import Button from './Button.astro';
const repo = 'papra-hq/papra';
const repoUrl = `https://github.com/${repo}`;
const apiUrl = `https://ungh.cc/repos/${repo}`;
const storage = createStorage<{ stars: number; formattedStars: string }>({
driver: fsLiteDriver({ base: './.cache/stars' }),
});
async function getStars() {
const cachedStars = await storage.getItem(repo);
if (cachedStars) {
return cachedStars;
}
const stars: number = await fetch(apiUrl)
.then(res => res.json())
.then(data => data?.repo?.stars);
const formattedStars = new Intl.NumberFormat('en-US', { notation: 'compact' }).format(stars).toLowerCase();
await storage.setItem(repo, { stars, formattedStars });
return { stars, formattedStars };
}
const { stars, formattedStars } = await getStars();
---
<Button as="a" variant="outline" href={repoUrl} target="_blank" rel="noopener noreferrer" class="flex items-center gap-2" size={stars ? undefined : 'icon'} aria-label="GitHub repository">
<div class="i-tabler-brand-github size-4.5"></div>
{stars && <span>{formattedStars}</span>}
</Button>

View File

@@ -1,45 +0,0 @@
---
import Button from './Button.astro';
import GitHubStarsButton from './GitHubStarsButton.astro';
import ToggleThemeButton from './ToggleThemeButton.astro';
---
<nav class="flex justify-between items-center py-4">
<div class="flex items-center gap-2">
<Button variant="ghost" aria-label="Toggle menu" size="icon" class="sm:hidden" data-open-sidebar>
<div class="i-tabler-menu-2 size-4"></div>
</Button>
<Button variant="outline" class="justify-start items-center gap-2 md:min-w-60 bg-card" data-open-search-modal>
<div class="i-tabler-search size-4"></div>
<span class="flex-1 text-left">Search...</span>
</Button>
</div>
<div class="hidden sm:flex items-center gap-2">
<Button as="a" href="https://demo.papra.app" target="_blank" rel="noreferrer" variant="ghost" class="text-foreground flex items-center gap-2">
Demo app
<div class="i-tabler-external-link size-4"></div>
</Button>
<GitHubStarsButton />
<ToggleThemeButton />
</div>
<div class="flex sm:hidden items-center gap-2">
<Button as="a" href="https://github.com/papra-hq/papra" target="_blank" rel="noreferrer" variant="ghost" size="icon" aria-label="GitHub repository">
<div class="i-tabler-brand-github size-4.5"></div>
</Button>
</div>
</nav>
<script>
const searchButton = document.querySelector('[data-open-search-modal]')!;
// @ts-expect-error navigator.userAgentData is not supported in all browsers
const isMac = navigator.userAgentData?.platform?.toLowerCase().includes('mac') ?? navigator.userAgent.toLowerCase().includes('mac');
const shortcut = document.createElement('span');
shortcut.textContent = isMac ? '⌘ + K' : 'Ctrl + K';
shortcut.classList.add('text-xs', 'text-muted-foreground', 'px-1.5', 'py-0.5', 'bg-muted', 'rounded-sm', 'hidden', 'md:inline');
searchButton.appendChild(shortcut);
</script>

View File

@@ -1,37 +0,0 @@
---
import type { HTMLAttributes } from 'astro/types';
import { cn } from '@/utils/cn';
type Props = {
slug: string;
title: string;
direction: 'prev' | 'next';
} & HTMLAttributes<'a'>;
const { slug: rawSlug, title, direction, class: className, ...props } = Astro.props;
const slug = `/${rawSlug.replace(/^\//, '')}`;
const variants
= direction === 'prev'
? {
textAlign: 'text-left',
labelWrapper: 'justify-start',
icon: 'i-tabler-arrow-left',
label: 'Previous',
}
: {
textAlign: 'text-right',
labelWrapper: 'flex-row-reverse',
icon: 'i-tabler-arrow-right',
label: 'Next',
};
---
<a href={slug} class={cn('w-full p-6 bg-card rounded-lg border border-border hover:bg-accent hover:text-accent-foreground', variants.textAlign, className)} {...props}>
<div class={cn('flex items-center justify-start gap-2 text-sm text-muted-foreground', variants.labelWrapper)}>
<div class={cn('size-4', variants.icon)}></div>
<span>{variants.label}</span>
</div>
<div class="text-base font-semibold">{title}</div>
</a>

View File

@@ -1,188 +0,0 @@
---
import Button from './Button.astro';
---
<div class="fixed inset-0 bg-black backdrop-blur-sm bg-opacity-50 flex justify-center items-center z-60 hidden!" role="dialog" aria-labelledby="search-title" aria-hidden="true" id="search-modal">
<div class="absolute inset-0" role="button" tabindex="0" aria-label="Close Search" data-close-search-modal></div>
<div class="bg-card border rounded-lg max-w-lg w-full z-10">
<header class="flex items-center border-b py-1.5 pl-4 pr-1.5 gap-3">
<div class="i-tabler-search size-4"></div>
<div class="flex-1">
<label for="search-input" class="sr-only">Search Query</label>
<input
type="text"
id="search-input"
class="bg-transparent border-none focus:ring-none outline-none w-full text-base"
placeholder="Type to search..."
aria-describedby="search-results"
autofocus
/>
</div>
<Button variant="ghost" aria-label="Close" data-close-search-modal size="icon">
<div class="i-tabler-x size-4"></div>
</Button>
</header>
<div class="text-muted-foreground text-center pt-8 pb-4" id="no-results">No results found</div>
<ul id="search-results" class="flex flex-col gap-2 p-2" role="list" />
</div>
</div>
<script>
import MiniSearch, { type SearchResult } from 'minisearch';
// eslint-disable-next-line antfu/no-top-level-await
const docsIndex = await fetch('/search.json').then(res => res.json());
type Doc = {
title: string;
description: string;
slug: string;
};
const miniSearch = MiniSearch.loadJS<Doc>(docsIndex, {
idField: 'slug',
fields: ['title', 'description', 'content'],
storeFields: ['title', 'description', 'slug'],
searchOptions: {
fuzzy: 0.2,
},
});
const modalContainer = document.getElementById('search-modal')!;
const resultsList = document.getElementById('search-results')!;
const noResults = document.getElementById('no-results')!;
const searchInput = document.getElementById('search-input')!;
const closeSearch = document.querySelectorAll('[data-close-search-modal]');
const openSearch = document.querySelectorAll('[data-open-search-modal]');
let currentIndex = -1;
let filteredResults: SearchResult[] = [];
function openModal() {
modalContainer.setAttribute('aria-hidden', 'false');
modalContainer.classList.remove('hidden!');
searchInput.focus();
}
function closeModal() {
modalContainer.setAttribute('aria-hidden', 'true');
modalContainer.classList.add('hidden!');
}
function filterResults(event: Event) {
const query = (event.target as HTMLInputElement).value.toLowerCase();
resultsList.innerHTML = '';
currentIndex = -1;
filteredResults = miniSearch
.search(query, {
boost: { title: 2 },
prefix: true,
})
.slice(0, 10);
if (filteredResults.length === 0) {
noResults.style.display = 'block';
currentIndex = -1;
} else {
currentIndex = 0;
noResults.style.display = 'none';
const resultItems = filteredResults.map((item, index) => {
const li = document.createElement('li');
li.className = 'py-1.5 px-3 rounded cursor-pointer hover:bg-accent';
li.dataset.index = String(index);
const titleDiv = document.createElement('div');
titleDiv.className = 'text-base font-semibold';
titleDiv.textContent = item.title;
li.appendChild(titleDiv);
const contentDiv = document.createElement('div');
contentDiv.className = 'text-muted-foreground';
contentDiv.textContent = item.description;
li.appendChild(contentDiv);
li.onclick = () => navigateTo(item.slug);
resultsList.appendChild(li);
return li;
});
highlightResult(resultItems);
}
}
function handleKeyDown(event: KeyboardEvent) {
const resultItems = Array.from(resultsList.querySelectorAll('li'));
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
if (currentIndex < filteredResults.length - 1) {
currentIndex++;
highlightResult(resultItems);
}
break;
case 'ArrowUp':
event.preventDefault();
if (currentIndex > 0) {
currentIndex--;
highlightResult(resultItems);
}
break;
case 'Enter':
if (currentIndex >= 0 && filteredResults.length > 0) {
event.preventDefault();
selectResult(currentIndex);
}
break;
case 'Escape':
closeModal();
break;
}
}
function highlightResult(resultItems: Element[]) {
resultItems.forEach((item, index) => {
if (index === currentIndex) {
item.classList.add('bg-accent');
item.scrollIntoView({ block: 'nearest' });
} else {
item.classList.remove('bg-accent');
}
});
}
function selectResult(index: number) {
const selectedResult = filteredResults[index];
navigateTo(selectedResult.slug);
closeModal();
}
function navigateTo(slug: string) {
const url = slug === 'index' ? '/' : `/${slug}`;
window.location.href = url;
}
// open modal on Ctrl/Cmd + K
document.addEventListener('keydown', (event) => {
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
event.preventDefault();
openModal();
}
});
searchInput.addEventListener('input', event => filterResults(event));
searchInput.addEventListener('keydown', event => handleKeyDown(event));
openSearch.forEach(button => button.addEventListener('click', openModal));
closeSearch.forEach(button => button.addEventListener('click', closeModal));
</script>

View File

@@ -1,81 +0,0 @@
---
import Button from './Button.astro';
const navigation: { title: string; links: { title: string; href: string }[] }[] = [
{
title: 'Getting Started',
links: [
{ title: 'Introduction', href: '/' },
],
},
{
title: 'Self-hosting',
links: [
{ title: 'Using Docker', href: '/self-hosting/using-docker' },
{ title: 'Using Docker Compose', href: '/self-hosting/using-docker-compose' },
],
},
{
title: 'Configuration',
links: [
{ title: 'Environment variables', href: '/configuration/environment-variables' },
],
},
];
---
<aside id="sidebar" class="bg-card flex-1 justify-end sm:sticky top-0 h-screen border-r sm:flex absolute top-0 left-0 z-50 -translate-x-full sm:translate-x-0 transition-transform duration-200 ease-in-out">
<div class="min-w-260px p-4">
<a href="/" class="flex items-center gap-2 px-3 py-1.5">
<div class="i-tabler-file-text size-6"></div>
<div class="text-lg font-semibold">Papra Docs</div>
</a>
<div class="flex flex-col gap-0.5 mt-6">
{
navigation?.map(section => (
<div class="mb-6">
<div class="text-sm pl-3 py-1.5 font-semibold">{section.title}</div>
<ul class="flex flex-col ">
{section.links.map(link => (
<li>
<Button as="a" href={link.href} class="w-full flex justify-start p-0 py-1.5 pl-3 h-auto text-muted-foreground hover:bg-accent hover:text-accent-foreground" variant="ghost">
{link.title}
</Button>
</li>
))}
</ul>
</div>
))
}
</div>
</div>
</aside>
<div id="overlay" class="fixed inset-0 bg-black/40 z-40 sm:hidden transition-opacity duration-200 ease-in-out opacity-0 pointer-events-none backdrop-blur-sm"></div>
<script>
const menuButtons = document.querySelectorAll('[data-open-sidebar]');
const sidebar = document.querySelector<HTMLDivElement>('#sidebar')!;
const overlay = document.querySelector('#overlay')!;
function openSidebar() {
sidebar.classList.remove('-translate-x-full');
sidebar.classList.add('translate-x-0');
overlay.classList.remove('opacity-0');
overlay.classList.remove('pointer-events-none');
}
function closeSidebar() {
sidebar.classList.remove('translate-x-0');
sidebar.classList.add('-translate-x-full');
overlay.classList.add('opacity-0');
overlay.classList.add('pointer-events-none');
}
menuButtons.forEach(button => button.addEventListener('click', openSidebar));
overlay.addEventListener('click', closeSidebar);
</script>

View File

@@ -1,32 +0,0 @@
---
import type { MarkdownHeading } from 'astro';
import Button from './Button.astro';
type Props = {
headings?: MarkdownHeading[];
};
const { headings: allHeadings } = Astro.props;
const headings = allHeadings?.filter(item => item.depth > 1);
---
{
headings && headings.length > 0 && (
<>
<div class="text-sm font-semibold flex items-center gap-2">
<div class="i-tabler-align-justified size-4" />
On this page
</div>
<ul class="flex flex-col gap-0.5 mt-2 border-l pl-3 ml-2 py-1">
{headings.map(item => (
<li>
<Button as="a" href={`#${item.slug}`} class={`w-full flex justify-start p-0 py-0.5 h-auto text-muted-foreground pl-${(item.depth - 2) * 4}`} variant="link">
{item.text}
</Button>
</li>
))}
</ul>
</>
)
}

View File

@@ -1,25 +0,0 @@
---
import Button from './Button.astro';
---
<Button variant="outline" size="icon" class="toggle-theme-button" aria-label="Toggle theme">
<div class="i-tabler-moon dark:i-tabler-sun size-4.5!"></div>
</Button>
<script>
const theme = localStorage.getItem('theme');
if (theme) {
document.body.dataset.kbTheme = theme;
} else {
const isDarkPreferred = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.body.dataset.kbTheme = isDarkPreferred ? 'dark' : 'light';
}
const toggleThemeButtons = document.querySelectorAll('.toggle-theme-button');
toggleThemeButtons.forEach((button) => {
button.addEventListener('click', () => {
document.body.dataset.kbTheme = document.body.dataset.kbTheme === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', document.body.dataset.kbTheme);
});
});
</script>

View File

@@ -0,0 +1,88 @@
import type { ConfigDefinition, ConfigDefinitionElement } from 'figue';
import { isArray, isEmpty, isNil } from 'lodash-es';
import { configDefinition } from '../../papra-server/src/modules/config/config';
function walk(configDefinition: ConfigDefinition, path: string[] = []): (ConfigDefinitionElement & { path: string[] })[] {
return Object
.entries(configDefinition)
.flatMap(([key, value]) => {
if ('schema' in value) {
return [{ ...value, path: [...path, key] }] as (ConfigDefinitionElement & { path: string[] })[];
}
return walk(value, [...path, key]);
});
}
const configDetails = walk(configDefinition);
function formatDoc(doc: string | undefined): string {
const coerced = (doc ?? '').trim();
if (coerced.endsWith('.')) {
return coerced;
}
return `${coerced}.`;
}
const rows = configDetails
.filter(({ path }) => path[0] !== 'env')
.map(({ doc, default: defaultValue, env, path }) => {
const isEmptyDefaultValue = isNil(defaultValue) || (isArray(defaultValue) && isEmpty(defaultValue)) || defaultValue === '';
const rawDocumentation = formatDoc(doc);
// The client baseUrl default value is overridden in the Dockerfiles
const defaultOverride = path.join('.') === 'client.baseUrl' ? 'http://localhost:1221' : undefined;
return {
path,
env,
documentation: rawDocumentation,
defaultValue: defaultOverride ?? (isEmptyDefaultValue ? undefined : defaultValue),
};
});
const mdSections = rows.map(({ documentation, env, path, defaultValue }) => `
### ${env}
${documentation}
- Path: \`${path.join('.')}\`
- Environment variable: \`${env}\`
- Default value: \`${defaultValue}\`
`.trim()).join('\n\n---\n\n');
function wrapText(text: string, maxLength = 75) {
const words = text.split(' ');
const lines: string[] = [];
let currentLine = '';
words.forEach((word) => {
if ((currentLine + word).length + 1 <= maxLength) {
currentLine += (currentLine ? ' ' : '') + word;
} else {
lines.push(currentLine);
currentLine = word;
}
});
if (currentLine) {
lines.push(currentLine);
}
return lines.map(line => `# ${line}`);
}
const fullDotEnv = rows.map(({ env, defaultValue, documentation }) => {
const isEmptyDefaultValue = isNil(defaultValue) || (isArray(defaultValue) && isEmpty(defaultValue)) || defaultValue === '';
return [
...wrapText(documentation),
`# ${env}=${isEmptyDefaultValue ? '' : defaultValue}`,
].join('\n');
}).join('\n\n');
export { fullDotEnv, mdSections };

View File

@@ -0,0 +1,10 @@
import { docsLoader } from '@astrojs/starlight/loaders';
import { docsSchema } from '@astrojs/starlight/schema';
import { defineCollection } from 'astro:content';
export const collections = {
docs: defineCollection({
loader: docsLoader(),
schema: docsSchema(),
}),
};

View File

@@ -1,13 +0,0 @@
import { glob } from 'astro/loaders';
import { defineCollection, z } from 'astro:content';
export const docsCollection = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/docs' }),
schema: z.object({
title: z.string(),
description: z.string(),
slug: z.string(),
}),
});
export const collections = { docs: docsCollection };

View File

@@ -0,0 +1,111 @@
---
title: Installing Papra using Docker
description: Self-host Papra using Docker.
slug: self-hosting/using-docker
---
import { Steps } from '@astrojs/starlight/components';
Papra provides optimized Docker images for streamlined deployment. This method is recommended for users seeking a production-ready setup with minimal maintenance overhead.
- **Simplified management**: Single container handles all components
- **Lightweight**: Optimized image sizes across architectures
- **Cross-platform support**: Compatible with `arm64`, `arm/v7`, and `x86_64` systems
- **Security options**: Supports both rootless (recommended) and rootful configurations
## Prerequisites
Ensure Docker is installed on your host system. Official installation guides are available at:
[docker.com/get-started](https://www.docker.com/get-started)
Verify Docker installation with:
```bash
docker --version
```
## Quick Deployment
Launch Papra with default configuration using:
```bash
docker run -d \
--name papra \
--restart unless-stopped \
-p 1221:1221 \
ghcr.io/papra-hq/papra:latest
```
This command will:
1. Pull the latest rootless image from GitHub Container Registry
2. Expose the web interface on [http://localhost:1221](http://localhost:1221)
3. Configure automatic restarts for service continuity
## Image Variants
Choose between two security models based on your requirements:
- **Rootless**: Tagged as `latest`, `latest-rootless` or `<version>-rootless` (like `0.2.1-rootless`). Recommended for most users.
- **Root**: Tagged as `latest-root` or `<version>-root` (like `0.2.1-root`). Only use if you need to run Papra as the root user.
The `:latest` tag always references the latest rootless build.
## Persistent Data Configuration
For production deployments, mount host directories to preserve application data between container updates.
<Steps>
1. Create Storage Directories
Create a directory for Papra data `./papra-data`, with `./papra-data/db` and `./papra-data/documents` subdirectories:
```bash
mkdir -p ./papra-data/{db,documents}
```
2. Launch Container with Volume Binding
```bash
docker run -d \
--name papra \
--restart unless-stopped \
-p 1221:1221 \
-v $(pwd)/papra-data:/app/app-data \
--user $(id -u):$(id -g) \
ghcr.io/papra-hq/papra:latest
```
This configuration:
- Maintains data integrity across container lifecycle events
- Enforces proper file ownership without manual permission adjustments
- Stores both database files and document assets persistently
</Steps>
## Image Registries
Papra images are distributed through multiple channels:
**Primary Source (GHCR):**
```bash
docker pull ghcr.io/papra-hq/papra:latest
docker pull ghcr.io/papra-hq/papra:latest-rootless
docker pull ghcr.io/papra-hq/papra:latest-root
```
**Community Mirror (Docker Hub):**
```bash
docker pull corentinth/papra:latest
docker pull corentinth/papra:latest-rootless
docker pull corentinth/papra:latest-root
```
## Updating Papra
Regularly pull updated images and recreate containers to receive security patches and feature updates.
```bash
docker pull ghcr.io/papra-hq/papra:latest
# Or
docker pull corentinth/papra:latest
```

View File

@@ -0,0 +1,103 @@
---
title: Using Docker Compose
slug: self-hosting/using-docker-compose
---
import { Steps } from '@astrojs/starlight/components';
This guide covers how to deploy Papra using Docker Compose, ideal for users who prefer declarative configurations or plan to integrate Papra into a broader service stack.
Using Docker Compose provides:
- A single, versioned configuration file
- Easy integration with volumes, networks, and service dependencies
- Simplified updates and re-deployments
This method supports both `rootless` and `rootful` Papra images, please refer to the [Docker](/self-hosting/using-docker) guide for more information about the difference between the two. The following example uses the recommended `rootless` setup.
## Prerequisites
Ensure Docker and Docker Compose are installed on your host system. Official installation guides are available at: [docker.com/get-started](https://www.docker.com/get-started)
Verify Docker installation with:
```bash
docker --version
docker compose version
```
<Steps>
1. Initialize Project Structure
Create working directory and persistent storage subdirectories:
```bash
mkdir -p papra/app-data/{db,documents} && cd papra
```
2. Create Docker Compose file
Create a file named `docker-compose.yml` with the following content:
```yaml
services:
papra:
container_name: papra
image: ghcr.io/papra-hq/papra:latest
restart: unless-stopped
ports:
- "1221:1221"
volumes:
- ./app-data:/app/app-data
user: "${UID}:${GID}"
```
3. Start Papra
From the directory containing your `docker-compose.yml` file, run:
```bash
UID=$(id -u) GID=$(id -g) docker compose up -d
```
This command downloads the latest Papra image, sets up the container, and starts the Papra service. The `UID` and `GID` variables are used to set the user and group for the container, ensuring proper file ownership. If you don't want to use the `UID` and `GID` variables, you can replace the image with the rootful variant.
4. Access Papra
Once your container is running, access Papra via your browser at:
```
http://localhost:1221
```
Your Papra instance is now ready for use!
5. To go further
Check the [configuration](/self-hosting/configuration) page for more information on how to configure your Papra instance.
</Steps>
## Maintenance
Check logs
```bash
docker compose logs -f
```
Stop the service
```bash
docker compose down
```
Update Papra
```bash
docker compose pull
docker compose up -d
```
You're all set! Enjoy managing your documents with Papra.

View File

@@ -0,0 +1,88 @@
---
title: Configuration
slug: self-hosting/configuration
---
import { mdSections, fullDotEnv } from '../../../config.data.ts';
import { marked } from 'marked';
import { Tabs, TabItem } from '@astrojs/starlight/components';
import { Aside } from '@astrojs/starlight/components';
import { Code } from '@astrojs/starlight/components';
Configuring your self hosted Papra allows you to customize the application to better suit your environment and requirements. This guide covers the key environment variables you can set to control various aspects of the application, including port settings, security options, and storage configurations.
## Configuration files
You can configure Papra using standard environment variables or use some configuration files.
Papra uses [c12](https://github.com/unjs/c12) to load configuration files and [figue](https://github.com/CorentinTh/figue) to validate and merge environment variables and configuration files.
The [c12](https://github.com/unjs/c12) allows you to use the file format you want. The configuration file should be named `papra.config.[ext]` and should be located in the root of the project or in `/app/app-data/` directory in docker container (it can be changed using `PAPRA_CONFIG_DIR` environment variable).
The supported formats are: `json`, `jsonc`, `json5`, `yaml`, `yml`, `toml`, `js`, `ts`, `cjs`, `mjs`.
Example of configuration files:
<Tabs>
<TabItem label="papra.config.yaml">
```yaml
server:
baseUrl: https://papra.example.com
corsOrigins: *
client:
baseUrl: https://papra.example.com
auth:
secret: your-secret-key
isRegistrationEnabled: true
# ...
```
</TabItem>
<TabItem label="papra.config.json">
```json
{
"$schema": "https://docs.papra.com/papra-config-schema.json",
"server": {
"baseUrl": "https://papra.example.com"
},
"client": {
"baseUrl": "https://papra.example.com"
},
"auth": {
"secret": "your-secret-key",
"isRegistrationEnabled": true
}
}
```
<Aside type="tip">
When using an IDE, you can use the [papra-config-schema.json](/papra-config-schema.json) file to get autocompletion for the configuration file. Just add a `$schema` property to your configuration file and point it to the schema file.
```json
{
"$schema": "https://docs.papra.com/papra-config-schema.json",
// ...
}
```
</Aside>
</TabItem>
</Tabs>
You'll find the complete list of configuration variables with their environment variables equivalents and path for files in the next section.
## Complete .env
Here is the full configuration file that you can use to configure Papra. The variables values are the default values.
<Code code={fullDotEnv} language="env" title=".env" />
## Configuration variables
Here is the complete list of configuration variables that you can use to configure Papra. You can set these variables in the `.env` file or pass them as environment variables when running the Docker container.
<Fragment set:html={marked.parse(mdSections)} />

View File

@@ -0,0 +1,77 @@
---
title: Setup Intake Emails with OwlRelay
description: Step-by-step guide to setup OwlRelay to receive emails in your Papra instance.
slug: guides/intake-emails-with-owlrelay
---
import { Aside } from '@astrojs/starlight/components';
import { Steps } from '@astrojs/starlight/components';
This guide will show you how to setup Papra to receive emails using [OwlRelay](https://owlrelay.email).
[OwlRelay](https://owlrelay.email) is an open-source agnostic email-to-http service developed by the Papra team. It's a free service that allows you to receive emails and forward them to your self-hosted Papra instance.
## Prerequisites
In order to follow this guide, your Papra instance needs to be running and accessible from the internet.
## How it works
By integrating Papra with OwlRelay, your instance will generate email addresses on OwlRelay through it's API (specifying a webhook on your Papra instance). And OwlRelay will forward the emails to your Papra instance through a webhook.
![How it works](../../../assets/owlrelay-intake-email.png)
## Setup
<Steps>
1. **Create an account on OwlRelay**
Go to [owlrelay.email](https://owlrelay.email) and create an account.
2. **Create an OwlRelay API key**
Once you have created your account, you can create an API key by going to the [API keys page](https://app.owlrelay.email/api-keys).
![OwlRelay API keys page](../../../assets/owlrelay-api-keys.png)
3. **Configure your Papra instance**
Once you have created your API key, you can configure your Papra instance to receive emails by setting the `OWLRELAY_API_KEY` and `OWLRELAY_WEBHOOK_SECRET` environment variables.
```bash
# Enable intake emails
INTAKE_EMAILS_IS_ENABLED=true
# Tell your Papra instance to use OwlRelay
INTAKE_EMAILS_DRIVER=owlrelay
# This is your OwlRelay API key
OWLRELAY_API_KEY=owrl_*****
# Set a random key that will be transmitted to OwlRelay to sign the requests,
# this is to authenticate that the emails are coming from OwlRelay
INTAKE_EMAILS_WEBHOOK_SECRET=a-random-key
# [Optional]
# This is the URL that OwlRelay will send the emails to,
# if not provided, the webhook will be inferred from the server URL.
# Can be relevant if you have multiple urls pointing to your Papra instance
# or when using tunnel services
OWLRELAY_WEBHOOK_URL=https://your-instance.com/api/intake-emails/ingest
```
4. **That's it!**
You can now generate intake emails in your Papra instance and send emails with attachments to the email address generated.
</Steps>
## Troubleshooting
If you encounter any issues, you can:
- Check the logs of your Papra instance to see if there are any errors.
- Connect to your OwlRelay account to see if the emails address are generated and emails are received.

View File

@@ -0,0 +1,132 @@
---
title: Setup Intake Emails with CF Email Workers
description: Step-by-step guide to setup a Cloudflare Email worker to receive emails in your Papra instance.
slug: guides/intake-emails-with-cloudflare-email-workers
---
import { Aside } from '@astrojs/starlight/components';
import { Steps } from '@astrojs/starlight/components';
This guide will show you how to setup a Cloudflare Email worker to receive emails in your Papra instance.
<Aside type="note">
Setting up a Papra Intake Emails with a Cloudflare Email worker requires bit of knowledge about how to setup a CF Email worker and how to configure your Papra instance to receive emails.
For a simpler solution, you can use the official [OwlRelay integration](/guides/intake-emails-with-owlrelay) guide.
</Aside>
## Prerequisites
In order to follow this guide, you need:
- a [Cloudflare](https://www.cloudflare.com/) account
- a custom domain name available on Cloudflare
- a publicly accessible Papra instance
- basic development skills (git and node.js to setup the Email Worker)
If you prefer a simpler solution, you can use the official [OwlRelay integration](/guides/intake-emails-with-owlrelay) guide.
## How it works
In order to receive emails in your Papra instance, we need to convert the email to an HTTP request. This is currently done by setting up a Cloudflare Email Worker that will forward the email to your Papra instance, basically acting as a bridge between the email and your Papra instance.
The code for the Email Worker proxy is available in the [papra-hq/email-proxy](https://github.com/papra-hq/email-proxy) repository.
![diagram](../../../assets/cf-intake-email.light.png)
## Setup
<Steps>
1. **Create the Email Worker**
There are two ways to create an Email Worker, either from the Cloudflare dashboard or by cloning and deploying the code from the [papra-hq/email-proxy](https://github.com/papra-hq/email-proxy) repository.
- **Option 1**: From the Cloudflare dashboard (easier).
- Go to the [Cloudflare dashboard](https://dash.cloudflare.com/).
- Select your domain.
- Go to the `Compute (Workers)` tab.
- Click on the `Create Worker` button.
- Name your worker (e.g. `email-proxy`).
- Copy the code from the [index.js](https://github.com/papra-hq/email-proxy/releases/latest/download/index.js) file (from the [papra-hq/email-proxy](https://github.com/papra-hq/email-proxy) repository) and paste it in the editor.
- **Option 2**: Build and deploy the Email Worker
Clone the [papra-hq/email-proxy](https://github.com/papra-hq/email-proxy) repository and deploy the worker using Wrangler cli. You will need to have Node.js v22 and pnpm installed.
```bash
# Clone the repository
git clone https://github.com/papra-hq/email-proxy.git
# Change directory
cd email-proxy
# Install dependencies
pnpm install
# Build the worker
pnpm build
# Deploy the worker (you will be prompted to login to Cloudflare through wrangler)
pnpm deploy
```
2. **Configure the Email Worker**
Add the following environment variables to the worker:
- `WEBHOOK_URL`: The email intake endpoint in your Papra instance (basically. `https://<your-papra-instance.com>/api/intake-emails/ingest`).
- `WEBHOOK_SECRET`: The secret key to authenticate the webhook requests, set the same as the `INTAKE_EMAILS_WEBHOOK_SECRET` environment variable in your Papra instance.
3. **Configure your Papra instance**
In your Papra instance, add the following environment variables:
```bash
# Enable intake emails
INTAKE_EMAILS_IS_ENABLED=true
# Tell your Papra instance that it can generate any email address from the
# domain you setup in the Email Worker as it's a wildcard redirection
INTAKE_EMAILS_DRIVER=random-username
# This is the domain from which the intake email will be generated
# eg. `domain.com`
INTAKE_EMAILS_EMAIL_GENERATION_DOMAIN=papra.email
# This is the secret key to authenticate the webhook requests
# set the same as the `WEBHOOK_SECRET` variable in the Email Worker
INTAKE_EMAILS_WEBHOOK_SECRET=a-random-key
```
4. **Configure the Email Routing**
- Go to the `Email Routing` tab in the Cloudflare dashboard.
- Follow the email onboarding process.
- Add a catch-all rule to forward all emails to the Email Worker you created.
![email-routing](../../../assets/cf-catchall-config.png)
5. **Test the setup**
In your Papra instance, go to the `Integrations` page in your organization and generates an intake email URL, setup an allowed sender (basically your email address), and copy the generated email address. Send an email to the generated address with a file attached, and check if the file is uploaded to your Papra instance.
</Steps>
## Troubleshooting
### Email Worker not receiving emails
If the Email Worker is not receiving emails, make sure that the email routing is correctly configured in the Cloudflare dashboard.
Also, check the [logs in the Email Worker dashboard](https://developers.cloudflare.com/workers/observability/logs/real-time-logs/) for any errors.
### Papra instance returning 403 Forbidden
If your Papra instance is returning a `403 Forbidden` error, make sure that the `INTAKE_EMAILS_IS_ENABLED` environment variable is set to `true` in your Papra instance.
### Papra instance is returning 401 Unauthorized
If your Papra instance is returning a `401 Unauthorized` error, make sure that the `INTAKE_EMAILS_WEBHOOK_SECRET` environment variable is correctly set in your Papra instance.
### The worker is not forwarding the email to the Papra instance
Make sure that the `WEBHOOK_URL` and `WEBHOOK_SECRET` environment variables are correctly set in the Email Worker, and the `INTAKE_EMAILS_WEBHOOK_SECRET` environment variable is correctly set in your Papra instance.
Also, check the [logs in the Email Worker dashboard](https://developers.cloudflare.com/workers/observability/logs/real-time-logs/) or the logs in your Papra instance for any errors.

View File

@@ -0,0 +1,105 @@
---
title: Setup Ingestion Folder
description: Step-by-step guide to setup an ingestion folder to automatically ingest documents into your Papra instance.
slug: guides/setup-ingestion-folder
---
import { Steps } from '@astrojs/starlight/components';
import { Aside } from '@astrojs/starlight/components';
import { FileTree } from '@astrojs/starlight/components';
The ingestion folder is a special folder that is watched by Papra for new files. When a new file is added to the ingestion folder, Papra will automatically import it.
## Multi-Organization Structure
Papra supports multiple organizations within a single instance, each requiring a dedicated ingestion folder. The ingestion system uses a hierarchical structure where:
<FileTree>
- ingestion-folder
- org_abc123
- document.pdf
- report.docx
- org_def456
- file.txt
- foo.txt # Ignored as it's not in an organization
</FileTree>
This allows you to have a single instance of Papra watching multiple organizations' ingestion folders.
<Aside>
Files and folders that are within the `ingestion-root-folder` but not within an organization folder are ignored.
</Aside>
## Setup
Add the following to your `docker-compose.yml` file:
```yaml title="docker-compose.yml" ins={9,12}
services:
papra:
container_name: papra
image: ghcr.io/papra-hq/papra:latest
restart: unless-stopped
ports:
- "1221:1221"
environment:
- INGESTION_FOLDER_IS_ENABLED=true
volumes:
- ./app-data:/app/app-data
- <your-ingestion-folder>:/app/ingestion
user: "${UID}:${GID}"
```
Then add files to a folder named with the organization id (available in Papra URL, e.g. `https://papra.example.com/organizations/<organization-id>`, the format is `org_<random>`).
```bash
mkdir -p <your-ingestion-folder>/<org_id>
touch <your-ingestion-folder>/<org_id>/hello.txt
```
## Post-processing
Once a file has been ingested in your Papra organization, you can configure what happens to it by setting the `INGESTION_FOLDER_POST_PROCESSING_STRATEGY` environment variable. There are two strategies:
- `delete`: The file is deleted from the ingestion folder (default strategy)
- `move`: The file is moved to the `INGESTION_FOLDER_POST_PROCESSING_MOVE_FOLDER_PATH` folder (default: `./ingestion-done`)
Note that the `INGESTION_FOLDER_POST_PROCESSING_MOVE_FOLDER_PATH` path is relative to the organization ingestion folder.
So with `INGESTION_FOLDER_POST_PROCESSING_MOVE_FOLDER_PATH=ingestion-done`, the file `<ingestion-folder>/<org_id>/file.pdf` will be moved to `<ingestion-folder>/<org_id>/ingestion-done/file.pdf` once ingested.
## Safeguards
To avoid accidental data loss, if for some reason the ingestion fails, the file is moved to the `INGESTION_FOLDER_ERROR_FOLDER_PATH` folder (default: `./ingestion-error`).
<Aside>
As for the `INGESTION_FOLDER_POST_PROCESSING_MOVE_FOLDER_PATH`, the `INGESTION_FOLDER_ERROR_FOLDER_PATH` path is relative to the organization ingestion folder.
</Aside>
## Polling
By default, Papra uses native file watchers to detect changes in the ingestion folder. On some OS (like Windows), this can be flaky with Docker. To avoid this issue, you can enable polling by setting the `INGESTION_FOLDER_WATCHER_USE_POLLING` environment variable to `true`.
The default polling interval is 2 seconds, you can change it by setting the `INGESTION_FOLDER_WATCHER_POLLING_INTERVAL_MS` environment variable.
```yaml title="docker-compose.yml" ins={2-3}
environment:
- INGESTION_FOLDER_WATCHER_USE_POLLING=true
- INGESTION_FOLDER_WATCHER_POLLING_INTERVAL_MS=2000
```
## Configuration
You can find the list of all configuration options in the [configuration reference](/self-hosting/configuration), the related variables are prefixed with `INGESTION_FOLDER_`.
## Edge cases and behaviors
- The ingestion folder is watched recursively.
- Files in the ingestion folder `done` and `error` folders are ignored.
- When a file from the ingestion folder is already present (and not in the trash) in the organization, no ingestion is done, but the file is post-processed (deleted or moved) as successfully ingested.
- When a file is moved to "done" or "error" folder
- If a file with the same name and same content is present in the destination folder, the original file is deleted
- If a file with the same name but different content is present in the destination folder, the original file is moved and a timestamp is added to the filename
- Some files are ignored by default (`.DS_Store`, `Thumbs.db`, `desktop.ini`, etc.) see [ingestion-folders.constants.ts](https://github.com/papra-hq/papra/blob/main/apps/papra-server/src/modules/ingestion-folders/ingestion-folders.constants.ts) for the list of ignored files and patterns. You can change this by setting the `INGESTION_FOLDER_IGNORED_PATTERNS` environment variable.

View File

@@ -0,0 +1,181 @@
---
title: Setup Custom OAuth2 Providers
description: Step-by-step guide to setup custom OAuth2 providers for authentication in your Papra instance.
slug: guides/setup-custom-oauth2-providers
---
import { Aside } from '@astrojs/starlight/components';
import { Steps } from '@astrojs/starlight/components';
This guide will show you how to configure custom OAuth2 providers for authentication in your Papra instance.
<Aside type="note">
Papra's OAuth2 implementation is based on the [Better Auth Generic OAuth plugin](https://www.better-auth.com/docs/plugins/generic-oauth). For more detailed information about the configuration options and advanced usage, please refer to their documentation.
</Aside>
## Prerequisites
In order to follow this guide, you need:
- A custom OAuth2 provider
- An accessible Papra instance
- Basic understanding of OAuth2 flows
## Configuration
To set up custom OAuth2 providers, you'll need to configure the `AUTH_PROVIDERS_CUSTOMS` environment variable with an array of provider configurations. Here's an example:
```bash
AUTH_PROVIDERS_CUSTOMS='[
{
"providerId": "custom-oauth2",
"providerName": "Custom OAuth2",
"providerIconUrl": "https://api.iconify.design/tabler:login-2.svg",
"clientId": "your-client-id",
"clientSecret": "your-client-secret",
"type": "oidc",
"discoveryUrl": "https://your-provider.tld/.well-known/openid-configuration",
"scopes": ["openid", "profile", "email"]
}
]'
```
Each provider configuration supports the following fields:
- `providerId`: A unique identifier for the OAuth provider
- `providerName`: The display name of the provider
- `providerIconUrl`: URL of the icon to display (optional) you can use a base64 encoded image or an url to a remote image.
- `clientId`: OAuth client ID
- `clientSecret`: OAuth client secret
- `type`: Type of OAuth flow ("oauth2" or "oidc")
- `discoveryUrl`: URL to fetch OAuth 2.0 configuration (recommended for OIDC providers)
- `authorizationUrl`: URL for the authorization endpoint (required for OAuth2 if not using discoveryUrl)
- `tokenUrl`: URL for the token endpoint (required for OAuth2 if not using discoveryUrl)
- `userInfoUrl`: URL for the user info endpoint (required for OAuth2 if not using discoveryUrl)
- `scopes`: Array of OAuth scopes to request
- `redirectURI`: Custom redirect URI (optional)
- `responseType`: OAuth response type (defaults to "code")
- `prompt`: Controls the authentication experience ("select_account", "consent", "login", "none")
- `pkce`: Whether to use PKCE (Proof Key for Code Exchange)
- `accessType`: Access type for the authorization request
## Setup
<Steps>
1. **Configure your OAuth2 Provider**
First, you'll need to register your application with your OAuth2 provider. This typically involves:
- Creating a new application in your provider's dashboard
- Setting up the redirect URI (usually `https://<your-papra-instance>/api/auth/oauth2/callback/:providerId`)
- Obtaining the client ID and client secret
- Configuring the required scopes
2. **Configure Papra**
Add the `AUTH_PROVIDERS_CUSTOMS` environment variable to your Papra instance. Here are some examples:
For OIDC providers:
```json
[
{
"providerId": "custom-oauth2",
"providerName": "Custom OAuth2",
"providerIconUrl": "https://api.iconify.design/tabler:login-2.svg",
"clientId": "your-client-id",
"clientSecret": "your-client-secret",
"type": "oidc",
"discoveryUrl": "https://your-provider.tld/.well-known/openid-configuration",
"scopes": ["openid", "profile", "email"]
}
]
```
For standard OAuth2 providers:
```json
[
{
"providerId": "custom-oauth2",
"providerName": "Custom OAuth2",
"providerIconUrl": "https://api.iconify.design/tabler:login-2.svg",
"clientId": "your-client-id",
"clientSecret": "your-client-secret",
"type": "oauth2",
"authorizationUrl": "https://your-provider.tld/oauth2/authorize",
"tokenUrl": "https://your-provider.tld/oauth2/token",
"userInfoUrl": "https://your-provider.tld/oauth2/userinfo",
"scopes": ["profile", "email"]
}
]
```
<Aside type="note">
The `discoveryUrl` is recommended for OIDC providers as it automatically configures all the necessary endpoints.
For standard OAuth2 providers, you'll need to specify the endpoints manually.
</Aside>
3. **Test the Configuration**
- Restart your Papra instance to apply the changes
- Go to the login page
- You should see your custom OAuth2 providers as login options
- Try logging in with a test account
</Steps>
## Troubleshooting
### Providers Not Showing Up
If your OAuth2 providers are not showing up on the login page:
- Check that the JSON configuration in `AUTH_PROVIDERS_CUSTOMS` is valid
- Ensure all required fields are provided
- Verify that the provider IDs are unique
### Authentication Fails
If authentication fails:
- Verify that the redirect URI is correctly configured in your OAuth2 provider
- Check that the client ID and client secret are correct
- Ensure the required scopes are properly configured
- Check the Papra logs for any error messages
### OIDC Discovery Issues
If you're using OIDC and experiencing issues:
- Verify that the `discoveryUrl` is accessible
- Check that the provider supports OIDC discovery
- Ensure the provider's configuration is properly exposed through the discovery endpoint
## Security Considerations
<Aside type="caution">
Always use HTTPS for your OAuth2 endpoints and ensure your client secret is kept secure.
Consider using PKCE (Proof Key for Code Exchange) for additional security by setting `pkce: true` in your configuration.
</Aside>
## Multiple Providers
You can configure multiple custom OAuth2 providers by adding them to the array:
```json
[
{
"providerId": "custom-oauth2-1",
"providerName": "Custom OAuth2 Provider 1",
"type": "oidc",
"discoveryUrl": "https://provider1.tld/.well-known/openid-configuration",
"clientId": "client-id-1",
"clientSecret": "client-secret-1",
"scopes": ["openid", "profile", "email"]
},
{
"providerId": "custom-oauth2-2",
"providerName": "Custom OAuth2 Provider 2",
"type": "oidc",
"discoveryUrl": "https://provider2.tld/.well-known/openid-configuration",
"clientId": "client-id-2",
"clientSecret": "client-secret-2",
"scopes": ["openid", "profile", "email"]
}
]
```

View File

@@ -0,0 +1,89 @@
---
title: CLI Documentation
description: Learn how to use the Papra CLI to interact with your Papra instance from the command line.
slug: resources/cli
---
The Papra CLI is a command-line interface tool that helps you interact with the Papra platform from your terminal.
## Installation
For the moment, the CLI is only available as an NPM package.
```bash
# using pnpm
pnpm i -g @papra/cli
# or using npm
npm i -g @papra/cli
# or using yarn
yarn add -g @papra/cli
```
The CLI will be installed globally, so you can use it from anywhere in your system with the `papra` command.
## Configuration
Before using the CLI, you need to configure it with your API credentials.
### Initial Setup
To initialize the configuration, run:
```bash
papra config init
```
This command will prompt you for:
- **Instance URL**: Your Papra instance URL (e.g., `https://api.papra.app`)
- **API Key**: Your personal API key (can be created in your User Settings)
### Managing Configuration
You can manage your configuration using the following commands:
- `papra config list`: View your current configuration
- `papra config set api-key`: Set or update your API key
- `papra config set api-url`: Set or update your instance URL
- `papra config set default-org-id`: Set a default organization ID
### Organization IDs
Since Papra supports multiple organizations, you may need to specify the organization ID when importing documents for example. If want, you can set a default organization ID in your configuration.
```bash
papra config set default-org-id <organization-id>
papra documents import <file-path>
# or
papra documents import -o <organization-id> <file-path>
```
## Available Commands
### Importing documents
The `import` command allows you to import a document into your Papra organization.
```bash
papra documents import -o <organization-id> <file-path>
```
## Getting Help
For more information about any command, you can use the `--help` flag:
```bash
papra --help
papra config --help
papra documents --help
```
## About the CLI
The CLI is built using the [citty](https://github.com/unjs/citty) framework and the [Papra TS SDK](https://github.com/papra-hq/papra/tree/main/packages/api-sdk).

View File

@@ -0,0 +1,74 @@
---
title: Papra documentation
description: Papra documentation.
hero:
title: Papra Docs
tagline: Documentation for Papra, the minimalistic document archiving platform.
image:
alt: A glittering, brightly colored logo
dark: ../../assets/logo-dark.svg
light: ../../assets/logo-light.svg
actions:
- text: Self-hosting guide
link: /self-hosting/using-docker
icon: right-arrow
variant: primary
---
import { LinkCard } from '@astrojs/starlight/components';
Welcome to the official documentation of Papra, an intuitive open-source document management and archiving platform. It is designed to be simple to use and accessible to everyone. Papra is a plateform for long-term document storage and management, like a digital archive for your documents.
Papra can be used in two different ways:
- As a **self-hosted solution**, using the fully packaged lightweight [Docker image](/self-hosting/using-docker).
- As a **fully managed solution**, using our cloud service available on [papra.app](https://papra.app).
<div style="margin-top: 40px">
<LinkCard
title="Get started"
description="Learn how to self-host Papra using Docker."
href="/self-hosting/using-docker"
/>
</div>
## Why Papra?
In today's digital world, managing countless important documents efficiently and securely has become crucial. Papra helps you effortlessly keep track of everything from personal files to critical business records, providing peace of mind and enhancing productivity through a robust yet user-friendly system.
## Features
- **Document management**: Upload, store, and manage your documents in one place.
- **Organizations**: Create organizations to manage documents with family, friends, or colleagues.
- **Search**: Quickly search for documents with full-text search.
- **Authentication**: User accounts and authentication.
- **Dark Mode**: A dark theme for those late-night document management sessions.
- **Responsive Design**: Works on all devices, from desktops to mobile phones.
- **Open Source**: The project is open-source and free to use.
- **Self-hosting**: Host your own instance of Papra using Docker or other methods.
- **Tags**: Organize your documents with tags.
- **Email ingestion**: Send/forward emails to a generated address to automatically import documents.
- **Content extraction**: Automatically extract text from images or scanned documents for search.
- **Tagging Rules**: Automatically tag documents based on custom rules.
- **Folder ingestion**: Automatically import documents from a folder.
- **API, SDK and webhooks**: Build your own applications on top of Papra.
- **CLI**: Manage your documents from the command line.
- *In progress:* **i18n**: Support for multiple languages.
- *Coming soon:* **Document sharing**: Share documents with others.
- *Coming soon:* **Document requests**: Generate upload links for people to add documents.
## Community & Open Source
Papra is proudly open-source under the [AGPL3](https://github.com/papra-hq/papra/blob/main/LICENSE) license. You can contribute to the project by reporting issues, suggesting features, or even contributing code.
<div style="margin-top: 40px">
<LinkCard
title="Get started"
description="Learn how to self-host Papra using Docker."
href="/self-hosting/using-docker"
/>
</div>

View File

@@ -0,0 +1,56 @@
import type { StarlightUserConfig } from '@astrojs/starlight/types';
export const sidebar: StarlightUserConfig['sidebar'] = [
{
label: 'Getting Started',
items: [
{ label: 'Introduction', slug: '' },
],
},
{
label: 'Self Hosting',
items: [
{ label: 'Using Docker', slug: 'self-hosting/using-docker' },
{ label: 'Using Docker Compose', slug: 'self-hosting/using-docker-compose' },
{ label: 'Docker Compose Generator', link: '/docker-compose-generator', badge: { text: 'new', variant: 'note' } },
{ label: 'Configuration', slug: 'self-hosting/configuration' },
],
},
{
label: 'Guides',
items: [
{
label: 'Setup intake emails with OwlRelay',
slug: 'guides/intake-emails-with-owlrelay',
},
{
label: 'Setup intake emails with CF Email Workers',
slug: 'guides/intake-emails-with-cloudflare-email-workers',
},
{
label: 'Setup Ingestion Folder',
slug: 'guides/setup-ingestion-folder',
},
{
label: 'Setup Custom OAuth2 Providers',
slug: 'guides/setup-custom-oauth2-providers',
},
],
},
{
label: 'Resources',
items: [
{
label: 'CLI Documentation',
slug: 'resources/cli',
},
{
label: 'Security Policy',
link: 'https://github.com/papra-hq/papra/blob/main/SECURITY.md',
attrs: {
target: '_blank',
},
},
],
},
];

View File

@@ -0,0 +1,379 @@
---
import { codeToHtml } from 'shiki';
const images = {
GitHub: 'ghcr.io/papra-hq/papra',
DockerHub: 'corentinth/papra',
};
const defaultDockerCompose = `
services:
papra:
image: ghcr.io/papra-hq/papra:latest
container_name: papra
restart: unless-stopped
ports:
- 1221:1221
environment:
- AUTH_SECRET=change-me
- CLIENT_BASE_URL=http://localhost:1221
- SERVER_BASE_URL=http://localhost:1221
volumes:
- ./app-data:/app/app-data
user: 1000:1000
`.trim();
const dcHtml = await codeToHtml(defaultDockerCompose, { theme: 'vitesse-black', lang: 'yaml' });
---
<p>This tool will help you generate a custom docker-compose.yml file for Papra, tailored to your needs. You can personalize the service name, the port, the auth secret, and the source image.</p>
<h2 class="mt-8 mb-2">General settings</h2>
<div class="flex items-center gap-2 mt-1">
<label for="port" class="min-w-32">External port</label>
<input id="port" class="input-field" value="1221" type="number" min="1024" max="65535" placeholder="eg: 1221" />
</div>
<div class="flex items-center gap-2 mt-1">
<label for="app-base-url" class="min-w-32">App base URL</label>
<input id="app-base-url" class="input-field" type="text" placeholder="eg: https://papra.example.com" value="http://localhost:1221" />
</div>
<div class="flex items-center gap-2 mt-1">
<label for="source" class="min-w-32">Image source</label>
<select class="input-field mt-0" id="source">
{Object.entries(images).map(([registry, imageName]) => <option class="bg-background" value={imageName}>{`${registry} - ${imageName}`}</option>)}
</select>
</div>
<div class="flex items-center gap-2 mt-1">
<label for="service-name" class="min-w-32">Service Name</label>
<input id="service-name" class="input-field" value="papra" type="text" placeholder="eg: papra" />
</div>
<div class="flex items-center gap-2 mt-1">
<label
for="auth-secret"
class="min-w-32"
>
Auth secret
</label>
<div class="flex items-center gap-2 mt-0 w-full">
<input class="input-field font-mono" id="auth-secret" type="text" placeholder="eg: 1234567890" />
<button class="btn bg-muted" id="refresh-secret"> Refresh </button>
</div>
</div>
<div class="flex items-center gap-2 mt-1">
<label for="volume-path" class="min-w-32">Volume path</label>
<input id="volume-path" class="input-field" value="./app-data" type="text" placeholder="eg: ./app-data" />
</div>
<div class="flex items-center gap-2 mt-1">
<label for="privileged-mode" class="min-w-32">Privileged mode</label>
<div class="flex items-center gap-2 mt-0 w-full">
<select class="input-field mt-0" id="privileged-mode">
<option value="false" class="bg-background">Rootless</option>
<option value="true" class="bg-background">Root</option>
</select>
</div>
</div>
<h2 class="mt-8 mb-2">Ingestion folder</h2>
<div class="flex items-center gap-2 mt-1">
<label for="ingestion-enabled" class="min-w-32">Enable ingestion</label>
<div class="flex items-center gap-2 mt-0 w-full">
<select class="input-field mt-0" id="ingestion-enabled">
<option value="false" class="bg-background">Disabled</option>
<option value="true" class="bg-background">Enabled</option>
</select>
</div>
</div>
<div class="flex items-center gap-2 mt-1" id="ingestion-path-container" style="display: none;">
<label for="ingestion-path" class="min-w-32">Ingestion path</label>
<input id="ingestion-path" class="input-field" value="./ingestion" type="text" placeholder="eg: ./ingestion" />
</div>
<h2 class="mt-8 mb-2">Intake emails</h2>
<div class="flex items-center gap-2 mt-1">
<label for="intake-email-enabled" class="min-w-32">Enabled</label>
<div class="flex items-center gap-2 mt-0 w-full">
<select class="input-field mt-0" id="intake-email-enabled">
<option value="false" class="bg-background">Disabled</option>
<option value="true" class="bg-background">Enabled</option>
</select>
</div>
</div>
<div class="flex items-center gap-2 mt-1" id="intake-email-driver-container" style="display: none;">
<label for="intake-email-driver" class="min-w-32">Driver</label>
<div class="flex items-center gap-2 mt-0 w-full">
<select class="input-field mt-0" id="intake-email-driver">
<option value="owlrelay" class="bg-background">OwlRelay</option>
<option value="random-username" class="bg-background">Cloudflare Email Worker</option>
</select>
</div>
</div>
<div id="intake-email-owlrelay-config" style="display: none;" class="mt-1">
<div class="flex items-center gap-2 mt-1">
<label for="intake-email-owlrelay-api-key" class="min-w-32">API Key</label>
<input id="intake-email-owlrelay-api-key" class="input-field" type="text" placeholder="owrl_*****" />
</div>
<div class="flex items-center gap-2 mt-1">
<label for="intake-email-owlrelay-webhook-url" class="min-w-32">Webhook URL</label>
<input id="intake-email-owlrelay-webhook-url" class="input-field" type="text" placeholder="https://your-instance.com/api/intake-emails/ingest" />
</div>
</div>
<div id="intake-email-cf-worker-config" style="display: none;" class="mt-1">
<div class="flex items-center gap-2 mt-1">
<label for="intake-email-cf-email-domain" class="min-w-32">Email domain</label>
<input id="intake-email-cf-email-domain" class="input-field" type="text" placeholder="papra.email" />
</div>
</div>
<div class="flex items-center gap-2 mt-1" id="intake-email-webhook-secret-container" style="display: none;">
<label for="intake-email-webhook-secret" class="min-w-32">Webhook secret</label>
<div class="flex items-center gap-2 mt-0 w-full">
<input class="input-field font-mono" id="intake-email-webhook-secret" type="text" placeholder="a-random-key" />
<button class="btn bg-muted" id="refresh-webhook-secret">Refresh</button>
</div>
</div>
<div id="docker-compose-output" class="mt-12" set:html={dcHtml} />
<div class="flex items-center gap-2 mt-4">
<button class="btn bg-muted mt-0" id="download-button">Download docker-compose.yml</button>
<button class="btn bg-muted mt-0" id="copy-button">Copy to clipboard</button>
</div>
<script>
import { codeToHtml } from 'shiki';
import { stringify } from 'yaml';
const portInput = document.getElementById('port') as HTMLInputElement;
const sourceSelect = document.getElementById('source') as HTMLSelectElement;
const serviceNameInput = document.getElementById('service-name') as HTMLInputElement;
const authSecretInput = document.getElementById('auth-secret') as HTMLInputElement;
const appBaseUrlInput = document.getElementById('app-base-url') as HTMLInputElement;
const refreshSecretButton = document.getElementById('refresh-secret');
const copyButton = document.getElementById('copy-button');
const dockerComposeOutput = document.getElementById('docker-compose-output');
const downloadButton = document.getElementById('download-button');
const volumePathInput = document.getElementById('volume-path') as HTMLInputElement;
const privilegedModeSelect = document.getElementById('privileged-mode') as HTMLSelectElement;
const ingestionEnabledSelect = document.getElementById('ingestion-enabled') as HTMLSelectElement;
const ingestionPathInput = document.getElementById('ingestion-path') as HTMLInputElement;
const ingestionPathContainer = document.getElementById('ingestion-path-container') as HTMLDivElement;
const intakeEmailEnabledSelect = document.getElementById('intake-email-enabled') as HTMLSelectElement;
const intakeDriverSelect = document.getElementById('intake-email-driver') as HTMLSelectElement;
const owlrelayConfig = document.getElementById('intake-email-owlrelay-config') as HTMLDivElement;
const cfWorkerConfig = document.getElementById('intake-email-cf-worker-config') as HTMLDivElement;
const owlrelayApiKeyInput = document.getElementById('intake-email-owlrelay-api-key') as HTMLInputElement;
const owlrelayWebhookUrlInput = document.getElementById('intake-email-owlrelay-webhook-url') as HTMLInputElement;
const cfEmailDomainInput = document.getElementById('intake-email-cf-email-domain') as HTMLInputElement;
const webhookSecretInput = document.getElementById('intake-email-webhook-secret') as HTMLInputElement;
const refreshWebhookSecretButton = document.getElementById('refresh-webhook-secret');
function getRandomString() {
const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
return Array.from({ length: 48 }, () => alphabet[Math.floor(Math.random() * alphabet.length)]).join('');
}
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text);
}
function getDockerComposeYml() {
const serviceName = serviceNameInput.value;
const isRootless = privilegedModeSelect.value === 'false';
const image = sourceSelect.value;
const port = portInput.value;
const authSecret = authSecretInput.value;
const volumePath = volumePathInput.value;
const isIngestionEnabled = ingestionEnabledSelect.value === 'true';
const ingestionPath = ingestionPathInput.value;
const intakeEmailEnabled = intakeEmailEnabledSelect.value === 'true';
const intakeDriver = intakeDriverSelect.value;
const webhookSecret = webhookSecretInput.value;
const appBaseUrl = appBaseUrlInput.value.trim();
const version = isRootless ? 'latest' : 'latest-root';
const fullImage = `${image}:${version}`;
// Determine base URLs
const clientBaseUrl = appBaseUrl || `http://localhost:${port}`;
const serverBaseUrl = appBaseUrl || `http://localhost:${port}`;
const environment = [
`AUTH_SECRET=${authSecret}`,
`CLIENT_BASE_URL=${clientBaseUrl}`,
`SERVER_BASE_URL=${serverBaseUrl}`,
isIngestionEnabled && 'INGESTION_FOLDER_IS_ENABLED=true',
intakeEmailEnabled && 'INTAKE_EMAILS_IS_ENABLED=true',
intakeEmailEnabled && `INTAKE_EMAILS_DRIVER=${intakeDriver}`,
intakeEmailEnabled && `INTAKE_EMAILS_WEBHOOK_SECRET=${webhookSecret}`,
intakeEmailEnabled && intakeDriver === 'owlrelay' && owlrelayApiKeyInput.value && `OWLRELAY_API_KEY=${owlrelayApiKeyInput.value}`,
intakeEmailEnabled && intakeDriver === 'owlrelay' && owlrelayWebhookUrlInput.value && `OWLRELAY_WEBHOOK_URL=${owlrelayWebhookUrlInput.value}`,
intakeEmailEnabled && intakeDriver === 'random-username' && cfEmailDomainInput.value && `INTAKE_EMAILS_EMAIL_GENERATION_DOMAIN=${cfEmailDomainInput.value}`,
].flat().filter(Boolean);
const volumes = [
`${volumePath}:/app/app-data`,
isIngestionEnabled && `${ingestionPath}:/app/ingestion`,
].filter(Boolean);
const dc = {
services: {
[serviceName]: {
image: fullImage,
container_name: serviceName,
restart: 'unless-stopped',
ports: [`${port}:1221`],
environment,
volumes,
...(isRootless && {
user: '1000:1000',
}),
},
},
};
return stringify(dc);
}
async function updateDockerCompose() {
const dockerCompose = getDockerComposeYml();
const html = await codeToHtml(dockerCompose, { theme: 'vitesse-black', lang: 'yaml' });
if (dockerComposeOutput) {
dockerComposeOutput.innerHTML = html;
}
}
function handleCopy() {
const dockerCompose = getDockerComposeYml();
copyToClipboard(dockerCompose);
if (copyButton) {
copyButton.textContent = 'Copied!';
}
setTimeout(() => {
if (copyButton) {
copyButton.textContent = 'Copy to clipboard';
}
}, 1000);
}
function handleRefreshSecret() {
authSecretInput.value = getRandomString();
updateDockerCompose();
}
function handleDownload() {
const dockerCompose = getDockerComposeYml();
const blob = new Blob([dockerCompose], { type: 'text/yaml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'docker-compose.yml';
a.click();
}
function handleIngestionEnabledChange() {
const isEnabled = ingestionEnabledSelect.value === 'true';
ingestionPathContainer.style.display = isEnabled ? 'flex' : 'none';
updateDockerCompose();
}
function handleIntakeEmailEnabledChange() {
const isEnabled = intakeEmailEnabledSelect.value === 'true';
const driverContainer = document.getElementById('intake-email-driver-container');
const webhookSecretContainer = document.getElementById('intake-email-webhook-secret-container');
if (driverContainer) {
driverContainer.style.display = isEnabled ? 'flex' : 'none';
}
if (webhookSecretContainer) {
webhookSecretContainer.style.display = isEnabled ? 'flex' : 'none';
}
if (!isEnabled) {
// Reset driver-specific configs when disabled
if (owlrelayConfig) {
owlrelayConfig.style.display = 'none';
}
if (cfWorkerConfig) {
cfWorkerConfig.style.display = 'none';
}
} else {
// Show the appropriate driver config
handleIntakeDriverChange();
}
updateDockerCompose();
}
function handleIntakeDriverChange() {
const driver = intakeDriverSelect.value;
const isEnabled = intakeEmailEnabledSelect.value === 'true';
if (!isEnabled) {
return;
}
if (owlrelayConfig) {
owlrelayConfig.style.display = driver === 'owlrelay' ? 'block' : 'none';
}
if (cfWorkerConfig) {
cfWorkerConfig.style.display = driver === 'random-username' ? 'block' : 'none';
}
updateDockerCompose();
}
function handleRefreshWebhookSecret() {
webhookSecretInput.value = getRandomString();
updateDockerCompose();
}
// Add event listeners
portInput.addEventListener('input', updateDockerCompose);
sourceSelect.addEventListener('change', updateDockerCompose);
serviceNameInput.addEventListener('input', updateDockerCompose);
authSecretInput.addEventListener('input', updateDockerCompose);
appBaseUrlInput.addEventListener('input', updateDockerCompose);
refreshSecretButton?.addEventListener('click', handleRefreshSecret);
copyButton?.addEventListener('click', handleCopy);
downloadButton?.addEventListener('click', handleDownload);
volumePathInput.addEventListener('input', updateDockerCompose);
privilegedModeSelect.addEventListener('change', updateDockerCompose);
ingestionEnabledSelect.addEventListener('change', handleIngestionEnabledChange);
ingestionPathInput.addEventListener('input', updateDockerCompose);
intakeEmailEnabledSelect.addEventListener('change', handleIntakeEmailEnabledChange);
intakeDriverSelect.addEventListener('change', handleIntakeDriverChange);
owlrelayApiKeyInput.addEventListener('input', updateDockerCompose);
owlrelayWebhookUrlInput.addEventListener('input', updateDockerCompose);
cfEmailDomainInput.addEventListener('input', updateDockerCompose);
webhookSecretInput.addEventListener('input', updateDockerCompose);
refreshWebhookSecretButton?.addEventListener('click', handleRefreshWebhookSecret);
authSecretInput.value = getRandomString();
// Initial render
updateDockerCompose();
// Initial setup
handleIngestionEnabledChange();
handleIntakeEmailEnabledChange();
webhookSecretInput.value = getRandomString();
</script>

View File

@@ -1,11 +0,0 @@
---
title: Introduction
description: Papra documentation.
slug: index
---
# Papra docs
**WIP**: This is still a work in progress. The documentation is not yet complete.
Papra is a minimalistic document management and archiving platform. It is designed to be simple to use and accessible to everyone. Papra is a plateform for long-term document storage and management, like a digital archive for your documents.

View File

@@ -1,9 +0,0 @@
---
title: Using Docker
description: Self-host Papra using Docker.
slug: self-hosting/using-docker
---
# Using Docker
Coming soon.

View File

@@ -1,9 +0,0 @@
---
title: Using Docker Compose
description: Self-host Papra using Docker Compose.
slug: self-hosting/using-docker-compose
---
# Using Docker Compose
Coming soon.

View File

@@ -1,9 +0,0 @@
---
title: Environment variables
description: Environment variables for Papra.
slug: configuration/environment-variables
---
# Environment variables
Coming soon.

View File

@@ -1,78 +0,0 @@
---
import type { MarkdownHeading } from 'astro';
import Header from '@/components/Header.astro';
import SearchModal from '@/components/SearchModal.astro';
import Sidenav from '@/components/Sidenav.astro';
import ToC from '@/components/ToC.astro';
import '../styles/app.css';
type Props = {
title?: string;
description?: string;
headings?: MarkdownHeading[];
};
const defaultTitle = 'Papra Docs';
const defaultDescription = 'Documentation for Papra, the open-source document management platform.';
const { title, description, headings } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>{title ?? defaultTitle}</title>
<meta name="description" content={description ?? defaultDescription} />
<meta name="author" content="Corentin Thomasset" />
<link rel="sitemap" href="/sitemap-index.xml" />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="Papra docs" />
<link rel="manifest" href="/site.webmanifest" />
<meta property="og:title" content={title ?? defaultTitle} />
<meta property="og:description" content={description ?? defaultDescription} />
<meta property="og:image" content={new URL('/og-image.png', Astro.url).toString()} />
<meta property="og:url" content={Astro.url} />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Papra Docs" />
<meta property="og:locale" content="en_US" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="Papra Docs" />
<meta property="og:image:type" content="image/png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:creator" content="@cthmsst" />
<meta name="twitter:title" content={title ?? defaultTitle} />
<meta name="twitter:description" content={description ?? defaultDescription} />
<meta name="twitter:image" content={new URL('/og-image.png', Astro.url).toString()} />
<link rel="canonical" href="https://docs.papra.app" />
</head>
<body class="min-h-screen font-sans text-sm bg-background">
<SearchModal />
<main class="flex min-h-screen items-start">
<Sidenav />
<article class="max-w-860px w-full px-4 sm:px-6 z-10 pb-42">
<Header />
<slot />
</article>
<aside class="sticky top-0 flex-1 hidden lg:block">
<div class="min-w-260px p-4 mt-16">
<ToC headings={headings} />
</div>
</aside>
</main>
</body>
</html>

View File

@@ -1,15 +0,0 @@
---
import Layout from '@/layouts/Layout.astro';
---
<Layout title="404 - Not Found" description="The page you are looking for does not exist.">
<div class="pt-12 flex items-center justify-center gap-4 flex-col sm:flex-row text-center sm:text-left">
<div class="i-tabler-coffee size-20 mb-4"></div>
<div>
<h1 class="text-lg font-medium">404 - Not Found</h1>
<p class="text-sm text-muted-foreground">The page you are looking for does not exist.</p>
</div>
</div>
</Layout>

View File

@@ -1,41 +0,0 @@
---
import PrevNextDocCard from '@/components/PrevNextDocCard.astro';
import Layout from '@/layouts/Layout.astro';
import { getCollection, render } from 'astro:content';
const docs = await getCollection('docs');
export async function getStaticPaths() {
const docs = await getCollection('docs');
return docs.map(doc => ({
params: { slug: doc.data.slug === 'index' ? undefined : doc.data.slug },
props: { doc },
}));
}
const { doc } = Astro.props;
const { Content, headings } = await render(doc);
const currentDocIndex = docs.findIndex(d => d.data.slug === doc.data.slug);
const nextDoc = docs[currentDocIndex + 1];
const prevDoc = docs[currentDocIndex - 1];
---
<Layout title={doc.data.title} description={doc.data.description} headings={headings}>
<div class="prose max-w-none text-base prose-coolgray dark:prose-invert mb-12">
<Content />
</div>
<a class="text-sm flex gap-2 items-center" href={`https://github.com/papra-hq/papra/blob/main/apps/docs/${doc.filePath}`} target="_blank" rel="noopener noreferrer">
<div class="i-tabler-edit size-4.5"></div>
Edit this page on GitHub
</a>
<hr class="my-4" />
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{ prevDoc && (<PrevNextDocCard direction="prev" {...prevDoc.data} />)}
{ nextDoc && (<PrevNextDocCard direction="next" {...nextDoc.data} class={!prevDoc ? 'grid-col-start-2' : ''} />) }
</div>
</Layout>

View File

@@ -0,0 +1,15 @@
---
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
import DockerComposeGeneratorComp from '../docker-compose-generator/dc-generator.astro';
---
<StarlightPage
frontmatter={{
title: 'Papra docker-compose.yml generator',
description: 'Generate a custom docker-compose.yml file for Papra, tailored to your needs.',
tableOfContents: false,
}}
>
<DockerComposeGeneratorComp />
</StarlightPage>

View File

@@ -0,0 +1,49 @@
import type { APIRoute } from 'astro';
import type { ConfigDefinition } from 'figue';
import { z } from 'astro:content';
import { mapValues } from 'lodash-es';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { configDefinition } from '../../../papra-server/src/modules/config/config';
function buildConfigSchema({ configDefinition }: { configDefinition: ConfigDefinition }) {
const schema: any = mapValues(configDefinition, (config) => {
if (typeof config === 'object' && config !== null && 'schema' in config && 'doc' in config) {
return config.schema;
} else {
return buildConfigSchema({
configDefinition: config as ConfigDefinition,
});
}
});
return z.object(schema);
}
function stripRequired(schema: any) {
if (schema.type === 'object') {
schema.required = [];
for (const key in schema.properties) {
stripRequired(schema.properties[key]);
}
}
}
function addSchema(schema: any) {
schema.properties.$schema = {
type: 'string',
description: 'The schema of the configuration file, to be used by IDEs to provide autocompletion and validation',
};
}
function getConfigSchema() {
const schema = buildConfigSchema({ configDefinition });
const jsonSchema = zodToJsonSchema(schema, { pipeStrategy: 'output' });
stripRequired(jsonSchema);
addSchema(jsonSchema);
return jsonSchema;
}
export const GET: APIRoute = () => {
return new Response(JSON.stringify(getConfigSchema()));
};

View File

@@ -6,7 +6,7 @@ User-agent: *
Allow: /
Sitemap: ${sitemapURL.href}
`;
`.trim();
}
export const GET: APIRoute = ({ site }) => {

View File

@@ -1,38 +0,0 @@
import type { APIRoute } from 'astro';
import { getCollection } from 'astro:content';
import { markdownToTxt } from 'markdown-to-txt';
import MiniSearch from 'minisearch';
function getRawContent(docsMarkdown: string | undefined) {
return markdownToTxt(docsMarkdown ?? '').replace(/\s+/g, ' ');
}
export const GET: APIRoute = async () => {
const docs = await getCollection('docs');
const docsWithContent = docs.map(doc => ({
...doc.data,
content: getRawContent(doc.body),
}));
const stopWords = new Set(['the', 'is', 'in', 'to', 'of', 'at', 'by', 'with', 'from', 'up', 'down', 'out', 'over', 'under', 'again', 'further', 'then', 'once', 'this', 'that', 'these', 'those', 'which', 'who', 'whom', 'whose', 'what', 'why', 'how', 'all', 'any', 'some', 'a', 'an', 'and', 'as', 'but', 'if', 'or', 'because', 'as', 'until', 'while']);
const miniSearch = new MiniSearch({
idField: 'slug',
fields: ['title', 'description', 'content'],
storeFields: ['title', 'description', 'slug'],
searchOptions: { fuzzy: 0.2 },
processTerm: term => term.toLowerCase().split(' ').filter(word => !stopWords.has(word)).join(' '),
});
miniSearch.addAll(docsWithContent);
return new Response(
JSON.stringify(miniSearch),
{
headers: {
'Content-Type': 'application/json',
},
},
);
};

View File

@@ -0,0 +1,5 @@
!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="init capture register register_once register_for_session unregister unregister_for_session getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty createPersonProfile opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing debug getPageViewId captureTraceFeedback captureTraceMetric".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
posthog.init('[POSTHOG-API-KEY]', {
api_host: '[POSTHOG-API-HOST]',
person_profiles: 'identified_only', // or 'always' to create profiles for anonymous users as well
})

View File

@@ -1,97 +0,0 @@
:root {
--background: 0 0% 100%;
--foreground: 168 4% 25%;
--card: 0 0% 98%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 252 94% 69%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--warning: 31 98% 50%;
--warning-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
}
[data-kb-theme="dark"] {
--background: 240 4% 10%;
--foreground: 0 0% 98%;
--card: 240 4% 8%;
--card-foreground: 0 0% 98%;
--popover: 240 4% 8%;
--popover-foreground: 0 0% 98%;
--primary: 256 100% 73%;
--primary: 77 100% 74%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--warning: 31 98% 50%;
--warning-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
[data-kb-theme="dark"] .astro-code,
[data-kb-theme="dark"] .astro-code span {
--shiki-dark-bg: hsl(var(--card)) !important;
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
font-style: var(--shiki-dark-font-style) !important;
font-weight: var(--shiki-dark-font-weight) !important;
text-decoration: var(--shiki-dark-text-decoration) !important;
}
.astro-code{
background-color: hsl(var(--card)) !important;
/* border: 1px solid hsl(var(--border) / 0.5) !important; */
}
.astro-code span {
background-color: hsl(var(--card)) !important;
}

View File

@@ -1,5 +0,0 @@
import type { ClassValue } from 'clsx';
import clsx from 'clsx';
import { twMerge } from 'tailwind-merge';
export const cn = (...classLists: ClassValue[]) => twMerge(clsx(classLists));

View File

@@ -1,17 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "solid-js",
"baseUrl": "./",
"paths": {
"@/*": [
"./src/*"
]
},
"allowJs": true,
"strictNullChecks": true
},
"include": [
".astro/types.d.ts",
"**/*"

View File

@@ -1,9 +1,6 @@
import {
defineConfig,
presetIcons,
presetTypography,
presetUno,
presetWebFonts,
transformerDirectives,
transformerVariantGroup,
} from 'unocss';
@@ -19,25 +16,6 @@ export default defineConfig({
prefix: '',
}),
presetAnimations(),
presetIcons(),
presetTypography({
cssExtend: {
h1: {
'font-size': '1.875rem',
'font-weight': '700',
'line-height': '2.25rem',
},
pre: {
position: 'relative',
},
},
}),
presetWebFonts({
provider: 'bunny',
fonts: {
sans: 'DM Sans:300,400,500,600,700,800',
},
}),
],
transformers: [transformerVariantGroup(), transformerDirectives()],
theme: {
@@ -84,13 +62,13 @@ export default defineConfig({
animation: {
keyframes: {
'accordion-down':
'{ from { height: 0 } to { height: var(--kb-accordion-content-height) } }',
'{ from { height: 0 } to { height: var(--kb-accordion-content-height) } }',
'accordion-up':
'{ from { height: var(--kb-accordion-content-height) } to { height: 0 } }',
'{ from { height: var(--kb-accordion-content-height) } to { height: 0 } }',
'collapsible-down':
'{ from { height: 0 } to { height: var(--kb-collapsible-content-height) } }',
'{ from { height: 0 } to { height: var(--kb-collapsible-content-height) } }',
'collapsible-up':
'{ from { height: var(--kb-collapsible-content-height) } to { height: 0 } }',
'{ from { height: var(--kb-collapsible-content-height) } to { height: 0 } }',
'caret-blink': '{ 0%,70%,100% { opacity: 1 } 20%,50% { opacity: 0 } }',
},
timingFns: {
@@ -112,10 +90,8 @@ export default defineConfig({
},
},
},
safelist: [
...'hidden'.split(' ').flatMap(word => [word, `${word}!`]),
...Array.from({ length: 30 }, (_, i) => `pl-${i}`),
],
shortcuts: {
'input-field': 'flex h-9 w-full bg-none outline-none rounded-lg border border-border border-solid bg-inherit px-3 py-1 text-sm shadow-none placeholder:text-muted-foreground focus-visible:(outline-none ring-1.5 ring-ring) disabled:(cursor-not-allowed opacity-50) transition-shadow',
'btn': 'text-sm font-medium hover:opacity-80 rounded-lg transition-all px-4 py-2 bg-none outline-none border-none cursor-pointer',
},
});

View File

@@ -1 +0,0 @@
VITE_BASE_API_URL=http://localhost:1221

View File

@@ -0,0 +1,53 @@
# @papra/app-client
## 0.6.0
### Minor Changes
- [#317](https://github.com/papra-hq/papra/pull/317) [`79c1d32`](https://github.com/papra-hq/papra/commit/79c1d3206b140cf8b3d33ef8bda6098dcf4c9c9c) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added document activity log
- [#319](https://github.com/papra-hq/papra/pull/319) [`60059c8`](https://github.com/papra-hq/papra/commit/60059c895c4860cbfda69d3c989ad00542def65b) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added pending invitation management page
### Patch Changes
- [#309](https://github.com/papra-hq/papra/pull/309) [`d4f72e8`](https://github.com/papra-hq/papra/commit/d4f72e889a4d39214de998942bc0eb88cd5cee3d) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Disable "Manage subscription" from organization setting by default
- [#308](https://github.com/papra-hq/papra/pull/308) [`759a3ff`](https://github.com/papra-hq/papra/commit/759a3ff713db8337061418b9c9b122b957479343) Thanks [@CorentinTh](https://github.com/CorentinTh)! - I18n: full support for French language
- [#312](https://github.com/papra-hq/papra/pull/312) [`e5ef40f`](https://github.com/papra-hq/papra/commit/e5ef40f36c27ea25dc8a79ef2805d673761eec2a) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fixed an issue with the reset-password page navigation guard that prevented reset
## 0.5.1
## 0.5.0
### Minor Changes
- [#295](https://github.com/papra-hq/papra/pull/295) [`438a311`](https://github.com/papra-hq/papra/commit/438a31171c606138c4b7fa299fdd58dcbeaaf298) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added support for custom oauth2 providers
- [#291](https://github.com/papra-hq/papra/pull/291) [`0627ec2`](https://github.com/papra-hq/papra/commit/0627ec25a422b7b820b08740cfc2905f9c55c00e) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added invitation system to add users to an organization
### Patch Changes
- [#296](https://github.com/papra-hq/papra/pull/296) [`0ddc234`](https://github.com/papra-hq/papra/commit/0ddc2340f092cf6fe5bf2175b55fb46db7681c36) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix register page description
## 0.4.0
### Minor Changes
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added webhook management
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added API keys support
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added document searchable content edit
### Patch Changes
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added tag creation button in document page
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Improved tag selector input wrapping
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Properly handle file names without extensions
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Wrap text in document preview
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Excluded deleted documents from doc count

View File

@@ -1,3 +1,23 @@
# Papra app client
# Papra - App Client
This is the client app for Papra. It is a SolidJS SPA.
This is the server for [Papra](https://papra.app).
## Development
> [!IMPORTANT]
> Unless you are developing for the demo mode (`VITE_IS_DEMO_MODE=true` in adjacent `.env` file), you will need to have the server running locally. See the [server README](../papra-server/README.md) for instructions on how to start the server.
To start the development server, run:
```bash
# Navigate to the docs directory
cd apps/papra-client
# Install dependencies
pnpm install
# Start the development server
pnpm dev
```
The development server will be available at [http://localhost:3000](http://localhost:3000).

View File

@@ -1,8 +1,9 @@
{
"name": "@papra/papra-app-client",
"name": "@papra/app-client",
"type": "module",
"version": "0.0.1-beta.1",
"packageManager": "pnpm@9.12.3",
"version": "0.6.0",
"private": true,
"packageManager": "pnpm@10.9.0",
"description": "Papra frontend client",
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
"license": "AGPL-3.0-or-later",
@@ -20,54 +21,57 @@
"serve": "vite preview",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"test": "pnpm run test:unit",
"test:unit": "vitest run",
"test:unit:watch": "vitest watch",
"test": "vitest run",
"test:watch": "vitest watch",
"test:e2e": "playwright test",
"typecheck": "tsc --noEmit",
"script:get-missing-i18n-keys": "tsx src/scripts/get-missing-i18n-keys.script.ts"
"script:get-missing-i18n-keys": "tsx src/scripts/get-missing-i18n-keys.script.ts",
"script:generate-i18n-types": "tsx src/scripts/generate-i18n-types.script.ts",
"script:sync-i18n-key-order": "tsx src/scripts/sync-i18n-key-order.script.ts"
},
"dependencies": {
"@corentinth/chisels": "^1.0.2",
"@kobalte/core": "^0.13.4",
"@modular-forms/solid": "^0.25.0",
"@pdfslick/solid": "^2.0.0",
"@solid-primitives/i18n": "^2.1.1",
"@solid-primitives/storage": "^4.2.1",
"@solidjs/router": "^0.14.3",
"@tanstack/solid-query": "^5.61.5",
"@tanstack/solid-table": "^8.20.5",
"@unocss/reset": "^0.64.0",
"class-variance-authority": "^0.7.0",
"@corentinth/chisels": "^1.3.1",
"@kobalte/core": "^0.13.9",
"@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-table": "^8.21.3",
"@unocss/reset": "^0.64.1",
"better-auth": "catalog:",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk-solid": "^1.1.0",
"cmdk-solid": "^1.1.2",
"date-fns": "^4.1.0",
"jszip": "^3.10.1",
"lodash-es": "^4.17.21",
"ofetch": "^1.4.1",
"posthog-js": "^1.246.0",
"radix3": "^1.1.2",
"solid-js": "^1.8.11",
"solid-js": "^1.9.7",
"solid-sonner": "^0.2.8",
"tailwind-merge": "^2.6.0",
"ts-pattern": "^5.5.0",
"unocss-preset-animations": "^1.1.0",
"unstorage": "^1.14.4",
"uqr": "^0.1.2",
"ts-pattern": "^5.7.1",
"unocss-preset-animations": "^1.2.1",
"unstorage": "^1.16.0",
"valibot": "1.0.0-beta.10"
},
"devDependencies": {
"@antfu/eslint-config": "catalog:",
"@iconify-json/tabler": "^1.1.120",
"@playwright/test": "^1.46.1",
"@iconify-json/tabler": "^1.2.18",
"@playwright/test": "^1.52.0",
"@types/lodash-es": "^4.17.12",
"@types/node": "catalog:",
"eslint": "catalog:",
"jsdom": "^25.0.0",
"tsx": "^4.19.1",
"jsdom": "^25.0.1",
"tinyglobby": "^0.2.14",
"tsx": "^4.19.4",
"typescript": "catalog:",
"unocss": "0.65.0-beta.2",
"vite": "^5.0.11",
"vite-plugin-solid": "^2.8.2",
"vitest": "catalog:"
"vite": "^5.4.19",
"vite-plugin-solid": "^2.11.6",
"vitest": "catalog:",
"yaml": "^2.8.0"
}
}

View File

@@ -0,0 +1,3 @@
/*
X-Frame-Options: DENY
X-Content-Type-Options: nosniff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 592 KiB

View File

@@ -8,8 +8,8 @@
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 252 94% 69%;
--primary-foreground: 0 0% 98%;
--primary: 16 99% 65%;
--primary-foreground: 0 0% 3.9%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
@@ -43,9 +43,7 @@
--popover: 240 4% 8%;
--popover-foreground: 0 0% 98%;
--primary: 256 100% 73%;
--primary: 77 100% 74%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;

View File

@@ -7,9 +7,14 @@ import { QueryClientProvider } from '@tanstack/solid-query';
import { render, Suspense } from 'solid-js/web';
import { CommandPaletteProvider } from './modules/command-palette/command-palette.provider';
import { ConfigProvider } from './modules/config/config.provider';
import { DemoIndicator } from './modules/demo/demo.provider';
import { RenameDocumentDialogProvider } from './modules/documents/components/rename-document-button.component';
import { I18nProvider } from './modules/i18n/i18n.provider';
import { ConfirmModalProvider } from './modules/shared/confirm';
import { queryClient } from './modules/shared/query/query-client';
import { IdentifyUser } from './modules/tracking/components/identify-user.component';
import { PageViewTracker } from './modules/tracking/components/pageview-tracker.component';
import { Toaster } from './modules/ui/components/sonner';
import { routes } from './routes';
import '@unocss/reset/tailwind.css';
@@ -27,22 +32,33 @@ render(
children={routes}
root={props => (
<QueryClientProvider client={queryClient}>
<PageViewTracker />
<IdentifyUser />
<Suspense>
<ConfirmModalProvider>
<ColorModeScript storageType={localStorageManager.type} storageKey={colorModeStorageKey} initialColorMode={initialColorMode} />
<ColorModeProvider
initialColorMode={initialColorMode}
storageManager={localStorageManager}
>
<CommandPaletteProvider>
<div class="min-h-screen font-sans text-sm font-400">{props.children}</div>
<I18nProvider>
<ConfirmModalProvider>
<ColorModeScript storageType={localStorageManager.type} storageKey={colorModeStorageKey} initialColorMode={initialColorMode} />
<ColorModeProvider
initialColorMode={initialColorMode}
storageManager={localStorageManager}
>
<CommandPaletteProvider>
<ConfigProvider>
<RenameDocumentDialogProvider>
<div class="min-h-screen font-sans text-sm font-400">
{props.children}
</div>
</RenameDocumentDialogProvider>
<DemoIndicator />
</ConfigProvider>
<Toaster />
<DemoIndicator />
</CommandPaletteProvider>
</ColorModeProvider>
<Toaster />
</CommandPaletteProvider>
</ColorModeProvider>
</ConfirmModalProvider>
</ConfirmModalProvider>
</I18nProvider>
</Suspense>
</QueryClientProvider>
)}

View File

@@ -0,0 +1,552 @@
# Authentication
auth.request-password-reset.title: Reset your password
auth.request-password-reset.description: Enter your email to reset your password.
auth.request-password-reset.requested: If an account exists for this email, we've sent you an email to reset your password.
auth.request-password-reset.back-to-login: Back to login
auth.request-password-reset.form.email.label: Email
auth.request-password-reset.form.email.placeholder: 'Example: ada@papra.app'
auth.request-password-reset.form.email.required: Please enter your email address
auth.request-password-reset.form.email.invalid: This email address is invalid
auth.request-password-reset.form.submit: Request password reset
auth.reset-password.title: Reset your password
auth.reset-password.description: Enter your new password to reset your password.
auth.reset-password.reset: Your password has been reset.
auth.reset-password.back-to-login: Back to login
auth.reset-password.form.new-password.label: New password
auth.reset-password.form.new-password.placeholder: 'Example: **********'
auth.reset-password.form.new-password.required: Please enter your new password
auth.reset-password.form.new-password.min-length: Password must be at least {{ minLength }} characters
auth.reset-password.form.new-password.max-length: Password must be less than {{ maxLength }} characters
auth.reset-password.form.submit: Reset password
auth.email-provider.open: Open {{ provider }}
auth.login.title: Login to Papra
auth.login.description: Enter your email or use social login to access your Papra account.
auth.login.login-with-provider: Login with {{ provider }}
auth.login.no-account: Don't have an account?
auth.login.register: Register
auth.login.form.email.label: Email
auth.login.form.email.placeholder: 'Example: ada@papra.app'
auth.login.form.email.required: Please enter your email address
auth.login.form.email.invalid: This email address is invalid
auth.login.form.password.label: Password
auth.login.form.password.placeholder: Set a password
auth.login.form.password.required: Please enter your password
auth.login.form.remember-me.label: Remember me
auth.login.form.forgot-password.label: Forgot password?
auth.login.form.submit: Login
auth.register.title: Register to Papra
auth.register.description: Create an account to start using Papra.
auth.register.register-with-email: Register with email
auth.register.register-with-provider: Register with {{ provider }}
auth.register.providers.google: Google
auth.register.providers.github: GitHub
auth.register.have-account: Already have an account?
auth.register.login: Login
auth.register.registration-disabled.title: Registration is disabled
auth.register.registration-disabled.description: The creation of new accounts is currently disabled on this instance of Papra. Only users with existing accounts can log in. If you think this is a mistake, please contact the administrator of this instance.
auth.register.form.email.label: Email
auth.register.form.email.placeholder: 'Example: ada@papra.app'
auth.register.form.email.required: Please enter your email address
auth.register.form.email.invalid: This email address is invalid
auth.register.form.password.label: Password
auth.register.form.password.placeholder: Set a password
auth.register.form.password.required: Please enter your password
auth.register.form.password.min-length: Password must be at least {{ minLength }} characters
auth.register.form.password.max-length: Password must be less than {{ maxLength }} characters
auth.register.form.name.label: Name
auth.register.form.name.placeholder: 'Example: Ada Lovelace'
auth.register.form.name.required: Please enter your name
auth.register.form.name.max-length: Name must be less than {{ maxLength }} characters
auth.register.form.submit: Register
auth.email-validation-required.title: Verify your email
auth.email-validation-required.description: A verification email has been sent to your email address. Please verify your email address by clicking the link in the email.
auth.legal-links.description: By continuing, you acknowledge that you understand and agree to the {{ terms }} and {{ privacy }}.
auth.legal-links.terms: Terms of Service
auth.legal-links.privacy: Privacy Policy
# User settings
user.settings.title: User settings
user.settings.description: Manage your account settings here.
user.settings.email.title: Email address
user.settings.email.description: Your email address cannot be changed.
user.settings.email.label: Email address
user.settings.name.title: Full name
user.settings.name.description: Your full name is displayed to other organization members.
user.settings.name.label: Full name
user.settings.name.placeholder: Eg. John Doe
user.settings.name.update: Update name
user.settings.name.updated: Your full name has been updated
user.settings.logout.title: Logout
user.settings.logout.description: Logout from your account. You can login again later.
user.settings.logout.button: Logout
# Organizations
organizations.list.title: Your organizations
organizations.list.description: Organizations are a way to group your documents and manage access to them. You can create multiple organizations and invite your team members to collaborate.
organizations.list.create-new: Create new organization
organizations.details.no-documents.title: No documents
organizations.details.no-documents.description: There are no documents in this organization yet. Start by uploading some documents.
organizations.details.upload-documents: Upload documents
organizations.details.documents-count: documents in total
organizations.details.total-size: total size
organizations.details.latest-documents: Latest imported documents
organizations.create.title: Create a new organization
organizations.create.description: Your documents will be grouped by organization. You can create multiple organizations to separate your documents, for example, for personal and work documents.
organizations.create.back: Back
organizations.create.error.max-count-reached: You have reached the maximum number of organizations you can create, if you need to create more, please contact support.
organizations.create.form.name.label: Organization name
organizations.create.form.name.placeholder: Eg. Acme Inc.
organizations.create.form.name.required: Please enter an organization name
organizations.create.form.submit: Create organization
organizations.create.success: Organization created successfully
organizations.create-first.title: Create your organization
organizations.create-first.description: Your documents will be grouped by organization. You can create multiple organizations to separate your documents, for example, for personal and work documents.
organizations.create-first.default-name: My organization
organizations.create-first.user-name: "{{ name }}'s organization"
organization.settings.title: Organization Settings
organization.settings.page.title: Organization settings
organization.settings.page.description: Manage your organization settings here.
organization.settings.name.title: Organization name
organization.settings.name.update: Update name
organization.settings.name.placeholder: Eg. Acme Inc.
organization.settings.name.updated: Organization name updated
organization.settings.subscription.title: Subscription
organization.settings.subscription.description: Manage your billing, invoices and payment methods.
organization.settings.subscription.manage: Manage subscription
organization.settings.subscription.error: Failed to get customer portal URL
organization.settings.delete.title: Delete organization
organization.settings.delete.description: Deleting this organization will permanently remove all data associated with it.
organization.settings.delete.confirm.title: Delete organization
organization.settings.delete.confirm.message: Are you sure you want to delete this organization? This action cannot be undone, and all data associated with this organization will be permanently removed.
organization.settings.delete.confirm.confirm-button: Delete organization
organization.settings.delete.confirm.cancel-button: Cancel
organization.settings.delete.success: Organization deleted
organizations.members.title: Members
organizations.members.description: Manage your organization members
organizations.members.invite-member: Invite member
organizations.members.invite-member-disabled-tooltip: Only admins or owners can invite members to the organization
organizations.members.remove-from-organization: Remove from organization
organizations.members.role: Role
organizations.members.roles.owner: Owner
organizations.members.roles.admin: Admin
organizations.members.roles.member: Member
organizations.members.delete.confirm.title: Remove member
organizations.members.delete.confirm.message: Are you sure you want to remove this member from the organization?
organizations.members.delete.confirm.confirm-button: Remove
organizations.members.delete.confirm.cancel-button: Cancel
organizations.members.delete.success: Member removed from organization
organizations.members.update-role.success: Member role updated
organizations.members.table.headers.name: Name
organizations.members.table.headers.email: Email
organizations.members.table.headers.role: Role
organizations.members.table.headers.created: Created
organizations.members.table.headers.actions: Actions
organizations.invite-member.title: Invite member
organizations.invite-member.description: Invite a member to your organization
organizations.invite-member.form.email.label: Email
organizations.invite-member.form.email.placeholder: 'Example: ada@papra.app'
organizations.invite-member.form.email.required: Please enter a valid email address
organizations.invite-member.form.role.label: Role
organizations.invite-member.form.submit: Invite to organization
organizations.invite-member.success.message: Member invited
organizations.invite-member.success.description: The email has been invited to the organization.
organizations.invite-member.error.message: Failed to invite member
organizations.invitations.title: Invitations
organizations.invitations.description: Manage your organization invitations
organizations.invitations.list.cta: Invite member
organizations.invitations.list.empty.title: No pending invitations
organizations.invitations.list.empty.description: You haven't been invited to any organizations yet.
organizations.invitations.status.pending: Pending
organizations.invitations.status.accepted: Accepted
organizations.invitations.status.rejected: Rejected
organizations.invitations.status.expired: Expired
organizations.invitations.status.cancelled: Cancelled
organizations.invitations.resend: Resend invitation
organizations.invitations.cancel.title: Cancel invitation
organizations.invitations.cancel.description: Are you sure you want to cancel this invitation?
organizations.invitations.cancel.confirm: Cancel invitation
organizations.invitations.cancel.cancel: Cancel
organizations.invitations.resend.title: Resend invitation
organizations.invitations.resend.description: Are you sure you want to resend this invitation? This will send a new email to the recipient.
organizations.invitations.resend.confirm: Resend invitation
organizations.invitations.resend.cancel: Cancel
invitations.list.title: Invitations
invitations.list.description: Manage your organization invitations
invitations.list.empty.title: No pending invitations
invitations.list.empty.description: You haven't been invited to any organizations yet.
invitations.list.headers.organization: Organization
invitations.list.headers.status: Status
invitations.list.headers.created: Created
invitations.list.headers.actions: Actions
invitations.list.actions.accept: Accept
invitations.list.actions.reject: Reject
invitations.list.actions.accept.success.message: Invitation accepted
invitations.list.actions.accept.success.description: The invitation has been accepted.
invitations.list.actions.reject.success.message: Invitation rejected
invitations.list.actions.reject.success.description: The invitation has been rejected.
# Documents
documents.list.title: Documents
documents.list.no-documents.title: No documents
documents.list.no-documents.description: There are no documents in this organization yet. Start by uploading some documents.
documents.list.no-results: No documents found
documents.tabs.info: Info
documents.tabs.content: Content
documents.tabs.activity: Activity
documents.deleted.message: This document has been deleted and will be permanently removed in {{ days }} days.
documents.actions.download: Download
documents.actions.open-in-new-tab: Open in new tab
documents.actions.restore: Restore
documents.actions.delete: Delete
documents.actions.edit: Edit
documents.actions.cancel: Cancel
documents.actions.save: Save
documents.actions.saving: Saving...
documents.content.alert: The content of the document is automatically extracted from the document on upload. It is only used for search and indexing purposes.
documents.info.id: ID
documents.info.name: Name
documents.info.type: Type
documents.info.size: Size
documents.info.created-at: Created At
documents.info.updated-at: Updated At
documents.info.never: Never
documents.rename.title: Rename document
documents.rename.form.name.label: Name
documents.rename.form.name.placeholder: 'Example: Invoice 2024'
documents.rename.form.name.required: Please enter a name for the document
documents.rename.form.name.max-length: The name must be less than 255 characters
documents.rename.form.submit: Rename document
documents.rename.success: Document renamed successfully
documents.rename.cancel: Cancel
import-documents.title.error: '{{ count }} documents failed'
import-documents.title.success: '{{ count }} documents imported'
import-documents.title.pending: '{{ count }} / {{ total }} documents imported'
import-documents.title.none: Import documents
import-documents.no-import-in-progress: No document import in progress
documents.deleted.title: Deleted documents
documents.deleted.empty.title: No deleted documents
documents.deleted.empty.description: You have no deleted documents. Documents that are deleted will be moved to the trash bin for {{ days }} days.
documents.deleted.retention-notice: All deleted documents are stored in the trash bin for {{ days }} days. Passing this delay, the documents will be permanently deleted, and you will not be able to restore them.
documents.deleted.deleted-at: Deleted
documents.deleted.restoring: Restoring...
documents.deleted.deleting: Deleting...
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.
trash.delete-all.confirm.label: Delete
trash.delete-all.confirm.cancel: Cancel
trash.delete.button: Delete
trash.delete.confirm.title: Permanently delete document?
trash.delete.confirm.description: Are you sure you want to permanently delete this document from the trash? This action cannot be undone.
trash.delete.confirm.label: Delete
trash.delete.confirm.cancel: Cancel
trash.deleted.success.title: Document deleted
trash.deleted.success.description: The document has been permanently deleted.
activity.document.created: The document has been created
activity.document.updated.single: The {{ field }} has been updated
activity.document.updated.multiple: The {{ fields }} have been updated
activity.document.updated: The document has been updated
activity.document.deleted: The document has been deleted
activity.document.restored: The document has been restored
activity.document.tagged: Tag {{ tag }} has been added
activity.document.untagged: Tag {{ tag }} has been removed
activity.document.user.name: by {{ name }}
activity.load-more: Load more
activity.no-more-activities: No more activities for this document
# Tags
tags.no-tags.title: No tags yet
tags.no-tags.description: This organization has no tags yet. Tags are used to categorize documents. You can add tags to your documents to make them easier to find and organize.
tags.no-tags.create-tag: Create tag
tags.title: Documents Tags
tags.description: Tags are used to categorize documents. You can add tags to your documents to make them easier to find and organize.
tags.create: Create tag
tags.update: Update tag
tags.delete: Delete tag
tags.delete.confirm.title: Delete tag
tags.delete.confirm.message: Are you sure you want to delete this tag? Deleting a tag will remove it from all documents.
tags.delete.confirm.confirm-button: Delete
tags.delete.confirm.cancel-button: Cancel
tags.delete.success: Tag deleted successfully
tags.create.success: Tag "{{ name }}" created successfully.
tags.update.success: Tag "{{ name }}" updated successfully.
tags.form.name.label: Name
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
tags.form.description.optional: (optional)
tags.form.description.placeholder: Eg. All the contracts signed by the company
tags.form.description.max-length: Description must be less than 256 characters
tags.form.no-description: No description
tags.table.headers.tag: Tag
tags.table.headers.description: Description
tags.table.headers.documents: Documents
tags.table.headers.created: Created
tags.table.headers.actions: Actions
# Tagging rules
tagging-rules.field.name: document name
tagging-rules.field.content: document content
tagging-rules.operator.equals: equals
tagging-rules.operator.not-equals: not equals
tagging-rules.operator.contains: contains
tagging-rules.operator.not-contains: not contains
tagging-rules.operator.starts-with: starts with
tagging-rules.operator.ends-with: ends with
tagging-rules.list.title: Tagging rules
tagging-rules.list.description: Manage your organization's tagging rules, to automatically tag documents based on conditions you define.
tagging-rules.list.demo-warning: 'Note: As this is a demo environment (with no server), tagging rules will not be applied to newly added documents.'
tagging-rules.list.no-tagging-rules.title: No tagging rules
tagging-rules.list.no-tagging-rules.description: Create a tagging rule to automatically tag your added documents based on conditions you define.
tagging-rules.list.no-tagging-rules.create-tagging-rule: Create tagging rule
tagging-rules.list.card.no-conditions: No conditions
tagging-rules.list.card.one-condition: 1 condition
tagging-rules.list.card.conditions: '{{ count }} conditions'
tagging-rules.list.card.delete: Delete rule
tagging-rules.list.card.edit: Edit rule
tagging-rules.create.title: Create tagging rule
tagging-rules.create.success: Tagging rule created successfully
tagging-rules.create.error: Failed to create tagging rule
tagging-rules.create.submit: Create rule
tagging-rules.form.name.label: Name
tagging-rules.form.name.placeholder: 'Example: Tag invoices'
tagging-rules.form.name.min-length: Please enter a name for the rule
tagging-rules.form.name.max-length: The name must be less than 64 characters
tagging-rules.form.description.label: Description
tagging-rules.form.description.placeholder: "Example: Tag documents with 'invoice' in the name"
tagging-rules.form.description.max-length: The description must be less than 256 characters
tagging-rules.form.conditions.label: Conditions
tagging-rules.form.conditions.description: Define the conditions that must be met for the rule to apply. All conditions must be met for the rule to apply.
tagging-rules.form.conditions.add-condition: Add condition
tagging-rules.form.conditions.no-conditions.title: No conditions
tagging-rules.form.conditions.no-conditions.description: You didn't add any conditions to this rule. This rule will apply its tags to all documents.
tagging-rules.form.conditions.no-conditions.confirm: Apply rule without conditions
tagging-rules.form.conditions.no-conditions.cancel: Cancel
tagging-rules.form.conditions.value.placeholder: 'Example: invoice'
tagging-rules.form.conditions.value.min-length: Please enter a value for the condition
tagging-rules.form.tags.label: Tags
tagging-rules.form.tags.description: Select the tags to apply to the added documents that match the conditions
tagging-rules.form.tags.min-length: At least one tag to apply is required
tagging-rules.form.tags.add-tag: Create tag
tagging-rules.form.submit: Create rule
tagging-rules.update.title: Update tagging rule
tagging-rules.update.error: Failed to update tagging rule
tagging-rules.update.submit: Update rule
tagging-rules.update.cancel: Cancel
# Intake emails
intake-emails.title: Intake Emails
intake-emails.description: Intake emails address are used to automatically ingest emails into Papra. Just forward emails to the intake email address and their attachments will be added to your organization's documents.
intake-emails.disabled.title: Intake Emails are disabled
intake-emails.disabled.description: Intake emails are disabled on this instance. Please contact your administrator to enable them. See the {{ documentation }} for more information.
intake-emails.disabled.documentation: documentation
intake-emails.info: Only enabled intake emails from allowed origins will be processed. You can enable or disable an intake email at any time.
intake-emails.empty.title: No intake emails
intake-emails.empty.description: Generate an intake address to easily ingest emails attachments.
intake-emails.empty.generate: Generate intake email
intake-emails.count: '{{ count }} intake email{{ plural }} for this organization'
intake-emails.new: New intake email
intake-emails.disabled-label: (Disabled)
intake-emails.no-origins: No allowed email origins
intake-emails.allowed-origins: Allowed from {{ count }} address{{ plural }}
intake-emails.actions.enable: Enable
intake-emails.actions.disable: Disable
intake-emails.actions.manage-origins: Manage origins addresses
intake-emails.actions.delete: Delete
intake-emails.delete.confirm.title: Delete intake email?
intake-emails.delete.confirm.message: Are you sure you want to delete this intake email? This action cannot be undone.
intake-emails.delete.confirm.confirm-button: Delete intake email
intake-emails.delete.confirm.cancel-button: Cancel
intake-emails.delete.success: Intake email deleted
intake-emails.create.success: Intake email created
intake-emails.update.success.enabled: Intake email enabled
intake-emails.update.success.disabled: Intake email disabled
intake-emails.allowed-origins.title: Allowed origins
intake-emails.allowed-origins.description: Only emails sent to {{ email }} from these origins will be processed. If no origins are specified, all emails will be discarded.
intake-emails.allowed-origins.add.label: Add allowed origin email
intake-emails.allowed-origins.add.placeholder: Eg. ada@papra.app
intake-emails.allowed-origins.add.button: Add
intake-emails.allowed-origins.add.error.exists: This email is already in the allowed origins for this intake email
# API keys
api-keys.permissions.documents.title: Documents
api-keys.permissions.documents.documents:create: Create documents
api-keys.permissions.documents.documents:read: Read documents
api-keys.permissions.documents.documents:update: Update documents
api-keys.permissions.documents.documents:delete: Delete documents
api-keys.permissions.tags.title: Tags
api-keys.permissions.tags.tags:create: Create tags
api-keys.permissions.tags.tags:read: Read tags
api-keys.permissions.tags.tags:update: Update tags
api-keys.permissions.tags.tags:delete: Delete tags
api-keys.create.title: Create API key
api-keys.create.description: Create a new API key to access the Papra API.
api-keys.create.success: The API key has been created successfully.
api-keys.create.back: Back to API keys
api-keys.create.form.name.label: Name
api-keys.create.form.name.placeholder: 'Example: My API key'
api-keys.create.form.name.required: Please enter a name for the API key
api-keys.create.form.permissions.label: Permissions
api-keys.create.form.permissions.required: Please select at least one permission
api-keys.create.form.submit: Create API key
api-keys.create.created.title: API key created
api-keys.create.created.description: The API key has been created successfully. Save it in a secure location as it will not be displayed again.
api-keys.list.title: API keys
api-keys.list.description: Manage your API keys here.
api-keys.list.create: Create API key
api-keys.list.empty.title: No API keys
api-keys.list.empty.description: Create an API key to access the Papra API.
api-keys.list.card.last-used: Last used
api-keys.list.card.never: Never
api-keys.list.card.created: Created
api-keys.delete.success: The API key has been deleted successfully
api-keys.delete.confirm.title: Delete API key
api-keys.delete.confirm.message: Are you sure you want to delete this API key? This action cannot be undone.
api-keys.delete.confirm.confirm-button: Delete
api-keys.delete.confirm.cancel-button: Cancel
# Webhooks
webhooks.list.title: Webhooks
webhooks.list.description: Manage your organization webhooks
webhooks.list.empty.title: No webhooks
webhooks.list.empty.description: Create your first webhook to start receiving events
webhooks.list.create: Create webhook
webhooks.list.card.last-triggered: Last triggered
webhooks.list.card.never: Never
webhooks.list.card.created: Created
webhooks.create.title: Create webhook
webhooks.create.description: Create a new webhook to receive events
webhooks.create.success: Webhook created successfully
webhooks.create.back: Back
webhooks.create.form.submit: Create webhook
webhooks.create.form.name.label: Webhook name
webhooks.create.form.name.placeholder: Enter webhook name
webhooks.create.form.name.required: Name is required
webhooks.create.form.url.label: Webhook URL
webhooks.create.form.url.placeholder: Enter webhook URL
webhooks.create.form.url.required: URL is required
webhooks.create.form.url.invalid: URL is invalid
webhooks.create.form.secret.label: Secret
webhooks.create.form.secret.placeholder: Enter webhook secret
webhooks.create.form.events.label: Events
webhooks.create.form.events.required: At least one event is required
webhooks.update.title: Edit webhook
webhooks.update.description: Update your webhook details
webhooks.update.success: Webhook updated successfully
webhooks.update.submit: Update webhook
webhooks.update.cancel: Cancel
webhooks.update.form.secret.placeholder: Enter new secret
webhooks.update.form.secret.placeholder-redacted: '[Redacted secret]'
webhooks.update.form.rotate-secret.button: Rotate secret
webhooks.delete.success: Webhook deleted successfully
webhooks.delete.confirm.title: Delete webhook
webhooks.delete.confirm.message: Are you sure you want to delete this webhook?
webhooks.delete.confirm.confirm-button: Delete
webhooks.delete.confirm.cancel-button: Cancel
webhooks.events.documents.document:created.description: Document created
webhooks.events.documents.document:deleted.description: Document deleted
# Navigation
layout.menu.home: Home
layout.menu.documents: Documents
layout.menu.tags: Tags
layout.menu.tagging-rules: Tagging rules
layout.menu.deleted-documents: Deleted documents
layout.menu.organization-settings: Settings
layout.menu.api-keys: API keys
layout.menu.settings: Settings
layout.menu.account: Account
layout.menu.general-settings: General settings
layout.menu.intake-emails: Intake emails
layout.menu.webhooks: Webhooks
layout.menu.members: Members
layout.menu.invitations: Invitations
layout.theme.light: Light mode
layout.theme.dark: Dark mode
layout.theme.system: System mode
layout.search.placeholder: Search...
layout.menu.import-document: Import a document
user-menu.account-settings: Account settings
user-menu.api-keys: API keys
user-menu.invitations: Invitations
user-menu.language: Language
user-menu.logout: Logout
# Command palette
command-palette.search.placeholder: Search commands or documents
command-palette.no-results: No results found
command-palette.sections.documents: Documents
command-palette.sections.theme: Theme
# API errors
api-errors.document.already_exists: The document already exists
api-errors.document.file_too_big: The document file is too big
api-errors.intake_email.limit_reached: The maximum number of intake emails for this organization has been reached. Please upgrade your plan to create more intake emails.
api-errors.user.max_organization_count_reached: You have reached the maximum number of organizations you can create, if you need to create more, please contact support.
api-errors.default: An error occurred while processing your request.
api-errors.organization.invitation_already_exists: An invitation for this email already exists in this organization.
api-errors.user.already_in_organization: This user is already in this organization.
api-errors.user.organization_invitation_limit_reached: The maximum number of invitations has been reached for today. Please try again tomorrow.
api-errors.demo.not_available: This feature is not available in demo
api-errors.tags.already_exists: A tag with this name already exists for this organization
# Not found
not-found.title: 404 - Not Found
not-found.description: Sorry, the page you are looking for does not seem to exist. Please check the URL and try again.
not-found.back-to-home: Go back to home
# Demo
demo.popup.description: This is a demo environment, all data is save to your browser local storage.
demo.popup.discord: Join the {{ discordLink }} to get support, propose features or just chat.
demo.popup.discord-link-label: Discord server
demo.popup.reset: Reset demo data
demo.popup.hide: Hide

View File

@@ -0,0 +1,552 @@
# Authentication
auth.request-password-reset.title: Réinitialiser votre mot de passe
auth.request-password-reset.description: Entrez votre email pour réinitialiser votre mot de passe.
auth.request-password-reset.requested: Si un compte existe pour cet email, nous vous avons envoyé un email pour réinitialiser votre mot de passe.
auth.request-password-reset.back-to-login: Retour à la connexion
auth.request-password-reset.form.email.label: Email
auth.request-password-reset.form.email.placeholder: 'Exemple: ada@papra.app'
auth.request-password-reset.form.email.required: Veuillez entrer votre adresse email
auth.request-password-reset.form.email.invalid: Cette adresse email est invalide
auth.request-password-reset.form.submit: Réinitialiser le mot de passe
auth.reset-password.title: Réinitialiser votre mot de passe
auth.reset-password.description: Entrez votre nouveau mot de passe pour réinitialiser votre mot de passe.
auth.reset-password.reset: Votre mot de passe a été réinitialisé.
auth.reset-password.back-to-login: Retour à la connexion
auth.reset-password.form.new-password.label: Nouveau mot de passe
auth.reset-password.form.new-password.placeholder: 'Exemple: **********'
auth.reset-password.form.new-password.required: Veuillez entrer votre nouveau mot de passe
auth.reset-password.form.new-password.min-length: Le mot de passe doit contenir au moins {{ minLength }} caractères
auth.reset-password.form.new-password.max-length: Le mot de passe doit contenir moins de {{ maxLength }} caractères
auth.reset-password.form.submit: Réinitialiser le mot de passe
auth.email-provider.open: Ouvrir {{ provider }}
auth.login.title: Connexion à Papra
auth.login.description: Entrez votre email ou utilisez une connexion sociale pour accéder à votre compte Papra.
auth.login.login-with-provider: Connexion avec {{ provider }}
auth.login.no-account: Je n'ai pas de compte
auth.login.register: S'inscrire
auth.login.form.email.label: Email
auth.login.form.email.placeholder: 'Exemple: ada@papra.app'
auth.login.form.email.required: Veuillez entrer votre adresse email
auth.login.form.email.invalid: Cette adresse email est invalide
auth.login.form.password.label: Mot de passe
auth.login.form.password.placeholder: Définir un mot de passe
auth.login.form.password.required: Veuillez entrer votre mot de passe
auth.login.form.remember-me.label: Se souvenir de moi
auth.login.form.forgot-password.label: Mot de passe oublié ?
auth.login.form.submit: Connexion
auth.register.title: S'inscrire à Papra
auth.register.description: Créez un compte pour commencer à utiliser Papra.
auth.register.register-with-email: S'inscrire avec email
auth.register.register-with-provider: S'inscrire avec {{ provider }}
auth.register.providers.google: Google
auth.register.providers.github: GitHub
auth.register.have-account: Je possède déjà un compte
auth.register.login: Connexion
auth.register.registration-disabled.title: Inscription désactivée
auth.register.registration-disabled.description: La création de nouveaux comptes est actuellement désactivée sur cette instance de Papra. Seuls les utilisateurs avec un compte existant peuvent se connecter. Si vous pensez que c'est une erreur, veuillez contacter l'administrateur de cette instance.
auth.register.form.email.label: Email
auth.register.form.email.placeholder: 'Exemple: ada@papra.app'
auth.register.form.email.required: Veuillez entrer votre adresse email
auth.register.form.email.invalid: Cette adresse email est invalide
auth.register.form.password.label: Mot de passe
auth.register.form.password.placeholder: Définir un mot de passe
auth.register.form.password.required: Veuillez entrer votre mot de passe
auth.register.form.password.min-length: Le mot de passe doit contenir au moins {{ minLength }} caractères
auth.register.form.password.max-length: Le mot de passe doit contenir moins de {{ maxLength }} caractères
auth.register.form.name.label: Nom
auth.register.form.name.placeholder: 'Exemple: Ada Lovelace'
auth.register.form.name.required: Veuillez entrer votre nom
auth.register.form.name.max-length: Le nom doit contenir moins de {{ maxLength }} caractères
auth.register.form.submit: S'inscrire
auth.email-validation-required.title: Vérifier votre email
auth.email-validation-required.description: Un email de vérification a été envoyé à votre adresse email. Veuillez vérifier votre adresse email en cliquant sur le lien dans l'email.
auth.legal-links.description: En continuant, vous reconnaissez que vous comprenez et acceptez les {{ terms }} et {{ privacy }}.
auth.legal-links.terms: Conditions d'utilisation
auth.legal-links.privacy: Politique de confidentialité
# User settings
user.settings.title: Paramètres de l'utilisateur
user.settings.description: Gérez vos paramètres de compte ici.
user.settings.email.title: Adresse email
user.settings.email.description: Votre adresse email ne peut pas être modifiée.
user.settings.email.label: Adresse email
user.settings.name.title: Nom complet
user.settings.name.description: Votre nom complet est affiché aux autres membres de l'organisation.
user.settings.name.label: Nom complet
user.settings.name.placeholder: 'Exemple: John Doe'
user.settings.name.update: Mettre à jour le nom
user.settings.name.updated: Votre nom complet a été mis à jour
user.settings.logout.title: Déconnexion
user.settings.logout.description: Déconnectez-vous de votre compte. Vous pouvez vous reconnecter plus tard.
user.settings.logout.button: Déconnexion
# Organizations
organizations.list.title: Vos organisations
organizations.list.description: Les organisations sont un moyen de grouper vos documents et de gérer l'accès à eux. Vous pouvez créer plusieurs organisations et inviter vos membres de l'équipe à collaborer.
organizations.list.create-new: Créer une nouvelle organisation
organizations.details.no-documents.title: Aucun document
organizations.details.no-documents.description: Il n'y a pas de documents dans cette organisation. Commencez par télécharger des documents.
organizations.details.upload-documents: Télécharger des documents
organizations.details.documents-count: documents en total
organizations.details.total-size: taille totale
organizations.details.latest-documents: Derniers documents importés
organizations.create.title: Créer une nouvelle organisation
organizations.create.description: Vos documents seront regroupés par organisation. Vous pouvez créer plusieurs organisations pour séparer vos documents, par exemple, pour les documents personnels et professionnels.
organizations.create.back: Retour
organizations.create.error.max-count-reached: Vous avez atteint le nombre maximum d'organisations que vous pouvez créer, si vous avez besoin de créer plus, veuillez contacter le support.
organizations.create.form.name.label: Nom de l'organisation
organizations.create.form.name.placeholder: 'Exemple: Acme Inc.'
organizations.create.form.name.required: Veuillez entrer un nom pour l'organisation
organizations.create.form.submit: Créer l'organisation
organizations.create.success: Organisation créée avec succès
organizations.create-first.title: Créer votre organisation
organizations.create-first.description: Vos documents seront regroupés par organisation. Vous pouvez créer plusieurs organisations pour séparer vos documents, par exemple, pour les documents personnels et professionnels.
organizations.create-first.default-name: Mon organisation
organizations.create-first.user-name: "{{ name }}'s organisation"
organization.settings.title: Paramètres de l'organisation
organization.settings.page.title: Paramètres de l'organisation
organization.settings.page.description: Gérez les paramètres de votre organisation ici.
organization.settings.name.title: Nom de l'organisation
organization.settings.name.update: Modifier le nom
organization.settings.name.placeholder: 'Exemple: Acme Inc.'
organization.settings.name.updated: Nom de l'organisation mis à jour
organization.settings.subscription.title: Subscription
organization.settings.subscription.description: Gérez votre facturation, vos factures et vos méthodes de paiement.
organization.settings.subscription.manage: Gérer la souscription
organization.settings.subscription.error: Échec de la récupération de l'URL du portail client
organization.settings.delete.title: Supprimer l'organisation
organization.settings.delete.description: Supprimer cette organisation supprimera définitivement toutes les données associées à elle.
organization.settings.delete.confirm.title: Supprimer l'organisation
organization.settings.delete.confirm.message: Êtes-vous sûr de vouloir supprimer cette organisation ? Cette action est irréversible, et toutes les données associées à cette organisation seront supprimées définitivement.
organization.settings.delete.confirm.confirm-button: Supprimer l'organisation
organization.settings.delete.confirm.cancel-button: Annuler
organization.settings.delete.success: Organisation supprimée
organizations.members.title: Membres
organizations.members.description: Gérez les membres de votre organisation.
organizations.members.invite-member: Inviter un membre
organizations.members.invite-member-disabled-tooltip: Seuls les administrateurs ou les propriétaires peuvent inviter des membres à l'organisation
organizations.members.remove-from-organization: Retirer de l'organisation
organizations.members.role: Rôle
organizations.members.roles.owner: Propriétaire
organizations.members.roles.admin: Admin
organizations.members.roles.member: Membre
organizations.members.delete.confirm.title: Retirer un membre
organizations.members.delete.confirm.message: Êtes-vous sûr de vouloir retirer ce membre de l'organisation ?
organizations.members.delete.confirm.confirm-button: Retirer
organizations.members.delete.confirm.cancel-button: Annuler
organizations.members.delete.success: Membre retiré de l'organisation
organizations.members.update-role.success: Rôle du membre mis à jour
organizations.members.table.headers.name: Nom
organizations.members.table.headers.email: Email
organizations.members.table.headers.role: Rôle
# organizations.members.table.headers.created: Created
organizations.members.table.headers.actions: Actions
organizations.invite-member.title: Inviter un membre
organizations.invite-member.description: Invite un membre à votre organisation
organizations.invite-member.form.email.label: Email
organizations.invite-member.form.email.placeholder: 'Exemple: ada@papra.app'
organizations.invite-member.form.email.required: Veuillez entrer une adresse email valide
organizations.invite-member.form.role.label: Rôle
organizations.invite-member.form.submit: Inviter à l'organisation
organizations.invite-member.success.message: Membre invité
organizations.invite-member.success.description: L'email a été invité à l'organisation.
organizations.invite-member.error.message: Échec de l'invitation du membre
organizations.invitations.title: Invitations
organizations.invitations.description: Gérez les invitations de votre organisation.
organizations.invitations.list.cta: Inviter un membre
organizations.invitations.list.empty.title: Aucune invitation en attente
organizations.invitations.list.empty.description: Vous n'avez pas été invité à aucune organisation.
organizations.invitations.status.pending: En attente
organizations.invitations.status.accepted: Accepté
organizations.invitations.status.rejected: Refusé
organizations.invitations.status.expired: Expiré
organizations.invitations.status.cancelled: Annulé
organizations.invitations.resend: Renvoyer l'invitation
organizations.invitations.cancel.title: Annuler l'invitation
organizations.invitations.cancel.description: Êtes-vous sûr de vouloir annuler cette invitation ?
organizations.invitations.cancel.confirm: Annuler l'invitation
organizations.invitations.cancel.cancel: Annuler
organizations.invitations.resend.title: Renvoyer l'invitation
organizations.invitations.resend.description: Êtes-vous sûr de vouloir renvoyer cette invitation ? Cela enverra un nouvel email à l'invité.
organizations.invitations.resend.confirm: Renvoyer l'invitation
organizations.invitations.resend.cancel: Annuler
invitations.list.title: Invitations
invitations.list.description: Gérez les invitations de votre organisation.
invitations.list.empty.title: Aucune invitation en attente
invitations.list.empty.description: Vous n'avez pas été invité à aucune organisation.
invitations.list.headers.organization: Organisation
# invitations.list.headers.status: Status
invitations.list.headers.created: Créé
invitations.list.headers.actions: Actions
invitations.list.actions.accept: Accepter
invitations.list.actions.reject: Refuser
invitations.list.actions.accept.success.message: Invitation acceptée
invitations.list.actions.accept.success.description: L'invitation a été acceptée.
invitations.list.actions.reject.success.message: Invitation refusée
invitations.list.actions.reject.success.description: L'invitation a été refusée.
# Documents
documents.list.title: Documents
documents.list.no-documents.title: Aucun document
documents.list.no-documents.description: Il n'y a pas de documents dans cette organisation. Commencez par télécharger des documents.
documents.list.no-results: Aucun document trouvé
documents.tabs.info: Info
documents.tabs.content: Contenu
documents.tabs.activity: Activité
documents.deleted.message: Ce document a été supprimé et sera supprimé définitivement dans {{ days }} jours.
documents.actions.download: Télécharger
documents.actions.open-in-new-tab: Ouvrir dans un nouvel onglet
documents.actions.restore: Restaurer
documents.actions.delete: Supprimer
documents.actions.edit: Modifier
documents.actions.cancel: Annuler
documents.actions.save: Enregistrer
documents.actions.saving: Enregistrement...
documents.content.alert: Le contenu du document est automatiquement extrait du document lors de l'import. Il est uniquement utilisé pour la recherche et l'indexation.
documents.info.id: ID
documents.info.name: Nom
documents.info.type: Type
documents.info.size: Taille
documents.info.created-at: Créé le
documents.info.updated-at: Mis à jour le
documents.info.never: Jamais
documents.rename.title: Renommer le document
documents.rename.form.name.label: Nom
documents.rename.form.name.placeholder: 'Exemple: Facture 2024'
documents.rename.form.name.required: Veuillez entrer un nom pour le document
documents.rename.form.name.max-length: Le nom doit contenir moins de 255 caractères
documents.rename.form.submit: Renommer
documents.rename.success: Document renommé avec succès
documents.rename.cancel: Annuler
import-documents.title.error: '{{ count }} documents ont échoué'
import-documents.title.success: '{{ count }} documents ont été importés'
import-documents.title.pending: '{{ count }} / {{ total }} documents importés'
import-documents.title.none: Importer des documents
import-documents.no-import-in-progress: Aucune importation de documents en cours
documents.deleted.title: Documents supprimés
documents.deleted.empty.title: Aucun document supprimé
documents.deleted.empty.description: Vous n'avez pas de documents supprimés. Les documents supprimés seront déplacés dans la corbeille pour {{ days }} jours.
documents.deleted.retention-notice: Tous les documents supprimés sont stockés dans la corbeille pour {{ days }} jours. Passé ce délai, les documents seront supprimés définitivement, et vous ne pourrez plus les restaurer.
documents.deleted.deleted-at: Supprimé
documents.deleted.restoring: Restauration...
documents.deleted.deleting: Suppression...
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.
trash.delete-all.confirm.label: Supprimer
trash.delete-all.confirm.cancel: Annuler
trash.delete.button: Supprimer
trash.delete.confirm.title: Supprimer définitivement le document ?
trash.delete.confirm.description: Êtes-vous sûr de vouloir supprimer définitivement ce document de la corbeille ? Cette action est irréversible.
trash.delete.confirm.label: Supprimer
trash.delete.confirm.cancel: Annuler
trash.deleted.success.title: Document supprimé
trash.deleted.success.description: Le document a été supprimé définitivement.
activity.document.created: Le document a été créé
activity.document.updated.single: Le {{ field }} a été mis à jour
activity.document.updated.multiple: Les {{ fields }} ont été mis à jour
activity.document.updated: Le document a été mis à jour
activity.document.deleted: Le document a été supprimé
activity.document.restored: Le document a été restauré
activity.document.tagged: Le tag {{ tag }} a été ajouté
activity.document.untagged: Le tag {{ tag }} a été supprimé
activity.document.user.name: par {{ name }}
activity.load-more: Charger plus
activity.no-more-activities: Aucune activité pour ce document
# Tags
tags.no-tags.title: Aucun tag
tags.no-tags.description: Cette organisation n'a pas de tags. Les tags sont utilisés pour catégoriser les documents. Vous pouvez ajouter des tags à vos documents pour les rendre plus faciles à trouver et à organiser.
tags.no-tags.create-tag: Créer un tag
tags.title: Tags de documents
tags.description: Les tags sont utilisés pour catégoriser les documents. Vous pouvez ajouter des tags à vos documents pour les rendre plus faciles à trouver et à organiser.
tags.create: Créer un tag
tags.update: Mettre à jour un tag
tags.delete: Supprimer un tag
tags.delete.confirm.title: Supprimer un tag
tags.delete.confirm.message: Êtes-vous sûr de vouloir supprimer ce tag ? Supprimer un tag supprimera toutes les règles de catégorisation qui l'utilisent.
tags.delete.confirm.confirm-button: Supprimer
tags.delete.confirm.cancel-button: Annuler
tags.delete.success: Tag supprimé avec succès
tags.create.success: Tag "{{ name }}" créé avec succès.
tags.update.success: Tag "{{ name }}" mis à jour avec succès.
tags.form.name.label: Nom
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
tags.form.description.optional: (optionnel)
tags.form.description.placeholder: "Exemple: Tous les contrats signés par l'entreprise"
tags.form.description.max-length: La description doit contenir moins de 256 caractères
tags.form.no-description: Aucune description
tags.table.headers.tag: Tag
tags.table.headers.description: Description
tags.table.headers.documents: Documents
tags.table.headers.created: Date de création
tags.table.headers.actions: Actions
# Tagging rules
tagging-rules.field.name: nom du document
tagging-rules.field.content: contenu du document
tagging-rules.operator.equals: égal à
tagging-rules.operator.not-equals: différent de
tagging-rules.operator.contains: contient
tagging-rules.operator.not-contains: ne contient pas
tagging-rules.operator.starts-with: commence par
tagging-rules.operator.ends-with: finit par
tagging-rules.list.title: Règles de catégorisation
tagging-rules.list.description: Gérez vos règles de catégorisation, pour catégoriser automatiquement les documents en fonction de conditions que vous définissez.
tagging-rules.list.demo-warning: 'Note: Cette instance est une démo, les règles de catégorisation ne seront pas appliquées aux documents ajoutés.'
tagging-rules.list.no-tagging-rules.title: Aucune règle de catégorisation
tagging-rules.list.no-tagging-rules.description: Créez une règle de catégorisation pour catégoriser automatiquement vos documents en fonction de conditions que vous définissez.
tagging-rules.list.no-tagging-rules.create-tagging-rule: Créer une règle de catégorisation
tagging-rules.list.card.no-conditions: Aucune condition
tagging-rules.list.card.one-condition: 1 condition
tagging-rules.list.card.conditions: '{{ count }} conditions'
tagging-rules.list.card.delete: Supprimer la règle
tagging-rules.list.card.edit: Modifier la règle
tagging-rules.create.title: Créer une règle de catégorisation
tagging-rules.create.success: Règle de catégorisation créée avec succès
tagging-rules.create.error: Échec de la création de la règle de catégorisation
tagging-rules.create.submit: Créer la règle
tagging-rules.form.name.label: Nom
tagging-rules.form.name.placeholder: 'Exemple: Catégoriser les factures'
tagging-rules.form.name.min-length: Veuillez entrer un nom pour la règle
tagging-rules.form.name.max-length: Le nom doit contenir moins de 64 caractères
tagging-rules.form.description.label: Description
tagging-rules.form.description.placeholder: "Exemple: Catégoriser les documents avec 'facture' dans le nom"
tagging-rules.form.description.max-length: La description doit contenir moins de 256 caractères
tagging-rules.form.conditions.label: Conditions
tagging-rules.form.conditions.description: Définissez les conditions que doivent remplir la règle pour qu'elle s'applique. Toutes les conditions doivent être remplies pour que la règle s'applique.
tagging-rules.form.conditions.add-condition: Ajouter une condition
tagging-rules.form.conditions.no-conditions.title: Aucune condition
tagging-rules.form.conditions.no-conditions.description: Vous n'avez pas ajouté de conditions à cette règle. Cette règle appliquera ses tags à tous les documents.
tagging-rules.form.conditions.no-conditions.confirm: Appliquer la règle sans conditions
tagging-rules.form.conditions.no-conditions.cancel: Annuler
tagging-rules.form.conditions.value.placeholder: 'Exemple: facture'
tagging-rules.form.conditions.value.min-length: Veuillez entrer une valeur pour la condition
tagging-rules.form.tags.label: Tags
tagging-rules.form.tags.description: Sélectionnez les tags à appliquer aux documents ajoutés qui correspondent aux conditions
tagging-rules.form.tags.min-length: Au moins un tag à appliquer est requis
tagging-rules.form.tags.add-tag: Créer un tag
tagging-rules.form.submit: Créer la règle
tagging-rules.update.title: Mettre à jour la règle de catégorisation
tagging-rules.update.error: Échec de la mise à jour de la règle de catégorisation
tagging-rules.update.submit: Mettre à jour la règle
tagging-rules.update.cancel: Annuler
# Intake emails
intake-emails.title: Adresses de réception
intake-emails.description: Les adresses de réception sont utilisées pour ingérer automatiquement les emails dans Papra. Il suffit de les envoyer à l'adresse de réception et leurs pièces jointes seront ajoutées à vos documents.
intake-emails.disabled.title: Les adresses de réception sont désactivées
intake-emails.disabled.description: Les adresses de réception sont désactivées sur cette instance. Veuillez contacter votre administrateur pour les activer. Voir la {{ documentation }} pour plus d'informations.
intake-emails.disabled.documentation: documentation
intake-emails.info: Seules les adresses de réception activées depuis les origines autorisées seront traitées. Vous pouvez activer ou désactiver une adresse de réception à tout moment.
intake-emails.empty.title: Aucune adresse de réception
intake-emails.empty.description: Générez une adresse de réception pour ingérer facilement les pièces jointes des emails.
intake-emails.empty.generate: Générer une adresse de réception
intake-emails.count: '{{ count }} intake email{{ plural }} for this organization'
intake-emails.new: Nouvelle adresse de réception
intake-emails.disabled-label: (Désactivé)
intake-emails.no-origins: Aucune adresse de réception autorisée
intake-emails.allowed-origins: Autorisées depuis {{ count }} adresse{{ plural }}
intake-emails.actions.enable: Activer
intake-emails.actions.disable: Désactiver
intake-emails.actions.manage-origins: Gérer les adresses d'origine
intake-emails.actions.delete: Supprimer
intake-emails.delete.confirm.title: Supprimer l'adresse de réception ?
intake-emails.delete.confirm.message: Êtes-vous sûr de vouloir supprimer cette adresse de réception ? Cette action est irréversible.
intake-emails.delete.confirm.confirm-button: Supprimer l'adresse de réception
intake-emails.delete.confirm.cancel-button: Annuler
intake-emails.delete.success: Adresse de réception supprimée
intake-emails.create.success: Adresse de réception créée
intake-emails.update.success.enabled: Adresse de réception activée
intake-emails.update.success.disabled: Adresse de réception désactivée
intake-emails.allowed-origins.title: Adresses d'origine autorisées
intake-emails.allowed-origins.description: Seuls les emails envoyés à {{ email }} depuis ces adresses d'origine seront traités. Si aucune adresse d'origine n'est spécifiée, tous les emails seront rejetés.
intake-emails.allowed-origins.add.label: Ajouter une adresse d'origine autorisée
intake-emails.allowed-origins.add.placeholder: 'Exemple: ada@papra.app'
intake-emails.allowed-origins.add.button: Ajouter
intake-emails.allowed-origins.add.error.exists: Cette adresse email est déjà dans les adresses d'origine autorisées pour cette adresse de réception
# API keys
api-keys.permissions.documents.title: Documents
api-keys.permissions.documents.documents:create: Créer des documents
api-keys.permissions.documents.documents:read: Lire des documents
api-keys.permissions.documents.documents:update: Mettre à jour des documents
api-keys.permissions.documents.documents:delete: Supprimer des documents
api-keys.permissions.tags.title: Tags
api-keys.permissions.tags.tags:create: Créer des tags
api-keys.permissions.tags.tags:read: Lire des tags
api-keys.permissions.tags.tags:update: Mettre à jour des tags
api-keys.permissions.tags.tags:delete: Supprimer des tags
api-keys.create.title: Créer une clé API
api-keys.create.description: Créer une nouvelle clé API pour accéder à l'API de Papra.
api-keys.create.success: La clé API a été créée avec succès.
api-keys.create.back: Retour aux clés API
api-keys.create.form.name.label: Nom
api-keys.create.form.name.placeholder: 'Exemple: Ma clé API'
api-keys.create.form.name.required: Veuillez entrer un nom pour la clé API
api-keys.create.form.permissions.label: Permissions
api-keys.create.form.permissions.required: Veuillez sélectionner au moins une permission
api-keys.create.form.submit: Créer la clé API
api-keys.create.created.title: Clé API créée
api-keys.create.created.description: La clé API a été créée avec succès. Enregistrez-la dans un endroit sûr car elle ne sera plus affichée.
api-keys.list.title: Clés API
api-keys.list.description: Gérez vos clés API ici.
api-keys.list.create: Créer une clé API
api-keys.list.empty.title: Aucune clé API
api-keys.list.empty.description: Créez une clé API pour accéder à l'API de Papra.
api-keys.list.card.last-used: Dernière utilisation
api-keys.list.card.never: Jamais
api-keys.list.card.created: Créée
api-keys.delete.success: La clé API a été supprimée avec succès
api-keys.delete.confirm.title: Supprimer la clé API
api-keys.delete.confirm.message: Êtes-vous sûr de vouloir supprimer cette clé API ? Cette action est irréversible.
api-keys.delete.confirm.confirm-button: Supprimer
api-keys.delete.confirm.cancel-button: Annuler
# Webhooks
webhooks.list.title: Webhooks
webhooks.list.description: Gérez vos webhooks ici.
webhooks.list.empty.title: Aucun webhook
webhooks.list.empty.description: Créez votre premier webhook pour commencer à recevoir des événements.
webhooks.list.create: Créer un webhook
webhooks.list.card.last-triggered: Dernière invocation
webhooks.list.card.never: Jamais
webhooks.list.card.created: Créée
webhooks.create.title: Créer un webhook
webhooks.create.description: Créez un webhook pour recevoir des événements lorsque des documents sont ajoutés à votre organisation.
webhooks.create.success: Le webhook a été créé avec succès.
webhooks.create.back: Retour aux webhooks
webhooks.create.form.submit: Créer le webhook
webhooks.create.form.name.label: Nom du webhook
webhooks.create.form.name.placeholder: Entrez le nom du webhook
webhooks.create.form.name.required: Le nom est requis
webhooks.create.form.url.label: URL du webhook
webhooks.create.form.url.placeholder: Entrez l'URL du webhook
webhooks.create.form.url.required: L'URL est requise
webhooks.create.form.url.invalid: L'URL est invalide
webhooks.create.form.secret.label: Secret
webhooks.create.form.secret.placeholder: Entrez le secret du webhook
webhooks.create.form.events.label: Événements
webhooks.create.form.events.required: Au moins un événement est requis
webhooks.update.title: Modifier le webhook
webhooks.update.description: Mettez à jour les détails de votre webhook
webhooks.update.success: Le webhook a été mis à jour avec succès
webhooks.update.submit: Mettre à jour le webhook
webhooks.update.cancel: Annuler
webhooks.update.form.secret.placeholder: Entrez un nouveau secret
webhooks.update.form.secret.placeholder-redacted: '[Secret masqué]'
webhooks.update.form.rotate-secret.button: Rotation du secret
webhooks.delete.success: Le webhook a été supprimé avec succès
webhooks.delete.confirm.title: Supprimer le webhook
webhooks.delete.confirm.message: Êtes-vous sûr de vouloir supprimer ce webhook ? Cette action est irréversible.
webhooks.delete.confirm.confirm-button: Supprimer
webhooks.delete.confirm.cancel-button: Annuler
webhooks.events.documents.document:created.description: Document créé
webhooks.events.documents.document:deleted.description: Document supprimé
# Navigation
layout.menu.home: Accueil
layout.menu.documents: Documents
layout.menu.tags: Tags
layout.menu.tagging-rules: Règles de catégorisation
layout.menu.deleted-documents: Documents supprimés
layout.menu.organization-settings: Paramètres
layout.menu.api-keys: API keys
layout.menu.settings: Paramètres
layout.menu.account: Compte
layout.menu.general-settings: Paramètres généraux
layout.menu.intake-emails: Adresses de réception
layout.menu.webhooks: Webhooks
layout.menu.members: Membres
layout.menu.invitations: Invitations
layout.theme.light: Mode clair
layout.theme.dark: Mode sombre
layout.theme.system: Mode système
layout.search.placeholder: Rechercher...
layout.menu.import-document: Importer un document
user-menu.account-settings: Paramètres du compte
user-menu.api-keys: Clés d'API
user-menu.invitations: Invitations
user-menu.language: Langue
user-menu.logout: Déconnexion
# Command palette
command-palette.search.placeholder: Rechercher des commandes ou des documents
command-palette.no-results: Aucun résultat trouvé
command-palette.sections.documents: Documents
command-palette.sections.theme: Thème
# API errors
api-errors.document.already_exists: Le document existe déjà
api-errors.document.file_too_big: Le fichier du document est trop grand
api-errors.intake_email.limit_reached: Le nombre maximum d'emails de réception pour cette organisation a été atteint. Veuillez mettre à niveau votre plan pour créer plus d'emails de réception.
api-errors.user.max_organization_count_reached: Vous avez atteint le nombre maximum d'organisations que vous pouvez créer, si vous avez besoin de créer plus, veuillez contacter le support.
api-errors.default: Une erreur est survenue lors du traitement de votre requête.
api-errors.organization.invitation_already_exists: Une invitation pour cet email existe déjà dans cette organisation.
api-errors.user.already_in_organization: Cet utilisateur est déjà dans cette organisation.
api-errors.user.organization_invitation_limit_reached: Le nombre maximum d'invitations a été atteint pour aujourd'hui. Veuillez réessayer demain.
api-errors.demo.not_available: Cette fonctionnalité n'est pas disponible dans la démo
api-errors.tags.already_exists: Un tag avec ce nom existe déjà pour cette organisation
# Not found
not-found.title: 404 - Not Found
not-found.description: Désolé, la page que vous cherchez n'existe pas. Veuillez vérifier l'URL et réessayer.
not-found.back-to-home: Retour à l'accueil
# Demo
demo.popup.description: Cette instance est une démo, toutes les données sont sauvegardées dans le stockage local de votre navigateur.
demo.popup.discord: Rejoignez le {{ discordLink }} pour obtenir de l'aide, proposer des fonctionnalités ou simplement discuter.
demo.popup.discord-link-label: Serveur Discord
demo.popup.reset: Réinitialiser la démo
demo.popup.hide: Masquer

View File

@@ -0,0 +1,28 @@
// export const API_KEY_PERMISSIONS = {
// documents: {
// create: 'documents:create',
// },
// } as const;
export const API_KEY_PERMISSIONS = [
{
section: 'documents',
permissions: [
'documents:create',
'documents:read',
'documents:update',
'documents:delete',
],
},
{
section: 'tags',
permissions: [
'tags:create',
'tags:read',
'tags:update',
'tags:delete',
],
},
] as const;
export const API_KEY_PERMISSIONS_LIST = API_KEY_PERMISSIONS.flatMap(permission => permission.permissions);

View File

@@ -0,0 +1,56 @@
import type { ApiKey } from './api-keys.types';
import { apiClient } from '../shared/http/api-client';
import { coerceDates } from '../shared/http/http-client.models';
export async function createApiKey({
name,
permissions,
organizationIds,
allOrganizations,
expiresAt,
}: {
name: string;
permissions: string[];
organizationIds: string[];
allOrganizations: boolean;
expiresAt?: Date;
}) {
const { apiKey, token } = await apiClient<{
apiKey: ApiKey;
token: string;
}>({
path: '/api/api-keys',
method: 'POST',
body: {
name,
permissions,
organizationIds,
allOrganizations,
expiresAt,
},
});
return {
apiKey: coerceDates(apiKey),
token,
};
}
export async function fetchApiKeys() {
const { apiKeys } = await apiClient<{
apiKeys: ApiKey[];
}>({
path: '/api/api-keys',
});
return {
apiKeys: apiKeys.map(coerceDates),
};
}
export async function deleteApiKey({ apiKeyId }: { apiKeyId: string }) {
await apiClient({
path: `/api/api-keys/${apiKeyId}`,
method: 'DELETE',
});
}

View File

@@ -0,0 +1,12 @@
export type ApiKey = {
id: string;
name: string;
permissions: string[];
organizationIds: string[];
allOrganizations: boolean;
expiresAt?: Date;
prefix: string;
lastUsedAt?: Date;
createdAt: Date;
updatedAt: Date;
};

View File

@@ -0,0 +1,75 @@
import type { Component } from 'solid-js';
import type { LocaleKeys } from '@/modules/i18n/locales.types';
import { createSignal, For } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { Checkbox, CheckboxControl, CheckboxLabel } from '@/modules/ui/components/checkbox';
import { API_KEY_PERMISSIONS } from '../api-keys.constants';
export const ApiKeyPermissionsPicker: Component<{ permissions: string[]; onChange: (permissions: string[]) => void }> = (props) => {
const [permissions, setPermissions] = createSignal<string[]>(props.permissions);
const { t } = useI18n();
const getPermissionsSections = () => {
return API_KEY_PERMISSIONS.map(section => ({
...section,
title: t(`api-keys.permissions.${section.section}.title`),
permissions: section.permissions.map((permission) => {
const [prefix, suffix] = permission.split(':');
return {
name: permission,
prefix,
suffix,
description: t(`api-keys.permissions.${section.section}.${permission}` as LocaleKeys),
};
}),
}));
};
const isPermissionSelected = (permission: string) => {
return permissions().includes(permission);
};
const togglePermission = (permission: string) => {
setPermissions((prev) => {
if (prev.includes(permission)) {
return prev.filter(p => p !== permission);
}
return [...prev, permission];
});
props.onChange(permissions());
};
return (
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<For each={getPermissionsSections()}>
{section => (
<div>
<p class="text-muted-foreground text-xs">{section.title}</p>
<div class="pl-4 flex flex-col gap-4 mt-4">
<For each={section.permissions}>
{permission => (
<Checkbox
class="flex items-center gap-2"
checked={isPermissionSelected(permission.name)}
onChange={() => togglePermission(permission.name)}
>
<CheckboxControl />
<div class="flex flex-col gap-1">
<CheckboxLabel class="text-sm leading-none">
{permission.description}
</CheckboxLabel>
</div>
</Checkbox>
)}
</For>
</div>
</div>
)}
</For>
</div>
);
};

View File

@@ -0,0 +1,140 @@
import type { Component } from 'solid-js';
import type { ApiKey } from '../api-keys.types';
import { A } from '@solidjs/router';
import { useMutation, useQuery } from '@tanstack/solid-query';
import { format } from 'date-fns';
import { For, Match, Show, Suspense, Switch } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { useConfirmModal } from '@/modules/shared/confirm';
import { queryClient } from '@/modules/shared/query/query-client';
import { Button } from '@/modules/ui/components/button';
import { EmptyState } from '@/modules/ui/components/empty';
import { createToast } from '@/modules/ui/components/sonner';
import { deleteApiKey, fetchApiKeys } from '../api-keys.services';
export const ApiKeyCard: Component<{ apiKey: ApiKey }> = ({ apiKey }) => {
const { t } = useI18n();
const { confirm } = useConfirmModal();
const deleteApiKeyMutation = useMutation(() => ({
mutationFn: deleteApiKey,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['api-keys'] });
createToast({
message: t('api-keys.delete.success'),
});
},
}));
const handleDelete = async () => {
const confirmed = await confirm({
title: t('api-keys.delete.confirm.title'),
message: t('api-keys.delete.confirm.message'),
confirmButton: {
text: t('api-keys.delete.confirm.confirm-button'),
variant: 'destructive',
},
cancelButton: {
text: t('api-keys.delete.confirm.cancel-button'),
},
});
if (!confirmed) {
return;
}
deleteApiKeyMutation.mutate({ apiKeyId: apiKey.id });
};
return (
<div class="bg-card rounded-lg border p-4 flex items-center gap-4">
<div class="rounded-lg bg-muted p-2">
<div class="i-tabler-key text-muted-foreground size-5 text-primary" />
</div>
<div class="flex-1">
<h2 class="text-sm font-medium leading-tight">{apiKey.name}</h2>
<p class="text-muted-foreground text-xs font-mono">{`${apiKey.prefix}...`}</p>
</div>
<div>
<p class="text-muted-foreground text-xs">
{t('api-keys.list.card.last-used')}
{' '}
{apiKey.lastUsedAt ? format(apiKey.lastUsedAt, 'MMM d, yyyy') : t('api-keys.list.card.never')}
</p>
<p class="text-muted-foreground text-xs">
{t('api-keys.list.card.created')}
{' '}
{format(apiKey.createdAt, 'MMM d, yyyy')}
</p>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
isLoading={deleteApiKeyMutation.isPending}
onClick={handleDelete}
>
<div class="i-tabler-trash text-muted-foreground size-4" />
</Button>
</div>
</div>
);
};
export const ApiKeysPage: Component = () => {
const { t } = useI18n();
const query = useQuery(() => ({
queryKey: ['api-keys'],
queryFn: () => fetchApiKeys(),
}));
return (
<div class="p-6 mt-12 pb-32 mx-auto max-w-xl w-full">
<div class="border-b pb-4 flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold mb-1">{t('api-keys.list.title')}</h1>
<p class="text-muted-foreground">{t('api-keys.list.description')}</p>
</div>
<div>
<Show when={query.data?.apiKeys?.length}>
<Button as={A} href="/api-keys/create" class="gap-2">
<div class="i-tabler-plus size-4" />
{t('api-keys.list.create')}
</Button>
</Show>
</div>
</div>
<Suspense>
<Switch>
<Match when={query.data?.apiKeys?.length === 0}>
<EmptyState
title={t('api-keys.list.empty.title')}
description={t('api-keys.list.empty.description')}
icon="i-tabler-key"
cta={(
<Button as={A} href="/api-keys/create" class="gap-2">
<div class="i-tabler-plus size-4" />
{t('api-keys.list.create')}
</Button>
)}
/>
</Match>
<Match when={query.data?.apiKeys?.length}>
<div class="mt-6 flex flex-col gap-2">
<For each={query.data?.apiKeys}>
{apiKey => (
<ApiKeyCard apiKey={apiKey} />
)}
</For>
</div>
</Match>
</Switch>
</Suspense>
</div>
);
};

View File

@@ -0,0 +1,118 @@
import type { Component } from 'solid-js';
import { setValue } from '@modular-forms/solid';
import { A } from '@solidjs/router';
import { createSignal, Show } from 'solid-js';
import * as v from 'valibot';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createForm } from '@/modules/shared/form/form';
import { queryClient } from '@/modules/shared/query/query-client';
import { CopyButton } from '@/modules/shared/utils/copy';
import { Button } from '@/modules/ui/components/button';
import { createToast } from '@/modules/ui/components/sonner';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { API_KEY_PERMISSIONS_LIST } from '../api-keys.constants';
import { createApiKey } from '../api-keys.services';
import { ApiKeyPermissionsPicker } from '../components/api-key-permissions-picker.component';
export const CreateApiKeyPage: Component = () => {
const { t } = useI18n();
const [getToken, setToken] = createSignal<string | null>(null);
const { form, Form, Field } = createForm({
onSubmit: async ({ name, permissions }) => {
const { token } = await createApiKey({
name,
permissions,
organizationIds: [],
allOrganizations: false,
});
await queryClient.invalidateQueries({ queryKey: ['api-keys'] });
setToken(token);
createToast({
type: 'success',
message: t('api-keys.create.success'),
});
},
schema: v.object({
name: v.pipe(
v.string(),
v.nonEmpty(t('api-keys.create.form.name.required')),
),
permissions: v.pipe(
v.array(v.picklist(API_KEY_PERMISSIONS_LIST as string[])),
v.nonEmpty(t('api-keys.create.form.permissions.required')),
),
}),
initialValues: {
name: '',
permissions: API_KEY_PERMISSIONS_LIST,
},
});
return (
<div class="p-6 mt-12 pb-32 mx-auto max-w-xl w-full">
<div class="border-b pb-4 mb-6">
<h1 class="text-2xl font-bold">{t('api-keys.create.title')}</h1>
<p class="text-sm text-muted-foreground">{t('api-keys.create.description')}</p>
</div>
<Show when={getToken()}>
<div class="bg-card border p-6 rounded-md mt-6">
<h2 class="text-lg font-semibold mb-2">{t('api-keys.create.created.title')}</h2>
<p class="text-sm text-muted-foreground mb-4">{t('api-keys.create.created.description')}</p>
<TextFieldRoot class="flex items-center gap-2 space-y-0">
<TextField type="text" placeholder={t('api-keys.create.form.name.placeholder')} value={getToken() ?? ''} />
<CopyButton text={getToken() ?? ''} />
</TextFieldRoot>
</div>
<div class="flex justify-end mt-6">
<Button type="button" variant="secondary" as={A} href="/api-keys">
{t('api-keys.create.back')}
</Button>
</div>
</Show>
<Show when={!getToken()}>
<Form>
<Field name="name">
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col mb-6">
<TextFieldLabel for="name">{t('api-keys.create.form.name.label')}</TextFieldLabel>
<TextField type="text" id="name" placeholder={t('api-keys.create.form.name.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</TextFieldRoot>
)}
</Field>
<Field name="permissions" type="string[]">
{field => (
<div>
<p class="text-sm font-bold">{t('api-keys.create.form.permissions.label')}</p>
<div class="p-6 pb-8 border rounded-md mt-2">
<ApiKeyPermissionsPicker permissions={field.value ?? []} onChange={permissions => setValue(form, 'permissions', permissions)} />
</div>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</div>
)}
</Field>
<div class="flex justify-end mt-6">
<Button type="submit" isLoading={form.submitting}>
{t('api-keys.create.form.submit')}
</Button>
</div>
</Form>
</Show>
</div>
);
};

View File

@@ -0,0 +1,12 @@
export const ssoProviders = [
{
key: 'google',
name: 'Google',
icon: 'i-tabler-brand-google-filled',
},
{
key: 'github',
name: 'GitHub',
icon: 'i-tabler-brand-github',
},
] as const;

View File

@@ -0,0 +1,33 @@
import type { createAuthClient } from './auth.services';
export function createDemoAuthClient() {
const baseClient = {
useSession: () => () => ({
isPending: false,
data: {
user: {
id: '1',
email: 'test@test.com',
},
},
}),
signIn: {
email: () => Promise.resolve({}),
social: () => Promise.resolve({}),
},
signOut: () => Promise.resolve({}),
signUp: () => Promise.resolve({}),
forgetPassword: () => Promise.resolve({}),
resetPassword: () => Promise.resolve({}),
sendVerificationEmail: () => Promise.resolve({}),
};
return new Proxy(baseClient, {
get: (target, prop) => {
if (!(prop in target)) {
console.warn(`Accessing undefined property "${String(prop)}" in demo auth client`);
}
return target[prop as keyof typeof target];
},
}) as unknown as ReturnType<typeof createAuthClient>;
}

View File

@@ -1,12 +1,23 @@
export {
isAccessTokenExpired,
};
import type { Config } from '../config/config';
import type { SsoProviderConfig } from './auth.types';
import { get } from 'lodash-es';
import { ssoProviders } from './auth.constants';
function isAccessTokenExpired({ accessToken }: { accessToken: string }) {
try {
const token = JSON.parse(atob(accessToken.split('.')[1]));
return token.exp < Date.now() / 1000;
} catch {
return true;
}
export function isAuthErrorWithCode({ error, code }: { error: unknown; code: string }) {
return get(error, 'code') === code;
}
export const isEmailVerificationRequiredError = ({ error }: { error: unknown }) => isAuthErrorWithCode({ error, code: 'EMAIL_NOT_VERIFIED' });
export function getEnabledSsoProviderConfigs({ config }: { config: Config }): SsoProviderConfig[] {
const enabledSsoProviders: SsoProviderConfig[] = [
...ssoProviders.filter(({ key }) => get(config, `auth.providers.${key}.isEnabled`)),
...config.auth.providers.customs.map(({ providerId, providerName, providerIconUrl }) => ({
key: providerId,
name: providerName,
icon: providerIconUrl ?? 'i-tabler-login-2',
})),
];
return enabledSsoProviders;
}

View File

@@ -1,58 +1,60 @@
import { config } from '../config/config';
import { apiClient } from '../shared/http/api-client';
import { httpClient } from '../shared/http/http-client';
import type { Config } from '../config/config';
export { login };
import type { SsoProviderConfig } from './auth.types';
import { genericOAuthClient } from 'better-auth/client/plugins';
import { createAuthClient as createBetterAuthClient } from 'better-auth/solid';
import { buildTimeConfig } from '../config/config';
import { trackingServices } from '../tracking/tracking.services';
import { createDemoAuthClient } from './auth.demo.services';
async function login({ email, password }: { email: string; password: string }) {
const { accessToken } = await apiClient<{ accessToken: string }>({
path: 'api/auth/login',
method: 'POST',
body: {
email,
password,
export function createAuthClient() {
const client = createBetterAuthClient({
baseURL: buildTimeConfig.baseApiUrl,
plugins: [
genericOAuthClient(),
],
});
return {
// we can't spread the client because it is a proxy object
signIn: client.signIn,
signUp: client.signUp,
forgetPassword: client.forgetPassword,
resetPassword: client.resetPassword,
sendVerificationEmail: client.sendVerificationEmail,
useSession: client.useSession,
signOut: async () => {
trackingServices.capture({ event: 'User logged out' });
const result = await client.signOut();
trackingServices.reset();
return result;
},
});
return { accessToken };
};
}
export async function requestAuthTokensRefresh() {
// Do not use apiClient here top prevent loops since requestAuthTokensRefresh might be called from apiClient
const { accessToken } = await httpClient<{ accessToken: string }>({
baseUrl: config.baseApiUrl,
url: '/api/auth/refresh',
method: 'POST',
export const {
useSession,
signIn,
signUp,
signOut,
forgetPassword,
resetPassword,
sendVerificationEmail,
} = buildTimeConfig.isDemoMode
? createDemoAuthClient()
: createAuthClient();
// Required to send the refresh token
credentials: 'include',
});
export async function authWithProvider({ provider, config }: { provider: SsoProviderConfig; config: Config }) {
const isCustomProvider = config.auth.providers.customs.some(({ providerId }) => providerId === provider.key);
return { accessToken };
}
export async function logout() {
await apiClient({
path: '/api/auth/logout',
method: 'POST',
credentials: 'include',
});
}
export async function requestMagicLink({ email }: { email: string }) {
await apiClient({
path: '/api/auth/magic-link',
method: 'POST',
body: { email },
});
}
export async function verifyMagicLink({ token }: { token: string }) {
const { accessToken } = await apiClient<{ accessToken: string }>({
path: '/api/auth/magic-link/verification',
method: 'POST',
body: { token },
});
return { accessToken };
if (isCustomProvider) {
signIn.oauth2({
providerId: provider.key,
callbackURL: config.baseUrl,
});
return;
}
await signIn.social({ provider: provider.key as 'github' | 'google', callbackURL: config.baseUrl });
}

View File

@@ -1,87 +0,0 @@
import { safely } from '@corentinth/chisels';
import { makePersisted } from '@solid-primitives/storage';
import { createRoot, createSignal } from 'solid-js';
import { createHook, createWaitForHook } from '../shared/hooks/hooks';
import { isAccessTokenExpired } from './auth.models';
import { logout, requestAuthTokensRefresh } from './auth.services';
export const authStore = createRoot(() => {
const [getAccessToken, setAccessTokenValue] = makePersisted(createSignal<string | null>(null), { name: 'papra_access_token', storage: localStorage });
const onAuthChangeHook = createHook<{ isAuthenticated: boolean }>();
const [getRedirectUrl, setRedirectUrl] = makePersisted(createSignal<string | null>(null), { name: 'papra_redirect_url', storage: localStorage });
const [getIsRefreshTokenInProgress, setIsRefreshTokenInProgress] = createSignal(false);
const [getMagicLinkRequestEmail, setMagicLinkRequestEmail] = createSignal<string | undefined>(undefined);
const waitForRefreshTokenHook = createWaitForHook();
const refreshToken = async () => {
setIsRefreshTokenInProgress(true);
const { accessToken } = await requestAuthTokensRefresh();
setAccessTokenValue(accessToken);
setIsRefreshTokenInProgress(false);
waitForRefreshTokenHook.trigger();
await onAuthChangeHook.trigger({ isAuthenticated: false });
};
const getIsAuthenticated = async () => {
const accessToken = getAccessToken();
if (!accessToken) {
return false;
}
const isExpired = isAccessTokenExpired({ accessToken });
if (isExpired && !getIsRefreshTokenInProgress()) {
await safely(refreshToken());
}
return Boolean(getAccessToken());
};
const setAccessToken = async ({ accessToken }: { accessToken: string }) => {
setAccessTokenValue(accessToken);
await onAuthChangeHook.trigger({ isAuthenticated: true });
};
const clearAccessToken = async () => {
setAccessTokenValue(null);
await onAuthChangeHook.trigger({ isAuthenticated: false });
};
return {
setAccessToken,
getAccessToken,
clearAccessToken,
getIsAuthenticated,
getRedirectUrl,
setRedirectUrl,
getIsRefreshTokenInProgress,
setIsRefreshTokenInProgress,
getMagicLinkRequestEmail,
setMagicLinkRequestEmail,
async waitForRefreshTokenToBeRefreshed() {
if (!getIsRefreshTokenInProgress()) {
return;
}
return waitForRefreshTokenHook.waitFor();
},
refreshToken,
async logout() {
await safely(logout());
await clearAccessToken();
window.location.href = '/login';
},
onAuthChange: onAuthChangeHook.on,
};
});

View File

@@ -0,0 +1,4 @@
import type { ssoProviders } from './auth.constants';
export type SsoProviderKey = (typeof ssoProviders)[number]['key'] | string & {};
export type SsoProviderConfig = { key: SsoProviderKey; name: string; icon: string };

Some files were not shown because too many files have changed in this diff Show More