├── .dockerignore ├── .github └── workflows │ ├── build-cloud-image.yml │ ├── build-standard-image.yml │ ├── frontend-build.yml │ ├── tests.yml │ └── version-bump.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── alembic.ini ├── artwork ├── logo-w160px.png ├── logo.png ├── logo.svg ├── logo2.png ├── logo2.svg ├── logo_transparent_bg.svg ├── papermerge3-3.png └── papermerge3.png ├── changelog.md ├── crowdin.yml ├── docker ├── __cloud__obsolete │ ├── Dockerfile │ ├── config │ │ ├── __init__.py │ │ ├── asgi.py │ │ ├── celery.py │ │ ├── settings.py │ │ ├── urls.py │ │ └── wsgi.py │ ├── etc │ │ ├── Caddyfile │ │ ├── logging.yaml │ │ └── supervisord.conf │ ├── manage.py │ └── run.bash ├── __prod__obsolete │ └── Dockerfile └── standard │ ├── Dockerfile │ ├── bundles │ ├── nginx │ │ ├── nginx.default.conf │ │ └── nginx.remote.conf │ └── supervisor │ │ ├── supervisord.default.conf │ │ └── supervisord.remote.conf │ ├── core.js.tmpl │ ├── entrypoint.sh │ ├── logging.yaml │ └── scripts │ ├── create_token.sh │ └── list_users.sh ├── frontend ├── .editorconfig ├── .gitattributes ├── .gitignore ├── .prettierrc.json ├── .yarn │ └── releases │ │ └── yarn-4.9.1.cjs ├── .yarnrc.yml ├── README.md ├── apps │ ├── hooks.dev │ │ ├── .gitignore │ │ ├── README.md │ │ ├── eslint.config.js │ │ ├── index.html │ │ ├── package.json │ │ ├── public │ │ │ └── vite.svg │ │ ├── src │ │ │ ├── App.css │ │ │ ├── App.tsx │ │ │ ├── assets │ │ │ │ └── react.svg │ │ │ ├── index.css │ │ │ ├── main.tsx │ │ │ └── vite-env.d.ts │ │ ├── tsconfig.app.json │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts │ └── ui │ │ ├── .gitignore │ │ ├── index.html │ │ ├── package.json │ │ ├── public │ │ ├── favicon.ico │ │ ├── favicon_transparent_bg.png │ │ ├── favicon_transparent_bg.svg │ │ ├── folder.svg │ │ ├── localization │ │ │ ├── de │ │ │ │ └── _default.json │ │ │ ├── en │ │ │ │ └── _default.json │ │ │ ├── kk │ │ │ │ └── _default.json │ │ │ └── ru │ │ │ │ └── _default.json │ │ └── logo_transparent_bg.svg │ │ ├── src │ │ ├── _mantine.scss │ │ ├── app │ │ │ ├── App.css │ │ │ ├── App.tsx │ │ │ ├── hooks.ts │ │ │ ├── listenerMiddleware.ts │ │ │ ├── store.ts │ │ │ ├── types.ts │ │ │ ├── utils.ts │ │ │ └── withTypes.ts │ │ ├── cconstants.ts │ │ ├── components │ │ │ ├── Breadcrumbs │ │ │ │ ├── Breadcrumbs.module.css │ │ │ │ ├── Breadcrumbs.tsx │ │ │ │ └── index.tsx │ │ │ ├── Check.tsx │ │ │ ├── ColorSchemeToggle │ │ │ │ └── ColorSchemeToggle.tsx │ │ │ ├── CopyButton.tsx │ │ │ ├── DualPanel │ │ │ │ ├── DualPanel.tsx │ │ │ │ ├── DuplicatePanelButton.tsx │ │ │ │ ├── ToggleSecondaryPanel.tsx │ │ │ │ └── index.tsx │ │ │ ├── EditNodeTags.tsx │ │ │ ├── Error.tsx │ │ │ ├── Header │ │ │ │ ├── Header.module.css │ │ │ │ ├── Header.tsx │ │ │ │ ├── LanguageMenu.tsx │ │ │ │ ├── Search.tsx │ │ │ │ ├── SidebarToggle.tsx │ │ │ │ └── UserMenu.tsx │ │ │ ├── ManageAccessButton.tsx │ │ │ ├── NavBar │ │ │ │ ├── NavBar.tsx │ │ │ │ └── index.tsx │ │ │ ├── NodeThumbnail │ │ │ │ ├── Thumbnail.tsx │ │ │ │ └── ThumbnailPlaceholder.tsx │ │ │ ├── OwnerSelect │ │ │ │ ├── OwnerSelect.tsx │ │ │ │ └── index.tsx │ │ │ ├── Pagination │ │ │ │ ├── Pagination.module.css │ │ │ │ ├── Pagination.tsx │ │ │ │ └── index.tsx │ │ │ ├── QuickFilter │ │ │ │ ├── QuickFilter.tsx │ │ │ │ └── index.tsx │ │ │ ├── ScheduleOCRProcess.tsx │ │ │ ├── ScheduleOCRProcessCheckbox │ │ │ │ ├── ScheduleOCRProcessCheckbox.tsx │ │ │ │ └── index.tsx │ │ │ ├── ShareButton.tsx │ │ │ ├── SharedBreadcrumb │ │ │ │ ├── Breadcrumbs.module.css │ │ │ │ ├── SharedBreadcrumb.tsx │ │ │ │ └── index.tsx │ │ │ ├── SinglePanel │ │ │ │ ├── SinglePanel.tsx │ │ │ │ └── index.tsx │ │ │ ├── TableSort │ │ │ │ ├── TableSort.module.css │ │ │ │ └── Th.tsx │ │ │ ├── Uploader │ │ │ │ ├── index.ts │ │ │ │ ├── uploader.tsx │ │ │ │ ├── uploaderItem.module.css │ │ │ │ └── uploaderItem.tsx │ │ │ └── document │ │ │ │ ├── ActionButtons.tsx │ │ │ │ ├── Contextmenu.tsx │ │ │ │ ├── DeleteEntireDocumentConfirm.tsx │ │ │ │ ├── DeletePagesButton.tsx │ │ │ │ ├── DocumentDetails │ │ │ │ ├── CustomFields.tsx │ │ │ │ ├── DocumentDetails.module.css │ │ │ │ ├── DocumentDetails.tsx │ │ │ │ └── index.tsx │ │ │ │ ├── DocumentDetailsToggle │ │ │ │ ├── DocumentDetailsToggle.module.css │ │ │ │ ├── DocumentDetailsToggle.tsx │ │ │ │ └── index.tsx │ │ │ │ ├── DownloadButton │ │ │ │ ├── DownloadButton.tsx │ │ │ │ └── index.tsx │ │ │ │ ├── EditTitleButton.tsx │ │ │ │ ├── MoveDocumentDialogConfirm.tsx │ │ │ │ ├── Page │ │ │ │ ├── Page.module.css │ │ │ │ ├── Page.tsx │ │ │ │ └── index.tsx │ │ │ │ ├── PageHaveChangedDialog.tsx │ │ │ │ ├── PageOCRDialog.tsx │ │ │ │ ├── Pages │ │ │ │ ├── Pages.module.css │ │ │ │ ├── Pages.tsx │ │ │ │ └── index.tsx │ │ │ │ ├── RotateButton.tsx │ │ │ │ ├── RotateCCButton.tsx │ │ │ │ ├── RunOCRButton.tsx │ │ │ │ ├── RunOCRModal.tsx │ │ │ │ ├── Thumbnail │ │ │ │ ├── Thumbnail.module.scss │ │ │ │ ├── Thumbnail.tsx │ │ │ │ └── index.tsx │ │ │ │ ├── Thumbnails │ │ │ │ ├── Thumbnails.module.css │ │ │ │ ├── Thumbnails.tsx │ │ │ │ └── index.tsx │ │ │ │ ├── ThumbnailsToggle │ │ │ │ ├── ThumbnailsToggle.module.css │ │ │ │ ├── ThumbnailsToggle.tsx │ │ │ │ └── index.tsx │ │ │ │ ├── TransferPagesModal.tsx │ │ │ │ ├── Viewer.module.css │ │ │ │ ├── Zoom │ │ │ │ ├── Zoom.module.css │ │ │ │ ├── Zoom.tsx │ │ │ │ └── index.tsx │ │ │ │ └── customFields │ │ │ │ ├── Boolean.tsx │ │ │ │ ├── Date.tsx │ │ │ │ ├── Monetary.tsx │ │ │ │ ├── YearMonth.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── types.ts │ │ ├── contexts │ │ │ └── PanelContext.ts │ │ ├── features │ │ │ ├── api │ │ │ │ └── slice.ts │ │ │ ├── auth │ │ │ │ └── slice.ts │ │ │ ├── custom-fields │ │ │ │ ├── apiSlice.ts │ │ │ │ ├── components │ │ │ │ │ ├── ActionButtons.tsx │ │ │ │ │ ├── CustomFieldForm.tsx │ │ │ │ │ ├── CustomFieldRow.tsx │ │ │ │ │ ├── DeleteButton.tsx │ │ │ │ │ ├── DeleteModal.tsx │ │ │ │ │ ├── Details.tsx │ │ │ │ │ ├── EditButton.tsx │ │ │ │ │ ├── EditCustomFieldModal.tsx │ │ │ │ │ ├── List.tsx │ │ │ │ │ ├── NewButton.tsx │ │ │ │ │ └── NewCustomFieldModal.tsx │ │ │ │ ├── customFieldsSlice.ts │ │ │ │ ├── pages │ │ │ │ │ ├── Details.tsx │ │ │ │ │ ├── List.tsx │ │ │ │ │ └── index.tsx │ │ │ │ └── types.ts │ │ │ ├── document-types │ │ │ │ ├── apiSlice.ts │ │ │ │ ├── components │ │ │ │ │ ├── ActionButtons.tsx │ │ │ │ │ ├── DeleteButton.tsx │ │ │ │ │ ├── DeleteModal.tsx │ │ │ │ │ ├── Details.tsx │ │ │ │ │ ├── DocumentTypeForm.tsx │ │ │ │ │ ├── DocumentTypeRow.tsx │ │ │ │ │ ├── EditButton.tsx │ │ │ │ │ ├── EditDocumentTypeModal.tsx │ │ │ │ │ ├── List.tsx │ │ │ │ │ ├── NewButton.tsx │ │ │ │ │ └── NewDocumentTypeModal.tsx │ │ │ │ ├── documentTypesSlice.ts │ │ │ │ ├── pages │ │ │ │ │ ├── Details.tsx │ │ │ │ │ ├── List.tsx │ │ │ │ │ └── index.tsx │ │ │ │ └── types.ts │ │ │ ├── document │ │ │ │ ├── apiSlice.ts │ │ │ │ ├── components │ │ │ │ │ ├── Viewer.tsx │ │ │ │ │ └── index.tsx │ │ │ │ ├── documentVersSlice.ts │ │ │ │ ├── imageObjectsSlice.ts │ │ │ │ ├── pagesSlice.ts │ │ │ │ └── selectors.ts │ │ │ ├── groups │ │ │ │ ├── apiSlice.ts │ │ │ │ ├── components │ │ │ │ │ ├── ActionButtons.tsx │ │ │ │ │ ├── DeleteButton.tsx │ │ │ │ │ ├── DeleteModal.tsx │ │ │ │ │ ├── EditButton.tsx │ │ │ │ │ ├── EditGroupModal.tsx │ │ │ │ │ ├── GroupDetails.tsx │ │ │ │ │ ├── GroupForm.tsx │ │ │ │ │ ├── GroupRow.tsx │ │ │ │ │ ├── List.tsx │ │ │ │ │ ├── NewButton.tsx │ │ │ │ │ ├── NewGroupModal.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── groupsSlice.ts │ │ │ │ └── pages │ │ │ │ │ ├── Details.tsx │ │ │ │ │ ├── List.tsx │ │ │ │ │ └── index.tsx │ │ │ ├── nodes │ │ │ │ ├── apiSlice.ts │ │ │ │ ├── components │ │ │ │ │ ├── Commander │ │ │ │ │ │ ├── DocumentsByTypeCommander │ │ │ │ │ │ │ ├── ActionButtons.tsx │ │ │ │ │ │ │ ├── ColumnsMenu.tsx │ │ │ │ │ │ │ ├── DocumentRow.tsx │ │ │ │ │ │ │ ├── DocumentTypeFilter.tsx │ │ │ │ │ │ │ ├── DocumentsByTypeCommander.tsx │ │ │ │ │ │ │ ├── TableSort.module.css │ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ │ └── types.ts │ │ │ │ │ │ ├── NodesCommander │ │ │ │ │ │ │ ├── Commander.module.scss │ │ │ │ │ │ │ ├── DeleteButton.tsx │ │ │ │ │ │ │ ├── DeleteModal.tsx │ │ │ │ │ │ │ ├── DraggingIcon.tsx │ │ │ │ │ │ │ ├── DropFiles.tsx │ │ │ │ │ │ │ ├── DropNodesDialog.tsx │ │ │ │ │ │ │ ├── EditNodeTagsButton.tsx │ │ │ │ │ │ │ ├── EditNodeTitleButton.tsx │ │ │ │ │ │ │ ├── ExtractPagesModal.tsx │ │ │ │ │ │ │ ├── FolderNodeActions.tsx │ │ │ │ │ │ │ ├── NewFolderButton.tsx │ │ │ │ │ │ │ ├── Node │ │ │ │ │ │ │ │ ├── Document │ │ │ │ │ │ │ │ │ ├── Document.module.scss │ │ │ │ │ │ │ │ │ └── Document.tsx │ │ │ │ │ │ │ │ ├── Folder │ │ │ │ │ │ │ │ │ ├── Folder.module.scss │ │ │ │ │ │ │ │ │ └── Folder.tsx │ │ │ │ │ │ │ │ ├── Node.module.scss │ │ │ │ │ │ │ │ ├── Node.tsx │ │ │ │ │ │ │ │ ├── Tags │ │ │ │ │ │ │ │ │ ├── Tags.module.css │ │ │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ │ ├── NodesCommander.tsx │ │ │ │ │ │ │ ├── NodesList.tsx │ │ │ │ │ │ │ ├── SortMenu.tsx │ │ │ │ │ │ │ ├── UploadButton.tsx │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ ├── ViewOptionsMenu.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── EditNodeTitle.tsx │ │ │ │ │ ├── NewFolder.tsx │ │ │ │ │ └── index.tsx │ │ │ │ ├── nodesSlice.ts │ │ │ │ ├── pages │ │ │ │ │ ├── CategoryListView.tsx │ │ │ │ │ └── index.tsx │ │ │ │ ├── selectors.ts │ │ │ │ ├── thumbnailObjectsSlice.ts │ │ │ │ └── uploadFile.ts │ │ │ ├── roles │ │ │ │ ├── apiSlice.ts │ │ │ │ ├── components │ │ │ │ │ ├── ActionButtons.tsx │ │ │ │ │ ├── DeleteButton.tsx │ │ │ │ │ ├── DeleteModal.tsx │ │ │ │ │ ├── EditButton.tsx │ │ │ │ │ ├── EditRoleModal.tsx │ │ │ │ │ ├── List.tsx │ │ │ │ │ ├── NewButton.tsx │ │ │ │ │ ├── NewRoleModal.tsx │ │ │ │ │ ├── RoleDetails.tsx │ │ │ │ │ ├── RoleForm.tsx │ │ │ │ │ ├── RoleRow.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── pages │ │ │ │ │ ├── Details.tsx │ │ │ │ │ ├── List.tsx │ │ │ │ │ └── index.tsx │ │ │ │ └── rolesSlice.ts │ │ │ ├── search │ │ │ │ ├── apiSlice.ts │ │ │ │ ├── components │ │ │ │ │ ├── ActionButtons.tsx │ │ │ │ │ ├── Breadcrumb.tsx │ │ │ │ │ ├── GoBackButton.tsx │ │ │ │ │ ├── OpenInOtherPanelCheckbox.tsx │ │ │ │ │ ├── SearchResultItem.tsx │ │ │ │ │ ├── SearchResultItems.tsx │ │ │ │ │ ├── SearchResults.module.css │ │ │ │ │ ├── SearchResults.tsx │ │ │ │ │ ├── Tags.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── item.module.css │ │ │ │ └── searchSlice.ts │ │ │ ├── shared_nodes │ │ │ │ ├── apiSlice.ts │ │ │ │ ├── components │ │ │ │ │ ├── ManageAccessModal │ │ │ │ │ │ ├── GroupAccessButtons.tsx │ │ │ │ │ │ ├── GroupRow.tsx │ │ │ │ │ │ ├── ManageAccessGroups.tsx │ │ │ │ │ │ ├── ManageAccessModal.tsx │ │ │ │ │ │ ├── ManageAccessUsers.tsx │ │ │ │ │ │ ├── ManageRole.tsx │ │ │ │ │ │ ├── UserAccessButtons.tsx │ │ │ │ │ │ ├── UserRow.tsx │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── type.ts │ │ │ │ │ ├── ShareNodesModal │ │ │ │ │ │ ├── SelectGroups.tsx │ │ │ │ │ │ ├── SelectRoles.tsx │ │ │ │ │ │ ├── SelectUsers.tsx │ │ │ │ │ │ ├── ShareNodesModal.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── SharedCommander │ │ │ │ │ │ ├── Commander.module.scss │ │ │ │ │ │ ├── EditNodeTagsButton.tsx │ │ │ │ │ │ ├── EditNodeTitleButton.tsx │ │ │ │ │ │ ├── FolderNodeActions.tsx │ │ │ │ │ │ ├── Node │ │ │ │ │ │ │ ├── Document │ │ │ │ │ │ │ │ ├── Document.module.scss │ │ │ │ │ │ │ │ └── Document.tsx │ │ │ │ │ │ │ ├── Folder │ │ │ │ │ │ │ │ ├── Folder.module.scss │ │ │ │ │ │ │ │ └── Folder.tsx │ │ │ │ │ │ │ ├── Node.module.scss │ │ │ │ │ │ │ ├── Node.tsx │ │ │ │ │ │ │ ├── Tags │ │ │ │ │ │ │ │ ├── Tags.module.css │ │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ ├── NodesList.tsx │ │ │ │ │ │ ├── ShareTypeSwitch.tsx │ │ │ │ │ │ ├── SharedCommander.tsx │ │ │ │ │ │ ├── SortMenu.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── SharedViewer │ │ │ │ │ │ ├── ActionButtons.tsx │ │ │ │ │ │ ├── SharedViewer.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ ├── pages │ │ │ │ │ ├── SharedDocumentView.tsx │ │ │ │ │ ├── SharedFolderView.tsx │ │ │ │ │ ├── SharedNodesListView.tsx │ │ │ │ │ └── index.tsx │ │ │ │ └── sharedNodesSlice.ts │ │ │ ├── tags │ │ │ │ ├── apiSlice.ts │ │ │ │ ├── components │ │ │ │ │ ├── ActionButtons.tsx │ │ │ │ │ ├── DeleteButton.tsx │ │ │ │ │ ├── DeleteModal.tsx │ │ │ │ │ ├── EditButton.tsx │ │ │ │ │ ├── EditTagModal.tsx │ │ │ │ │ ├── List.tsx │ │ │ │ │ ├── NewButton.tsx │ │ │ │ │ ├── NewTagModal.tsx │ │ │ │ │ ├── TagDetails.tsx │ │ │ │ │ ├── TagForm.tsx │ │ │ │ │ └── TagRow.tsx │ │ │ │ ├── pages │ │ │ │ │ ├── Details.tsx │ │ │ │ │ ├── List.tsx │ │ │ │ │ └── index.tsx │ │ │ │ ├── tagsSlice.ts │ │ │ │ └── types.ts │ │ │ ├── tasks │ │ │ │ └── apiSlice.ts │ │ │ ├── ui │ │ │ │ └── uiSlice.ts │ │ │ ├── users │ │ │ │ ├── apiSlice.ts │ │ │ │ ├── components │ │ │ │ │ ├── ActionButtons.tsx │ │ │ │ │ ├── ChangePasswordButton.tsx │ │ │ │ │ ├── DeleteButton.tsx │ │ │ │ │ ├── DeleteModal.tsx │ │ │ │ │ ├── EditButton.tsx │ │ │ │ │ ├── EditUserModal.tsx │ │ │ │ │ ├── List.tsx │ │ │ │ │ ├── NewButton.tsx │ │ │ │ │ ├── NewUserModal.tsx │ │ │ │ │ ├── UserDetails.tsx │ │ │ │ │ ├── UserForm.tsx │ │ │ │ │ ├── UserRow.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── validators.ts │ │ │ │ ├── pages │ │ │ │ │ ├── Details.tsx │ │ │ │ │ ├── List.tsx │ │ │ │ │ └── index.tsx │ │ │ │ └── usersSlice.ts │ │ │ └── version │ │ │ │ └── apiSlice.ts │ │ ├── hooks │ │ │ ├── PageImagePolling.ts │ │ │ ├── runtime_config.tsx │ │ │ └── userUser.ts │ │ ├── httpClient.ts │ │ ├── index.css │ │ ├── initializeI18n.ts │ │ ├── main.tsx │ │ ├── pages │ │ │ ├── Document.tsx │ │ │ ├── Error.tsx │ │ │ ├── Folder.tsx │ │ │ ├── Home.tsx │ │ │ ├── Inbox.tsx │ │ │ └── errors │ │ │ │ ├── AccessForbidden.tsx │ │ │ │ ├── NotFound.tsx │ │ │ │ ├── UnprocessableContent.tsx │ │ │ │ └── index.tsx │ │ ├── router.tsx │ │ ├── scopes.ts │ │ ├── services │ │ │ └── helpers.ts │ │ ├── slices │ │ │ └── currentUser.ts │ │ ├── themes │ │ │ ├── blue.ts │ │ │ ├── brown.ts │ │ │ ├── gray.ts │ │ │ ├── green.ts │ │ │ └── index.ts │ │ ├── types.d │ │ │ ├── common.ts │ │ │ ├── groups.ts │ │ │ ├── node_thumbnail.ts │ │ │ ├── page_image.ts │ │ │ ├── shared_nodes.ts │ │ │ └── ui.ts │ │ ├── types.ts │ │ ├── types │ │ │ ├── breadcrumb.ts │ │ │ ├── document.ts │ │ │ ├── index.ts │ │ │ ├── ocr.ts │ │ │ └── runtime_config.ts │ │ ├── utils.ts │ │ └── vite-env.d.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts ├── package.json ├── packages │ └── @papermerge │ │ └── hooks │ │ ├── README.md │ │ ├── package.json │ │ ├── src │ │ └── index.ts │ │ └── tsconfig.json ├── tsconfig.base.json ├── tsconfig.json └── yarn.lock ├── papermerge ├── app.py ├── celery_app.py ├── cli.py ├── core │ ├── __init__.py │ ├── alembic │ │ ├── README │ │ ├── env.py │ │ ├── script.py.mako │ │ └── versions │ │ │ ├── 1240862ec13d_update_documents_preview_status_type_as_.py │ │ │ ├── 1a5a9bffcad4_add_roles_table.py │ │ │ ├── 2118951c4d90_add_shared_nodes.py │ │ │ ├── 2518bf648ffd_add_group_ownership_related_columns.py │ │ │ ├── 4a2bc1bb17ae_add_pages_preview_status_and_pages_.py │ │ │ ├── 85fda75f19f1_make_document_type_name_unique_for_user_.py │ │ │ ├── 88b6b2d497ea_add_ondelete_cascase_to_node_tags.py │ │ │ ├── 973801cf0c71_add_group_flags_delete_me_and_delete_.py │ │ │ ├── a03014b93c1e_add_documents_preview_status_field.py │ │ │ ├── bafd773c8533_add_on_casecase_delete_for_tags_.py │ │ │ ├── bc29f69daca4_initial_migration.py │ │ │ ├── cea868700f4e_add_value_yearmonth_and_value_year_.py │ │ │ └── f0e0da122a9a_alter_document_type_id_to_set_null.py │ ├── cache │ │ ├── __init__.py │ │ ├── empty.py │ │ └── redis_client.py │ ├── cli │ │ ├── cf_sign_url.py │ │ ├── docs.py │ │ ├── perms.py │ │ ├── scopes.py │ │ └── token.py │ ├── cloudfront.py │ ├── config.py │ ├── constants.py │ ├── db │ │ ├── __init__.py │ │ ├── base.py │ │ ├── common.py │ │ ├── engine.py │ │ ├── exceptions.py │ │ └── nodes.py │ ├── dbapi.py │ ├── exceptions.py │ ├── features │ │ ├── __init__.py │ │ ├── auth │ │ │ ├── __init__.py │ │ │ ├── remote_scheme.py │ │ │ ├── scopes.py │ │ │ └── tests │ │ │ │ ├── conftest.py │ │ │ │ └── test_auth.py │ │ ├── conftest.py │ │ ├── custom_fields │ │ │ ├── db │ │ │ │ ├── api.py │ │ │ │ └── orm.py │ │ │ ├── router.py │ │ │ ├── schema.py │ │ │ ├── tests │ │ │ │ ├── conftest.py │ │ │ │ ├── test_dbapi_custom_fields.py │ │ │ │ ├── test_router_custom_fields.py │ │ │ │ └── test_schema_custom_fields.py │ │ │ └── types.py │ │ ├── document │ │ │ ├── cli │ │ │ │ └── cli.py │ │ │ ├── db │ │ │ │ ├── api.py │ │ │ │ ├── orm.py │ │ │ │ └── selectors.py │ │ │ ├── ordered_document_cfv.py │ │ │ ├── router.py │ │ │ ├── router_document_version.py │ │ │ ├── router_pages.py │ │ │ ├── s3.py │ │ │ ├── schema.py │ │ │ └── tests │ │ │ │ ├── conftest.py │ │ │ │ ├── resources │ │ │ │ ├── d3.pdf │ │ │ │ ├── dummy.txt │ │ │ │ ├── living-things.pdf │ │ │ │ ├── one-page.png │ │ │ │ ├── s3.pdf │ │ │ │ └── three-pages.pdf │ │ │ │ ├── test_dbapi_document.py │ │ │ │ ├── test_document_router.py │ │ │ │ ├── test_document_schema.py │ │ │ │ ├── test_document_version_router.py │ │ │ │ ├── test_ordered_document_cfv.py │ │ │ │ ├── test_pages_router.py │ │ │ │ ├── test_router_document_access_negatives.py │ │ │ │ └── test_selectors.py │ │ ├── document_types │ │ │ ├── db │ │ │ │ ├── api.py │ │ │ │ └── orm.py │ │ │ ├── router.py │ │ │ ├── schema.py │ │ │ ├── tests │ │ │ │ ├── test_document_type.py │ │ │ │ ├── test_document_type_dbapi.py │ │ │ │ └── test_router_document_types.py │ │ │ └── types.py │ │ ├── groups │ │ │ ├── cli │ │ │ │ └── cli.py │ │ │ ├── db │ │ │ │ ├── api.py │ │ │ │ └── orm.py │ │ │ ├── router.py │ │ │ ├── schema.py │ │ │ └── tests │ │ │ │ ├── test_models_groups.py │ │ │ │ └── test_router_groups.py │ │ ├── liveness_probe │ │ │ ├── router.py │ │ │ └── tests │ │ │ │ └── test_router.py │ │ ├── nodes │ │ │ ├── db │ │ │ │ ├── api.py │ │ │ │ └── orm.py │ │ │ ├── events.py │ │ │ ├── router.py │ │ │ ├── router_folders.py │ │ │ ├── router_thumbnails.py │ │ │ ├── schema.py │ │ │ └── tests │ │ │ │ ├── test_common.py │ │ │ │ ├── test_db_orm_nodes.py │ │ │ │ ├── test_implicit_ownership_transfer.py │ │ │ │ ├── test_nodes_router.py │ │ │ │ ├── test_nodes_schema.py │ │ │ │ ├── test_router_nodes_access_negatives.py │ │ │ │ └── test_thumbnails_router.py │ │ ├── page_mngm │ │ │ ├── db │ │ │ │ └── api.py │ │ │ └── test_page_mngm.py │ │ ├── roles │ │ │ ├── cli │ │ │ │ └── cli.py │ │ │ ├── db │ │ │ │ ├── api.py │ │ │ │ └── orm.py │ │ │ ├── router.py │ │ │ ├── schema.py │ │ │ └── tests │ │ │ │ ├── test_models_roles.py │ │ │ │ └── test_router_roles.py │ │ ├── shared_nodes │ │ │ ├── db │ │ │ │ ├── api.py │ │ │ │ └── orm.py │ │ │ ├── router.py │ │ │ ├── router_documents.py │ │ │ ├── router_folders.py │ │ │ ├── schema.py │ │ │ └── tests │ │ │ │ ├── test_get_shared_node_access.py │ │ │ │ ├── test_model_shared_nodes.py │ │ │ │ ├── test_router_access_to_shared_nodes.py │ │ │ │ └── test_update_shared_node_access.py │ │ ├── tags │ │ │ ├── db │ │ │ │ ├── api.py │ │ │ │ └── orm.py │ │ │ ├── router.py │ │ │ ├── schema.py │ │ │ ├── tests │ │ │ │ ├── conftest.py │ │ │ │ ├── test_orm_tags.py │ │ │ │ ├── test_router_tags.py │ │ │ │ └── test_tags_schema.py │ │ │ └── types.py │ │ ├── tasks │ │ │ ├── router.py │ │ │ └── schema.py │ │ └── users │ │ │ ├── cli │ │ │ └── cli.py │ │ │ ├── db │ │ │ ├── api.py │ │ │ └── orm.py │ │ │ ├── router.py │ │ │ ├── schema.py │ │ │ └── tests │ │ │ ├── __init__.py │ │ │ ├── test_dbapi_get_user_details.py │ │ │ ├── test_dbapi_users.py │ │ │ ├── test_users_router.py │ │ │ ├── test_users_schema.py │ │ │ └── utils.py │ ├── lib │ │ ├── __init__.py │ │ ├── lang.py │ │ ├── mime.py │ │ ├── pagecount.py │ │ └── utils.py │ ├── log.py │ ├── models │ │ ├── __init__.py │ │ ├── document.py │ │ ├── document_version.py │ │ ├── folder.py │ │ ├── node.py │ │ ├── page.py │ │ └── utils.py │ ├── orm.py │ ├── pathlib.py │ ├── routers │ │ ├── __init__.py │ │ ├── common.py │ │ ├── ocr_languanges.py │ │ ├── params.py │ │ ├── scopes.py │ │ └── version.py │ ├── schema.py │ ├── schemas │ │ ├── __init__.py │ │ ├── common.py │ │ ├── error.py │ │ ├── perms.py │ │ ├── scopes.py │ │ ├── token.py │ │ └── version.py │ ├── storage.py │ ├── tasks.py │ ├── tests │ │ ├── resource_file.py │ │ ├── test_base64.py │ │ ├── test_misc_utils.py │ │ ├── test_pathlib.py │ │ ├── types.py │ │ └── utils.py │ ├── types.py │ ├── utils │ │ ├── __init__.py │ │ ├── base64.py │ │ ├── decorators.py │ │ ├── image.py │ │ └── misc.py │ └── version.py └── search │ ├── __init__.py │ ├── cli │ ├── index.py │ ├── index_schema.py │ └── search.py │ ├── routers │ ├── __init__.py │ └── search.py │ └── schema.py ├── poetry.lock └── pyproject.toml /.dockerignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | *.pyo 4 | .eggs 5 | .git 6 | .pytest-cache 7 | __pycache__ 8 | build 9 | dist 10 | tmp 11 | ui2/.yarn/sdks/ 12 | ui2/.yarn/*.gz 13 | ui2/.yarn/unplugged/ 14 | ui2/dist 15 | ui2/node_modules 16 | ui2/.vscode 17 | ui2/.pnp.cjs 18 | ui2/.pnp.loader.mjs 19 | ui2/.env.development.local 20 | ui2/public/papermerge-runtime-config.js 21 | -------------------------------------------------------------------------------- /.github/workflows/build-cloud-image.yml: -------------------------------------------------------------------------------- 1 | name: Build Cloud Image 2 | 3 | on: 4 | push: 5 | tags: 6 | - '[0-9]+.[0-9]+.[0-9]+\-cloud\-rc[0-9]+' 7 | - '[0-9]+.[0-9]+.[0-9]+\-cloud\-b[0-9]+' 8 | - '[0-9]+.[0-9]+.[0-9]+\-cloud' 9 | - '[0-9]+.[0-9]+\-cloud\-rc[0-9]+' 10 | - '[0-9]+.[0-9]+\-cloud\-b[0-9]+' 11 | - '[0-9]+.[0-9]+\-cloud' 12 | 13 | jobs: 14 | build: 15 | name: Build 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | with: 20 | ref: ${{ github.ref }} # checkout current branch 21 | - name: Set up QEMU 22 | uses: docker/setup-qemu-action@v2 23 | - name: Set up Docker Buildx 24 | uses: docker/setup-buildx-action@v2 25 | - name: Login to DockerHub 26 | uses: docker/login-action@v2 27 | with: 28 | username: ${{ secrets.DOCKERHUB_USERNAME }} 29 | password: ${{ secrets.DOCKERHUB_TOKEN }} 30 | - name: Build ${{ github.ref_name }} Prod Image 31 | uses: docker/build-push-action@v3 32 | with: 33 | push: true 34 | tags: papermerge/papermerge:${{ github.ref_name }} 35 | file: docker/cloud/Dockerfile 36 | platforms: linux/amd64 37 | -------------------------------------------------------------------------------- /.github/workflows/build-standard-image.yml: -------------------------------------------------------------------------------- 1 | name: Build Standard Image 2 | 3 | on: 4 | push: 5 | tags: 6 | - '[0-9]+.[0-9]+.[0-9]+a[0-9]+' 7 | - '[0-9]+.[0-9]+.[0-9]+b[0-9]+' 8 | - '[0-9]+.[0-9]+.[0-9]+rc[0-9]+' 9 | - '[0-9]+.[0-9]+.[0-9]+' 10 | - '[0-9]+.[0-9]+a[0-9]+' 11 | - '[0-9]+.[0-9]+b[0-9]+' 12 | - '[0-9]+.[0-9]+rc[0-9]+' 13 | - '[0-9]+.[0-9]+' 14 | - '[0-9]+' 15 | 16 | jobs: 17 | build: 18 | name: Build 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v3 22 | with: 23 | ref: ${{ github.ref }} # checkout current branch 24 | - name: Set up QEMU 25 | uses: docker/setup-qemu-action@v2 26 | - name: Set up Docker Buildx 27 | uses: docker/setup-buildx-action@v2 28 | - name: Login to DockerHub 29 | uses: docker/login-action@v2 30 | with: 31 | username: ${{ secrets.DOCKERHUB_USERNAME }} 32 | password: ${{ secrets.DOCKERHUB_TOKEN }} 33 | - name: Build ${{ github.ref_name }} Prod Image 34 | uses: docker/build-push-action@v3 35 | with: 36 | push: true 37 | tags: papermerge/papermerge:${{ github.ref_name }} 38 | file: docker/standard/Dockerfile 39 | platforms: linux/amd64 40 | -------------------------------------------------------------------------------- /.github/workflows/frontend-build.yml: -------------------------------------------------------------------------------- 1 | name: Build Frontend 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | push: 7 | paths: 8 | - 'papermerge/**' 9 | - 'frontend/apps/ui/**' 10 | - '.github/workflows/frontend-build.yml' 11 | 12 | jobs: 13 | ui: 14 | runs-on: ubuntu-latest 15 | defaults: 16 | run: 17 | working-directory: frontend/ 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Set Node.js 20.x 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: 20.x 24 | cache: 'yarn' 25 | cache-dependency-path: frontend/yarn.lock 26 | - name: Install latest Yarn 27 | run: corepack prepare yarn@stable --activate 28 | - name: Activate latest Yarn 29 | run: yarn set version stable 30 | - name: Install Node.js dependencies 31 | run: yarn install 32 | - name: Run yarn build hooks package 33 | run: yarn workspace @papermerge/hooks build 34 | - name: Run yarn build UI 35 | run: yarn workspace @papermerge/ui build 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## yarn 2 | .yarn-integrity 3 | .yarn/cache 4 | .yarn/unplugged 5 | .yarn/build-state.yml 6 | .yarn/install-state.gz 7 | .pnp.* 8 | 9 | 10 | # env vars 11 | 12 | .envok.yaml 13 | 14 | 15 | *.pid 16 | .idea/ 17 | .env_services 18 | docker/dev/config/local.py 19 | build/ 20 | .eggs/ 21 | .venv 22 | *.pyc 23 | __pycache__ 24 | *.swp 25 | *.swn 26 | *.swo 27 | *.log 28 | *.sqlite3 29 | *.sublime-project 30 | *.png~ 31 | papermerge_core.egg-info/ 32 | dist 33 | # Sphinx documentation 34 | docs/build/ 35 | _build 36 | _static 37 | media/ 38 | ./static/ 39 | celerybeat-schedule 40 | .vscode 41 | .tar 42 | .tar.gz 43 | .zip 44 | media_root/ 45 | index_db/ 46 | index_db_test/ 47 | custom_logging.yml 48 | fronttend/apps/ui/public/runtime/config.js 49 | .ruff_cache/ 50 | .pytest_cache/ 51 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v2.3.0 4 | hooks: 5 | - id: check-yaml 6 | - id: trailing-whitespace 7 | - id: end-of-file-fixer 8 | #- repo: https://github.com/astral-sh/ruff-pre-commit 9 | #rev: v0.6.8 10 | #hooks: 11 | # Run the linter. 12 | # - id: ruff 13 | # types_or: [ python, pyi ] 14 | # args: [ --fix ] 15 | # # Run the formatter. 16 | # - id: ruff-format 17 | # types_or: [ python, pyi ] 18 | -------------------------------------------------------------------------------- /artwork/logo-w160px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/papermerge/papermerge-core/b3f2ac64b8aad42f8a2ddcb7b4a0215877b339f6/artwork/logo-w160px.png -------------------------------------------------------------------------------- /artwork/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/papermerge/papermerge-core/b3f2ac64b8aad42f8a2ddcb7b4a0215877b339f6/artwork/logo.png -------------------------------------------------------------------------------- /artwork/logo2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/papermerge/papermerge-core/b3f2ac64b8aad42f8a2ddcb7b4a0215877b339f6/artwork/logo2.png -------------------------------------------------------------------------------- /artwork/papermerge3-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/papermerge/papermerge-core/b3f2ac64b8aad42f8a2ddcb7b4a0215877b339f6/artwork/papermerge3-3.png -------------------------------------------------------------------------------- /artwork/papermerge3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/papermerge/papermerge-core/b3f2ac64b8aad42f8a2ddcb7b4a0215877b339f6/artwork/papermerge3.png -------------------------------------------------------------------------------- /crowdin.yml: -------------------------------------------------------------------------------- 1 | "project_id_env": CROWDIN_PROJ_ID 2 | "api_token_env": CROWDIN_API_TOKEN 3 | "base_path": "./ui2" 4 | "base_url": "https://api.crowdin.com" 5 | 6 | "preserve_hierarchy": true 7 | 8 | # 9 | # Files configuration. 10 | # See https://support.crowdin.com/developer/configuration-file/ for all available options 11 | # 12 | files: [ 13 | { 14 | "source": "public/localization/en/_default.json", 15 | # 16 | # Translation files filter 17 | # e.g. "/resources/%two_letters_code%/%original_file_name%" 18 | # 19 | "translation": "public/localization/%two_letters_code%/_default.json" 20 | } 21 | ] 22 | -------------------------------------------------------------------------------- /docker/__cloud__obsolete/config/__init__.py: -------------------------------------------------------------------------------- 1 | # This will make sure the app is always imported when 2 | # Django starts so that shared_task will use this app. 3 | from .celery import app as celery_app 4 | 5 | __all__ = ['celery_app'] 6 | -------------------------------------------------------------------------------- /docker/__cloud__obsolete/config/asgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.asgi import get_asgi_application 4 | from fastapi import FastAPI 5 | from fastapi.middleware.cors import CORSMiddleware 6 | 7 | from papermerge.core.version import __version__ 8 | 9 | get_asgi_application() 10 | 11 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') 12 | 13 | fastapp = FastAPI( 14 | title="Papermerge DMS", 15 | version=__version__ 16 | ) 17 | 18 | fastapp.add_middleware( 19 | CORSMiddleware, 20 | allow_origins=['*'], 21 | allow_credentials=True, 22 | allow_methods=["*"], 23 | allow_headers=["*"] 24 | ) 25 | 26 | def init(app: FastAPI): 27 | from papermerge.core.routers import \ 28 | register_routers as register_core_routes 29 | from papermerge.search.routers import \ 30 | register_routers as register_search_routes 31 | 32 | register_core_routes(app) 33 | register_search_routes(app) 34 | 35 | init(fastapp) 36 | -------------------------------------------------------------------------------- /docker/__cloud__obsolete/config/settings.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import dj_database_url 4 | 5 | from papermerge.conf.settings import * # noqa 6 | 7 | DEBUG = False 8 | TESTING = False 9 | 10 | PROJ_ROOT = Path(__file__).resolve().parent.parent 11 | 12 | MEDIA_ROOT = config.get( 13 | 'main', 14 | 'media_root', 15 | default=os.path.join(PROJ_ROOT, "media") 16 | ) 17 | 18 | STATIC_ROOT = config.get( 19 | 'main', 20 | 'static_root', 21 | default=os.path.join(PROJ_ROOT, "static") 22 | ) 23 | 24 | DATABASES = { 25 | 'default': dj_database_url.config( 26 | env='PAPERMERGE__DATABASE__URL', 27 | default='sqlite:////db/db.sqlite3', 28 | conn_max_age=0 29 | ), 30 | } 31 | 32 | SEARCH_URL = config.get( 33 | 'search', 34 | 'url', 35 | default=f'xapian:///{os.path.join(PROJ_ROOT, "index_db")}' 36 | ) 37 | 38 | PAPERMERGE_CREATE_SPECIAL_FOLDERS = False 39 | -------------------------------------------------------------------------------- /docker/__cloud__obsolete/config/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | 3 | urlpatterns = [ 4 | path('', include('papermerge.core.urls')), 5 | path('search/', include('papermerge.search.urls')), 6 | ] 7 | -------------------------------------------------------------------------------- /docker/__cloud__obsolete/config/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.wsgi import get_wsgi_application 4 | 5 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') 6 | 7 | application = get_wsgi_application() 8 | -------------------------------------------------------------------------------- /docker/__cloud__obsolete/etc/Caddyfile: -------------------------------------------------------------------------------- 1 | 2 | :80 3 | 4 | @ExceptPaths { 5 | not { 6 | path /oidc/callback 7 | path /probe/ 8 | } 9 | } 10 | 11 | @ExceptBackendPaths { 12 | not { 13 | path /oidc/callback 14 | path /probe/ 15 | path /api/ 16 | } 17 | } 18 | 19 | forward_auth @ExceptPaths :8010 { 20 | uri /verify 21 | } 22 | 23 | reverse_proxy /oidc/callback :8010 24 | 25 | reverse_proxy /probe/ :8000 26 | handle_path /api/* { 27 | reverse_proxy :8000 28 | } 29 | 30 | # Will server /index.html for all paths except the ones for the backend. 31 | # The point is that react router will take over URL. 32 | # For example: 33 | # /home/e87e06ed-66ee-482f-9be5-ee85501d59c4 -> index.html 34 | # /document/42533b61-c856-491c-8e65-a901d9c777c4 -> index.html 35 | # /api/users/me -> won't be handled here, but in the backend part 36 | handle @ExceptBackendPaths { 37 | try_files {path} /index.html 38 | } 39 | root * /usr/share/html/ui 40 | file_server 41 | -------------------------------------------------------------------------------- /docker/__cloud__obsolete/etc/logging.yaml: -------------------------------------------------------------------------------- 1 | version: 1 2 | disable_existing_loggers: false 3 | 4 | formatters: 5 | verbose: 6 | format: '%(levelname)s:%(name)s:%(funcName)s:%(lineno)d: %(message)s' 7 | 8 | handlers: 9 | console: 10 | class: logging.StreamHandler 11 | formatter: verbose 12 | 13 | 14 | root: 15 | level: INFO 16 | handlers: [console] 17 | 18 | loggers: 19 | oidc_app: 20 | level: INFO 21 | handlers: [console] 22 | s3worker: 23 | level: INFO 24 | handlers: [console] 25 | ocrworker.tasks: 26 | level: DEBUG 27 | handlers: [console] 28 | salinic: 29 | level: INFO 30 | handlers: [console] 31 | i3worker: 32 | level: INFO 33 | handlers: [console] 34 | papermerge.core.features.router: 35 | level: DEBUG 36 | handlers: [ console ] 37 | path_tmpl_worker: 38 | level: DEBUG 39 | handlers: [ console ] 40 | papermerge.search.tasks: 41 | level: INFO 42 | handlers: [console] 43 | propagate: no 44 | -------------------------------------------------------------------------------- /docker/__cloud__obsolete/etc/supervisord.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | 4 | [program:core] 5 | command=uvicorn config.asgi:fastapp --host 0.0.0.0 --app-dir /core_app/ --port 8000 --log-config /etc/papermerge/logging.yaml 6 | priority=2 7 | directory=/core_app 8 | environment=PATH="/core_app/venv/bin:%(ENV_PATH)s" 9 | stdout_logfile=/dev/stdout 10 | stdout_logfile_maxbytes=0 11 | stderr_logfile=/dev/stderr 12 | stderr_logfile_maxbytes=0 13 | 14 | [program:oidc] 15 | command=uvicorn oidc_app.main:app --host 0.0.0.0 --app-dir /oidc_app/ --port 8010 --log-config /etc/papermerge/logging.yaml 16 | priority=2 17 | directory=/oidc_app 18 | environment=PATH="/oidc_app/venv/bin:%(ENV_PATH)s" 19 | stdout_logfile=/dev/stdout 20 | stdout_logfile_maxbytes=0 21 | stderr_logfile=/dev/stderr 22 | stderr_logfile_maxbytes=0 23 | 24 | [program:caddy] 25 | command=/usr/bin/caddy run -c /etc/papermerge/Caddyfile 26 | priority=100 27 | stdout_logfile=/dev/stdout 28 | stdout_logfile_maxbytes=0 29 | stderr_logfile=/dev/stderr 30 | stderr_logfile_maxbytes=0 31 | 32 | [supervisorctl] 33 | serverurl = http://127.0.0.1:9001 34 | 35 | [inet_http_server] 36 | port = 127.0.0.1:9001 37 | 38 | [rpcinterface:supervisor] 39 | supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface 40 | -------------------------------------------------------------------------------- /docker/__cloud__obsolete/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /docker/standard/bundles/nginx/nginx.remote.conf: -------------------------------------------------------------------------------- 1 | daemon off; 2 | error_log /dev/stdout info; 3 | 4 | events { 5 | worker_connections 1024; 6 | } 7 | 8 | http { 9 | include mime.types; 10 | default_type application/octet-stream; 11 | 12 | access_log /dev/stdout; 13 | error_log /dev/stderr notice; 14 | 15 | proxy_buffers 16 16k; 16 | proxy_buffer_size 16k; 17 | 18 | keepalive_timeout 65; 19 | client_max_body_size 100M; 20 | 21 | server { 22 | listen 80; 23 | server_name _; 24 | 25 | sendfile off; 26 | 27 | index index.html; 28 | 29 | location ~ ^/api(/?)(.*) { 30 | proxy_pass http://127.0.0.1:8000/$2$is_args$args; 31 | } 32 | 33 | location /openapi.json { 34 | proxy_pass http://127.0.0.1:8000/openapi.json; 35 | } 36 | 37 | location ~ ^/ws(/?)(.*) { 38 | proxy_http_version 1.1; 39 | proxy_set_header Upgrade $http_upgrade; 40 | proxy_set_header Connection "Upgrade"; 41 | proxy_pass http://127.0.0.1:8000/ws/$2$is_args$args; 42 | } 43 | 44 | location / { 45 | proxy_set_header Host $host; 46 | root /usr/share/nginx/html/ui; 47 | try_files $uri /index.html =404; 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /docker/standard/bundles/supervisor/supervisord.default.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | 4 | [program:core] 5 | command=poetry run fastapi run papermerge/app.py --workers 2 --host 0.0.0.0 --port 8000 6 | priority=2 7 | directory=/core_app 8 | stdout_logfile=/dev/stdout 9 | stdout_logfile_maxbytes=0 10 | stderr_logfile=/dev/stderr 11 | stderr_logfile_maxbytes=0 12 | 13 | [program:auth_server] 14 | command=poetry run fastapi run auth_server/main.py --workers 1 --host 0.0.0.0 --port 4010 15 | priority=2 16 | directory=/auth_server_app 17 | stdout_logfile=/dev/stdout 18 | stdout_logfile_maxbytes=0 19 | stderr_logfile=/dev/stderr 20 | stderr_logfile_maxbytes=0 21 | 22 | [program:nginx] 23 | command=/usr/sbin/nginx 24 | priority=100 25 | stdout_logfile=/dev/stdout 26 | stdout_logfile_maxbytes=0 27 | stderr_logfile=/dev/stderr 28 | stderr_logfile_maxbytes=0 29 | 30 | [supervisorctl] 31 | serverurl = http://127.0.0.1:9001 32 | 33 | [inet_http_server] 34 | port = 127.0.0.1:9001 35 | 36 | [rpcinterface:supervisor] 37 | supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface 38 | -------------------------------------------------------------------------------- /docker/standard/bundles/supervisor/supervisord.remote.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | 4 | [program:core] 5 | command=poetry run uvicorn config.asgi:fastapp --workers 4 --host 0.0.0.0 --app-dir /core_app/ --log-config /etc/papermerge/logging.yaml --port 8000 6 | priority=2 7 | directory=/core_app 8 | environment=PATH="/core_app/.venv/bin:%(ENV_PATH)s" 9 | stdout_logfile=/dev/stdout 10 | stdout_logfile_maxbytes=0 11 | stderr_logfile=/dev/stderr 12 | stderr_logfile_maxbytes=0 13 | 14 | [program:nginx] 15 | command=/usr/sbin/nginx -c /etc/nginx/nginx.conf 16 | priority=100 17 | stdout_logfile=/dev/stdout 18 | stdout_logfile_maxbytes=0 19 | stderr_logfile=/dev/stderr 20 | stderr_logfile_maxbytes=0 21 | 22 | [supervisorctl] 23 | serverurl = http://127.0.0.1:9001 24 | 25 | [inet_http_server] 26 | port = 127.0.0.1:9001 27 | 28 | [rpcinterface:supervisor] 29 | supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface 30 | -------------------------------------------------------------------------------- /docker/standard/core.js.tmpl: -------------------------------------------------------------------------------- 1 | window.__PAPERMERGE_RUNTIME_CONFIG__ = { 2 | ocr__lang_codes: "{{ .PAPERMERGE__OCR__LANG_CODES }}", 3 | ocr__default_lang_code: "{{ .PAPERMERGE__OCR__DEFAULT_LANG_CODE }}", 4 | ocr__automatic: {{ .PAPERMERGE__OCR__AUTOMATIC }} 5 | } 6 | -------------------------------------------------------------------------------- /docker/standard/logging.yaml: -------------------------------------------------------------------------------- 1 | version: 1 2 | disable_existing_loggers: false 3 | 4 | formatters: 5 | verbose: 6 | format: '%(levelname)s %(asctime)s %(module)s %(message)s' 7 | 8 | handlers: 9 | console: 10 | level: INFO 11 | class: logging.StreamHandler 12 | formatter: verbose 13 | 14 | loggers: 15 | auth_server: 16 | level: INFO 17 | handlers: [console] 18 | papermerge.search.tasks: 19 | level: INFO 20 | handlers: [console] 21 | propagate: no 22 | -------------------------------------------------------------------------------- /docker/standard/scripts/create_token.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cd /auth_server_app && poetry run task auth-cli "$@" 4 | -------------------------------------------------------------------------------- /docker/standard/scripts/list_users.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cd /auth_server_app && poetry run task auth-cli "$@" 4 | -------------------------------------------------------------------------------- /frontend/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [*.{js,json,yml}] 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /frontend/.gitattributes: -------------------------------------------------------------------------------- 1 | /.yarn/** linguist-vendored 2 | /.yarn/releases/* binary 3 | /.yarn/plugins/**/* binary 4 | /.pnp.* binary linguist-generated 5 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | .yarn/* 2 | .yarn/sdks/* 3 | !.yarn/patches 4 | !.yarn/plugins 5 | !.yarn/releases 6 | !.yarn/sdks 7 | !.yarn/versions 8 | .vite/ 9 | 10 | # Swap the comments on the following lines if you wish to use zero-installs 11 | # In that case, don't forget to run `yarn config set enableGlobalCache false`! 12 | # Documentation here: https://yarnpkg.com/features/caching#zero-installs 13 | 14 | #!.yarn/cache 15 | .pnp.* 16 | node_modules/ 17 | .vscode/ 18 | .env.development.local 19 | -------------------------------------------------------------------------------- /frontend/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "arrowParens": "avoid", 4 | "bracketSpacing": false, 5 | "endOfLine": "auto", 6 | "jsxSingleQuote": false, 7 | "printWidth": 80, 8 | "singleQuote": false, 9 | "tabWidth": 2, 10 | "trailingComma": "none", 11 | "useTabs": false 12 | } 13 | -------------------------------------------------------------------------------- /frontend/.yarnrc.yml: -------------------------------------------------------------------------------- 1 | enableGlobalCache: false 2 | 3 | yarnPath: .yarn/releases/yarn-4.9.1.cjs 4 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Frontend 2 | 3 | Server's REST API base url is read from `.env.development.local` file: 4 | 5 | ``` 6 | VITE_BASE_URL=http://localhost:8000/ 7 | ``` 8 | 9 | In order to start in dev mode as user `admin` (without authentication) 10 | use `VITE_REMOTE_USER` and `VITE_REMOTE_GROUPS` variables in .env.development.local` file: 11 | 12 | ``` 13 | VITE_REMOTE_USER=admin 14 | VITE_REMOTE_GROUPS=admin 15 | VITE_BASE_URL=http://localhost:8000/ 16 | ``` 17 | 18 | Start in dev mode (on port 5173): 19 | 20 | ``` 21 | yarn dev 22 | ``` 23 | 24 | 25 | ## Build Frontend 26 | 27 | ``` 28 | yarn workspace @papermerge/ui build 29 | ``` 30 | -------------------------------------------------------------------------------- /frontend/apps/hooks.dev/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /frontend/apps/hooks.dev/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /frontend/apps/hooks.dev/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /frontend/apps/hooks.dev/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hooks.dev", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "react": "^19.1.0", 14 | "react-dom": "^19.1.0" 15 | }, 16 | "devDependencies": { 17 | "@eslint/js": "^9.25.0", 18 | "@types/react": "^19.1.2", 19 | "@types/react-dom": "^19.1.2", 20 | "@vitejs/plugin-react": "^4.4.1", 21 | "eslint": "^9.25.0", 22 | "eslint-plugin-react-hooks": "^5.2.0", 23 | "eslint-plugin-react-refresh": "^0.4.19", 24 | "globals": "^16.0.0", 25 | "typescript": "~5.8.3", 26 | "typescript-eslint": "^8.30.1", 27 | "vite": "^6.3.5", 28 | "vite-tsconfig-paths": "^5.1.4" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /frontend/apps/hooks.dev/src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | -------------------------------------------------------------------------------- /frontend/apps/hooks.dev/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './index.css' 4 | import App from './App.tsx' 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /frontend/apps/hooks.dev/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /frontend/apps/hooks.dev/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | "composite": true, 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "moduleDetection": "force", 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | 19 | /* Linting */ 20 | "strict": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "erasableSyntaxOnly": true, 24 | "noFallthroughCasesInSwitch": true, 25 | "noUncheckedSideEffectImports": true 26 | }, 27 | "include": ["src"] 28 | } 29 | -------------------------------------------------------------------------------- /frontend/apps/hooks.dev/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | "composite": true, 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "node", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "moduleDetection": "force", 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | 19 | /* Linting */ 20 | "strict": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "erasableSyntaxOnly": true, 24 | "noFallthroughCasesInSwitch": true, 25 | "noUncheckedSideEffectImports": true, 26 | "rootDir": "src", 27 | "paths": { 28 | "@papermerge/hooks": ["../../packages/@papermerge/hooks/src"] 29 | } 30 | }, 31 | "references": [ 32 | { "path": "../../packages/@papermerge/hooks" } 33 | ], 34 | "include": ["src"] 35 | } 36 | -------------------------------------------------------------------------------- /frontend/apps/hooks.dev/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "composite": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "verbatimModuleSyntax": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "erasableSyntaxOnly": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true 24 | }, 25 | "include": ["vite.config.ts"] 26 | } 27 | -------------------------------------------------------------------------------- /frontend/apps/hooks.dev/vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react'; 2 | import { defineConfig } from 'vite'; 3 | import tsconfigPaths from 'vite-tsconfig-paths'; 4 | 5 | export default defineConfig({ 6 | plugins: [react(), tsconfigPaths()], 7 | }); 8 | -------------------------------------------------------------------------------- /frontend/apps/ui/.gitignore: -------------------------------------------------------------------------------- 1 | .yarn/cache/ 2 | .yarn/unplugged/ 3 | .yarn/install-state.gz 4 | tsconfig.tsbuildinfo 5 | -------------------------------------------------------------------------------- /frontend/apps/ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Papermerge DMS 8 | 9 | 10 |
11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /frontend/apps/ui/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/papermerge/papermerge-core/b3f2ac64b8aad42f8a2ddcb7b4a0215877b339f6/frontend/apps/ui/public/favicon.ico -------------------------------------------------------------------------------- /frontend/apps/ui/public/favicon_transparent_bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/papermerge/papermerge-core/b3f2ac64b8aad42f8a2ddcb7b4a0215877b339f6/frontend/apps/ui/public/favicon_transparent_bg.png -------------------------------------------------------------------------------- /frontend/apps/ui/src/_mantine.scss: -------------------------------------------------------------------------------- 1 | // 2 | // https://mantine.dev/styles/sass/ 3 | // 4 | @use "sass:math"; 5 | 6 | // Define variables for your breakpoints, 7 | // values must be the same as in your theme 8 | $mantine-breakpoint-xs: "36em"; 9 | $mantine-breakpoint-sm: "48em"; 10 | $mantine-breakpoint-md: "62em"; 11 | $mantine-breakpoint-lg: "75em"; 12 | $mantine-breakpoint-xl: "88em"; 13 | 14 | @function rem($value) { 15 | @return #{math.div(math.div($value, $value * 0 + 1), 16)}rem; 16 | } 17 | 18 | @mixin light { 19 | [data-mantine-color-scheme="light"] & { 20 | @content; 21 | } 22 | } 23 | 24 | @mixin dark { 25 | [data-mantine-color-scheme="dark"] & { 26 | @content; 27 | } 28 | } 29 | 30 | @mixin hover { 31 | @media (hover: hover) { 32 | &:hover { 33 | @content; 34 | } 35 | } 36 | 37 | @media (hover: none) { 38 | &:active { 39 | @content; 40 | } 41 | } 42 | } 43 | 44 | @mixin smaller-than($breakpoint) { 45 | @media (max-width: $breakpoint) { 46 | @content; 47 | } 48 | } 49 | 50 | @mixin larger-than($breakpoint) { 51 | @media (min-width: $breakpoint) { 52 | @content; 53 | } 54 | } 55 | 56 | // Add direction mixins if you need rtl support 57 | @mixin rtl { 58 | [dir="rtl"] & { 59 | @content; 60 | } 61 | } 62 | 63 | @mixin ltr { 64 | [dir="ltr"] & { 65 | @content; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/app/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | margin: 0; 3 | padding: 0; 4 | width: 100%; 5 | } 6 | 7 | .mantine-Modal-inner { 8 | left: 0; 9 | right: 0; 10 | } 11 | 12 | main { 13 | margin: 0.5rem; 14 | } 15 | 16 | .borderline-top { 17 | border-top: #7474ff 3px solid; 18 | } 19 | 20 | .borderline-bottom { 21 | border-bottom: #7474ff 3px solid; 22 | } 23 | 24 | .dragged { 25 | opacity: 0.3; 26 | } 27 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/app/hooks.ts: -------------------------------------------------------------------------------- 1 | import {useDispatch, useSelector} from "react-redux" 2 | import type {AppDispatch, RootState} from "./types" 3 | 4 | // Use throughout your app instead of plain `useDispatch` and `useSelector` 5 | export const useAppDispatch = useDispatch.withTypes() 6 | export const useAppSelector = useSelector.withTypes() 7 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/app/listenerMiddleware.ts: -------------------------------------------------------------------------------- 1 | import {customFieldCRUDListeners} from "@/features/custom-fields/customFieldsSlice" 2 | import {documentTypeCRUDListeners} from "@/features/document-types/documentTypesSlice" 3 | import {moveNodesListeners} from "@/features/nodes/nodesSlice" 4 | import {roleCRUDListeners} from "@/features/roles/rolesSlice" 5 | 6 | import {addListener, createListenerMiddleware} from "@reduxjs/toolkit" 7 | import type {AppDispatch, RootState} from "./types" 8 | 9 | export const listenerMiddleware = createListenerMiddleware() 10 | 11 | export const startAppListening = listenerMiddleware.startListening.withTypes< 12 | RootState, 13 | AppDispatch 14 | >() 15 | export type AppStartListening = typeof startAppListening 16 | 17 | export const addAppListener = addListener.withTypes() 18 | export type AppAddListener = typeof addAppListener 19 | 20 | moveNodesListeners(startAppListening) 21 | roleCRUDListeners(startAppListening) 22 | documentTypeCRUDListeners(startAppListening) 23 | customFieldCRUDListeners(startAppListening) 24 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/app/types.ts: -------------------------------------------------------------------------------- 1 | import {store} from "@/app/store" 2 | 3 | export type AppStore = typeof store 4 | export type RootState = ReturnType 5 | export type AppDispatch = AppStore["dispatch"] 6 | export type AppThunk = ( 7 | dispatch: AppDispatch, 8 | getState: () => RootState 9 | ) => ReturnType 10 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/app/utils.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/papermerge/papermerge-core/b3f2ac64b8aad42f8a2ddcb7b4a0215877b339f6/frontend/apps/ui/src/app/utils.ts -------------------------------------------------------------------------------- /frontend/apps/ui/src/app/withTypes.ts: -------------------------------------------------------------------------------- 1 | import {createAsyncThunk} from "@reduxjs/toolkit" 2 | 3 | import type {RootState, AppDispatch} from "./types" 4 | 5 | export const createAppAsyncThunk = createAsyncThunk.withTypes<{ 6 | state: RootState 7 | dispatch: AppDispatch 8 | }>() 9 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/Breadcrumbs/Breadcrumbs.module.css: -------------------------------------------------------------------------------- 1 | .breadcrumbs { 2 | a { 3 | padding: 0.5rem 0; 4 | color: light-dark(var(--mantine-color-pmg-9), var(--mantine-color-pmg-2)); 5 | } 6 | margin-top: 0; 7 | margin-bottom: 0; 8 | } 9 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/Breadcrumbs/index.tsx: -------------------------------------------------------------------------------- 1 | import Breadcrumbs from "./Breadcrumbs" 2 | 3 | export default Breadcrumbs 4 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/Check.tsx: -------------------------------------------------------------------------------- 1 | import {IconCheck, IconX} from "@tabler/icons-react" 2 | 3 | export default function Check({check}: {check: boolean}) { 4 | if (check) { 5 | return 6 | } 7 | 8 | return 9 | } 10 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/ColorSchemeToggle/ColorSchemeToggle.tsx: -------------------------------------------------------------------------------- 1 | import {useState} from "react" 2 | import { 3 | Switch, 4 | useMantineColorScheme, 5 | useMantineTheme, 6 | rem 7 | } from "@mantine/core" 8 | import {IconSun, IconMoonStars} from "@tabler/icons-react" 9 | 10 | export function ColorSchemeToggle() { 11 | const theme = useMantineTheme() 12 | const {setColorScheme} = useMantineColorScheme() 13 | const [checked, setChecked] = useState(false) 14 | 15 | const sunIcon = ( 16 | 21 | ) 22 | 23 | const moonIcon = ( 24 | 29 | ) 30 | 31 | const onToggleColorScheme = () => { 32 | if (checked) { 33 | setColorScheme("light") 34 | } else { 35 | setColorScheme("dark") 36 | } 37 | setChecked(!checked) 38 | } 39 | 40 | return ( 41 | 48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/CopyButton.tsx: -------------------------------------------------------------------------------- 1 | import {CopyButton, ActionIcon, Tooltip, rem} from "@mantine/core" 2 | import {IconCopy, IconCheck} from "@tabler/icons-react" 3 | 4 | type Args = { 5 | value: string 6 | timeout?: number 7 | } 8 | 9 | export default function CustomCopyButton({value, timeout}: Args) { 10 | if (!timeout) { 11 | timeout = 2000 12 | } 13 | return ( 14 | 15 | {({copied, copy}) => ( 16 | 17 | 22 | {copied ? ( 23 | 24 | ) : ( 25 | 26 | )} 27 | 28 | 29 | )} 30 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/DualPanel/DualPanel.tsx: -------------------------------------------------------------------------------- 1 | import {Group} from "@mantine/core" 2 | import {useAppSelector} from "@/app/hooks" 3 | import SinglePanel from "@/components/SinglePanel" 4 | 5 | import PanelContext from "@/contexts/PanelContext" 6 | import {selectPanelComponent} from "@/features/ui/uiSlice" 7 | 8 | export default function DualPanel() { 9 | const secondayPanelComponent = useAppSelector(s => 10 | selectPanelComponent(s, "secondary") 11 | ) 12 | 13 | if (secondayPanelComponent) { 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ) 24 | } 25 | 26 | return ( 27 | 28 | 29 | 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/DualPanel/index.tsx: -------------------------------------------------------------------------------- 1 | import DualPanel from "./DualPanel" 2 | 3 | export default DualPanel 4 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/Error.tsx: -------------------------------------------------------------------------------- 1 | import {Text, rem} from "@mantine/core" 2 | 3 | export default function Error({message}: {message: string}) { 4 | return ( 5 | 6 | {message} 7 | 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/Header/Header.module.css: -------------------------------------------------------------------------------- 1 | .logo { 2 | width: 5rem; 3 | } 4 | 5 | .search { 6 | flex: 0.7; 7 | } 8 | 9 | .inner { 10 | display: flex; 11 | justify-content: space-between; 12 | align-items: center; 13 | padding: 0.75rem; 14 | } 15 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | import {Group, useMantineTheme} from "@mantine/core" 2 | import logoURL from "/logo_transparent_bg.svg" 3 | 4 | import {ColorSchemeToggle} from "@/components/ColorSchemeToggle/ColorSchemeToggle" 5 | import classes from "./Header.module.css" 6 | 7 | import Search from "./Search" 8 | import SidebarToggle from "./SidebarToggle" 9 | import UserMenu from "./UserMenu" 10 | import LanguageMenu from "./LanguageMenu" 11 | 12 | function Header() { 13 | const theme = useMantineTheme() 14 | 15 | return ( 16 |
23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 |
37 |
38 | ) 39 | } 40 | 41 | export default Header 42 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/Header/LanguageMenu.tsx: -------------------------------------------------------------------------------- 1 | import { SUPPORTED_LANGS } from "@/cconstants" 2 | import { Group, Menu, UnstyledButton } from "@mantine/core" 3 | import { IconLanguage } from "@tabler/icons-react" 4 | import * as React from "react" 5 | import { useTranslation } from "react-i18next" 6 | 7 | const LanguageMenu: React.FC = () => { 8 | const {i18n} = useTranslation() 9 | 10 | const onLangSelected = (languageCode: string) => { 11 | i18n.changeLanguage(languageCode) 12 | } 13 | 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | {SUPPORTED_LANGS.map(language => ( 25 | 26 | onLangSelected(language.code)}>{language.name} 27 | 28 | ))} 29 | 30 | 31 | ) 32 | } 33 | 34 | export default LanguageMenu 35 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/Header/SidebarToggle.tsx: -------------------------------------------------------------------------------- 1 | import {useDispatch} from "react-redux" 2 | import {IconMenu2} from "@tabler/icons-react" 3 | import {UnstyledButton} from "@mantine/core" 4 | 5 | import {toggleNavBar} from "@/features/ui/uiSlice" 6 | 7 | export default function SidebarToggle() { 8 | const dispatch = useDispatch() 9 | 10 | const onClick = () => { 11 | console.log("SidebarToggled") 12 | dispatch(toggleNavBar()) 13 | } 14 | return ( 15 | onClick()}> 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/NavBar/index.tsx: -------------------------------------------------------------------------------- 1 | import NavBar from "./NavBar" 2 | 3 | export default NavBar 4 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/NodeThumbnail/Thumbnail.tsx: -------------------------------------------------------------------------------- 1 | import { useAppSelector } from "@/app/hooks" 2 | import { selectThumbnailByNodeId } from "@/features/nodes/selectors" 3 | import ThumbnailPlaceholder from "./ThumbnailPlaceholder" 4 | 5 | interface Args { 6 | nodeID: string 7 | } 8 | 9 | export default function Thumbnail({nodeID}: Args) { 10 | const objectURLState = useAppSelector(s => selectThumbnailByNodeId(s, nodeID)) 11 | 12 | if (!objectURLState) { 13 | return 14 | } 15 | 16 | if (objectURLState.url && !objectURLState.error) { 17 | return 18 | } 19 | 20 | return 21 | } 22 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/NodeThumbnail/ThumbnailPlaceholder.tsx: -------------------------------------------------------------------------------- 1 | import { Tooltip } from "@mantine/core" 2 | 3 | interface Args { 4 | error?: string 5 | } 6 | 7 | export default function ThumbnailPlaceholder({error}: Args) { 8 | return ( 9 | 10 | 17 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/OwnerSelect/index.tsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/papermerge/papermerge-core/b3f2ac64b8aad42f8a2ddcb7b4a0215877b339f6/frontend/apps/ui/src/components/OwnerSelect/index.tsx -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/Pagination/Pagination.module.css: -------------------------------------------------------------------------------- 1 | .select { 2 | width: 5rem; 3 | } 4 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/Pagination/Pagination.tsx: -------------------------------------------------------------------------------- 1 | import {Pagination, Skeleton, Group, Select} from "@mantine/core" 2 | import classes from "./Pagination.module.css" 3 | import {PAGINATION_PAGE_SIZES} from "@/cconstants" 4 | 5 | import type {PaginationType} from "@/types" 6 | 7 | type Args = { 8 | pagination: PaginationType | null | undefined 9 | onPageNumberChange: (page: number) => void 10 | onPageSizeChange: (value: string | null) => void 11 | lastPageSize: number 12 | } 13 | 14 | export default function PaginationWithSelector({ 15 | pagination, 16 | lastPageSize, 17 | onPageNumberChange, 18 | onPageSizeChange 19 | }: Args) { 20 | if (pagination) { 21 | return ( 22 | 23 | 28 | 36 | 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/ScheduleOCRProcessCheckbox/index.tsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/papermerge/papermerge-core/b3f2ac64b8aad42f8a2ddcb7b4a0215877b339f6/frontend/apps/ui/src/components/ScheduleOCRProcessCheckbox/index.tsx -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/SharedBreadcrumb/Breadcrumbs.module.css: -------------------------------------------------------------------------------- 1 | .breadcrumbs { 2 | a { 3 | padding: 0.5rem 0; 4 | color: light-dark(var(--mantine-color-pmg-9), var(--mantine-color-pmg-2)); 5 | } 6 | margin-top: 0; 7 | margin-bottom: 0; 8 | } 9 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/SharedBreadcrumb/index.tsx: -------------------------------------------------------------------------------- 1 | import SharedBreadcrumb from "./SharedBreadcrumb" 2 | 3 | export default SharedBreadcrumb 4 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/SinglePanel/SinglePanel.tsx: -------------------------------------------------------------------------------- 1 | import {useContext} from "react" 2 | 3 | import {useAppSelector} from "@/app/hooks" 4 | import PanelContext from "@/contexts/PanelContext" 5 | import Viewer from "@/features/document/components/Viewer" 6 | import Commander from "@/features/nodes/components/Commander" 7 | import SearchResults from "@/features/search/components/SearchResults" 8 | import SharedCommander from "@/features/shared_nodes/components/SharedCommander" 9 | import SharedViewer from "@/features/shared_nodes/components/SharedViewer" 10 | import {PanelMode} from "@/types" 11 | 12 | import {selectPanelComponent} from "@/features/ui/uiSlice" 13 | 14 | export default function SinglePanel() { 15 | const mode: PanelMode = useContext(PanelContext) 16 | const panelComponent = useAppSelector(s => selectPanelComponent(s, mode)) 17 | 18 | if (panelComponent == "commander") { 19 | return 20 | } 21 | 22 | if (panelComponent == "viewer") { 23 | return 24 | } 25 | 26 | if (panelComponent == "searchResults") { 27 | return 28 | } 29 | 30 | if (panelComponent == "sharedCommander") { 31 | return 32 | } 33 | 34 | if (panelComponent == "sharedViewer") { 35 | return 36 | } 37 | 38 | return <>Error: neither viewer nor commander 39 | } 40 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/SinglePanel/index.tsx: -------------------------------------------------------------------------------- 1 | import SinglePanel from "./SinglePanel" 2 | 3 | export default SinglePanel 4 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/TableSort/TableSort.module.css: -------------------------------------------------------------------------------- 1 | .th { 2 | padding: 0; 3 | } 4 | 5 | .control { 6 | width: 100%; 7 | padding: var(--mantine-spacing-xs) var(--mantine-spacing-md); 8 | 9 | @mixin hover { 10 | background-color: light-dark( 11 | var(--mantine-color-gray-0), 12 | var(--mantine-color-dark-6) 13 | ); 14 | } 15 | } 16 | 17 | .icon { 18 | width: 21px; 19 | height: 21px; 20 | border-radius: 21px; 21 | } 22 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/TableSort/Th.tsx: -------------------------------------------------------------------------------- 1 | import {Center, Group, Table, Text, UnstyledButton} from "@mantine/core" 2 | import {IconChevronDown, IconChevronUp, IconSelector} from "@tabler/icons-react" 3 | 4 | import classes from "./TableSort.module.css" 5 | 6 | interface ThProps { 7 | children: React.ReactNode 8 | reversed: boolean 9 | sorted: boolean 10 | onSort: () => void 11 | } 12 | 13 | export default function Th({children, reversed, sorted, onSort}: ThProps) { 14 | let Icon = IconSelector 15 | 16 | if (sorted) { 17 | if (reversed) { 18 | Icon = IconChevronUp 19 | } else { 20 | Icon = IconChevronDown 21 | } 22 | } 23 | 24 | return ( 25 | 26 | 27 | 28 | 29 | {children} 30 | 31 |
32 | 33 |
34 |
35 |
36 |
37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/Uploader/index.ts: -------------------------------------------------------------------------------- 1 | import Uploader from "./uploader" 2 | 3 | export default Uploader 4 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/Uploader/uploader.tsx: -------------------------------------------------------------------------------- 1 | import {closeUploader, selectFiles, selectOpened} from "@/features/ui/uiSlice" 2 | import {Container, Dialog, List} from "@mantine/core" 3 | import {useDispatch, useSelector} from "react-redux" 4 | import UploaderItem from "./uploaderItem" 5 | 6 | export default function Uploader() { 7 | const opened = useSelector(selectOpened) 8 | const files = useSelector(selectFiles) 9 | const dispatch = useDispatch() 10 | 11 | const fileItems = files.map(f => ( 12 | 13 | )) 14 | 15 | const onClose = () => { 16 | dispatch(closeUploader()) 17 | } 18 | 19 | return ( 20 | 28 | 29 | 30 | {fileItems} 31 | 32 | 33 | 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/Uploader/uploaderItem.module.css: -------------------------------------------------------------------------------- 1 | .uploaderItem:hover { 2 | background-color: rgb(236, 236, 236); 3 | cursor: pointer; 4 | } 5 | 6 | .uploaderItemTarget:hover { 7 | text-decoration: underline; 8 | } 9 | 10 | .uploaderItemFile:hover { 11 | text-decoration: underline; 12 | } 13 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/document/DocumentDetails/DocumentDetails.module.css: -------------------------------------------------------------------------------- 1 | .documentDetailsOpened { 2 | position: relative; 3 | padding-left: 1rem; 4 | padding-right: 1rem; 5 | padding-top: 1rem; 6 | width: 25%; 7 | min-width: 20rem; 8 | overflow-y: scroll; 9 | padding-bottom: 1rem; 10 | } 11 | 12 | .documentDetailsClosed { 13 | position: relative; 14 | padding-left: 0rem; 15 | padding-right: 0rem; 16 | } 17 | 18 | .documentDetailsContent { 19 | width: 100%; 20 | } 21 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/document/DocumentDetails/index.tsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/papermerge/papermerge-core/b3f2ac64b8aad42f8a2ddcb7b4a0215877b339f6/frontend/apps/ui/src/components/document/DocumentDetails/index.tsx -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/document/DocumentDetailsToggle/DocumentDetailsToggle.module.css: -------------------------------------------------------------------------------- 1 | .documentDetailsToggleOpened { 2 | button:focus { 3 | outline: none; 4 | } 5 | } 6 | 7 | .documentDetailsToggleClosed { 8 | button:focus { 9 | outline: none; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/document/DocumentDetailsToggle/index.tsx: -------------------------------------------------------------------------------- 1 | import DocumentDetailsToggle from "./DocumentDetailsToggle" 2 | 3 | export default DocumentDetailsToggle 4 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/document/DownloadButton/index.tsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/papermerge/papermerge-core/b3f2ac64b8aad42f8a2ddcb7b4a0215877b339f6/frontend/apps/ui/src/components/document/DownloadButton/index.tsx -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/document/Page/Page.module.css: -------------------------------------------------------------------------------- 1 | .page { 2 | display: block; 3 | text-align: center; 4 | } 5 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/document/Page/index.tsx: -------------------------------------------------------------------------------- 1 | import Page from "./Page" 2 | 3 | export default Page 4 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/document/Pages/Pages.module.css: -------------------------------------------------------------------------------- 1 | .pages { 2 | display: block; 3 | position: relative; 4 | padding-right: 1rem; 5 | overflow-y: auto; 6 | overflow-x: auto; 7 | background-color: #666; 8 | color: white; 9 | width: 100%; 10 | img { 11 | padding: 1rem 1rem 0rem 1rem; 12 | width: 600px; 13 | } 14 | } 15 | 16 | .loader { 17 | overflow-y: auto; 18 | overflow-x: auto; 19 | background-color: #666; 20 | color: white; 21 | width: 100%; 22 | } 23 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/document/Pages/index.tsx: -------------------------------------------------------------------------------- 1 | import Pages from "./Pages" 2 | 3 | export default Pages 4 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/document/Thumbnail/Thumbnail.module.scss: -------------------------------------------------------------------------------- 1 | .thumbnail { 2 | .checkbox { 3 | align-self: self-start; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/document/Thumbnail/index.tsx: -------------------------------------------------------------------------------- 1 | import Thumbnail from "./Thumbnail" 2 | 3 | export default Thumbnail 4 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/document/Thumbnails/Thumbnails.module.css: -------------------------------------------------------------------------------- 1 | .thumbnails { 2 | display: block; 3 | height: 100%; 4 | min-width: 150px; 5 | overflow-y: auto; 6 | overflow-x: hidden; 7 | background-color: #3d3b3b; 8 | color: white; 9 | padding: 0.5rem 1rem; 10 | img { 11 | padding: 0rem 1rem 0rem 1rem; 12 | width: 150px; 13 | } 14 | } 15 | 16 | .thumbnails:over { 17 | overflow-y: auto; 18 | overflow-x: hidden; 19 | } 20 | 21 | .loader { 22 | height: 100%; 23 | min-width: 150px; 24 | overflow-y: auto; 25 | overflow-x: hidden; 26 | background-color: #3d3b3b; 27 | color: white; 28 | padding: 0.5rem 1rem; 29 | } 30 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/document/Thumbnails/index.tsx: -------------------------------------------------------------------------------- 1 | import Thumbnails from "./Thumbnails" 2 | 3 | export default Thumbnails 4 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/document/ThumbnailsToggle/ThumbnailsToggle.module.css: -------------------------------------------------------------------------------- 1 | .thumbnailsToggle { 2 | background-color: #666; 3 | color: #dadada; 4 | button:focus { 5 | outline: none; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/document/ThumbnailsToggle/ThumbnailsToggle.tsx: -------------------------------------------------------------------------------- 1 | import {useAppDispatch, useAppSelector} from "@/app/hooks" 2 | import PanelContext from "@/contexts/PanelContext" 3 | import { 4 | selectThumbnailsPanelOpen, 5 | viewerThumbnailsPanelToggled 6 | } from "@/features/ui/uiSlice" 7 | import {PanelMode} from "@/types" 8 | import {Flex, UnstyledButton} from "@mantine/core" 9 | import { 10 | IconLayoutSidebarRightCollapse, 11 | IconLayoutSidebarRightExpand 12 | } from "@tabler/icons-react" 13 | import {useContext} from "react" 14 | import classes from "./ThumbnailsToggle.module.css" 15 | 16 | export default function ThumbnailsToggle() { 17 | const dispatch = useAppDispatch() 18 | const mode: PanelMode = useContext(PanelContext) 19 | const isOpen = useAppSelector(s => selectThumbnailsPanelOpen(s, mode)) 20 | 21 | const onClick = () => { 22 | dispatch(viewerThumbnailsPanelToggled(mode)) 23 | } 24 | 25 | const toggleElement = ( 26 | onClick()}> 27 | {isOpen ? ( 28 | 29 | ) : ( 30 | 31 | )} 32 | 33 | ) 34 | 35 | return ( 36 | 37 | {toggleElement} 38 | 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/document/ThumbnailsToggle/index.tsx: -------------------------------------------------------------------------------- 1 | import ThumbnailsToggle from "./ThumbnailsToggle" 2 | 3 | export default ThumbnailsToggle 4 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/document/Viewer.module.css: -------------------------------------------------------------------------------- 1 | .inner { 2 | outline: 1px solid light-dark(#ccc, #666); 3 | } 4 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/document/Zoom/Zoom.module.css: -------------------------------------------------------------------------------- 1 | .zoom { 2 | background-color: hsla(0, 0%, 83%, 0.49); 3 | color: #3e3e3e; 4 | width: 40%; 5 | position: sticky; 6 | bottom: 1rem; 7 | padding: 0.5rem; 8 | margin: auto; 9 | } 10 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/document/Zoom/Zoom.tsx: -------------------------------------------------------------------------------- 1 | import PanelContext from "@/contexts/PanelContext" 2 | import { 3 | zoomFactorDecremented, 4 | zoomFactorIncremented, 5 | zoomFactorReseted 6 | } from "@/features/ui/uiSlice" 7 | import type {PanelMode} from "@/types" 8 | import {Group} from "@mantine/core" 9 | import {IconZoomIn, IconZoomOut} from "@tabler/icons-react" 10 | import {useContext} from "react" 11 | import {useDispatch} from "react-redux" 12 | import classes from "./Zoom.module.css" 13 | 14 | export default function Zoom() { 15 | const mode: PanelMode = useContext(PanelContext) 16 | const dispatch = useDispatch() 17 | 18 | const incZoom = () => { 19 | dispatch(zoomFactorIncremented(mode)) 20 | } 21 | const decZoom = () => { 22 | dispatch(zoomFactorDecremented(mode)) 23 | } 24 | 25 | const fitZoom = () => { 26 | dispatch(zoomFactorReseted(mode)) 27 | } 28 | 29 | return ( 30 | 31 | 32 | 33 |
Fit
34 |
35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/document/Zoom/index.tsx: -------------------------------------------------------------------------------- 1 | import Zoom from "./Zoom" 2 | 3 | export default Zoom 4 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/document/customFields/Boolean.tsx: -------------------------------------------------------------------------------- 1 | import {Checkbox} from "@mantine/core" 2 | import {useEffect, useState} from "react" 3 | import {CustomFieldArgs} from "./types" 4 | 5 | export default function CustomFieldBoolean({ 6 | customField, 7 | onChange 8 | }: CustomFieldArgs) { 9 | const [value, setValue] = useState(Boolean(customField.value)) 10 | 11 | const onLocalChange = (event: React.ChangeEvent) => { 12 | const value = event.currentTarget.checked 13 | setValue(value) 14 | onChange({customField, value: value}) 15 | } 16 | 17 | useEffect(() => { 18 | setValue(Boolean(customField.value)) 19 | }, [customField.value]) 20 | 21 | return ( 22 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/document/customFields/index.tsx: -------------------------------------------------------------------------------- 1 | import CustomFieldDate from "./Date" 2 | import CustomFieldMonetary from "./Monetary" 3 | import CustomFieldYearMonth from "./YearMonth" 4 | 5 | export {CustomFieldDate, CustomFieldMonetary, CustomFieldYearMonth} 6 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/components/document/customFields/types.ts: -------------------------------------------------------------------------------- 1 | import type {CFV} from "@/types" 2 | 3 | export type onChangeArgs = { 4 | customField: CFV 5 | value: string | boolean 6 | } 7 | 8 | export type onChangeType = ({customField, value}: onChangeArgs) => void 9 | 10 | export interface CustomFieldArgs { 11 | customField: CFV 12 | onChange: onChangeType 13 | } 14 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/contexts/PanelContext.ts: -------------------------------------------------------------------------------- 1 | import {createContext} from "react" 2 | import type {PanelMode} from "@/types" 3 | 4 | /* 5 | `@/component/DualPanel` component features two panels. One of the panels 6 | is called "main" and another one "secondary". Both panels 7 | are rendered using same `@/components/SinglePanel` component. In 8 | order to distinguish between the two - panel mode is passed as via 9 | react context. 10 | Panel with mode "main" (i.e. main panel) is always present. 11 | Panel with mode "secondary" - may or may not be present; in other 12 | words, "secondary" panel may be closed, while "main" panel 13 | cannot be closed. 14 | */ 15 | const PanelContext = createContext("main") 16 | 17 | export default PanelContext 18 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/custom-fields/components/EditButton.tsx: -------------------------------------------------------------------------------- 1 | import {Button} from "@mantine/core" 2 | import {useDisclosure} from "@mantine/hooks" 3 | import {IconEdit} from "@tabler/icons-react" 4 | 5 | import EditCustomFieldModal from "./EditCustomFieldModal" 6 | import {useTranslation} from "react-i18next" 7 | 8 | interface Args { 9 | customFieldId: string 10 | } 11 | 12 | export default function EditButton({customFieldId}: Args) { 13 | const {t} = useTranslation() 14 | const [opened, {open, close}] = useDisclosure(false) 15 | 16 | if (!customFieldId) { 17 | return ( 18 | 21 | ) 22 | } 23 | 24 | return ( 25 | <> 26 | 29 | 35 | 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/custom-fields/components/NewButton.tsx: -------------------------------------------------------------------------------- 1 | import {Button} from "@mantine/core" 2 | import {useDisclosure} from "@mantine/hooks" 3 | import {IconPlus} from "@tabler/icons-react" 4 | import NewCustomFieldModal from "./NewCustomFieldModal" 5 | import {useTranslation} from "react-i18next" 6 | 7 | export default function NewButton() { 8 | const {t} = useTranslation() 9 | const [opened, {open, close}] = useDisclosure(false) 10 | 11 | return ( 12 | <> 13 | 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/custom-fields/pages/Details.tsx: -------------------------------------------------------------------------------- 1 | import CustomFieldDetails from "@/features/custom-fields/components/Details" 2 | import {useParams} from "react-router" 3 | 4 | export default function CustomFieldsPage() { 5 | const {customFieldID} = useParams() 6 | 7 | return 8 | } 9 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/custom-fields/pages/List.tsx: -------------------------------------------------------------------------------- 1 | import CustomFieldsList from "@/features/custom-fields/components/List" 2 | 3 | export default function CustomFieldsPage() { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/custom-fields/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import CustomFieldDetails from "./Details" 2 | import CustomFieldsList from "./List" 3 | 4 | export {CustomFieldDetails, CustomFieldsList} 5 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/custom-fields/types.ts: -------------------------------------------------------------------------------- 1 | export type CustomFieldListColumnName = "name" | "type" | "group_name" 2 | export type CustomFieldSortByInput = 3 | | "name" 4 | | "-name" 5 | | "type" 6 | | "-type" 7 | | "group_name" 8 | | "-group_name" 9 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/document-types/components/EditButton.tsx: -------------------------------------------------------------------------------- 1 | import {Button} from "@mantine/core" 2 | import {useDisclosure} from "@mantine/hooks" 3 | import {IconEdit} from "@tabler/icons-react" 4 | 5 | import EditDocumentTypeModal from "./EditDocumentTypeModal" 6 | import {useTranslation} from "react-i18next" 7 | 8 | interface Args { 9 | documentTypeId: string 10 | } 11 | 12 | export default function EditButton({documentTypeId}: Args) { 13 | const {t} = useTranslation() 14 | const [opened, {open, close}] = useDisclosure(false) 15 | 16 | if (!documentTypeId) { 17 | return ( 18 | 21 | ) 22 | } 23 | 24 | return ( 25 | <> 26 | 29 | 35 | 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/document-types/components/NewButton.tsx: -------------------------------------------------------------------------------- 1 | import {Button} from "@mantine/core" 2 | import {useDisclosure} from "@mantine/hooks" 3 | import {IconPlus} from "@tabler/icons-react" 4 | import NewDocumentTypeModal from "./NewDocumentTypeModal" 5 | import {useTranslation} from "react-i18next" 6 | 7 | export default function NewButton() { 8 | const {t} = useTranslation() 9 | const [opened, {open, close}] = useDisclosure(false) 10 | 11 | return ( 12 | <> 13 | 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/document-types/pages/Details.tsx: -------------------------------------------------------------------------------- 1 | import DocumentTypeDetails from "@/features/document-types/components/Details" 2 | import {useParams} from "react-router" 3 | 4 | export default function CustomFieldsPage() { 5 | const {documentTypeID} = useParams() 6 | 7 | return 8 | } 9 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/document-types/pages/List.tsx: -------------------------------------------------------------------------------- 1 | import DocumentTypesList from "@/features/document-types/components/List" 2 | 3 | export default function DocumentTypesPage() { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/document-types/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import DocumentTypeDetails from "./Details" 2 | import DocumentTypesList from "./List" 3 | 4 | export {DocumentTypeDetails, DocumentTypesList} 5 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/document-types/types.ts: -------------------------------------------------------------------------------- 1 | import type {CustomField} from "@/types" 2 | 3 | export type NewDocType = { 4 | name: string 5 | path_template?: string 6 | custom_field_ids: Array 7 | group_id?: string 8 | } 9 | 10 | export type DocType = { 11 | id: string 12 | name: string 13 | path_template?: string 14 | custom_fields: Array 15 | group_name?: string 16 | group_id?: string 17 | } 18 | 19 | export type DocTypeGroupedItem = { 20 | id: string 21 | name: string 22 | } 23 | 24 | export type DocTypeGrouped = { 25 | name: string 26 | items: Array 27 | } 28 | 29 | export type DocTypeUpdate = { 30 | id: string 31 | name: string 32 | custom_field_ids: Array 33 | group_id?: string 34 | } 35 | 36 | export type DocumentTypeListColumnName = "name" | "group_name" 37 | export type DocumentTypeSortByInput = 38 | | "name" 39 | | "-name" 40 | | "group_name" 41 | | "-group_name" 42 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/document/components/index.tsx: -------------------------------------------------------------------------------- 1 | import Viewer from "./Viewer" 2 | 3 | export default Viewer 4 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/document/selectors.ts: -------------------------------------------------------------------------------- 1 | import {RootState} from "@/app/types" 2 | import {PanelMode} from "@/types" 3 | 4 | export const selectCurrentDocumentVersionNumber = ( 5 | state: RootState, 6 | mode: PanelMode 7 | ) => { 8 | if (mode == "main") { 9 | return state.ui.mainViewer?.currentDocumentVersion 10 | } 11 | 12 | return state.ui.secondaryViewer?.currentDocumentVersion 13 | } 14 | 15 | export const selectBestImageByPageId = ( 16 | state: RootState, 17 | page_id: string 18 | ): string | undefined => { 19 | const sizes = state.imageObjects[page_id] 20 | return sizes?.xl || sizes?.lg || sizes?.md || sizes?.sm 21 | } 22 | 23 | export const selectSmallImageByPageId = ( 24 | state: RootState, 25 | page_id: string 26 | ): string | undefined => { 27 | const sizes = state.imageObjects[page_id] 28 | return sizes?.sm 29 | } 30 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/groups/components/ActionButtons.tsx: -------------------------------------------------------------------------------- 1 | import {useSelector} from "react-redux" 2 | import {Group} from "@mantine/core" 3 | import {selectSelectedIds} from "@/features/groups/groupsSlice" 4 | import NewButton from "./NewButton" 5 | import EditButton from "./EditButton" 6 | import {DeleteGroupsButton} from "./DeleteButton" 7 | 8 | export default function ActionButtons() { 9 | const selectedIds = useSelector(selectSelectedIds) 10 | 11 | return ( 12 | 13 | 14 | {selectedIds.length == 1 ? : ""} 15 | {selectedIds.length >= 1 ? : ""} 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/groups/components/EditButton.tsx: -------------------------------------------------------------------------------- 1 | import {Button} from "@mantine/core" 2 | import {useDisclosure} from "@mantine/hooks" 3 | import {IconEdit} from "@tabler/icons-react" 4 | 5 | import EditGroupModal from "./EditGroupModal" 6 | import {useTranslation} from "react-i18next" 7 | 8 | interface Args { 9 | groupId: string 10 | } 11 | 12 | export default function EditButton({groupId}: Args) { 13 | const {t} = useTranslation() 14 | const [opened, {open, close}] = useDisclosure(false) 15 | 16 | if (!groupId) { 17 | return ( 18 | 21 | ) 22 | } 23 | 24 | return ( 25 | <> 26 | 29 | 35 | 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/groups/components/GroupRow.tsx: -------------------------------------------------------------------------------- 1 | import Check from "@/components/Check" 2 | import { 3 | selectionAdd, 4 | selectionRemove, 5 | selectSelectedIds 6 | } from "@/features/groups/groupsSlice" 7 | import type {Group} from "@/types.d/groups" 8 | import {Checkbox, Table} from "@mantine/core" 9 | import {useDispatch, useSelector} from "react-redux" 10 | import {Link} from "react-router-dom" 11 | 12 | type Args = { 13 | group: Group 14 | } 15 | 16 | export default function GroupRow({group}: Args) { 17 | const selectedIds = useSelector(selectSelectedIds) 18 | const dispatch = useDispatch() 19 | 20 | const onChange = (e: React.ChangeEvent) => { 21 | if (e.currentTarget.checked) { 22 | dispatch(selectionAdd(group.id)) 23 | } else { 24 | dispatch(selectionRemove(group.id)) 25 | } 26 | } 27 | 28 | return ( 29 | 30 | 31 | 35 | 36 | 37 | {group.name} 38 | 39 | 40 | 41 | 42 | 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/groups/components/NewButton.tsx: -------------------------------------------------------------------------------- 1 | import {Button} from "@mantine/core" 2 | import {IconPlus} from "@tabler/icons-react" 3 | import {useDisclosure} from "@mantine/hooks" 4 | import NewGroupModal from "./NewGroupModal" 5 | import {useTranslation} from "react-i18next" 6 | 7 | export default function NewButton() { 8 | const {t} = useTranslation() 9 | const [opened, {open, close}] = useDisclosure(false) 10 | 11 | return ( 12 | <> 13 | 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/groups/components/index.ts: -------------------------------------------------------------------------------- 1 | import GroupsList from "./List" 2 | import GroupDetails from "./GroupDetails" 3 | 4 | export {GroupsList, GroupDetails} 5 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/groups/pages/Details.tsx: -------------------------------------------------------------------------------- 1 | import {useParams} from "react-router" 2 | import {GroupDetails} from "@/features/groups/components" 3 | 4 | export default function GroupDetailsPage() { 5 | const {groupId} = useParams() 6 | 7 | return 8 | } 9 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/groups/pages/List.tsx: -------------------------------------------------------------------------------- 1 | import {GroupsList} from "@/features/groups/components" 2 | 3 | export default function GroupsPage() { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/groups/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import GroupDetails from "./Details" 2 | import GroupsList from "./List" 3 | 4 | export {GroupDetails, GroupsList} 5 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/nodes/components/Commander/DocumentsByTypeCommander/ActionButtons.tsx: -------------------------------------------------------------------------------- 1 | import ToggleSecondaryPanel from "@/components/DualPanel/ToggleSecondaryPanel" 2 | import {Group} from "@mantine/core" 3 | import ColumnsMenu from "./ColumnsMenu" 4 | import DocumentTypeFilter from "./DocumentTypeFilter" 5 | 6 | export default function ActionButtons() { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/nodes/components/Commander/DocumentsByTypeCommander/TableSort.module.css: -------------------------------------------------------------------------------- 1 | .th { 2 | padding: 0; 3 | } 4 | 5 | .control { 6 | width: 100%; 7 | padding: var(--mantine-spacing-xs) var(--mantine-spacing-md); 8 | 9 | @mixin hover { 10 | background-color: light-dark( 11 | var(--mantine-color-gray-0), 12 | var(--mantine-color-dark-6) 13 | ); 14 | } 15 | } 16 | 17 | .icon { 18 | width: rem(21px); 19 | height: rem(21px); 20 | border-radius: rem(21px); 21 | } 22 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/nodes/components/Commander/DocumentsByTypeCommander/index.tsx: -------------------------------------------------------------------------------- 1 | import DocumentsByCategoryCommander from "./DocumentsByTypeCommander" 2 | 3 | export default DocumentsByCategoryCommander 4 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/nodes/components/Commander/DocumentsByTypeCommander/types.ts: -------------------------------------------------------------------------------- 1 | export type CategoryColumn = { 2 | name: string 3 | visible: boolean 4 | } 5 | 6 | export type CategoryColumns = { 7 | document_type_id: string 8 | columns: Array 9 | } 10 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/nodes/components/Commander/NodesCommander/Commander.module.scss: -------------------------------------------------------------------------------- 1 | %base { 2 | padding: 0.25rem; 3 | } 4 | 5 | .content { 6 | overflow: auto; 7 | } 8 | 9 | .accept_files { 10 | @extend %base; 11 | outline: 2px dashed blue; 12 | } 13 | 14 | .commander { 15 | @extend %base; 16 | } 17 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/nodes/components/Commander/NodesCommander/NewFolderButton.tsx: -------------------------------------------------------------------------------- 1 | import {useAppSelector} from "@/app/hooks" 2 | import {selectCurrentNodeID} from "@/features/ui/uiSlice" 3 | import {ActionIcon, Tooltip} from "@mantine/core" 4 | import {useDisclosure} from "@mantine/hooks" 5 | import {IconFolderPlus} from "@tabler/icons-react" 6 | import {useContext} from "react" 7 | 8 | import type {PanelMode} from "@/types" 9 | 10 | import PanelContext from "@/contexts/PanelContext" 11 | import {NewFolderModal} from "../../NewFolder" 12 | import {useTranslation} from "react-i18next" 13 | 14 | export default function NewFolderButton() { 15 | const {t} = useTranslation() 16 | const [opened, {open, close}] = useDisclosure(false) 17 | const mode: PanelMode = useContext(PanelContext) 18 | const currentFolderId = useAppSelector(s => selectCurrentNodeID(s, mode)) 19 | 20 | return ( 21 | <> 22 | 23 | 24 | 25 | 26 | 27 | 33 | 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/nodes/components/Commander/NodesCommander/Node/Document/Document.module.scss: -------------------------------------------------------------------------------- 1 | @use "../Node.module.scss"; 2 | 3 | .document { 4 | @extend %node; 5 | 6 | img { 7 | height: 5rem; 8 | } 9 | } 10 | 11 | .iconUsers { 12 | position: absolute; 13 | bottom: 2rem; 14 | left: 0rem; 15 | } 16 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/nodes/components/Commander/NodesCommander/Node/Folder/Folder.module.scss: -------------------------------------------------------------------------------- 1 | @use "../Node.module.scss"; 2 | 3 | .folder { 4 | @extend %node; 5 | } 6 | 7 | .folderIcon { 8 | background-image: url("/public/folder.svg"); 9 | background-size: 100% 100%; 10 | width: 64px; 11 | height: 64px; 12 | } 13 | 14 | .acceptFolder { 15 | outline: 2px dashed blue; 16 | } 17 | 18 | .iconUsers { 19 | position: absolute; 20 | bottom: 2rem; 21 | left: 0rem; 22 | } 23 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/nodes/components/Commander/NodesCommander/Node/Node.module.scss: -------------------------------------------------------------------------------- 1 | %node { 2 | padding: 0.3rem; 3 | position: relative; 4 | margin-top: 0.2rem; 5 | 6 | &:hover { 7 | background-color: #8ed2fe66; 8 | outline: 1px solid #6fc5ff; 9 | } 10 | 11 | .title { 12 | text-overflow: ellipsis; 13 | width: 8rem; 14 | overflow: hidden; 15 | white-space: nowrap; 16 | } 17 | 18 | a { 19 | color: light-dark(var(--mantine-color-pmg-9), var(--mantine-color-pmg-2)); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/nodes/components/Commander/NodesCommander/Node/Tags/Tags.module.css: -------------------------------------------------------------------------------- 1 | .tags { 2 | position: absolute; 3 | top: 0rem; 4 | right: -2rem; 5 | } 6 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/nodes/components/Commander/NodesCommander/Node/Tags/index.tsx: -------------------------------------------------------------------------------- 1 | import {useGetTagsQuery} from "@/features/tags/apiSlice" 2 | import type {ColoredTagType, NodeType} from "@/types" 3 | import {Pill, Stack} from "@mantine/core" 4 | import classes from "./Tags.module.css" 5 | 6 | type Args = { 7 | names: Array 8 | maxItems?: number 9 | node?: NodeType 10 | } 11 | 12 | export default function Tags({maxItems, node, names}: Args) { 13 | const {data: allTags, isLoading} = useGetTagsQuery(node?.group_id) 14 | 15 | if (!allTags || isLoading) { 16 | return 17 | } 18 | 19 | if (!maxItems) { 20 | maxItems = 4 21 | } 22 | 23 | let tags_list = allTags 24 | .filter(t => names.includes(t.name)) 25 | .map((item: ColoredTagType) => ( 26 | 30 | {item.name} 31 | 32 | )) 33 | 34 | if (tags_list.length > maxItems) { 35 | tags_list.splice(maxItems) 36 | tags_list.push(...) 37 | } 38 | 39 | return ( 40 | 41 | {tags_list} 42 | 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/nodes/components/Commander/NodesCommander/Node/index.tsx: -------------------------------------------------------------------------------- 1 | import Node from "./Node" 2 | 3 | export default Node 4 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/nodes/components/Commander/NodesCommander/index.tsx: -------------------------------------------------------------------------------- 1 | import NodesCommander from "./NodesCommander" 2 | 3 | export default NodesCommander 4 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/nodes/components/Commander/index.tsx: -------------------------------------------------------------------------------- 1 | import {useAppSelector} from "@/app/hooks" 2 | import PanelContext from "@/contexts/PanelContext" 3 | import {selectCommanderViewOption} from "@/features/ui/uiSlice" 4 | import {useContext} from "react" 5 | 6 | import DocumentsByTypeCommander from "./DocumentsByTypeCommander" 7 | import NodesCommander from "./NodesCommander" 8 | 9 | export default function Commander() { 10 | const mode = useContext(PanelContext) 11 | const viewOption = useAppSelector(s => selectCommanderViewOption(s, mode)) 12 | 13 | if (viewOption == "list" || viewOption == "tile") { 14 | return 15 | } 16 | 17 | return 18 | } 19 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/nodes/components/index.tsx: -------------------------------------------------------------------------------- 1 | import Commander from "./Commander" 2 | 3 | export default Commander 4 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/nodes/pages/CategoryListView.tsx: -------------------------------------------------------------------------------- 1 | import DualPanel from "@/components/DualPanel" 2 | import { 3 | commanderDocumentTypeIDUpdated, 4 | commanderViewOptionUpdated, 5 | mainPanelComponentUpdated 6 | } from "@/features/ui/uiSlice" 7 | import {LoaderFunctionArgs} from "react-router" 8 | 9 | import {store} from "@/app/store" 10 | 11 | export default function CategoryListView() { 12 | return 13 | } 14 | 15 | export async function loader({params, request}: LoaderFunctionArgs) { 16 | const url = new URL(request.url) 17 | let categoryId 18 | 19 | if (params.categoryId) { 20 | categoryId = params.categoryId 21 | store.dispatch( 22 | commanderDocumentTypeIDUpdated({mode: "main", documentTypeID: categoryId}) 23 | ) 24 | } 25 | 26 | store.dispatch(mainPanelComponentUpdated("commander")) 27 | 28 | store.dispatch( 29 | commanderViewOptionUpdated({mode: "main", viewOption: "document-type"}) 30 | ) 31 | 32 | return {categoryId, urlParams: url.searchParams} 33 | } 34 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/nodes/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import CategoryListView from "./CategoryListView" 2 | 3 | export {CategoryListView} 4 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/nodes/selectors.ts: -------------------------------------------------------------------------------- 1 | import {RootState} from "@/app/types" 2 | import type {ObjectURLState} from "@/types.d/common" 3 | import {createSelector} from "@reduxjs/toolkit" 4 | 5 | export const selectThumbnailByNodeId = ( 6 | state: RootState, 7 | node_id: string 8 | ): ObjectURLState | undefined => { 9 | return state.thumbnailObjects[node_id] 10 | } 11 | 12 | export const selectThumbnailObjects = (state: RootState) => 13 | state.thumbnailObjects 14 | 15 | export const selectNodesWithoutExistingThumbnails = (node_ids: string[]) => 16 | createSelector([selectThumbnailObjects], thumbnailObjects => { 17 | const result = node_ids.filter(node_id => !thumbnailObjects[node_id]) 18 | return result 19 | }) 20 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/roles/components/ActionButtons.tsx: -------------------------------------------------------------------------------- 1 | import {selectSelectedIds} from "@/features/roles/rolesSlice" 2 | import {Group} from "@mantine/core" 3 | import {useSelector} from "react-redux" 4 | import {DeleteRolesButton} from "./DeleteButton" 5 | import EditButton from "./EditButton" 6 | import NewButton from "./NewButton" 7 | 8 | export default function ActionButtons() { 9 | const selectedIds = useSelector(selectSelectedIds) 10 | 11 | return ( 12 | 13 | 14 | {selectedIds.length == 1 ? : ""} 15 | {selectedIds.length >= 1 ? : ""} 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/roles/components/EditButton.tsx: -------------------------------------------------------------------------------- 1 | import {Button} from "@mantine/core" 2 | import {useDisclosure} from "@mantine/hooks" 3 | import {IconEdit} from "@tabler/icons-react" 4 | 5 | import EditRoleModal from "./EditRoleModal" 6 | import {useTranslation} from "react-i18next" 7 | 8 | interface Args { 9 | roleId: string 10 | } 11 | 12 | export default function EditButton({roleId}: Args) { 13 | const {t} = useTranslation() 14 | const [opened, {open, close}] = useDisclosure(false) 15 | 16 | if (!roleId) { 17 | return ( 18 | 21 | ) 22 | } 23 | 24 | return ( 25 | <> 26 | 29 | 35 | 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/roles/components/NewButton.tsx: -------------------------------------------------------------------------------- 1 | import {Button} from "@mantine/core" 2 | import {useDisclosure} from "@mantine/hooks" 3 | import {IconPlus} from "@tabler/icons-react" 4 | import NewRoleModal from "./NewRoleModal" 5 | import {useTranslation} from "react-i18next" 6 | 7 | export default function NewButton() { 8 | const {t} = useTranslation() 9 | const [opened, {open, close}] = useDisclosure(false) 10 | 11 | return ( 12 | <> 13 | 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/roles/components/RoleRow.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | selectionAdd, 3 | selectionRemove, 4 | selectSelectedIds 5 | } from "@/features/roles/rolesSlice" 6 | import type {Role} from "@/types" 7 | import {Checkbox, Table} from "@mantine/core" 8 | import {useDispatch, useSelector} from "react-redux" 9 | import {Link} from "react-router-dom" 10 | 11 | type Args = { 12 | role: Role 13 | } 14 | 15 | export default function RoleRow({role}: Args) { 16 | const selectedIds = useSelector(selectSelectedIds) 17 | const dispatch = useDispatch() 18 | 19 | const onChange = (e: React.ChangeEvent) => { 20 | if (e.currentTarget.checked) { 21 | dispatch(selectionAdd(role.id)) 22 | } else { 23 | dispatch(selectionRemove(role.id)) 24 | } 25 | } 26 | 27 | return ( 28 | 29 | 30 | 31 | 32 | 33 | {role.name} 34 | 35 | 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/roles/components/index.ts: -------------------------------------------------------------------------------- 1 | import RolesList from "./List" 2 | import RoleDetails from "./RoleDetails" 3 | 4 | export {RoleDetails, RolesList} 5 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/roles/pages/Details.tsx: -------------------------------------------------------------------------------- 1 | import {RoleDetails} from "@/features/roles/components" 2 | import {useParams} from "react-router" 3 | 4 | export default function RoleDetailsPage() { 5 | const {roleId} = useParams() 6 | 7 | return 8 | } 9 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/roles/pages/List.tsx: -------------------------------------------------------------------------------- 1 | import {RolesList} from "@/features/roles/components" 2 | 3 | export default function RolesPage() { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/roles/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import RoleDetails from "./Details" 2 | import RolesList from "./List" 3 | 4 | export {RoleDetails, RolesList} 5 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/search/apiSlice.ts: -------------------------------------------------------------------------------- 1 | import {PAGINATION_DEFAULT_ITEMS_PER_PAGES} from "@/cconstants" 2 | import {apiSlice} from "@/features/api/slice" 3 | import {NodeType, Paginated, SearchResultNode} from "@/types" 4 | 5 | type SearchQueryArgs = { 6 | qs: string 7 | page_number?: number 8 | page_size?: number 9 | } 10 | 11 | export const apiSliceWithSearch = apiSlice.injectEndpoints({ 12 | endpoints: builder => ({ 13 | getPaginatedSearchResults: builder.query< 14 | Paginated, 15 | SearchQueryArgs 16 | >({ 17 | query: ({ 18 | qs, 19 | page_number = 1, 20 | page_size = PAGINATION_DEFAULT_ITEMS_PER_PAGES 21 | }: SearchQueryArgs) => 22 | `/search/?q=${qs}&page_number=${page_number}&page_size=${page_size}`, 23 | keepUnusedDataFor: 1 24 | }), 25 | /* Index does not store nodes' breadcrumb, tag color info. 26 | We need perform one extra query to get node's breadcrumb and tag color info. 27 | */ 28 | getNodes: builder.query({ 29 | query: node_ids => 30 | `/nodes/?${node_ids.map(i => `node_ids=${i}`).join("&")}`, 31 | keepUnusedDataFor: 1 32 | }) 33 | }) 34 | }) 35 | 36 | export const {useGetPaginatedSearchResultsQuery, useGetNodesQuery} = 37 | apiSliceWithSearch 38 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/search/components/GoBackButton.tsx: -------------------------------------------------------------------------------- 1 | import {Tooltip, ActionIcon} from "@mantine/core" 2 | import {IconArrowLeft} from "@tabler/icons-react" 3 | import {useNavigate} from "react-router-dom" 4 | import {selectCurrentUser} from "@/slices/currentUser" 5 | import {useSelector} from "react-redux" 6 | import type {User} from "@/types" 7 | 8 | export default function GoBackButton() { 9 | const user = useSelector(selectCurrentUser) as User 10 | const navigate = useNavigate() 11 | 12 | const onClick = () => { 13 | navigate(`/home/${user.home_folder_id}`) 14 | } 15 | 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/search/components/OpenInOtherPanelCheckbox.tsx: -------------------------------------------------------------------------------- 1 | import {useDispatch} from "react-redux" 2 | 3 | import {searchResultItemTargetUpdated} from "@/features/ui/uiSlice" 4 | import {Checkbox} from "@mantine/core" 5 | 6 | export default function OpenInOtherPanelCheckbox() { 7 | const dispatch = useDispatch() 8 | 9 | const onChange = (event: React.ChangeEvent) => { 10 | const inOtherPanel = Boolean(event.currentTarget.checked) 11 | dispatch(searchResultItemTargetUpdated(inOtherPanel)) 12 | } 13 | 14 | return ( 15 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/search/components/SearchResultItems.tsx: -------------------------------------------------------------------------------- 1 | import {NType, SearchResultNode} from "@/types" 2 | import {Text} from "@mantine/core" 3 | import SearchResultItem from "./SearchResultItem" 4 | 5 | type Args = { 6 | onClick: (n: NType, page?: number) => void 7 | items: Array 8 | } 9 | 10 | export default function SearchResultItems({items, onClick}: Args) { 11 | if (items?.length == 0) { 12 | return Nothing was found 13 | } 14 | 15 | const itemComponents = items?.map(i => ( 16 | 17 | )) 18 | 19 | return
{itemComponents}
20 | } 21 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/search/components/SearchResults.module.css: -------------------------------------------------------------------------------- 1 | .content { 2 | overflow: auto; 3 | } 4 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/search/components/Tags.tsx: -------------------------------------------------------------------------------- 1 | import {Group, Pill, Skeleton} from "@mantine/core" 2 | 3 | import type {NodeTag} from "@/types" 4 | 5 | type Args = { 6 | items: Array 7 | maxItems?: number 8 | } 9 | 10 | export default function Tags({items, maxItems}: Args) { 11 | if (!items) { 12 | return 13 | } 14 | 15 | if (!maxItems) { 16 | maxItems = 4 17 | } 18 | 19 | let tags_list = items.map(item => ( 20 | 24 | {item.name} 25 | 26 | )) 27 | 28 | if (tags_list.length > maxItems) { 29 | tags_list.splice(maxItems) 30 | tags_list.push(...) 31 | } 32 | 33 | return {tags_list} 34 | } 35 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/search/components/index.ts: -------------------------------------------------------------------------------- 1 | import SearchResults from "./SearchResults" 2 | 3 | export default SearchResults 4 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/search/components/item.module.css: -------------------------------------------------------------------------------- 1 | .item { 2 | cursor: pointer; 3 | color: light-dark(var(--mantine-color-pmg-9), var(--mantine-color-pmg-2)); 4 | } 5 | 6 | .folderIcon { 7 | margin-bottom: 0.7rem; 8 | background-image: url("/public/folder.svg"); 9 | background-size: 100% 100%; 10 | width: 32px; 11 | height: 32px; 12 | } 13 | 14 | .title:hover { 15 | text-decoration: underline; 16 | } 17 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/shared_nodes/components/ManageAccessModal/GroupRow.tsx: -------------------------------------------------------------------------------- 1 | import {Group} from "@/types.d/shared_nodes" 2 | import {Checkbox, Table} from "@mantine/core" 3 | 4 | interface Args { 5 | group: Group 6 | selectedIDs: string[] 7 | onChange: (group_id: string, checked: boolean) => void 8 | } 9 | 10 | export default function UserRow({group, selectedIDs, onChange}: Args) { 11 | const onLocalChange = (e: React.ChangeEvent) => { 12 | if (e.currentTarget.checked) { 13 | onChange(group.id, true) 14 | } else { 15 | onChange(group.id, false) 16 | } 17 | } 18 | 19 | return ( 20 | 21 | 22 | 26 | 27 | {group.name} 28 | {group.roles.map(r => r.name).join(",")} 29 | 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/shared_nodes/components/ManageAccessModal/UserRow.tsx: -------------------------------------------------------------------------------- 1 | import {User} from "@/types.d/shared_nodes" 2 | import {Checkbox, Table} from "@mantine/core" 3 | 4 | interface Args { 5 | user: User 6 | selectedIDs: string[] 7 | onChange: (user_id: string, checked: boolean) => void 8 | } 9 | 10 | export default function UserRow({user, selectedIDs, onChange}: Args) { 11 | const onLocalChange = (e: React.ChangeEvent) => { 12 | if (e.currentTarget.checked) { 13 | onChange(user.id, true) 14 | } else { 15 | onChange(user.id, false) 16 | } 17 | } 18 | 19 | return ( 20 | 21 | 22 | 26 | 27 | {user.username} 28 | {user.roles.map(r => r.name).join(",")} 29 | 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/shared_nodes/components/ManageAccessModal/index.tsx: -------------------------------------------------------------------------------- 1 | import {ManageAccessModal} from "./ManageAccessModal" 2 | export default ManageAccessModal 3 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/shared_nodes/components/ManageAccessModal/type.ts: -------------------------------------------------------------------------------- 1 | export type IDType = "user" | "group" 2 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/shared_nodes/components/ShareNodesModal/SelectGroups.tsx: -------------------------------------------------------------------------------- 1 | import {useGetGroupsQuery} from "@/features/groups/apiSlice" 2 | import {MultiSelect, Skeleton, Stack} from "@mantine/core" 3 | import {useState} from "react" 4 | 5 | interface Args { 6 | onChange: (value: string[]) => void 7 | } 8 | 9 | export default function SelectGroups({onChange}: Args) { 10 | const [groups, setGroups] = useState([]) 11 | const {data, isLoading} = useGetGroupsQuery() 12 | 13 | const onChangeLocal = (value: string[]) => { 14 | setGroups(value) 15 | onChange(value) 16 | } 17 | 18 | if (isLoading || !data) { 19 | return ( 20 | 21 | 22 | 23 | ) 24 | } 25 | 26 | return ( 27 | 28 | g.name)} 35 | /> 36 | 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/shared_nodes/components/ShareNodesModal/SelectRoles.tsx: -------------------------------------------------------------------------------- 1 | import {useGetRolesQuery} from "@/features/roles/apiSlice" 2 | import {MultiSelect, Skeleton, Stack} from "@mantine/core" 3 | import {useState} from "react" 4 | 5 | interface Args { 6 | onChange: (value: string[]) => void 7 | } 8 | 9 | export default function SelectRoles({onChange}: Args) { 10 | const [roles, setRoles] = useState([]) 11 | const {data, isLoading} = useGetRolesQuery() 12 | 13 | const onChangeLocal = (value: string[]) => { 14 | setRoles(value) 15 | onChange(value) 16 | } 17 | 18 | if (isLoading || !data) { 19 | return ( 20 | 21 | 22 | 23 | ) 24 | } 25 | 26 | return ( 27 | 28 | g.name)} 35 | /> 36 | 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/shared_nodes/components/ShareNodesModal/SelectUsers.tsx: -------------------------------------------------------------------------------- 1 | import {useGetUsersQuery} from "@/features/users/apiSlice" 2 | import {MultiSelect, Skeleton, Stack} from "@mantine/core" 3 | import {useState} from "react" 4 | 5 | interface Args { 6 | onChange: (value: string[]) => void 7 | } 8 | 9 | export default function SelectUsers({onChange}: Args) { 10 | const [users, setUsers] = useState([]) 11 | const {data, isLoading} = useGetUsersQuery() 12 | 13 | const onChangeLocal = (value: string[]) => { 14 | setUsers(value) 15 | onChange(value) 16 | } 17 | 18 | if (isLoading || !data) { 19 | return ( 20 | 21 | 22 | 23 | ) 24 | } 25 | 26 | return ( 27 | 28 | u.username)} 35 | /> 36 | 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/shared_nodes/components/ShareNodesModal/index.tsx: -------------------------------------------------------------------------------- 1 | import {ShareNodesModal} from "./ShareNodesModal" 2 | 3 | export default ShareNodesModal 4 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/shared_nodes/components/SharedCommander/Commander.module.scss: -------------------------------------------------------------------------------- 1 | %base { 2 | padding: 0.25rem; 3 | } 4 | 5 | .content { 6 | overflow: auto; 7 | } 8 | 9 | .accept_files { 10 | @extend %base; 11 | outline: 2px dashed blue; 12 | } 13 | 14 | .commander { 15 | @extend %base; 16 | } 17 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/shared_nodes/components/SharedCommander/Node/Document/Document.module.scss: -------------------------------------------------------------------------------- 1 | @use "../Node.module.scss"; 2 | 3 | .document { 4 | @extend %node; 5 | 6 | img { 7 | height: 5rem; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/shared_nodes/components/SharedCommander/Node/Folder/Folder.module.scss: -------------------------------------------------------------------------------- 1 | @use "../Node.module.scss"; 2 | 3 | .folder { 4 | @extend %node; 5 | } 6 | 7 | .folderIcon { 8 | background-image: url("/public/folder.svg"); 9 | background-size: 100% 100%; 10 | width: 64px; 11 | height: 64px; 12 | } 13 | 14 | .acceptFolder { 15 | outline: 2px dashed blue; 16 | } 17 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/shared_nodes/components/SharedCommander/Node/Node.module.scss: -------------------------------------------------------------------------------- 1 | %node { 2 | padding: 0.3rem; 3 | position: relative; 4 | margin-top: 0.2rem; 5 | 6 | &:hover { 7 | background-color: #8ed2fe66; 8 | outline: 1px solid #6fc5ff; 9 | } 10 | 11 | .title { 12 | text-overflow: ellipsis; 13 | width: 8rem; 14 | overflow: hidden; 15 | white-space: nowrap; 16 | } 17 | 18 | a { 19 | color: light-dark(var(--mantine-color-pmg-9), var(--mantine-color-pmg-2)); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/shared_nodes/components/SharedCommander/Node/Tags/Tags.module.css: -------------------------------------------------------------------------------- 1 | .tags { 2 | position: absolute; 3 | top: 0rem; 4 | right: -2rem; 5 | } 6 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/shared_nodes/components/SharedCommander/Node/Tags/index.tsx: -------------------------------------------------------------------------------- 1 | import {useGetTagsQuery} from "@/features/tags/apiSlice" 2 | import type {ColoredTagType, NodeType} from "@/types" 3 | import {Pill, Stack} from "@mantine/core" 4 | import classes from "./Tags.module.css" 5 | 6 | type Args = { 7 | names: Array 8 | maxItems?: number 9 | node?: NodeType 10 | } 11 | 12 | export default function Tags({maxItems, node, names}: Args) { 13 | const {data: allTags, isLoading} = useGetTagsQuery(node?.group_id) 14 | 15 | if (!allTags || isLoading) { 16 | return 17 | } 18 | 19 | if (!maxItems) { 20 | maxItems = 4 21 | } 22 | 23 | let tags_list = allTags 24 | .filter(t => names.includes(t.name)) 25 | .map((item: ColoredTagType) => ( 26 | 30 | {item.name} 31 | 32 | )) 33 | 34 | if (tags_list.length > maxItems) { 35 | tags_list.splice(maxItems) 36 | tags_list.push(...) 37 | } 38 | 39 | return ( 40 | 41 | {tags_list} 42 | 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/shared_nodes/components/SharedCommander/Node/index.tsx: -------------------------------------------------------------------------------- 1 | import Node from "./Node" 2 | 3 | export default Node 4 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/shared_nodes/components/SharedCommander/ShareTypeSwitch.tsx: -------------------------------------------------------------------------------- 1 | import {SegmentedControl} from "@mantine/core" 2 | 3 | export default function ShareTypeSwitch() { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/shared_nodes/components/SharedCommander/index.tsx: -------------------------------------------------------------------------------- 1 | import SharedCommander from "./SharedCommander" 2 | 3 | export default SharedCommander 4 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/shared_nodes/components/SharedViewer/index.tsx: -------------------------------------------------------------------------------- 1 | import SharedViewer from "./SharedViewer" 2 | 3 | export default SharedViewer 4 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/shared_nodes/pages/SharedDocumentView.tsx: -------------------------------------------------------------------------------- 1 | import {store} from "@/app/store" 2 | import DualPanel from "@/components/DualPanel" 3 | import { 4 | currentSharedNodeChanged, 5 | mainPanelComponentUpdated 6 | } from "@/features/ui/uiSlice" 7 | import {LoaderFunctionArgs} from "react-router" 8 | 9 | export default function SharedDocumentView() { 10 | return 11 | } 12 | 13 | export async function loader({params, request}: LoaderFunctionArgs) { 14 | const url = new URL(request.url) 15 | let documentId = "shared" 16 | 17 | if (params.documentId) { 18 | documentId = params.documentId 19 | } 20 | 21 | store.dispatch(mainPanelComponentUpdated("sharedViewer")) 22 | 23 | store.dispatch( 24 | currentSharedNodeChanged({id: documentId, ctype: "document", panel: "main"}) 25 | ) 26 | 27 | return {documentId, urlParams: url.searchParams} 28 | } 29 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/shared_nodes/pages/SharedFolderView.tsx: -------------------------------------------------------------------------------- 1 | import {store} from "@/app/store" 2 | import DualPanel from "@/components/DualPanel" 3 | import { 4 | currentSharedNodeChanged, 5 | mainPanelComponentUpdated 6 | } from "@/features/ui/uiSlice" 7 | import {LoaderFunctionArgs} from "react-router" 8 | 9 | export default function SharedFolderView() { 10 | return 11 | } 12 | 13 | export async function loader({params, request}: LoaderFunctionArgs) { 14 | const url = new URL(request.url) 15 | let folderId = "shared" 16 | 17 | if (params.folderId) { 18 | folderId = params.folderId 19 | } 20 | 21 | store.dispatch(mainPanelComponentUpdated("sharedCommander")) 22 | 23 | store.dispatch( 24 | currentSharedNodeChanged({id: folderId, ctype: "folder", panel: "main"}) 25 | ) 26 | 27 | return {folderId, urlParams: url.searchParams} 28 | } 29 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/shared_nodes/pages/SharedNodesListView.tsx: -------------------------------------------------------------------------------- 1 | import {store} from "@/app/store" 2 | import {SHARED_FOLDER_ROOT_ID} from "@/cconstants" 3 | import DualPanel from "@/components/DualPanel" 4 | import { 5 | currentSharedNodeChanged, 6 | mainPanelComponentUpdated 7 | } from "@/features/ui/uiSlice" 8 | import {LoaderFunctionArgs} from "react-router" 9 | 10 | export default function SharedNodesListView() { 11 | return 12 | } 13 | 14 | export async function loader({request}: LoaderFunctionArgs) { 15 | const url = new URL(request.url) 16 | 17 | store.dispatch(mainPanelComponentUpdated("sharedCommander")) 18 | 19 | store.dispatch( 20 | currentSharedNodeChanged({ 21 | id: SHARED_FOLDER_ROOT_ID, 22 | ctype: "folder", 23 | panel: "main" 24 | }) 25 | ) 26 | 27 | return {urlParams: url.searchParams} 28 | } 29 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/shared_nodes/pages/index.tsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/papermerge/papermerge-core/b3f2ac64b8aad42f8a2ddcb7b4a0215877b339f6/frontend/apps/ui/src/features/shared_nodes/pages/index.tsx -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/tags/components/EditButton.tsx: -------------------------------------------------------------------------------- 1 | import {Button} from "@mantine/core" 2 | import {useDisclosure} from "@mantine/hooks" 3 | import {IconEdit} from "@tabler/icons-react" 4 | 5 | import EditTagModal from "./EditTagModal" 6 | import {useTranslation} from "react-i18next" 7 | 8 | export default function EditButton({tagId}: {tagId?: string}) { 9 | const {t} = useTranslation() 10 | const [opened, {open, close}] = useDisclosure(false) 11 | 12 | if (!tagId) { 13 | return ( 14 | 17 | ) 18 | } 19 | 20 | return ( 21 | <> 22 | 25 | 31 | 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/tags/components/NewButton.tsx: -------------------------------------------------------------------------------- 1 | import {useDisclosure} from "@mantine/hooks" 2 | import {Button} from "@mantine/core" 3 | import {IconPlus} from "@tabler/icons-react" 4 | import NewTagModal from "./NewTagModal" 5 | import {useTranslation} from "react-i18next" 6 | 7 | export default function NewButton() { 8 | const {t} = useTranslation() 9 | const [opened, {open, close}] = useDisclosure(false) 10 | 11 | return ( 12 | <> 13 | 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/tags/pages/Details.tsx: -------------------------------------------------------------------------------- 1 | import {useParams} from "react-router" 2 | import TagDetails from "@/features/tags/components/TagDetails.tsx" 3 | 4 | export default function TagDetailsPage() { 5 | const {tagId} = useParams() 6 | 7 | return 8 | } 9 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/tags/pages/List.tsx: -------------------------------------------------------------------------------- 1 | import TagsList from "@/features/tags/components/List" 2 | 3 | export default function TagsPage() { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/tags/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import TagDetails from "./Details" 2 | import TagsList from "./List" 3 | 4 | export {TagsList, TagDetails} 5 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/tags/types.ts: -------------------------------------------------------------------------------- 1 | export type TagsListColumnName = 2 | | "name" 3 | | "pinned" 4 | | "description" 5 | | "ID" 6 | | "group_name" 7 | export type TagsSortByInput = 8 | | "name" 9 | | "-name" 10 | | "pinned" 11 | | "-pinned" 12 | | "description" 13 | | "-description" 14 | | "ID" 15 | | "-ID" 16 | | "group_name" 17 | | "-group_name" 18 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/tasks/apiSlice.ts: -------------------------------------------------------------------------------- 1 | import {apiSlice} from "@/features/api/slice" 2 | 3 | interface ScheduleOCRProcessType { 4 | document_id: string 5 | lang: string 6 | } 7 | 8 | export const apiSliceWithTasks = apiSlice.injectEndpoints({ 9 | endpoints: builder => ({ 10 | scheduleOCRProcess: builder.mutation({ 11 | query: data => ({ 12 | url: "/tasks/ocr", 13 | method: "POST", 14 | body: data 15 | }) 16 | }) 17 | }) 18 | }) 19 | 20 | export const {useScheduleOCRProcessMutation} = apiSliceWithTasks 21 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/users/components/ActionButtons.tsx: -------------------------------------------------------------------------------- 1 | import {useSelector} from "react-redux" 2 | import {Group} from "@mantine/core" 3 | import {selectSelectedIds} from "@/features/users/usersSlice" 4 | 5 | import NewButton from "./NewButton" 6 | import ChangePasswordButton from "./ChangePasswordButton" 7 | import {DeleteUsersButton} from "./DeleteButton" 8 | import EditButton from "./EditButton" 9 | 10 | export default function ActionButtons() { 11 | const selectedIds = useSelector(selectSelectedIds) 12 | 13 | return ( 14 | 15 | 16 | {selectedIds.length == 1 ? ( 17 | 18 | ) : ( 19 | "" 20 | )} 21 | {selectedIds.length == 1 ? : ""} 22 | {selectedIds.length >= 1 ? : ""} 23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/users/components/EditButton.tsx: -------------------------------------------------------------------------------- 1 | import {useDisclosure} from "@mantine/hooks" 2 | import {Button} from "@mantine/core" 3 | import {IconEdit} from "@tabler/icons-react" 4 | 5 | import EditUserModal from "./EditUserModal" 6 | import {useTranslation} from "react-i18next" 7 | 8 | export default function EditButton({userId}: {userId?: string}) { 9 | const {t} = useTranslation() 10 | const [opened, {open, close}] = useDisclosure(false) 11 | 12 | if (!userId) { 13 | return ( 14 | 17 | ) 18 | } 19 | 20 | return ( 21 | <> 22 | 25 | 31 | 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/users/components/NewButton.tsx: -------------------------------------------------------------------------------- 1 | import {useDisclosure} from "@mantine/hooks" 2 | import {Button} from "@mantine/core" 3 | import {IconPlus} from "@tabler/icons-react" 4 | import NewUserModal from "./NewUserModal" 5 | import {useTranslation} from "react-i18next" 6 | 7 | export default function NewButton() { 8 | const {t} = useTranslation() 9 | const [opened, {open, close}] = useDisclosure(false) 10 | 11 | return ( 12 | <> 13 | 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/users/components/UserRow.tsx: -------------------------------------------------------------------------------- 1 | import {Link} from "react-router-dom" 2 | import {useDispatch, useSelector} from "react-redux" 3 | import {Table, Checkbox} from "@mantine/core" 4 | 5 | import { 6 | selectionAdd, 7 | selectionRemove, 8 | selectSelectedIds 9 | } from "@/features/users/usersSlice" 10 | 11 | import type {User} from "@/types" 12 | 13 | type Args = { 14 | user: User 15 | } 16 | 17 | export default function UserRow({user}: Args) { 18 | const selectedIds = useSelector(selectSelectedIds) 19 | const dispatch = useDispatch() 20 | 21 | const onChange = (e: React.ChangeEvent) => { 22 | if (e.currentTarget.checked) { 23 | dispatch(selectionAdd(user.id)) 24 | } else { 25 | dispatch(selectionRemove(user.id)) 26 | } 27 | } 28 | 29 | return ( 30 | 31 | 32 | 33 | 34 | 35 | {user.username} 36 | 37 | {user.email} 38 | {user.id} 39 | 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/users/components/index.ts: -------------------------------------------------------------------------------- 1 | import Users from "./List" 2 | 3 | export default Users 4 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/users/components/validators.ts: -------------------------------------------------------------------------------- 1 | export function usernameValidator(value: string): string | null { 2 | if (!value) { 3 | return "Cannot be empty" 4 | } 5 | 6 | if (value.trim().length < 2) { 7 | return "Must be at least 2 characters long" 8 | } 9 | 10 | if (!/^[a-zA-Z0-9_\-\.]+$/.test(value)) { 11 | return "Must contain only a-z, A-Z, 0-9, - and . characters" 12 | } 13 | 14 | if (!/^(?=.*[a-zA-Z]).{1,}$/.test(value)) { 15 | return "Must contain at least one letter" 16 | } 17 | 18 | return null 19 | } 20 | 21 | export function emailValidator(value: string): string | null { 22 | if (!value) { 23 | return "Cannot be empty" 24 | } 25 | 26 | return null 27 | } 28 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/users/pages/Details.tsx: -------------------------------------------------------------------------------- 1 | import {useParams} from "react-router" 2 | import UserDetails from "@/features/users/components/UserDetails.tsx" 3 | 4 | export default function UserDetailsPage() { 5 | const {userId} = useParams() 6 | 7 | return 8 | } 9 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/users/pages/List.tsx: -------------------------------------------------------------------------------- 1 | import UsersList from "@/features/users/components/List.tsx" 2 | 3 | export default function UsersListPage() { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/users/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import UserDetails from "./Details" 2 | import UsersList from "./List" 3 | 4 | export {UserDetails, UsersList} 5 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/features/version/apiSlice.ts: -------------------------------------------------------------------------------- 1 | import {apiSlice} from "@/features/api/slice" 2 | 3 | interface Version { 4 | version: string 5 | } 6 | 7 | export const apiSliceWithVersion = apiSlice.injectEndpoints({ 8 | endpoints: builder => ({ 9 | getVersion: builder.query({ 10 | query: () => "/version/" 11 | }) 12 | }) 13 | }) 14 | 15 | export const {useGetVersionQuery} = apiSliceWithVersion 16 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/hooks/runtime_config.tsx: -------------------------------------------------------------------------------- 1 | import {RuntimeConfig} from "@/types/runtime_config" 2 | import {useEffect, useState} from "react" 3 | 4 | const RUNTIME_CONFIG_DEFAULT: RuntimeConfig = { 5 | ocr__lang_codes: "deu,eng,ron", 6 | ocr__default_lang_code: "deu", 7 | ocr__automatic: false 8 | } 9 | 10 | export function useRuntimeConfig(): RuntimeConfig { 11 | const [config, setConfig] = useState(RUNTIME_CONFIG_DEFAULT) 12 | 13 | useEffect(() => { 14 | if (window.hasOwnProperty("__PAPERMERGE_RUNTIME_CONFIG__")) { 15 | setConfig(window.__PAPERMERGE_RUNTIME_CONFIG__) 16 | } 17 | }, [JSON.stringify(window.__PAPERMERGE_RUNTIME_CONFIG__)]) 18 | 19 | return config 20 | } 21 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/hooks/userUser.ts: -------------------------------------------------------------------------------- 1 | import type {User} from "../types" 2 | 3 | export default function useUser(): User { 4 | const username = import.meta.env.VITE_USERNAME 5 | 6 | return username 7 | } 8 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { MantineProvider } from "@mantine/core" 2 | import { Notifications } from "@mantine/notifications" 3 | import * as React from "react" 4 | import * as ReactDOM from "react-dom/client" 5 | import { Provider } from "react-redux" 6 | import { RouterProvider } from "react-router-dom" 7 | 8 | import { store } from "@/app/store" 9 | import { cookieLoaded } from "@/features/auth/slice" 10 | import "@/index.css" 11 | import { fetchCurrentUser } from "@/slices/currentUser" 12 | import "@mantine/notifications/styles.css" 13 | 14 | import theme from "@/themes" 15 | import { initializeI18n } from "./initializeI18n" 16 | import router from "./router" 17 | 18 | async function start_app() { 19 | store.dispatch(cookieLoaded()) 20 | store.dispatch(fetchCurrentUser()) 21 | 22 | await initializeI18n() 23 | 24 | ReactDOM.createRoot(document.getElementById("root")!).render( 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | ) 34 | } 35 | 36 | start_app() 37 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/pages/Document.tsx: -------------------------------------------------------------------------------- 1 | import {LoaderFunctionArgs} from "react-router" 2 | import DualPanel from "@/components/DualPanel" 3 | 4 | import {store} from "@/app/store" 5 | import {currentNodeChanged} from "@/features/ui/uiSlice" 6 | 7 | export default function Document() { 8 | return 9 | } 10 | 11 | export async function loader({params, request}: LoaderFunctionArgs) { 12 | const url = new URL(request.url) 13 | const documentId = params.documentId 14 | 15 | store.dispatch( 16 | currentNodeChanged({ 17 | id: params.documentId!, 18 | ctype: "document", 19 | panel: "main" 20 | }) 21 | ) 22 | 23 | return {documentId, urlParams: url.searchParams} 24 | } 25 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/pages/Error.tsx: -------------------------------------------------------------------------------- 1 | import {useRouteError} from "react-router-dom" 2 | 3 | interface RouteError { 4 | data: string 5 | error: { 6 | columnNumber: number 7 | fileName: string 8 | lineNumber: number 9 | message: string 10 | stack: string 11 | } 12 | internal: boolean 13 | status: number 14 | statusText: string 15 | } 16 | 17 | export default function PageNotFound() { 18 | const error = useRouteError() as RouteError 19 | console.error(error) 20 | 21 | return ( 22 |
23 |

Oops!

24 |

Sorry, an unexpected error has occurred.

25 |
26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/pages/Folder.tsx: -------------------------------------------------------------------------------- 1 | import {LoaderFunctionArgs} from "react-router" 2 | 3 | import DualPanel from "@/components/DualPanel" 4 | import {currentNodeChanged} from "@/features/ui/uiSlice" 5 | 6 | import {getCurrentUser} from "@/utils" 7 | import {store} from "@/app/store" 8 | 9 | import type {User} from "@/types" 10 | 11 | export default function Home() { 12 | return 13 | } 14 | 15 | export async function loader({params, request}: LoaderFunctionArgs) { 16 | const url = new URL(request.url) 17 | const user: User = await getCurrentUser() 18 | let folderId 19 | 20 | if (params.folderId) { 21 | folderId = params.folderId 22 | } else { 23 | folderId = user.home_folder_id 24 | } 25 | 26 | store.dispatch( 27 | currentNodeChanged({id: folderId, ctype: "folder", panel: "main"}) 28 | ) 29 | 30 | return {nodeId: folderId, urlParams: url.searchParams} 31 | } 32 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/pages/Home.tsx: -------------------------------------------------------------------------------- 1 | import {LoaderFunctionArgs} from "react-router" 2 | 3 | import DualPanel from "@/components/DualPanel" 4 | 5 | import {store} from "@/app/store" 6 | import {getCurrentUser} from "@/utils" 7 | 8 | import {currentNodeChanged} from "@/features/ui/uiSlice" 9 | import type {User} from "@/types" 10 | 11 | export default function Home() { 12 | return 13 | } 14 | 15 | export async function loader({params, request}: LoaderFunctionArgs) { 16 | const url = new URL(request.url) 17 | const user: User = await getCurrentUser() 18 | let folderId 19 | 20 | if (params.folderId) { 21 | folderId = params.folderId 22 | } else { 23 | folderId = user.home_folder_id 24 | } 25 | 26 | store.dispatch( 27 | currentNodeChanged({ 28 | id: folderId, 29 | ctype: "folder", 30 | panel: "main" 31 | }) 32 | ) 33 | 34 | return {folderId, urlParams: url.searchParams} 35 | } 36 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/pages/Inbox.tsx: -------------------------------------------------------------------------------- 1 | import {LoaderFunctionArgs} from "react-router" 2 | 3 | import DualPanel from "@/components/DualPanel" 4 | import {currentNodeChanged} from "@/features/ui/uiSlice" 5 | 6 | import {getCurrentUser} from "@/utils" 7 | import {store} from "@/app/store" 8 | 9 | import type {User} from "@/types" 10 | 11 | export default function Home() { 12 | return 13 | } 14 | 15 | export async function loader({params, request}: LoaderFunctionArgs) { 16 | const url = new URL(request.url) 17 | const user: User = await getCurrentUser() 18 | let folderId 19 | 20 | if (params.folderId) { 21 | folderId = params.folderId 22 | } else { 23 | folderId = user.inbox_folder_id 24 | } 25 | 26 | store.dispatch( 27 | currentNodeChanged({ 28 | id: folderId, 29 | ctype: "folder", 30 | panel: "main" 31 | }) 32 | ) 33 | 34 | return {folderId, urlParams: url.searchParams} 35 | } 36 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/pages/errors/AccessForbidden.tsx: -------------------------------------------------------------------------------- 1 | import {Center, Stack, Text, Title} from "@mantine/core" 2 | import {useTranslation} from "react-i18next" 3 | 4 | export default function AccessForbidden() { 5 | const {t} = useTranslation() 6 | 7 | return ( 8 | <> 9 |
10 | 11 | 403 {t("pages.error.access_forbidden.title")} 12 | {t("pages.error.access_forbidden.message")} 13 | 14 |
15 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/pages/errors/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import {Center, Stack, Text, Title} from "@mantine/core" 2 | import {useTranslation} from "react-i18next" 3 | 4 | export default function NotFound() { 5 | const {t} = useTranslation() 6 | 7 | return ( 8 | <> 9 |
10 | 11 | 404 {t("pages.error.not_found.title")} 12 | {t("pages.error.access_forbidden.message")}d 13 | 14 |
15 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/pages/errors/UnprocessableContent.tsx: -------------------------------------------------------------------------------- 1 | import {Center, Stack, Text, Title} from "@mantine/core" 2 | import {useTranslation} from "react-i18next" 3 | 4 | export default function UnprocessableContent() { 5 | const {t} = useTranslation() 6 | 7 | return ( 8 | <> 9 |
10 | 11 | 422 {t("pages.error.unprocessable_content.title")} 12 | 13 | {t("pages.error.unprocessable_content.message")} 14 | 15 | 16 |
17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/pages/errors/index.tsx: -------------------------------------------------------------------------------- 1 | import AccessForbidden from "./AccessForbidden" 2 | import NotFound from "./NotFound" 3 | import UnprocessableContent from "./UnprocessableContent" 4 | 5 | export {AccessForbidden, NotFound, UnprocessableContent} 6 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/themes/blue.ts: -------------------------------------------------------------------------------- 1 | import {createTheme} from "@mantine/core" 2 | 3 | export const theme = createTheme({ 4 | primaryColor: "pmg", 5 | colors: { 6 | pmg: [ 7 | "#e5f4ff", 8 | "#cde2ff", 9 | "#9bc2ff", 10 | "#64a0ff", 11 | "#3984fe", 12 | "#1d72fe", 13 | "#0969ff", 14 | "#0058e4", 15 | "#004ecc", 16 | "#0043b5" 17 | ] 18 | } 19 | }) 20 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/themes/brown.ts: -------------------------------------------------------------------------------- 1 | import {createTheme} from "@mantine/core" 2 | 3 | export const theme = createTheme({ 4 | primaryColor: "pmg", 5 | colors: { 6 | pmg: [ 7 | "#f7f3f2", 8 | "#e7e5e5", 9 | "#d2c9c6", 10 | "#bdaaa4", 11 | "#ab9087", 12 | "#a17f75", 13 | "#9d766b", 14 | "#896459", 15 | "#7b584e", 16 | "#6d4b40" 17 | ] 18 | } 19 | }) 20 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/themes/gray.ts: -------------------------------------------------------------------------------- 1 | import {createTheme} from "@mantine/core" 2 | 3 | export const theme = createTheme({ 4 | primaryColor: "pmg", 5 | colors: { 6 | pmg: [ 7 | "#f5f5f5", 8 | "#e7e7e7", 9 | "#cdcdcd", 10 | "#b2b2b2", 11 | "#9a9a9a", 12 | "#8b8b8b", 13 | "#848484", 14 | "#717171", 15 | "#656565", 16 | "#575757" 17 | ] 18 | } 19 | }) 20 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/themes/green.ts: -------------------------------------------------------------------------------- 1 | import {createTheme} from "@mantine/core" 2 | 3 | export const theme = createTheme({ 4 | primaryColor: "pmg", 5 | colors: { 6 | pmg: [ 7 | "#e5feee", 8 | "#d2f9e0", 9 | "#a8f1c0", 10 | "#7aea9f", 11 | "#53e383", 12 | "#3bdf70", 13 | "#2bdd66", 14 | "#1ac455", 15 | "#0caf49", 16 | "#00963c" 17 | ] 18 | } 19 | }) 20 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/themes/index.ts: -------------------------------------------------------------------------------- 1 | import {theme as blue} from "./blue" 2 | import {theme as gray} from "./gray" 3 | import {theme as green} from "./green" 4 | import {theme as brown} from "./brown" 5 | 6 | const THEMES = {gray, blue, green, brown} 7 | 8 | const currentThemeName = "blue" 9 | 10 | const currentTheme = THEMES[currentThemeName] 11 | 12 | export default currentTheme 13 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/types.d/common.ts: -------------------------------------------------------------------------------- 1 | export type ImageStatus = "pending" | "ready" | "failed" 2 | export type UUID = string 3 | export type ObjectURLState = { 4 | url: string | null 5 | error: string | null 6 | } 7 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/types.d/groups.ts: -------------------------------------------------------------------------------- 1 | export type NewGroup = { 2 | name: string 3 | with_special_folders: boolean 4 | } 5 | 6 | export type Group = { 7 | id: string 8 | name: string 9 | home_folder_id: string | null 10 | inbox_folder_id: string | null 11 | } 12 | 13 | export type GroupDetails = Group 14 | 15 | export type GroupDetailsPostData = { 16 | id: string 17 | name: string 18 | with_special_folders: boolean 19 | } 20 | 21 | export type GroupUpdate = { 22 | id: string 23 | name?: string 24 | delete_special_folders?: boolean 25 | } 26 | 27 | export type GroupHome = { 28 | home_id: string 29 | group_name: string 30 | group_id: string 31 | } 32 | 33 | export type GroupInbox = { 34 | inbox_id: string 35 | group_name: string 36 | group_id: string 37 | } 38 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/types.d/node_thumbnail.ts: -------------------------------------------------------------------------------- 1 | import {ImageStatus, UUID} from "./common" 2 | 3 | export interface LoadThumbnailInputType { 4 | node_id: UUID 5 | status: ImageStatus | null 6 | url: string | null 7 | } 8 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/types.d/page_image.ts: -------------------------------------------------------------------------------- 1 | import {ImageStatus, UUID} from "./common" 2 | 3 | export interface StatusForSize { 4 | status: ImageStatus | null 5 | url: string | null 6 | size: PageImageSize 7 | } 8 | 9 | export type PageImageSize = "sm" | "md" | "lg" | "xl" 10 | export type PageImageDict = Record> 11 | 12 | export interface ProgressiveImageInputType { 13 | page_id: UUID 14 | previews: Array 15 | } 16 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/types.d/shared_nodes.ts: -------------------------------------------------------------------------------- 1 | export type NewSharedNodes = { 2 | user_ids: string[] 3 | role_ids: string[] 4 | group_ids: string[] 5 | node_ids: string[] 6 | } 7 | 8 | export type Role = { 9 | name: string 10 | id: string 11 | } 12 | 13 | export type User = { 14 | id: string 15 | username: string 16 | roles: Array 17 | } 18 | 19 | export type Group = { 20 | name: string 21 | id: string 22 | roles: Array 23 | } 24 | 25 | export type UserUpdate = { 26 | id: string // user id 27 | role_ids: string[] 28 | } 29 | 30 | export type GroupUpdate = { 31 | id: string // group id 32 | role_ids: string[] 33 | } 34 | 35 | export type SharedNodeAccessDetails = { 36 | id: string // node ID 37 | users: Array 38 | groups: Array 39 | } 40 | 41 | export type SharedNodeAccessUpdate = { 42 | id: string // node ID 43 | users: Array 44 | groups: Array 45 | } 46 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/types.d/ui.ts: -------------------------------------------------------------------------------- 1 | export type PanelComponent = 2 | | "commander" 3 | | "viewer" 4 | | "searchResults" 5 | | "sharedCommander" 6 | | "sharedViewer" 7 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/types/breadcrumb.ts: -------------------------------------------------------------------------------- 1 | export type BreadcrumbItemType = [string, string] 2 | 3 | export type BreadcrumbType = Array 4 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/types/document.ts: -------------------------------------------------------------------------------- 1 | import type {BreadcrumbType} from "./breadcrumb" 2 | import type {OCRCode, OcrStatusEnum} from "./ocr" 3 | 4 | export type PageType = { 5 | id: string 6 | document_version_id: string 7 | jpg_url: string | null 8 | svg_url: string | null 9 | lang: string 10 | number: number 11 | text: string 12 | } 13 | 14 | export type DocumentVersion = { 15 | id: string 16 | document_id: string 17 | download_url: string 18 | file_name: string 19 | lang: OCRCode 20 | number: number 21 | page_count: number 22 | pages: Array 23 | short_description: string 24 | size: number 25 | } 26 | 27 | export type DocumentType = { 28 | id: string 29 | ctype: "document" 30 | title: string 31 | breadcrumb: BreadcrumbType 32 | ocr: boolean 33 | ocr_status: OcrStatusEnum 34 | thumbnail_url: string 35 | versions: Array 36 | parent_id: string | null 37 | user_id: string 38 | updated_at: string 39 | } 40 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/types/ocr.ts: -------------------------------------------------------------------------------- 1 | export type OCRCode = 2 | | "ces" 3 | | "dan" 4 | | "deu" 5 | | "ell" 6 | | "eng" 7 | | "fin" 8 | | "fra" 9 | | "guj" 10 | | "heb" 11 | | "hin" 12 | | "ita" 13 | | "jpn" 14 | | "kor" 15 | | "lit" 16 | | "nld" 17 | | "nor" 18 | | "osd" 19 | | "pol" 20 | | "por" 21 | | "ron" 22 | | "san" 23 | | "spa" 24 | | "kaz" 25 | | "rus" 26 | 27 | export type OcrStatusEnum = 28 | | "UNKNOWN" 29 | | "RECEIVED" 30 | | "STARTED" 31 | | "SUCCESS" 32 | | "FAILURE" 33 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/types/runtime_config.ts: -------------------------------------------------------------------------------- 1 | import {OCRCode} from "@/types" 2 | 3 | export type RuntimeConfig = { 4 | ocr__lang_codes: string 5 | ocr__default_lang_code: OCRCode 6 | ocr__automatic: boolean 7 | } 8 | 9 | declare global { 10 | interface Window { 11 | __PAPERMERGE_RUNTIME_CONFIG__: RuntimeConfig 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /frontend/apps/ui/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /frontend/apps/ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", // relative to apps/ui 5 | "jsx": "react-jsx", 6 | "types": ["react"], 7 | "paths": { 8 | "@/*": ["src/*"], 9 | "@papermerge/hooks": ["../../packages/@papermerge/hooks/src"] 10 | } 11 | }, 12 | "include": ["src"], 13 | "references": [ 14 | { "path": "./tsconfig.node.json" }, 15 | { "path": "../../packages/@papermerge/hooks" } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /frontend/apps/ui/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "node", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true, 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /frontend/apps/ui/vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react"; 2 | import { defineConfig } from "vite"; 3 | import tsconfigPaths from 'vite-tsconfig-paths'; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react(), tsconfigPaths()], 8 | resolve: { 9 | alias: [ 10 | {find: "@", replacement: "/src"}, 11 | // https://github.com/tabler/tabler-icons/issues/1233#issuecomment-2428245119 12 | // https://stackoverflow.com/questions/79194970/tabler-icons-for-react-slowing-down-app-on-initial-load 13 | // /esm/icons/index.mjs only exports the icons statically, so no separate chunks are created 14 | { 15 | find: "@tabler/icons-react", 16 | replacement: "@tabler/icons-react/dist/esm/icons/index.mjs" 17 | } 18 | ] 19 | }, 20 | css: { 21 | preprocessorOptions: { 22 | scss: { 23 | additionalData: `@use "/src/_mantine";` 24 | } 25 | } 26 | } 27 | }) 28 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Papermerge DMS UI", 3 | "packageManager": "yarn@4.9.1", 4 | "workspaces": [ 5 | "packages/**/*", 6 | "apps/*" 7 | ], 8 | "devDependencies": { 9 | "@types/react": "^19.0.10", 10 | "@types/react-dom": "^19.0.4", 11 | "typescript": "^5.8.3" 12 | }, 13 | "dependencies": { 14 | "react": "^19.0.0", 15 | "react-dom": "^19.0.0" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /frontend/packages/@papermerge/hooks/README.md: -------------------------------------------------------------------------------- 1 | # Hooks.Dev 2 | 3 | 4 | Start local frontend server: 5 | 6 | ``` 7 | yarn dev 8 | ``` 9 | 10 | 11 | ## useDocumentThumbnailPolling 12 | 13 | Server example to test `useDocumentThumbnailPolling`: 14 | 15 | ```python 16 | from fastapi.middleware.cors import CORSMiddleware 17 | from fastapi import FastAPI, Query 18 | from typing import List 19 | 20 | app = FastAPI() 21 | 22 | app.add_middleware( 23 | CORSMiddleware, 24 | allow_origins=["http://localhost:5173"], # or ["*"] to allow all origins (not recommended for production) 25 | allow_credentials=True, 26 | allow_methods=["*"], 27 | allow_headers=["*"], 28 | ) 29 | 30 | counter = 1 31 | 32 | 33 | @app.get("/api/previews") 34 | async def root(doc_ids: List[str] = Query()): 35 | global counter 36 | 37 | result = [] 38 | if counter % 4 != 0: 39 | for doc_id in doc_ids: 40 | result.append({ 41 | 'doc_id': doc_id, 42 | 'status': 'pending', 43 | 'preview_image_url': None 44 | }) 45 | else: 46 | for doc_id in doc_ids: 47 | result.append({ 48 | 'doc_id': doc_id, 49 | 'status': 'ready', 50 | 'preview_image_url': f'http://image-cdn/{doc_id}/sm.jpg' 51 | }) 52 | 53 | counter += 1 54 | 55 | return result 56 | ``` 57 | -------------------------------------------------------------------------------- /frontend/packages/@papermerge/hooks/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@papermerge/hooks", 3 | "peerDependencies": { 4 | "react": "^19.0.0" 5 | }, 6 | "scripts": { 7 | "build": "tsc" 8 | }, 9 | "devDependencies": { 10 | "@types/react": "^19.0.10", 11 | "@types/react-dom": "^19.0.4", 12 | "react": "^19.0.0", 13 | "typescript": "^5.8.3" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /frontend/packages/@papermerge/hooks/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "node", 6 | "composite": true, 7 | "declaration": true, 8 | "declarationMap": true, 9 | "outDir": "dist", 10 | "baseUrl": "." 11 | }, 12 | "include": ["src"] 13 | } 14 | -------------------------------------------------------------------------------- /frontend/tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "packages/@papermerge/hooks" }, 5 | { "path": "apps/hooks.dev" } 6 | ], 7 | "compilerOptions": { 8 | "target": "ES2020", 9 | "module": "ESNext", 10 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 11 | "jsx": "react-jsx", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "allowImportingTsExtensions": true, 15 | "strict": true, 16 | "skipLibCheck": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noFallthroughCasesInSwitch": true, 20 | "isolatedModules": true, 21 | "noEmit": true, 22 | "composite": true, 23 | "baseUrl": ".", 24 | "paths": { 25 | "@papermerge/hooks": ["packages/@papermerge/hooks/src"], 26 | "@/*": ["apps/ui/src/*"] 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "packages/@papermerge/hooks" }, 5 | { "path": "apps/hooks.dev" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /papermerge/cli.py: -------------------------------------------------------------------------------- 1 | import typer 2 | 3 | from papermerge.core.cli import perms as perms_cli 4 | from papermerge.core.cli import scopes as scopes_cli 5 | from papermerge.core.features.users.cli import cli as usr_cli 6 | from papermerge.core.features.groups.cli import cli as groups_cli 7 | from papermerge.core.cli import token as token_cli 8 | from papermerge.search.cli import search 9 | from papermerge.search.cli import index 10 | from papermerge.search.cli import index_schema 11 | 12 | app = typer.Typer(help="Papermerge DMS command line management tool") 13 | app.add_typer(usr_cli.app, name="users") 14 | app.add_typer(groups_cli.app, name="groups") 15 | app.add_typer(perms_cli.app, name="perms") 16 | app.add_typer(scopes_cli.app, name="scopes") 17 | app.add_typer(token_cli.app, name="tokens") 18 | app.add_typer(search.app, name="search") 19 | app.add_typer(index.app, name="index") 20 | app.add_typer(index_schema.app, name="index-schema") 21 | 22 | if __name__ == "__main__": 23 | app() 24 | -------------------------------------------------------------------------------- /papermerge/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/papermerge/papermerge-core/b3f2ac64b8aad42f8a2ddcb7b4a0215877b339f6/papermerge/core/__init__.py -------------------------------------------------------------------------------- /papermerge/core/alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. 2 | 3 | 4 | Create initial migration: 5 | 6 | ``` 7 | $ alembic revision --autogenerate -m "Initial migration" 8 | ``` 9 | 10 | Run migration: 11 | 12 | ``` 13 | $ alembic upgrade head 14 | ``` 15 | 16 | 17 | Create a migration: 18 | 19 | ``` 20 | $ alembic revision -m "add value_yearmonth column to custom_field_values" 21 | ``` 22 | 23 | Navigate back and forth: 24 | 25 | ``` 26 | $ alembic downgrade -1 27 | $ alembic upgrade +1 28 | ``` 29 | 30 | To view migrations in chronological order: 31 | 32 | ``` 33 | alembic history 34 | ``` 35 | -------------------------------------------------------------------------------- /papermerge/core/alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | ${imports if imports else ""} 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = ${repr(up_revision)} 16 | down_revision: Union[str, None] = ${repr(down_revision)} 17 | branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} 18 | depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} 19 | 20 | 21 | def upgrade() -> None: 22 | ${upgrades if upgrades else "pass"} 23 | 24 | 25 | def downgrade() -> None: 26 | ${downgrades if downgrades else "pass"} 27 | -------------------------------------------------------------------------------- /papermerge/core/alembic/versions/1240862ec13d_update_documents_preview_status_type_as_.py: -------------------------------------------------------------------------------- 1 | """update documents.preview_status type as pg enum 2 | 3 | Revision ID: 1240862ec13d 4 | Revises: 4a2bc1bb17ae 5 | Create Date: 2025-05-18 06:25:33.643203 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | from alembic import op 12 | import sqlalchemy as sa 13 | 14 | 15 | # revision identifiers, used by Alembic. 16 | revision: str = "1240862ec13d" 17 | down_revision: Union[str, None] = "4a2bc1bb17ae" 18 | branch_labels: Union[str, Sequence[str], None] = None 19 | depends_on: Union[str, Sequence[str], None] = None 20 | 21 | 22 | def upgrade() -> None: 23 | op.drop_column("documents", "preview_status") 24 | op.add_column( 25 | "documents", 26 | sa.Column( 27 | "preview_status", 28 | sa.Enum("READY", "PENDING", "FAILED", name="preview_status"), 29 | nullable=True, 30 | ), 31 | ) 32 | 33 | 34 | def downgrade() -> None: 35 | pass 36 | -------------------------------------------------------------------------------- /papermerge/core/alembic/versions/85fda75f19f1_make_document_type_name_unique_for_user_.py: -------------------------------------------------------------------------------- 1 | """make document type name unique for user_id 2 | 3 | Revision ID: 85fda75f19f1 4 | Revises: bc29f69daca4 5 | Create Date: 2024-11-25 10:03:03.516065 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | from alembic import op 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = "85fda75f19f1" 16 | down_revision: Union[str, None] = "bc29f69daca4" 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | with op.batch_alter_table("document_types") as batch_op: 23 | batch_op.create_unique_constraint( 24 | "unique document type per user", 25 | columns=["name", "user_id"], 26 | ) 27 | 28 | 29 | def downgrade() -> None: 30 | with op.batch_alter_table("document_types") as batch_op: 31 | batch_op.drop_constraint("unique document type per user") 32 | -------------------------------------------------------------------------------- /papermerge/core/alembic/versions/973801cf0c71_add_group_flags_delete_me_and_delete_.py: -------------------------------------------------------------------------------- 1 | """add group flags: delete_me and delete_special_folders 2 | 3 | Revision ID: 973801cf0c71 4 | Revises: 2518bf648ffd 5 | Create Date: 2025-03-17 08:38:41.676748 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = '973801cf0c71' 16 | down_revision: Union[str, None] = '2518bf648ffd' 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.add_column('groups', sa.Column('delete_me', sa.Boolean(), nullable=True)) 24 | op.add_column('groups', sa.Column('delete_special_folders', sa.Boolean(), nullable=True)) 25 | # ### end Alembic commands ### 26 | 27 | 28 | def downgrade() -> None: 29 | # ### commands auto generated by Alembic - please adjust! ### 30 | op.drop_column('groups', 'delete_special_folders') 31 | op.drop_column('groups', 'delete_me') 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /papermerge/core/alembic/versions/a03014b93c1e_add_documents_preview_status_field.py: -------------------------------------------------------------------------------- 1 | """add documents.preview_status and documents.preview_error fields 2 | 3 | Revision ID: a03014b93c1e 4 | Revises: 2118951c4d90 5 | Create Date: 2025-05-12 07:25:19.171857 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | from alembic import op 12 | import sqlalchemy as sa 13 | 14 | 15 | # revision identifiers, used by Alembic. 16 | revision: str = "a03014b93c1e" 17 | down_revision: Union[str, None] = "2118951c4d90" 18 | branch_labels: Union[str, Sequence[str], None] = None 19 | depends_on: Union[str, Sequence[str], None] = None 20 | 21 | 22 | def upgrade() -> None: 23 | op.add_column("documents", sa.Column("preview_status", sa.String(), nullable=True)) 24 | op.add_column("documents", sa.Column("preview_error", sa.String(), nullable=True)) 25 | 26 | 27 | def downgrade() -> None: 28 | op.drop_column("documents", "preview_error") 29 | op.drop_column("documents", "preview_status") 30 | -------------------------------------------------------------------------------- /papermerge/core/alembic/versions/cea868700f4e_add_value_yearmonth_and_value_year_.py: -------------------------------------------------------------------------------- 1 | """add value_yearmonth and value_year columns to custom_field_values 2 | 3 | Revision ID: cea868700f4e 4 | Revises: 85fda75f19f1 5 | Create Date: 2024-11-27 07:30:19.631965 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | from alembic import op 12 | import sqlalchemy as sa 13 | 14 | 15 | # revision identifiers, used by Alembic. 16 | revision: str = "cea868700f4e" 17 | down_revision: Union[str, None] = "85fda75f19f1" 18 | branch_labels: Union[str, Sequence[str], None] = None 19 | depends_on: Union[str, Sequence[str], None] = None 20 | 21 | 22 | def upgrade() -> None: 23 | op.add_column( 24 | "custom_field_values", sa.Column("value_yearmonth", sa.Float(), nullable=True) 25 | ) 26 | 27 | 28 | def downgrade() -> None: 29 | op.drop_column("custom_field_values", "value_yearmonth") 30 | -------------------------------------------------------------------------------- /papermerge/core/cache/__init__.py: -------------------------------------------------------------------------------- 1 | from papermerge.core import config 2 | from .empty import Client as EmptyClient 3 | from .redis_client import Client as RedisClient 4 | 5 | settings = config.get_settings() 6 | 7 | redis_url = settings.papermerge__redis__url 8 | cache_enabled = settings.papermerge__main__cache_enabled 9 | 10 | if redis_url and cache_enabled: 11 | client = RedisClient(redis_url) 12 | else: 13 | client = EmptyClient() 14 | -------------------------------------------------------------------------------- /papermerge/core/cache/empty.py: -------------------------------------------------------------------------------- 1 | class Client: 2 | 3 | def get(self, key): 4 | return None 5 | 6 | def set(self, key, value, ex: int = 60): ... 7 | 8 | 9 | def get_client(): 10 | return Client() 11 | -------------------------------------------------------------------------------- /papermerge/core/cache/redis_client.py: -------------------------------------------------------------------------------- 1 | import redis 2 | 3 | 4 | class Client: 5 | def __init__(self, url): 6 | self.url = url 7 | self.client = redis.from_url(url) 8 | 9 | def get(self, key): 10 | if self.client.exists(key): 11 | return self.client.get(key).decode("utf-8") 12 | 13 | return None 14 | 15 | def set(self, key, value, ex: int = 60): 16 | """ex is number of SECONDS until key expires""" 17 | self.client.set(key, value, ex) 18 | 19 | 20 | def get_client(url): 21 | return Client(url) 22 | -------------------------------------------------------------------------------- /papermerge/core/cli/cf_sign_url.py: -------------------------------------------------------------------------------- 1 | import typer 2 | from rich import print 3 | from typing_extensions import Annotated 4 | 5 | from papermerge.core import cloudfront 6 | 7 | app = typer.Typer(help="List various entities") 8 | 9 | ValidFor = Annotated[ 10 | int, 11 | typer.Argument(help='Number of seconds the url will be valid for') 12 | ] 13 | 14 | 15 | @app.command() 16 | def cf_sign_url(url: str, valid_for: ValidFor = 600): 17 | """Sign URL using AWS CloudFront signer""" 18 | result = cloudfront.sign_url(url, valid_for) 19 | print(f"Signed URL: {result}") 20 | -------------------------------------------------------------------------------- /papermerge/core/cli/perms.py: -------------------------------------------------------------------------------- 1 | import typer 2 | from rich.console import Console 3 | from rich.table import Table 4 | 5 | from papermerge.core.db.engine import Session 6 | from papermerge.core import dbapi 7 | from papermerge.core import schema 8 | 9 | app = typer.Typer(help="Permissions management") 10 | 11 | 12 | @app.command("ls") 13 | def perms_list(): 14 | """List database stored permissions""" 15 | with Session() as db_session: 16 | perms: list[schema.Permission] = dbapi.get_perms(db_session) 17 | print_perms(perms) 18 | 19 | 20 | @app.command("sync") 21 | def perms_sync(): 22 | """Synchronizes permissions table with current scopes""" 23 | with Session() as db_session: 24 | dbapi.sync_perms(db_session) 25 | 26 | 27 | def print_perms(perms: list[schema.Permission]): 28 | table = Table(title="Permissions") 29 | 30 | table.add_column("ID", style="cyan", no_wrap=True) 31 | table.add_column("codename", style="green") 32 | table.add_column("name", style="magenta") 33 | 34 | for perm in perms: 35 | table.add_row( 36 | str(perm.id), 37 | perm.codename, 38 | perm.name 39 | ) 40 | 41 | console = Console() 42 | console.print(table) 43 | -------------------------------------------------------------------------------- /papermerge/core/cli/scopes.py: -------------------------------------------------------------------------------- 1 | import typer 2 | from rich.console import Console 3 | from rich.table import Table 4 | 5 | from papermerge.core.features.auth.scopes import SCOPES 6 | 7 | app = typer.Typer(help="Scopes management") 8 | 9 | 10 | @app.command("ls") 11 | def scopes_list(): 12 | """List current scopes (as defined in application code)""" 13 | table = Table(title="Scopes") 14 | 15 | table.add_column("codename") 16 | table.add_column("description") 17 | 18 | for codename, descr in SCOPES.items(): 19 | table.add_row(codename, descr) 20 | 21 | console = Console() 22 | console.print(table) 23 | -------------------------------------------------------------------------------- /papermerge/core/db/__init__.py: -------------------------------------------------------------------------------- 1 | from .engine import Session 2 | from .base import Base 3 | 4 | from papermerge.core.features.document.db.api import get_page 5 | 6 | __all__ = ["Session", "get_page"] 7 | -------------------------------------------------------------------------------- /papermerge/core/db/base.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import DeclarativeBase 2 | 3 | 4 | class Base(DeclarativeBase): 5 | pass 6 | -------------------------------------------------------------------------------- /papermerge/core/db/engine.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from sqlalchemy import Engine, create_engine 5 | from sqlalchemy.orm import sessionmaker 6 | from sqlalchemy.pool import NullPool 7 | 8 | 9 | SQLALCHEMY_DATABASE_URL = os.environ.get( 10 | "PAPERMERGE__DATABASE__URL", "sqlite:////db/db.sqlite3" 11 | ) 12 | connect_args = {} 13 | logger = logging.getLogger(__name__) 14 | 15 | if SQLALCHEMY_DATABASE_URL.startswith("sqlite"): 16 | # sqlite specific connection args 17 | connect_args = {"check_same_thread": False} 18 | 19 | engine = create_engine( 20 | SQLALCHEMY_DATABASE_URL, connect_args=connect_args, poolclass=NullPool 21 | ) 22 | 23 | Session = sessionmaker(engine, expire_on_commit=False) 24 | 25 | 26 | def get_engine() -> Engine: 27 | return engine 28 | -------------------------------------------------------------------------------- /papermerge/core/db/exceptions.py: -------------------------------------------------------------------------------- 1 | class UserNotFound(Exception): 2 | pass 3 | 4 | 5 | class PageNotFound(Exception): 6 | pass 7 | -------------------------------------------------------------------------------- /papermerge/core/exceptions.py: -------------------------------------------------------------------------------- 1 | from fastapi import HTTPException 2 | 3 | 4 | class HTTP401Unauthorized(HTTPException): 5 | def __init__(self, detail: str = "Not Authenticated"): 6 | super().__init__(status_code=401, detail=detail) 7 | 8 | 9 | 10 | class HTTP403Forbidden(HTTPException): 11 | def __init__(self, detail: str = "Access Forbidden"): 12 | super().__init__(status_code=403, detail=detail) 13 | 14 | 15 | class HTTP404NotFound(HTTPException): 16 | def __init__(self, detail: str = "Not Found"): 17 | super().__init__(status_code=404, detail=detail) 18 | 19 | 20 | class SuperuserDoesNotExist(Exception): 21 | """ 22 | Raised when superuser was not found. 23 | Papermerge must have at least one superuser. 24 | """ 25 | 26 | pass 27 | 28 | 29 | class FileTypeNotSupported(Exception): 30 | """File type not supported""" 31 | 32 | pass 33 | 34 | 35 | class InvalidDateFormat(Exception): 36 | pass 37 | 38 | 39 | class EntityNotFound(Exception): 40 | pass 41 | -------------------------------------------------------------------------------- /papermerge/core/features/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/papermerge/papermerge-core/b3f2ac64b8aad42f8a2ddcb7b4a0215877b339f6/papermerge/core/features/__init__.py -------------------------------------------------------------------------------- /papermerge/core/features/auth/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.fixture 5 | def montaigne(make_user): 6 | return make_user(username="montaigne") 7 | -------------------------------------------------------------------------------- /papermerge/core/features/auth/tests/test_auth.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | 3 | from papermerge.core import types 4 | from papermerge.core.features.auth import extract_token_data 5 | from papermerge.core.features.users.db.orm import User 6 | 7 | 8 | def test_get_current_user(token): 9 | token_data: types.TokenData = extract_token_data(token) 10 | 11 | assert token_data is not None 12 | 13 | 14 | def test_remote_based_authentication(montaigne: User, api_client: TestClient): 15 | response = api_client.get("/users/me/", headers={"Remote-User": "montaigne"}) 16 | assert response.status_code == 200 17 | 18 | 19 | def test_remote_based_authentication_no_headers(api_client: TestClient): 20 | response = api_client.get("/users/me/") 21 | assert response.status_code == 401 22 | -------------------------------------------------------------------------------- /papermerge/core/features/custom_fields/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from papermerge.core.db.engine import Session 4 | from papermerge.core.features.custom_fields import schema 5 | from papermerge.core.features.custom_fields.db import api as dbapi 6 | 7 | 8 | @pytest.fixture 9 | def custom_field_cf1(db_session: Session, user): 10 | return dbapi.create_custom_field( 11 | db_session, 12 | name="cf1", 13 | type=schema.CustomFieldType.text, 14 | user_id=user.id, 15 | ) 16 | -------------------------------------------------------------------------------- /papermerge/core/features/custom_fields/tests/test_schema_custom_fields.py: -------------------------------------------------------------------------------- 1 | import json 2 | import uuid 3 | 4 | from papermerge.core.features.custom_fields.schema import CustomField, CustomFieldType 5 | 6 | 7 | def test_custom_field_extra_data_as_str(): 8 | data = {"currency": "EUR"} 9 | cf = CustomField( 10 | id=uuid.uuid4(), 11 | name="coco", 12 | type=CustomFieldType.monetary, 13 | extra_data=json.dumps(data), 14 | ) 15 | 16 | assert cf 17 | 18 | 19 | def test_custom_field_extra_data_as_dict(): 20 | data = {"currency": "EUR"} 21 | cf = CustomField( 22 | id=uuid.uuid4(), 23 | name="coco", 24 | type=CustomFieldType.monetary, 25 | extra_data=data, 26 | ) 27 | 28 | assert cf 29 | -------------------------------------------------------------------------------- /papermerge/core/features/custom_fields/types.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from papermerge.core.types import PaginatedQueryParams as BaseParams 4 | 5 | 6 | class OrderBy(str, Enum): 7 | name_asc = "name" 8 | name_desc = "-name" 9 | type_asc = "type" 10 | type_desc = "-type" 11 | owner_asc = "group_name" 12 | owner_desc = "-group_name" 13 | 14 | 15 | class PaginatedQueryParams(BaseParams): 16 | order_by: OrderBy | None = None 17 | -------------------------------------------------------------------------------- /papermerge/core/features/document/cli/cli.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import typer 3 | 4 | from papermerge.core.tasks import send_task 5 | from papermerge.core.db.engine import Session 6 | 7 | from papermerge.core import dbapi, constants, types 8 | 9 | 10 | app = typer.Typer(help="OCR tasks") 11 | 12 | 13 | @app.command() 14 | def schedule_ocr(node_id: uuid.UUID, force: bool = False, lang: str | None = None): 15 | """Schedules OCR for given node ID""" 16 | with Session() as db_session: 17 | node_type: types.CType = dbapi.get_node_type(db_session, node_id) 18 | 19 | if node_type == "document": 20 | if lang is None: 21 | lang = dbapi.get_document_lang(db_session, node_id) 22 | send_task( 23 | constants.WORKER_OCR_DOCUMENT, 24 | kwargs={ 25 | "document_id": str(node_id), 26 | "lang": lang, 27 | }, 28 | route_name="ocr", 29 | ) 30 | else: 31 | # get all descendants of node_id 32 | pass 33 | -------------------------------------------------------------------------------- /papermerge/core/features/document/s3.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from papermerge.core.types import ImagePreviewSize 4 | from papermerge.core import pathlib as plib 5 | from papermerge.core import config 6 | 7 | settings = config.get_settings() 8 | 9 | VALID_FOR_SECONDS = 600 10 | 11 | 12 | def resource_sign_url(prefix, resource_path): 13 | from papermerge.core.cloudfront import sign_url 14 | 15 | if prefix: 16 | url = f"https://{settings.papermerge__main__cf_domain}/{prefix}/{resource_path}" 17 | else: 18 | url = f"https://{settings.papermerge__main__cf_domain}/{resource_path}" 19 | 20 | return sign_url( 21 | url, 22 | valid_for=VALID_FOR_SECONDS, 23 | ) 24 | 25 | 26 | def doc_thumbnail_signed_url(uid: UUID) -> str: 27 | resource_path = plib.thumbnail_path(uid) 28 | prefix = settings.papermerge__main__prefix 29 | 30 | return resource_sign_url(prefix, resource_path) 31 | 32 | 33 | def page_image_jpg_signed_url(uid: UUID, size: ImagePreviewSize) -> str: 34 | resource_path = plib.page_preview_jpg_path(uid, size=size) 35 | prefix = settings.papermerge__main__prefix 36 | 37 | return resource_sign_url(prefix, resource_path) 38 | -------------------------------------------------------------------------------- /papermerge/core/features/document/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import pytest 4 | 5 | from papermerge.core.db.engine import Session 6 | from papermerge.core import orm 7 | 8 | 9 | @pytest.fixture 10 | def make_document_zdf(db_session: Session, document_type_zdf): 11 | def _make_receipt(title: str, user: orm.User): 12 | doc = orm.Document( 13 | id=uuid.uuid4(), 14 | ctype="document", 15 | title=title, 16 | user=user, 17 | document_type_id=document_type_zdf.id, 18 | parent_id=user.home_folder_id, 19 | ) 20 | 21 | db_session.add(doc) 22 | 23 | db_session.commit() 24 | 25 | return doc 26 | 27 | return _make_receipt 28 | -------------------------------------------------------------------------------- /papermerge/core/features/document/tests/resources/d3.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/papermerge/papermerge-core/b3f2ac64b8aad42f8a2ddcb7b4a0215877b339f6/papermerge/core/features/document/tests/resources/d3.pdf -------------------------------------------------------------------------------- /papermerge/core/features/document/tests/resources/dummy.txt: -------------------------------------------------------------------------------- 1 | dummy 2 | -------------------------------------------------------------------------------- /papermerge/core/features/document/tests/resources/living-things.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/papermerge/papermerge-core/b3f2ac64b8aad42f8a2ddcb7b4a0215877b339f6/papermerge/core/features/document/tests/resources/living-things.pdf -------------------------------------------------------------------------------- /papermerge/core/features/document/tests/resources/one-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/papermerge/papermerge-core/b3f2ac64b8aad42f8a2ddcb7b4a0215877b339f6/papermerge/core/features/document/tests/resources/one-page.png -------------------------------------------------------------------------------- /papermerge/core/features/document/tests/resources/s3.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/papermerge/papermerge-core/b3f2ac64b8aad42f8a2ddcb7b4a0215877b339f6/papermerge/core/features/document/tests/resources/s3.pdf -------------------------------------------------------------------------------- /papermerge/core/features/document/tests/resources/three-pages.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/papermerge/papermerge-core/b3f2ac64b8aad42f8a2ddcb7b4a0215877b339f6/papermerge/core/features/document/tests/resources/three-pages.pdf -------------------------------------------------------------------------------- /papermerge/core/features/document_types/schema.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from pydantic import BaseModel, ConfigDict 4 | 5 | from papermerge.core.features.custom_fields.schema import CustomField 6 | 7 | 8 | class DocumentType(BaseModel): 9 | id: UUID 10 | name: str 11 | path_template: str | None = None 12 | custom_fields: list[CustomField] 13 | group_id: UUID | None = None 14 | group_name: str | None = None 15 | 16 | # Config 17 | model_config = ConfigDict(from_attributes=True) 18 | 19 | 20 | class CreateDocumentType(BaseModel): 21 | name: str 22 | path_template: str | None = None 23 | custom_field_ids: list[UUID] 24 | group_id: UUID | None = None 25 | 26 | # Config 27 | model_config = ConfigDict(from_attributes=True) 28 | 29 | 30 | class UpdateDocumentType(BaseModel): 31 | name: str | None = None 32 | path_template: str | None = None 33 | custom_field_ids: list[UUID] | None = None 34 | group_id: UUID | None = None 35 | user_id: UUID | None = None 36 | 37 | 38 | class GroupedDocumentTypeItem(BaseModel): 39 | id: UUID 40 | name: str # document type name 41 | 42 | 43 | class GroupedDocumentType(BaseModel): 44 | name: str # group name 45 | items: list[GroupedDocumentTypeItem] 46 | -------------------------------------------------------------------------------- /papermerge/core/features/document_types/tests/test_document_type.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import select, func 2 | from papermerge.core import dbapi, orm 3 | 4 | 5 | def test_on_delete_document_type_which_has_docs_associated( 6 | make_document_receipt, db_session, user 7 | ): 8 | """ 9 | If document type is deleted, then it's associated documents 10 | should stay (they, documents, will have doc.document_type_id set to NULL) 11 | """ 12 | # Arrange 13 | doc: orm.Document = make_document_receipt(title="receipt.pdf", user=user) 14 | doc_type_id = doc.document_type_id 15 | 16 | # Act 17 | dbapi.delete_document_type(db_session, doc.document_type_id) 18 | 19 | # Assert 20 | doc_count = db_session.execute( 21 | select(func.count(orm.Document.id)).where(orm.Document.id == doc.id) 22 | ).scalar() 23 | doc_type_count = db_session.execute( 24 | select(func.count(orm.DocumentType.id)).where( 25 | orm.DocumentType.id == doc_type_id 26 | ) 27 | ).scalar() 28 | 29 | # document is still there 30 | assert doc_count == 1 31 | # document type was deleted indeed 32 | assert doc_type_count == 0 33 | -------------------------------------------------------------------------------- /papermerge/core/features/document_types/tests/test_document_type_dbapi.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Session 2 | from papermerge.core import orm, dbapi 3 | 4 | 5 | def test_get_document_types_grouped_by_owner_without_pagination( 6 | db_session: Session, make_document_type, user, make_group 7 | ): 8 | family: orm.Group = make_group("Family") 9 | make_document_type(name="Family Shopping", group_id=family.id) 10 | make_document_type(name="Bills", group_id=family.id) 11 | make_document_type(name="My Private", user=user) 12 | 13 | user.groups.append(family) 14 | db_session.add(user) 15 | db_session.commit() 16 | 17 | results = dbapi.get_document_types_grouped_by_owner_without_pagination( 18 | db_session, user_id=user.id 19 | ) 20 | 21 | assert len(results) == 2 22 | -------------------------------------------------------------------------------- /papermerge/core/features/document_types/types.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from papermerge.core.types import PaginatedQueryParams as BaseParams 4 | 5 | 6 | class OrderBy(str, Enum): 7 | name_asc = "name" 8 | name_desc = "-name" 9 | owner_asc = "group_name" 10 | owner_desc = "-group_name" 11 | 12 | 13 | class PaginatedQueryParams(BaseParams): 14 | order_by: OrderBy | None = None 15 | -------------------------------------------------------------------------------- /papermerge/core/features/groups/cli/cli.py: -------------------------------------------------------------------------------- 1 | import typer 2 | from rich.console import Console 3 | from sqlalchemy import select 4 | 5 | from papermerge.core.db.engine import Session 6 | from papermerge.core.features.groups.db import orm 7 | from papermerge.core.features.groups.db import api as dbapi 8 | from papermerge.core.features.groups import schema 9 | 10 | 11 | app = typer.Typer(help="Groups basic management") 12 | 13 | 14 | @app.command() 15 | def create_admin(exists_ok: bool = True): 16 | """Creates group named 'admin'""" 17 | with Session() as db_session: 18 | dbapi.create_group(db_session, name="admin", exists_ok=exists_ok) 19 | 20 | 21 | @app.command("ls") 22 | def list_groups(): 23 | """List existing groups and their scopes""" 24 | with Session() as session: 25 | stmt = select(orm.Group) 26 | db_items = session.scalars(stmt).unique() 27 | result = [] 28 | for item in db_items: 29 | group = dict(name=item.name, id=item.id) 30 | result.append(schema.GroupDetails.model_validate(group)) 31 | 32 | console = Console() 33 | for g in result: 34 | console.print(f"Name={g.name}") 35 | -------------------------------------------------------------------------------- /papermerge/core/features/groups/schema.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from pydantic import BaseModel, ConfigDict, Field 4 | 5 | 6 | class Group(BaseModel): 7 | id: uuid.UUID 8 | name: str 9 | delete_me: bool | None = Field(default=False) 10 | delete_special_folders: bool | None = Field(default=False) 11 | home_folder_id: uuid.UUID | None = Field(default=None) 12 | inbox_folder_id: uuid.UUID | None = Field(default=None) 13 | 14 | # Config 15 | model_config = ConfigDict(from_attributes=True) 16 | 17 | 18 | class GroupDetails(BaseModel): 19 | id: uuid.UUID 20 | name: str 21 | home_folder_id: uuid.UUID | None = None 22 | inbox_folder_id: uuid.UUID | None = None 23 | 24 | # Config 25 | model_config = ConfigDict(from_attributes=True) 26 | 27 | 28 | class CreateGroup(BaseModel): 29 | name: str 30 | # create special folders (inbox & home) as well 31 | with_special_folders: bool = False 32 | 33 | # Config 34 | model_config = ConfigDict(from_attributes=True) 35 | 36 | 37 | class UpdateGroup(BaseModel): 38 | name: str 39 | with_special_folders: bool | None = Field(default=False) 40 | -------------------------------------------------------------------------------- /papermerge/core/features/groups/tests/test_models_groups.py: -------------------------------------------------------------------------------- 1 | from papermerge.core import dbapi 2 | 3 | 4 | def test_group_create(db_session): 5 | group = dbapi.create_group(db_session, "G1") 6 | group_details = dbapi.get_group(db_session, group_id=group.id) 7 | 8 | assert group_details.name == "G1" 9 | 10 | dbapi.delete_group(db_session, group.id) 11 | -------------------------------------------------------------------------------- /papermerge/core/features/liveness_probe/router.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Response 2 | from sqlalchemy import text 3 | 4 | from papermerge.core.db.engine import Session 5 | 6 | router = APIRouter( 7 | prefix="/probe", 8 | tags=["probe"], 9 | ) 10 | 11 | 12 | @router.get("/") 13 | def probe_endpoint(): 14 | """Liveness probe endpoint""" 15 | with Session() as db_session: 16 | db_session.execute(text("select 1")) 17 | 18 | return Response() 19 | -------------------------------------------------------------------------------- /papermerge/core/features/liveness_probe/tests/test_router.py: -------------------------------------------------------------------------------- 1 | def test_liveness_probe(api_client): 2 | response = api_client.get("/probe") 3 | assert response.status_code == 200, response.json() 4 | -------------------------------------------------------------------------------- /papermerge/core/features/nodes/events.py: -------------------------------------------------------------------------------- 1 | from papermerge.celery_app import app as celery_app 2 | from papermerge.core import constants as const 3 | from papermerge.core.utils.decorators import if_redis_present 4 | 5 | from .schema import DeleteDocumentsData 6 | 7 | 8 | @if_redis_present 9 | def delete_documents_s3_data(data: DeleteDocumentsData): 10 | celery_app.send_task( 11 | const.S3_WORKER_REMOVE_DOC_VER, 12 | kwargs={"doc_ver_ids": [str(i) for i in data.document_version_ids]}, 13 | route_name="s3", 14 | ) 15 | celery_app.send_task( 16 | const.S3_WORKER_REMOVE_DOCS_THUMBNAIL, 17 | kwargs={"doc_ids": [str(i) for i in data.document_ids]}, 18 | route_name="s3", 19 | ) 20 | celery_app.send_task( 21 | const.S3_WORKER_REMOVE_PAGE_THUMBNAIL, 22 | kwargs={"page_ids": [str(i) for i in data.page_ids]}, 23 | route_name="s3", 24 | ) 25 | -------------------------------------------------------------------------------- /papermerge/core/features/nodes/tests/test_nodes_schema.py: -------------------------------------------------------------------------------- 1 | from papermerge.core import schema 2 | from papermerge.core import orm 3 | from papermerge.core import constants as const 4 | 5 | 6 | def test_basic_home_folder(user: orm.User): 7 | folder = schema.Folder.model_validate(user.home_folder) 8 | 9 | assert folder.title == const.HOME_TITLE 10 | assert folder.ctype == const.CTYPE_FOLDER 11 | -------------------------------------------------------------------------------- /papermerge/core/features/nodes/tests/test_thumbnails_router.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from papermerge.core.tests.types import AuthTestClient, TestClient 4 | 5 | 6 | def test_thumbnails_router( 7 | auth_api_client: AuthTestClient, make_document_with_pages, user 8 | ): 9 | doc = make_document_with_pages( 10 | title="brief.pdf", parent=user.home_folder, user=user 11 | ) 12 | response = auth_api_client.get(f"/thumbnails/{doc.id}") 13 | 14 | assert response.status_code == 200 15 | 16 | 17 | def test_thumbnails_router_no_auth( 18 | api_client: TestClient, make_document_with_pages, user 19 | ): 20 | """route must be accessible only when user credentials are present 21 | 22 | `api_client` is plain HTTP client, without any user related info 23 | """ 24 | doc = make_document_with_pages( 25 | title="brief.pdf", parent=user.home_folder, user=user 26 | ) 27 | response = api_client.get(f"/thumbnails/{doc.id}") 28 | 29 | assert response.status_code == 401 30 | -------------------------------------------------------------------------------- /papermerge/core/features/roles/schema.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from pydantic import BaseModel, ConfigDict 4 | 5 | 6 | class Permission(BaseModel): 7 | id: uuid.UUID 8 | name: str # e.g. "Can create tags" 9 | codename: str # e.g. "tag.create" 10 | 11 | # Config 12 | model_config = ConfigDict(from_attributes=True) 13 | 14 | 15 | class Role(BaseModel): 16 | id: uuid.UUID 17 | name: str 18 | 19 | # Config 20 | model_config = ConfigDict(from_attributes=True) 21 | 22 | 23 | class RoleDetails(BaseModel): 24 | id: uuid.UUID 25 | name: str 26 | scopes: list[str] 27 | 28 | # Config 29 | model_config = ConfigDict(from_attributes=True) 30 | 31 | 32 | class CreateRole(BaseModel): 33 | name: str 34 | scopes: list[str] 35 | 36 | # Config 37 | model_config = ConfigDict(from_attributes=True) 38 | 39 | 40 | class UpdateRole(BaseModel): 41 | name: str 42 | scopes: list[str] 43 | -------------------------------------------------------------------------------- /papermerge/core/features/shared_nodes/tests/test_model_shared_nodes.py: -------------------------------------------------------------------------------- 1 | from papermerge.core.features.roles.db import api as dbapi 2 | from papermerge.core.features.auth.scopes import NODE_VIEW 3 | from papermerge.core.features.shared_nodes.db import api as sn_dbapi 4 | 5 | 6 | def test_basic_create_shared_node(db_session, make_user, make_folder): 7 | dbapi.sync_perms(db_session) 8 | john = make_user("john", is_superuser=False) 9 | david = make_user("david", is_superuser=False) 10 | receipts = make_folder("John's Receipts", user=john, parent=john.home_folder) 11 | role, err = dbapi.create_role(db_session, "View Node Role", scopes=[NODE_VIEW]) 12 | 13 | db_session.commit() 14 | 15 | assert role, err 16 | 17 | shared_nodes, _ = sn_dbapi.create_shared_nodes( 18 | db_session, 19 | user_ids=[david.id], 20 | node_ids=[receipts.id], 21 | role_ids=[role.id], 22 | owner_id=john.id, 23 | ) 24 | 25 | assert len(shared_nodes) == 1 26 | 27 | dbapi.delete_role(db_session, role.id) 28 | -------------------------------------------------------------------------------- /papermerge/core/features/tags/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import pytest 4 | 5 | from papermerge.core import orm 6 | 7 | 8 | @pytest.fixture() 9 | def make_tag(db_session): 10 | def _maker( 11 | name: str, 12 | user: orm.User | None = None, 13 | bg_color: str = "red", 14 | fg_color: str = "white", 15 | group_id: uuid.UUID | None = None, 16 | ): 17 | if group_id: 18 | db_tag = orm.Tag( 19 | name=name, bg_color=bg_color, fg_color=fg_color, group_id=group_id 20 | ) 21 | else: 22 | db_tag = orm.Tag( 23 | name=name, bg_color=bg_color, fg_color=fg_color, user_id=user.id 24 | ) 25 | db_session.add(db_tag) 26 | db_session.commit() 27 | 28 | return db_tag 29 | 30 | return _maker 31 | -------------------------------------------------------------------------------- /papermerge/core/features/tags/tests/test_tags_schema.py: -------------------------------------------------------------------------------- 1 | from papermerge.core import schema 2 | 3 | 4 | def test_basic_tag(make_tag, user): 5 | db_tag = make_tag(name="sent", user=user) 6 | tag = schema.Tag.model_validate(db_tag) 7 | 8 | assert tag.name == "sent" 9 | 10 | 11 | def test_model_validate_for_update_tag(): 12 | """PyUpdateTag pydantic model is valid when it 13 | receives only 'bg_color' and 'name' attributes""" 14 | 15 | model = {"bg_color": "#ff0011", "name": "edited_tag_name"} 16 | tag = schema.UpdateTag.model_validate(model) 17 | 18 | assert tag.name == "edited_tag_name" 19 | -------------------------------------------------------------------------------- /papermerge/core/features/tags/types.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from papermerge.core.types import PaginatedQueryParams as BaseParams 4 | 5 | 6 | class OrderBy(str, Enum): 7 | name_asc = "name" 8 | name_desc = "-name" 9 | pinned_asc = "pinned" 10 | pinned_desc = "-pinned" 11 | description_asc = "description" 12 | description_desc = "-description" 13 | id_asc = "ID" 14 | id_desc = "-ID" 15 | owner_asc = "group_name" 16 | owner_desc = "-group_name" 17 | 18 | 19 | class PaginatedQueryParams(BaseParams): 20 | order_by: OrderBy | None = None 21 | -------------------------------------------------------------------------------- /papermerge/core/features/tasks/router.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from fastapi import APIRouter, Security 4 | 5 | from papermerge.core import constants, schema, utils 6 | from papermerge.core.features.auth import get_current_user, scopes 7 | from papermerge.core import tasks 8 | 9 | from .schema import OCRTaskIn 10 | 11 | router = APIRouter( 12 | prefix="/tasks", 13 | tags=["tasks"], 14 | ) 15 | 16 | 17 | @router.post("/ocr") 18 | @utils.docstring_parameter(scope=scopes.TASK_OCR) 19 | def start_ocr( 20 | ocr_task: OCRTaskIn, 21 | user: Annotated[schema.User, Security(get_current_user, scopes=[scopes.TASK_OCR])], 22 | ): 23 | """Triggers OCR for specific document 24 | 25 | Required scope: `{scope}` 26 | """ 27 | 28 | tasks.send_task( 29 | constants.WORKER_OCR_DOCUMENT, 30 | kwargs={ 31 | "document_id": str(ocr_task.document_id), 32 | "lang": ocr_task.lang, 33 | }, 34 | route_name="ocr", 35 | ) 36 | -------------------------------------------------------------------------------- /papermerge/core/features/tasks/schema.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | from uuid import UUID 3 | 4 | from pydantic import BaseModel 5 | 6 | LangCode = Literal[ 7 | "ces", 8 | "dan", 9 | "deu", 10 | "ell", 11 | "eng", 12 | "fas", 13 | "fin", 14 | "fra", 15 | "guj", 16 | "heb", 17 | "hin", 18 | "ita", 19 | "jpn", 20 | "kor", 21 | "lit", 22 | "nld", 23 | "nor", 24 | "pol", 25 | "por", 26 | "ron", 27 | "san", 28 | "spa", 29 | "kaz", 30 | "rus", 31 | ] 32 | 33 | 34 | class OCRTaskIn(BaseModel): 35 | document_id: UUID # document model ID 36 | lang: LangCode 37 | -------------------------------------------------------------------------------- /papermerge/core/features/users/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/papermerge/papermerge-core/b3f2ac64b8aad42f8a2ddcb7b4a0215877b339f6/papermerge/core/features/users/tests/__init__.py -------------------------------------------------------------------------------- /papermerge/core/features/users/tests/utils.py: -------------------------------------------------------------------------------- 1 | def verify_password(str_password, hashed_password): 2 | from passlib.hash import pbkdf2_sha256 3 | 4 | return pbkdf2_sha256.verify(str_password, hashed_password) 5 | -------------------------------------------------------------------------------- /papermerge/core/lib/__init__.py: -------------------------------------------------------------------------------- 1 | import lxml.html 2 | 3 | 4 | def parse_bbox_title(bbox_title): 5 | """Gets a string like 6 | input: 'bbox 23 344 45 66; x_wconf 88' 7 | output a hash: {x1: 23, y1: 344, x2: 45, y2: 66, wconf: 88} 8 | 9 | Look here: http://kba.cloud/hocr-spec/1.2/ 10 | for bbox and x_wconf properties. 11 | """ 12 | pass 13 | 14 | 15 | def extract_words_from(hocr_file): 16 | html = None 17 | result = [] 18 | 19 | with open(hocr_file, "rb") as f: 20 | text = f.read() 21 | html = lxml.html.fromstring(text) 22 | 23 | for span in html.xpath("//span[@class='ocrx_word']"): 24 | elem = {} 25 | elem['class'] = span.attrib['class'] 26 | elem['id'] = span.attrib['id'] 27 | bbox = parse_bbox_title(span.attrib['title']) 28 | elem['title'] = span.attrib['title'] 29 | elem['text'] = span.text 30 | elem['x1'] = bbox['x1'] 31 | elem['x2'] = bbox['x1'] 32 | elem['y1'] = bbox['y1'] 33 | elem['y2'] = bbox['y2'] 34 | elem['wconf'] = bbox['wconf'] 35 | 36 | result.append(elem) 37 | 38 | return result 39 | -------------------------------------------------------------------------------- /papermerge/core/lib/mime.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from . import wrapper 4 | from ..app_settings import settings 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class Mime(wrapper.Wrapper): 10 | def __init__(self, filepath): 11 | super().__init__(exec_name=settings.BINARY_FILE) 12 | self.filepath = filepath 13 | 14 | def get_cmd(self): 15 | cmd = super().get_cmd() 16 | 17 | cmd.extend(['--mime-type']) 18 | cmd.extend(['-b']) 19 | cmd.extend([self.filepath]) 20 | 21 | return cmd 22 | 23 | def is_tiff(self): 24 | return self.guess() == 'image/tiff' 25 | 26 | def is_pdf(self): 27 | return self.guess() == 'application/pdf' 28 | 29 | def is_image(self): 30 | """ 31 | Returns true if MIME type is one of following: 32 | * image/png 33 | * image/jpg 34 | """ 35 | return self.guess() in ('image/png', 'image/jpg', 'image/jpeg') 36 | 37 | def guess(self): 38 | cmd = self.get_cmd() 39 | complete = self.run(cmd) 40 | 41 | return complete.stdout.strip() 42 | 43 | def __str__(self): 44 | 45 | mime_type = self.guess() 46 | return f"Mime({self.filepath}, {mime_type})" 47 | -------------------------------------------------------------------------------- /papermerge/core/lib/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from pathlib import Path 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | SAFE_EXTENSIONS = [".svg", ".txt", ".jpg", ".jpeg", ".png", ".hocr", ".pdf", ".tiff"] 8 | 9 | 10 | def get_bool(key, default="NO"): 11 | """ 12 | Returns True if environment variable named KEY is one of 13 | "yes", "y", "t", "true" (lowercase of uppercase) 14 | 15 | otherwise returns False 16 | """ 17 | env_var_value = os.getenv(key, default).lower() 18 | YES_VALUES = ("yes", "y", "1", "t", "true") 19 | if env_var_value in YES_VALUES: 20 | return True 21 | 22 | return False 23 | 24 | 25 | def safe_to_delete(path: Path) -> True: 26 | if not path.exists(): 27 | logging.warning(f"Trying to delete not exising folder" f" {path}") 28 | return False 29 | 30 | for root, dirs, files in os.walk(path): 31 | for name in files: 32 | base, ext = os.path.splitext(name) 33 | if ext.lower() not in SAFE_EXTENSIONS: 34 | logger.warning( 35 | f"Trying to delete unsefe location: " 36 | f"extention={ext} not found in {SAFE_EXTENSIONS}" 37 | ) 38 | return False 39 | 40 | return True 41 | -------------------------------------------------------------------------------- /papermerge/core/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from celery.app import default_app as celery_app 3 | 4 | logger = logging.getLogger(__name__) 5 | 6 | 7 | def log_task_routes(): 8 | logger.info("Task Routes:") 9 | 10 | try: 11 | conf = celery_app.conf 12 | except AttributeError: 13 | logger.info("No conf attribute found in celery app") 14 | return 15 | 16 | for key, value in celery_app.conf.task_routes.items(): 17 | logger.info(f"{key} -> {value['queue']}") 18 | -------------------------------------------------------------------------------- /papermerge/core/orm.py: -------------------------------------------------------------------------------- 1 | from .features.users.db.orm import User, user_groups_association 2 | from .features.document.db.orm import Document, DocumentVersion, Page 3 | from .features.nodes.db.orm import Folder, Node 4 | from .features.tags.db.orm import Tag, NodeTagsAssociation 5 | from .features.custom_fields.db.orm import CustomField, CustomFieldValue 6 | from .features.groups.db.orm import Group 7 | from .features.roles.db.orm import Role, Permission, roles_permissions_association 8 | from .features.document_types.db.orm import DocumentType, DocumentTypeCustomField 9 | from .features.shared_nodes.db.orm import SharedNode 10 | 11 | __all__ = [ 12 | 'User', 13 | 'user_groups_association', 14 | 'Document', 15 | 'DocumentVersion', 16 | 'Page', 17 | 'Folder', 18 | 'Node', 19 | 'Tag', 20 | 'NodeTagsAssociation', 21 | 'CustomField', 22 | 'CustomFieldValue', 23 | 'Group', 24 | 'Role', 25 | 'roles_permissions_association', 26 | 'Permission', 27 | 'DocumentType', 28 | 'DocumentTypeCustomField', 29 | 'SharedNode' 30 | ] 31 | -------------------------------------------------------------------------------- /papermerge/core/routers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/papermerge/papermerge-core/b3f2ac64b8aad42f8a2ddcb7b4a0215877b339f6/papermerge/core/routers/__init__.py -------------------------------------------------------------------------------- /papermerge/core/routers/common.py: -------------------------------------------------------------------------------- 1 | OPEN_API_GENERIC_JSON_DETAIL = { 2 | "application/json": { 3 | "schema": { 4 | "type": "object", 5 | "properties": { 6 | "detail": { 7 | "type": "string" 8 | } 9 | } 10 | }, 11 | "example": { 12 | "detail": "Status code message detail" 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /papermerge/core/routers/ocr_languanges.py: -------------------------------------------------------------------------------- 1 | from argparse import Namespace 2 | from typing import AbstractSet, Annotated 3 | 4 | from fastapi import APIRouter, Security 5 | 6 | from papermerge.core import schemas, utils 7 | from papermerge.core.features.auth import get_current_user 8 | from core.features.auth import scopes 9 | 10 | router = APIRouter( 11 | prefix="/ocr-languages", 12 | tags=["ocr-languages"], 13 | ) 14 | 15 | 16 | @router.get("/") 17 | @utils.docstring_parameter(scope=scopes.OCRLANG_VIEW) 18 | def get_ocr_langs( 19 | user: Annotated[ 20 | schemas.User, Security(get_current_user, scopes=[scopes.OCRLANG_VIEW]) 21 | ], 22 | ) -> AbstractSet[str]: 23 | """Returns list of languages supported by OCR engine 24 | 25 | Required scope: `{scope}` 26 | 27 | Languages are given in 3-letter ISO 3166-1 codes 28 | """ 29 | 30 | return {"deu", "eng", "fra"} 31 | -------------------------------------------------------------------------------- /papermerge/core/routers/params.py: -------------------------------------------------------------------------------- 1 | from fastapi import Query 2 | from pydantic import BaseModel 3 | from papermerge.core.features.nodes.schema import OrderBy 4 | 5 | 6 | class CommonQueryParams(BaseModel): 7 | page_size: int = Query(5, ge=1, description="Number of items per page") 8 | page_number: int = Query( 9 | 1, ge=1, description="Page number. It is first, second etc. page?" 10 | ) 11 | order_by: OrderBy | None = None 12 | filter: str | None = None 13 | -------------------------------------------------------------------------------- /papermerge/core/routers/scopes.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Annotated 3 | 4 | from fastapi import APIRouter, Depends 5 | 6 | from papermerge.core import schemas 7 | from papermerge.core.features import auth 8 | from core.features.auth import scopes 9 | 10 | router = APIRouter( 11 | prefix="/scopes", 12 | tags=["scopes"], 13 | ) 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | @router.get("/", response_model=schemas.Scopes) 19 | async def get_all_scopes( 20 | user: Annotated[schemas.User, Depends(auth.get_current_user)], 21 | ): 22 | """Returns all existing scopes""" 23 | return scopes.SCOPES 24 | -------------------------------------------------------------------------------- /papermerge/core/routers/version.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | 3 | from fastapi import APIRouter, Depends 4 | 5 | from papermerge.core import schema 6 | from papermerge.core.features.auth import get_current_user 7 | 8 | router = APIRouter( 9 | prefix="/version", 10 | tags=["version"], 11 | ) 12 | 13 | 14 | @router.get("/") 15 | async def get_version( 16 | user: schema.User = Depends(get_current_user), 17 | ) -> schema.Version: 18 | """Papermerge REST API version""" 19 | version_str = importlib.metadata.version("papermerge") 20 | 21 | return schema.Version(version=version_str) 22 | -------------------------------------------------------------------------------- /papermerge/core/schemas/__init__.py: -------------------------------------------------------------------------------- 1 | from papermerge.core.features.document_types.schema import ( 2 | CreateDocumentType, 3 | UpdateDocumentType, 4 | ) 5 | 6 | from .scopes import Scopes 7 | from .version import Version 8 | 9 | __all__ = [ 10 | "Version", 11 | "Scopes", 12 | "CreateDocumentType", 13 | "UpdateDocumentType", 14 | ] 15 | -------------------------------------------------------------------------------- /papermerge/core/schemas/common.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Sequence 2 | from typing import Generic, TypeVar 3 | 4 | 5 | from pydantic import BaseModel, ConfigDict 6 | 7 | T = TypeVar("T") 8 | 9 | 10 | class PaginatedResponse(BaseModel, Generic[T]): 11 | page_size: int 12 | page_number: int 13 | num_pages: int 14 | items: Sequence[T] 15 | 16 | model_config = ConfigDict(from_attributes=True) 17 | -------------------------------------------------------------------------------- /papermerge/core/schemas/error.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | class AttrError(BaseModel): 4 | name: str 5 | message: str 6 | 7 | 8 | class Error(BaseModel): 9 | attrs: list[AttrError] | None = None 10 | messages: list[str] | None = None 11 | -------------------------------------------------------------------------------- /papermerge/core/schemas/perms.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, ConfigDict 2 | 3 | 4 | class CreateUser(BaseModel): 5 | username: str 6 | email: str 7 | password: str 8 | 9 | # Config 10 | model_config = ConfigDict(from_attributes=True) 11 | 12 | 13 | class UpdateUser(BaseModel): 14 | username: str 15 | email: str 16 | password: str | None = None 17 | -------------------------------------------------------------------------------- /papermerge/core/schemas/scopes.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from pydantic import RootModel 4 | 5 | ScopesBase = RootModel[Dict[str, str]] 6 | 7 | 8 | class Scopes(ScopesBase): 9 | model_config = { 10 | "json_schema_extra": { 11 | "examples": [ 12 | { 13 | "node.create": "Create nodes", 14 | "node.view": "View nodes", 15 | "node.update": "Update nodes", 16 | "document.download": "Download documents" 17 | }, 18 | ] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /papermerge/core/schemas/token.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class Token(BaseModel): 5 | access_token: str 6 | token_type: str 7 | 8 | 9 | class TokenData(BaseModel): 10 | username: str | None = None 11 | -------------------------------------------------------------------------------- /papermerge/core/schemas/version.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class Version(BaseModel): 5 | version: str 6 | -------------------------------------------------------------------------------- /papermerge/core/storage.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from pathlib import Path 4 | 5 | logger = logging.getLogger(__name__) 6 | MEDIA_ROOT = os.environ.get("PAPERMERGE__MAIN__MEDIA_ROOT", "./media") 7 | 8 | 9 | def get_storage_class(import_path=None): 10 | return None 11 | 12 | 13 | def get_storage_instance(): 14 | return None 15 | 16 | 17 | def abs_path(some_relative_path: Path) -> Path: 18 | return Path(MEDIA_ROOT) / some_relative_path 19 | -------------------------------------------------------------------------------- /papermerge/core/tasks.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from celery import shared_task 4 | 5 | from papermerge.celery_app import app as celery_app 6 | from papermerge.core.utils.decorators import if_redis_present 7 | 8 | #from papermerge.core.models import User 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | @shared_task 14 | def delete_user_data(user_id): 15 | pass 16 | #try: 17 | # user = User.objects.get(id=user_id) 18 | # first delete all files associated with the user 19 | # user.delete_user_data() 20 | # then delete the user DB entry 21 | # user.delete() 22 | #except User.DoesNotExist: 23 | # logger.info(f"User: {user_id} already deleted") 24 | 25 | @if_redis_present 26 | def send_task(*args, **kwargs): 27 | logger.debug(f"Send task {args} {kwargs}") 28 | celery_app.send_task(*args, **kwargs) 29 | -------------------------------------------------------------------------------- /papermerge/core/tests/resource_file.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class ResourceFile(str, Enum): 5 | D3_PDF = "d3.pdf" 6 | S3_PDF = "s3.pdf" 7 | LIVING_THINGS = "living-things.pdf" 8 | THREE_PAGES = "three-pages.pdf" 9 | -------------------------------------------------------------------------------- /papermerge/core/tests/test_base64.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from papermerge.core.utils import base64 4 | 5 | 6 | def test_decode(): 7 | expected_result = {"user_id": "a1"} 8 | actual_result = base64.decode("eyJ1c2VyX2lkIjogImExIn0=") 9 | 10 | assert actual_result == expected_result 11 | 12 | 13 | def test_encode(): 14 | expected_result = "eyJ1c2VyX2lkIjogImExIn0=" 15 | actual_result = base64.encode({"user_id": "a1"}) 16 | 17 | assert actual_result == expected_result 18 | 19 | 20 | @pytest.mark.parametrize("junk", [None, 1, 0, ""]) 21 | def test_decode_junk_input(junk): 22 | with pytest.raises(ValueError): 23 | base64.decode(junk) 24 | 25 | 26 | @pytest.mark.parametrize("junk", [None, "", "Some string", 1, 0]) 27 | def test_encode_junk_input(junk): 28 | with pytest.raises(ValueError): 29 | base64.encode(junk) 30 | -------------------------------------------------------------------------------- /papermerge/core/tests/test_pathlib.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from pathlib import Path 3 | 4 | from papermerge.core.types import ImagePreviewSize 5 | from papermerge.core import constants as const 6 | from papermerge.core import pathlib as plib 7 | 8 | 9 | def test_thumbnail_path_1(): 10 | uid = uuid.uuid4() 11 | str_uuid = str(uid) 12 | actual = plib.thumbnail_path(uid) 13 | 14 | expected = Path( 15 | const.THUMBNAILS, 16 | const.JPG, 17 | str_uuid[0:2], 18 | str_uuid[2:4], 19 | str_uuid, 20 | f"{ImagePreviewSize.sm.value}.{const.JPG}", 21 | ) 22 | 23 | assert actual == expected 24 | 25 | 26 | def test_page_preview_jpg_path(): 27 | uid = uuid.uuid4() 28 | str_uuid = str(uid) 29 | 30 | actual = plib.page_preview_jpg_path(uid, ImagePreviewSize.md) 31 | expected = Path( 32 | const.PREVIEWS, 33 | const.PAGES, 34 | str_uuid[0:2], 35 | str_uuid[2:4], 36 | str_uuid, 37 | f"{ImagePreviewSize.md.value}.{const.JPG}", 38 | ) 39 | 40 | assert actual == expected 41 | -------------------------------------------------------------------------------- /papermerge/core/tests/types.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | from pydantic import BaseModel, ConfigDict 3 | 4 | from papermerge.core import orm 5 | 6 | 7 | class AuthTestClient(BaseModel): 8 | user: orm.User 9 | test_client: TestClient 10 | 11 | # Config 12 | model_config = ConfigDict(arbitrary_types_allowed=True) 13 | 14 | def post(self, *args, **kwargs): 15 | return self.test_client.post(*args, **kwargs) 16 | 17 | def get(self, *args, **kwargs): 18 | return self.test_client.get(*args, **kwargs) 19 | 20 | def delete(self, *args, **kwargs): 21 | return self.test_client.request("DELETE", *args, **kwargs) 22 | 23 | def patch(self, *args, **kwargs): 24 | """ 25 | Example: 26 | 27 | url = f'/nodes/{folder.id}' 28 | response = auth_api_client.patch( 29 | url, 30 | json={'title': 'New Title'} 31 | ) 32 | """ 33 | return self.test_client.patch(*args, **kwargs) 34 | -------------------------------------------------------------------------------- /papermerge/core/tests/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | from uuid import UUID 3 | 4 | from pdfminer.high_level import extract_text 5 | 6 | from papermerge.core.models import DocumentVersion 7 | 8 | 9 | def pdf_content( 10 | document_version: DocumentVersion, 11 | clean: bool = False 12 | ) -> str: 13 | """Returns text content of file associated with given document version 14 | 15 | :param clean: if True - replace non-alpha numeric characters with space 16 | 17 | :return: content (as string) of pdf file associated with document version 18 | """ 19 | file_path = document_version.file_path 20 | text = extract_text(file_path) 21 | stripped_text = text.strip() 22 | 23 | if clean: 24 | # replace old non-alpha numeric characters with space 25 | cleaned_text = re.sub('[^0-9a-zA-Z]+', ' ', stripped_text) 26 | return cleaned_text 27 | 28 | return stripped_text 29 | 30 | 31 | def breadcrumb_fmt(breadcrumb: list[UUID, str]) -> str: 32 | return '/'.join( 33 | title for _, title in breadcrumb 34 | ) 35 | -------------------------------------------------------------------------------- /papermerge/core/utils/decorators.py: -------------------------------------------------------------------------------- 1 | from papermerge.core.config import get_settings 2 | 3 | 4 | config = get_settings() 5 | 6 | 7 | def if_redis_present(orig_func): 8 | """Execute decorated function only if `papermerge__redis__url` is defined""" 9 | 10 | def inner(*args, **kwargs): 11 | if config.papermerge__redis__url is not None: 12 | orig_func(*args, **kwargs) 13 | 14 | return inner 15 | 16 | 17 | def docstring_parameter(**kwargs): 18 | def dec(obj): 19 | obj.__doc__ = obj.__doc__.format(**kwargs) 20 | return obj 21 | 22 | return dec 23 | -------------------------------------------------------------------------------- /papermerge/core/version.py: -------------------------------------------------------------------------------- 1 | import importlib.metadata 2 | 3 | # In order for this to work, you need to run first: 4 | # $ poetry install 5 | __version__ = importlib.metadata.version("papermerge") 6 | -------------------------------------------------------------------------------- /papermerge/search/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/papermerge/papermerge-core/b3f2ac64b8aad42f8a2ddcb7b4a0215877b339f6/papermerge/search/__init__.py -------------------------------------------------------------------------------- /papermerge/search/cli/search.py: -------------------------------------------------------------------------------- 1 | import os 2 | import uuid 3 | 4 | import typer 5 | from salinic import IndexRO, Search, create_engine 6 | from rich import print 7 | from papermerge.search.schema import SearchIndex as Index 8 | 9 | app = typer.Typer(help="Search command") 10 | 11 | 12 | @app.command("search") 13 | def search_cmd( 14 | query: str, user_id: uuid.UUID, page_number: int = 1, page_size: int = 10 15 | ): 16 | SEARCH_URL = os.environ.get("PAPERMERGE__SEARCH__URL") 17 | if not SEARCH_URL: 18 | print("[red][bold]PAPERMERGE__SEARCH__URL[/bold] is missing[/red]") 19 | print("Please set [bold]PAPERMERGE__SEARCH__URL[/bold] environment variable") 20 | raise typer.Exit(code=1) 21 | 22 | engine = create_engine(SEARCH_URL) 23 | index = IndexRO(engine, schema=Index) 24 | 25 | sq = Search(Index).query(query, page_number=page_number, page_size=page_size) 26 | 27 | results = index.search(sq, user_id=str(user_id)) 28 | print(results) 29 | -------------------------------------------------------------------------------- /papermerge/search/routers/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from fastapi import FastAPI 3 | 4 | from .search import router as search_router 5 | 6 | __all__ = ("register_routers",) 7 | 8 | API_PREFIX = os.environ.get("PAPERMERGE__MAIN__API_PREFIX", "/api") 9 | 10 | 11 | def register_routers(app: FastAPI): 12 | app.include_router(search_router, prefix=API_PREFIX) 13 | -------------------------------------------------------------------------------- /papermerge/search/routers/search.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, HTTPException 2 | from salinic import IndexRO, Search, create_engine 3 | 4 | from papermerge.core.features.users import schema as usr_schema 5 | from papermerge.core.features.auth import get_current_user 6 | from papermerge.core.config import get_settings 7 | from papermerge.search.schema import SearchIndex, PaginatedResponse 8 | 9 | router = APIRouter(prefix="/search", tags=["search"]) 10 | config = get_settings() 11 | 12 | 13 | @router.get("/", response_model=PaginatedResponse) 14 | def search( 15 | q: str, 16 | page_number: int = 1, 17 | page_size: int = 10, 18 | user: usr_schema.User = Depends(get_current_user), 19 | ): 20 | engine = create_engine(config.papermerge__search__url) 21 | index = IndexRO(engine, schema=SearchIndex) 22 | 23 | sq = Search(SearchIndex).query(q, page_number=page_number, page_size=page_size) 24 | results = index.search(sq, user_id=str(user.id)) 25 | 26 | return results 27 | --------------------------------------------------------------------------------