├── .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 |
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 |
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 |
35 |
36 | )
37 | }
38 |
39 | return (
40 |
41 |
42 |
43 | )
44 | }
45 |
--------------------------------------------------------------------------------
/frontend/apps/ui/src/components/Pagination/index.tsx:
--------------------------------------------------------------------------------
1 | import Pagination from "./Pagination"
2 |
3 | export default Pagination
4 |
--------------------------------------------------------------------------------
/frontend/apps/ui/src/components/QuickFilter/index.tsx:
--------------------------------------------------------------------------------
1 | import QuickFilter from "./QuickFilter"
2 |
3 | export default QuickFilter
4 |
--------------------------------------------------------------------------------
/frontend/apps/ui/src/components/ScheduleOCRProcess.tsx:
--------------------------------------------------------------------------------
1 | import {useRuntimeConfig} from "@/hooks/runtime_config"
2 | import {OCRCode} from "@/types/ocr"
3 | import {langCodes2ComboboxData} from "@/utils"
4 | import {Select, Stack} from "@mantine/core"
5 | import {useState} from "react"
6 |
7 | interface Args {
8 | defaultLang: OCRCode
9 | onLangChange: (newLang: OCRCode) => void
10 | }
11 |
12 | export default function ScheduleOCRProcessCheckbox({
13 | defaultLang,
14 | onLangChange
15 | }: Args) {
16 | const runtimeConfig = useRuntimeConfig()
17 | const langData = langCodes2ComboboxData(runtimeConfig.ocr__lang_codes)
18 | const [lang, setLang] = useState(defaultLang)
19 |
20 | const onLangChangeLocal = (value: string | null) => {
21 | if (value) {
22 | setLang(value as OCRCode)
23 | onLangChange(value as OCRCode)
24 | }
25 | }
26 |
27 | return (
28 |
29 |
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 |
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 | } variant={"default"} disabled={true}>
19 | {t("common.edit")}
20 |
21 | )
22 | }
23 |
24 | return (
25 | <>
26 | } variant={"default"} onClick={open}>
27 | {t("common.edit")}
28 |
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 | } onClick={open} variant="default">
14 | {t("common.new")}
15 |
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 | } variant={"default"} disabled={true}>
19 | {t("common.edit")}
20 |
21 | )
22 | }
23 |
24 | return (
25 | <>
26 | } variant={"default"} onClick={open}>
27 | {t("common.edit")}
28 |
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 | } onClick={open} variant="default">
14 | {t("common.new")}
15 |
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 | } variant={"default"} disabled={true}>
19 | {t("common.edit")}
20 |
21 | )
22 | }
23 |
24 | return (
25 | <>
26 | } variant={"default"} onClick={open}>
27 | {t("common.edit")}
28 |
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 | } onClick={open} variant="default">
14 | {t("common.new")}
15 |
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 | } variant={"default"} disabled={true}>
19 | {t("common.edit")}
20 |
21 | )
22 | }
23 |
24 | return (
25 | <>
26 | } variant={"default"} onClick={open}>
27 | {t("common.edit")}
28 |
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 | } onClick={open} variant="default">
14 | {t("common.new")}
15 |
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 | } variant={"default"} disabled={true}>
15 | {t("common.edit")}
16 |
17 | )
18 | }
19 |
20 | return (
21 | <>
22 | } variant={"default"} onClick={open}>
23 | {t("common.edit")}
24 |
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 | } onClick={open} variant="default">
14 | {t("common.new")}
15 |
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 | } variant={"default"} disabled={true}>
15 | {t("common.edit")}
16 |
17 | )
18 | }
19 |
20 | return (
21 | <>
22 | } variant={"default"} onClick={open}>
23 | {t("common.edit")}
24 |
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 | } onClick={open} variant="default">
14 | {t("common.new")}
15 |
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 |
--------------------------------------------------------------------------------