Merge branch 'dev' into travis-multiarch-builds

This commit is contained in:
jonaswinkler 2020-12-23 16:15:46 +01:00
commit 85cf931d0a
65 changed files with 1339 additions and 265 deletions

View File

@ -35,7 +35,10 @@ Here's what you get:
* Includes a dashboard that shows basic statistics and has document upload. * Includes a dashboard that shows basic statistics and has document upload.
* Filtering by tags, correspondents, types, and more. * Filtering by tags, correspondents, types, and more.
* Customizable views can be saved and displayed on the dashboard. * Customizable views can be saved and displayed on the dashboard.
* Full text search with auto completion, scored results and query highlighting allows you to quickly find what you need. * Full text search helps you find what you need.
* Auto completion suggests relevant words from your documents.
* Results are sorted by relevance to your search query.
* Highlighting shows you which parts of the document matched the query.
* Email processing: Paperless adds documents from your email accounts. * Email processing: Paperless adds documents from your email accounts.
* Configure multiple accounts and filters for each account. * Configure multiple accounts and filters for each account.
* When adding documents from mails, paperless can move these mails to a new folder, mark them as read, flag them or delete them. * When adding documents from mails, paperless can move these mails to a new folder, mark them as read, flag them or delete them.
@ -51,6 +54,8 @@ For a complete list of changes from paperless, check out the [changelog](https:/
# Roadmap for 1.0 # Roadmap for 1.0
- **Bulk editing**. Add/remove metadata from multiple documents at once.
- Make the front end nice (except mobile). - Make the front end nice (except mobile).
- Test coverage at 90%. - Test coverage at 90%.
- Fix whatever bugs I and you find. - Fix whatever bugs I and you find.
@ -59,7 +64,6 @@ For a complete list of changes from paperless, check out the [changelog](https:/
These are things that I want to add to paperless eventually. They are sorted by priority. These are things that I want to add to paperless eventually. They are sorted by priority.
- **Bulk editing**. Add/remove metadata from multiple documents at once.
- **More search.** The search backend is incredibly versatile and customizable. Searching is the most important feature of this project and thus, I want to implement things like: - **More search.** The search backend is incredibly versatile and customizable. Searching is the most important feature of this project and thus, I want to implement things like:
- Group and limit search results by correspondent, show “more from this” links in the results. - Group and limit search results by correspondent, show “more from this” links in the results.
- Ability to search for “Similar documents” in the search results - Ability to search for “Similar documents” in the search results
@ -68,6 +72,9 @@ These are things that I want to add to paperless eventually. They are sorted by
- With live updates ans websockets. This already works on a dev branch, but requires a lot of new dependencies, which I'm not particular happy about. - With live updates ans websockets. This already works on a dev branch, but requires a lot of new dependencies, which I'm not particular happy about.
- Notifications when a document was added with buttons to open the new document right away. - Notifications when a document was added with buttons to open the new document right away.
- **Arbitrary tag colors**. Allow the selection of any color with a color picker. - **Arbitrary tag colors**. Allow the selection of any color with a color picker.
- **More file types**. Possibly allow more file types to be processed by paperless, such as office .odt, .doc, .docx documents.
Apart from that, paperless is pretty much feature complete.
## On the chopping block. ## On the chopping block.

View File

@ -15,7 +15,7 @@ services:
POSTGRES_PASSWORD: paperless POSTGRES_PASSWORD: paperless
webserver: webserver:
image: jonaswinkler/paperless-ng:0.9.8 image: jonaswinkler/paperless-ng:0.9.9
restart: always restart: always
depends_on: depends_on:
- db - db

View File

@ -5,7 +5,7 @@ services:
restart: always restart: always
webserver: webserver:
image: jonaswinkler/paperless-ng:0.9.8 image: jonaswinkler/paperless-ng:0.9.9
restart: always restart: always
depends_on: depends_on:
- broker - broker

View File

@ -6,6 +6,40 @@ Changelog
********* *********
paperless-ng 0.9.9
##################
Christmas release!
* Bulk editing
* Paperless now supports bulk editing.
* The following operations are available: Add and remove correspondents, tags, document types from selected documents, as well as mass-deleting documents.
* We've got a more fancy UI in the works that makes these features more accessible, but that's not quite ready yet.
* Searching
* Paperless now supports searching for similar documents ("More like this") both from the document detail page as well as from individual search results.
* A search score indicates how well a document matches the search query, or how similar a document is to a given reference document.
* Other additions and changes
* Clarification in the UI that the fields "Match" and "Is insensitive" are not relevant for the Auto matching algorithm.
* New select interface for tags, types and correspondents allows filtering. This also improves tag selection. Thanks again to `Michael Shamoon`_!
* Page navigation controls for the document viewer, thanks to `Michael Shamoon`_.
* Layout changes to the small cards document list.
* The dashboard now displays the username (or full name if specified in the admin) on the dashboard.
* Fixes
* An error that caused the document importer to crash was fixed.
* An issue with changes not being possible when ``PAPERLESS_COOKIE_PREFIX`` is used was fixed.
* The date selection filters now allow manual entry of dates.
* Feature Removal
* Most of the guesswork features have been removed. Paperless no longer tries to extract correspondents and tags from file names.
paperless-ng 0.9.8 paperless-ng 0.9.8
################## ##################

View File

@ -25,6 +25,8 @@ that works right for you based on recommendations from other Paperless users.
+---------+----------------+-----+-----+-----+----------------+ +---------+----------------+-----+-----+-----+----------------+
| Fujitsu | `ix500`_ | yes | | yes | `eonist`_ | | Fujitsu | `ix500`_ | yes | | yes | `eonist`_ |
+---------+----------------+-----+-----+-----+----------------+ +---------+----------------+-----+-----+-----+----------------+
| Epson | `WF-7710DWF`_ | yes | | yes | `Skylinar`_ |
+---------+----------------+-----+-----+-----+----------------+
| Fujitsu | `S1300i`_ | yes | | yes | `jonaswinkler`_| | Fujitsu | `S1300i`_ | yes | | yes | `jonaswinkler`_|
+---------+----------------+-----+-----+-----+----------------+ +---------+----------------+-----+-----+-----+----------------+
@ -32,7 +34,8 @@ that works right for you based on recommendations from other Paperless users.
.. _MFC-J6930DW: https://www.brother.ca/en/p/MFCJ6930DW .. _MFC-J6930DW: https://www.brother.ca/en/p/MFCJ6930DW
.. _MFC-J5910DW: https://www.brother.co.uk/printers/inkjet-printers/mfcj5910dw .. _MFC-J5910DW: https://www.brother.co.uk/printers/inkjet-printers/mfcj5910dw
.. _MFC-9142CDN: https://www.brother.co.uk/printers/laser-printers/mfc9140cdn .. _MFC-9142CDN: https://www.brother.co.uk/printers/laser-printers/mfc9140cdn
.. _ix500: https://www.fujitsu.com/global/products/computing/peripheral/scanners/scansnap/ix500/ .. _ix500: http://www.fujitsu.com/us/products/computing/peripheral/scanners/scansnap/ix500/
.. _WF-7710DWF: https://www.epson.de/en/products/printers/inkjet-printers/for-home/workforce-wf-7710dwf
.. _S1300i: https://www.fujitsu.com/global/products/computing/peripheral/scanners/soho/s1300i/ .. _S1300i: https://www.fujitsu.com/global/products/computing/peripheral/scanners/soho/s1300i/
.. _danielquinn: https://github.com/danielquinn .. _danielquinn: https://github.com/danielquinn
@ -40,4 +43,5 @@ that works right for you based on recommendations from other Paperless users.
.. _bmsleight: https://github.com/bmsleight .. _bmsleight: https://github.com/bmsleight
.. _eonist: https://github.com/eonist .. _eonist: https://github.com/eonist
.. _REOLDEV: https://github.com/REOLDEV .. _REOLDEV: https://github.com/REOLDEV
.. _Skylinar: https://github.com/Skylinar
.. _jonaswinkler: https://github.com/jonaswinkler .. _jonaswinkler: https://github.com/jonaswinkler

View File

@ -120,6 +120,8 @@ The `bare metal route`_ is more complicated to setup but makes it easier
should you want to contribute some code back. You need to configure and should you want to contribute some code back. You need to configure and
run the above mentioned components yourself. run the above mentioned components yourself.
.. _setup-docker_route:
Docker Route Docker Route
============ ============
@ -460,6 +462,15 @@ management commands as below.
load data from an old database schema in SQLite into a newer database load data from an old database schema in SQLite into a newer database
schema in PostgreSQL, you will run into trouble. schema in PostgreSQL, you will run into trouble.
.. warning::
On some database fields, PostgreSQL enforces predefined limits on maximum
length, whereas SQLite does not. The fields in question are the title of documents
(128 characters), names of document types, tags and correspondents (128 characters),
and filenames (1024 characters). If you have data in these fields that surpasses these
limits, migration to PostgreSQL is not possible and will fail with an error.
1. Stop paperless, if it is running. 1. Stop paperless, if it is running.
2. Tell paperless to use PostgreSQL: 2. Tell paperless to use PostgreSQL:

View File

@ -39,7 +39,7 @@ Operation not permitted
You might see errors such as: You might see errors such as:
.. code:: .. code:: shell-session
chown: changing ownership of '../export': Operation not permitted chown: changing ownership of '../export': Operation not permitted
@ -49,3 +49,29 @@ to these folders. This happens when pointing these directories to NFS shares,
for example. for example.
Ensure that `chown` is possible on these directories. Ensure that `chown` is possible on these directories.
Classifier error: No training data available
############################################
This indicates that the Auto matching algorithm found no documents to learn from.
This may have two reasons:
* You don't use the Auto matching algorithm: The error can be safely ignored in this case.
* You are using the Auto matching algorithm: The classifier explicitly excludes documents
with Inbox tags. Verify that there are documents in your archive without inbox tags.
The algorithm will only learn from documents not in your inbox.
Permission denied errors in the consumption directory
#####################################################
You might encounter errors such as:
.. code:: shell-session
The following error occured while consuming document.pdf: [Errno 13] Permission denied: '/usr/src/paperless/src/../consume/document.pdf'
This happens when paperless does not have permission to delete files inside the consumption directory.
Ensure that ``USERMAP_UID`` and ``USERMAP_GID`` are set to the user id and group id you use on the host operating system, if these are
different from ``1000``. See :ref:`setup-docker_route`.
Also ensure that you are able to read and write to the consumption directory on the host.

View File

@ -55,6 +55,7 @@ import { FilterPipe } from './pipes/filter.pipe';
import { DocumentTitlePipe } from './pipes/document-title.pipe'; import { DocumentTitlePipe } from './pipes/document-title.pipe';
import { MetadataCollapseComponent } from './components/document-detail/metadata-collapse/metadata-collapse.component'; import { MetadataCollapseComponent } from './components/document-detail/metadata-collapse/metadata-collapse.component';
import { NgSelectModule } from '@ng-select/ng-select'; import { NgSelectModule } from '@ng-select/ng-select';
import { SelectDialogComponent } from './components/common/select-dialog/select-dialog.component';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -100,7 +101,8 @@ import { NgSelectModule } from '@ng-select/ng-select';
FileSizePipe, FileSizePipe,
FilterPipe, FilterPipe,
DocumentTitlePipe, DocumentTitlePipe,
MetadataCollapseComponent MetadataCollapseComponent,
SelectDialogComponent
], ],
imports: [ imports: [
BrowserModule, BrowserModule,

View File

@ -1,7 +1,7 @@
<nav class="navbar navbar-dark sticky-top bg-primary flex-md-nowrap p-0 shadow"> <nav class="navbar navbar-dark sticky-top bg-primary flex-md-nowrap p-0 shadow">
<span class="navbar-brand col-md-3 col-lg-2 mr-0 px-3" href="#"> <span class="navbar-brand col-md-3 col-lg-2 mr-0 px-3" href="#">
<img src="assets/logo-dark-notext.svg" height="18px" class="mr-2"> <img src="assets/logo-dark-notext.svg" height="18px" class="mr-2">
Paperless-ng <ng-container i18n="app title">Paperless-ng</ng-container>
</span> </span>
<button class="navbar-toggler position-absolute d-md-none collapsed" type="button" data-toggle="collapse" <button class="navbar-toggler position-absolute d-md-none collapsed" type="button" data-toggle="collapse"
data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation" data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation"
@ -9,8 +9,8 @@
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<form (ngSubmit)="search()" class="w-100 m-1"> <form (ngSubmit)="search()" class="w-100 m-1">
<input class="form-control form-control-dark" type="text" placeholder="Search" aria-label="Search" <input class="form-control form-control-dark" type="text" placeholder="Search for documents" aria-label="Search"
[formControl]="searchField" [ngbTypeahead]="searchAutoComplete" (selectItem)="itemSelected($event)"> [formControl]="searchField" [ngbTypeahead]="searchAutoComplete" (selectItem)="itemSelected($event)" i18n-placeholder>
</form> </form>
</nav> </nav>
@ -28,136 +28,122 @@
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"> <a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()">
<svg class="sidebaricon" fill="currentColor"> <svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#house"/> <use xlink:href="assets/bootstrap-icons.svg#house"/>
</svg> </svg>&nbsp;<ng-container i18n>Dashboard</ng-container>
Dashboard
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" routerLink="documents" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" (click)="closeMenu()"> <a class="nav-link" routerLink="documents" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" (click)="closeMenu()">
<svg class="sidebaricon" fill="currentColor"> <svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#files"/> <use xlink:href="assets/bootstrap-icons.svg#files"/>
</svg> </svg>&nbsp;<ng-container i18n>Documents</ng-container>
Documents
</a> </a>
</li> </li>
</ul> </ul>
<h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted" *ngIf='savedViewService.sidebarViews.length > 0'> <h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted" *ngIf='savedViewService.sidebarViews.length > 0'>
<span>Saved views</span> <ng-container i18n>Saved views</ng-container>
</h6> </h6>
<ul class="nav flex-column mb-2"> <ul class="nav flex-column mb-2">
<li class="nav-item w-100" *ngFor="let view of savedViewService.sidebarViews"> <li class="nav-item w-100" *ngFor="let view of savedViewService.sidebarViews">
<a class="nav-link text-truncate" routerLink="view/{{view.id}}" routerLinkActive="active" (click)="closeMenu()"> <a class="nav-link text-truncate" routerLink="view/{{view.id}}" routerLinkActive="active" (click)="closeMenu()">
<svg class="sidebaricon" fill="currentColor"> <svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#funnel"/> <use xlink:href="assets/bootstrap-icons.svg#funnel"/>
</svg> </svg>&nbsp;{{view.name}}
{{view.name}}
</a> </a>
</li> </li>
</ul> </ul>
<h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted" *ngIf='openDocuments.length > 0'> <h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted" *ngIf='openDocuments.length > 0'>
<span>Open documents</span> <ng-container i18n>Open documents</ng-container>
</h6> </h6>
<ul class="nav flex-column mb-2"> <ul class="nav flex-column mb-2">
<li class="nav-item w-100" *ngFor='let d of openDocuments'> <li class="nav-item w-100" *ngFor='let d of openDocuments'>
<a class="nav-link text-truncate" routerLink="documents/{{d.id}}" routerLinkActive="active" (click)="closeMenu()"> <a class="nav-link text-truncate" routerLink="documents/{{d.id}}" routerLinkActive="active" (click)="closeMenu()">
<svg class="sidebaricon" fill="currentColor"> <svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#file-text"/> <use xlink:href="assets/bootstrap-icons.svg#file-text"/>
</svg> </svg>&nbsp;{{d.title | documentTitle}}
{{d.title | documentTitle}}
</a> </a>
</li> </li>
<li class="nav-item w-100" *ngIf="openDocuments.length > 1"> <li class="nav-item w-100" *ngIf="openDocuments.length > 1">
<a class="nav-link text-truncate" [routerLink]="" (click)="closeAll()"> <a class="nav-link text-truncate" [routerLink]="" (click)="closeAll()">
<svg class="sidebaricon" fill="currentColor"> <svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#x"/> <use xlink:href="assets/bootstrap-icons.svg#x"/>
</svg> </svg>&nbsp;<ng-container i18n>Close all</ng-container>
Close all
</a> </a>
</li> </li>
</ul> </ul>
<h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted"> <h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted">
<span>Manage</span> <ng-container i18n>Manage</ng-container>
</h6> </h6>
<ul class="nav flex-column mb-2"> <ul class="nav flex-column mb-2">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()"> <a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()">
<svg class="sidebaricon" fill="currentColor"> <svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#person"/> <use xlink:href="assets/bootstrap-icons.svg#person"/>
</svg> </svg>&nbsp;<ng-container i18n>Correspondents</ng-container>
Correspondents
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" routerLink="tags" routerLinkActive="active" (click)="closeMenu()"> <a class="nav-link" routerLink="tags" routerLinkActive="active" (click)="closeMenu()">
<svg class="sidebaricon" fill="currentColor"> <svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#tags"/> <use xlink:href="assets/bootstrap-icons.svg#tags"/>
</svg> </svg>&nbsp;<ng-container i18n>Tags</ng-container>
Tags
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" routerLink="documenttypes" routerLinkActive="active" (click)="closeMenu()"> <a class="nav-link" routerLink="documenttypes" routerLinkActive="active" (click)="closeMenu()">
<svg class="sidebaricon" fill="currentColor"> <svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#hash"/> <use xlink:href="assets/bootstrap-icons.svg#hash"/>
</svg> </svg>&nbsp;<ng-container i18n>Document types</ng-container>
Document types
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()"> <a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()">
<svg class="sidebaricon" fill="currentColor"> <svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#text-left"/> <use xlink:href="assets/bootstrap-icons.svg#text-left"/>
</svg> </svg>&nbsp;<ng-container i18n>Logs</ng-container>
Logs
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"> <a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()">
<svg class="sidebaricon" fill="currentColor"> <svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#gear"/> <use xlink:href="assets/bootstrap-icons.svg#gear"/>
</svg> </svg>&nbsp;<ng-container i18n>Settings</ng-container>
Settings
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="admin/"> <a class="nav-link" href="admin/">
<svg class="sidebaricon" fill="currentColor"> <svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#toggles"/> <use xlink:href="assets/bootstrap-icons.svg#toggles"/>
</svg> </svg>&nbsp;<ng-container i18n>Admin</ng-container>
Admin
</a> </a>
</li> </li>
</ul> </ul>
<h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted"> <h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted">
<span>Misc</span> <ng-container i18n>Misc</ng-container>
</h6> </h6>
<ul class="nav flex-column mb-2"> <ul class="nav flex-column mb-2">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" target="_blank" rel="noopener noreferrer" href="https://paperless-ng.readthedocs.io/en/latest/"> <a class="nav-link" target="_blank" rel="noopener noreferrer" href="https://paperless-ng.readthedocs.io/en/latest/">
<svg class="sidebaricon" fill="currentColor"> <svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#question-circle"/> <use xlink:href="assets/bootstrap-icons.svg#question-circle"/>
</svg> </svg>&nbsp;<ng-container i18n>Documentation</ng-container>
Documentation
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" target="_blank" rel="noopener noreferrer" href="https://github.com/jonaswinkler/paperless-ng"> <a class="nav-link" target="_blank" rel="noopener noreferrer" href="https://github.com/jonaswinkler/paperless-ng">
<svg class="sidebaricon" fill="currentColor"> <svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#link"/> <use xlink:href="assets/bootstrap-icons.svg#link"/>
</svg> </svg>&nbsp;<ng-container i18n>GitHub</ng-container>
GitHub
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="accounts/logout/"> <a class="nav-link" href="accounts/logout/">
<svg class="sidebaricon" fill="currentColor"> <svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#door-open"/> <use xlink:href="assets/bootstrap-icons.svg#door-open"/>
</svg> </svg>&nbsp;<ng-container i18n>Logout</ng-container>
Logout
</a> </a>
</li> </li>
</ul> </ul>

View File

@ -10,5 +10,8 @@
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-dark" (click)="cancelClicked()">Cancel</button> <button type="button" class="btn btn-outline-dark" (click)="cancelClicked()">Cancel</button>
<button type="button" class="btn" [class]="btnClass" (click)="confirmClicked.emit()">{{btnCaption}}</button> <button type="button" class="btn" [class]="btnClass" (click)="confirmClicked.emit()" [disabled]="!confirmButtonEnabled">
</div> {{btnCaption}}
<span *ngIf="!confirmButtonEnabled"> ({{seconds}})</span>
</button>
</div>

View File

@ -28,6 +28,21 @@ export class ConfirmDialogComponent implements OnInit {
@Input() @Input()
btnCaption = "Confirm" btnCaption = "Confirm"
confirmButtonEnabled = true
seconds = 0
delayConfirm(seconds: number) {
this.confirmButtonEnabled = false
this.seconds = seconds
setTimeout(() => {
if (this.seconds <= 1) {
this.confirmButtonEnabled = true
} else {
this.delayConfirm(seconds - 1)
}
}, 1000)
}
ngOnInit(): void { ngOnInit(): void {
} }

View File

@ -5,6 +5,7 @@
[disabled]="disabled" [disabled]="disabled"
[style.color]="textColor" [style.color]="textColor"
[style.background]="backgroundColor" [style.background]="backgroundColor"
[clearable]="allowNull"
(change)="onChange(value)" (change)="onChange(value)"
(blur)="onTouched()"> (blur)="onTouched()">
<ng-option *ngFor="let i of items" [value]="i.id">{{i.name}}</ng-option> <ng-option *ngFor="let i of items" [value]="i.id">{{i.name}}</ng-option>

View File

@ -0,0 +1,15 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<button type="button" class="close" aria-label="Close" (click)="cancelClicked()">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<app-input-select [items]="objects" [title]="message" [(ngModel)]="selected"></app-input-select>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-dark" (click)="cancelClicked()">Cancel</button>
<button type="button" class="btn btn-primary" (click)="selectClicked.emit(selected)">Select</button>
</div>

View File

@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SelectDialogComponent } from './select-dialog.component';
describe('SelectDialogComponent', () => {
let component: SelectDialogComponent;
let fixture: ComponentFixture<SelectDialogComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ SelectDialogComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(SelectDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,34 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ObjectWithId } from 'src/app/data/object-with-id';
@Component({
selector: 'app-select-dialog',
templateUrl: './select-dialog.component.html',
styleUrls: ['./select-dialog.component.scss']
})
export class SelectDialogComponent implements OnInit {
constructor(public activeModal: NgbActiveModal) { }
@Output()
public selectClicked = new EventEmitter()
@Input()
title = "Select"
@Input()
message = "Please select an object"
@Input()
objects: ObjectWithId[] = []
selected: number
ngOnInit(): void {
}
cancelClicked() {
this.activeModal.close()
}
}

View File

@ -1,4 +1,4 @@
<app-page-header title="Dashboard" [subTitle]="subtitle"> <app-page-header title="Dashboard" [subTitle]="subtitle" i18n-title>
<img src="assets/logo.svg" height="80" class="m-2 d-none d-md-block"> <img src="assets/logo.svg" height="80" class="m-2 d-none d-md-block">
</app-page-header> </app-page-header>

View File

@ -24,15 +24,15 @@ export class DashboardComponent implements OnInit {
} else if (tagUsername && tagUsername.content) { } else if (tagUsername && tagUsername.content) {
return tagUsername.content return tagUsername.content
} else { } else {
return null return "null"
} }
} }
get subtitle() { get subtitle() {
if (this.displayName) { if (this.displayName) {
return `Hello ${this.displayName}, welcome to Paperless-ng!` return $localize`Hello ${this.displayName}, welcome to Paperless-ng!`
} else { } else {
return `Welcome to Paperless-ng!` return $localize`Welcome to Paperless-ng!`
} }
} }

View File

@ -1,13 +1,13 @@
<app-widget-frame [title]="savedView.name"> <app-widget-frame [title]="savedView.name">
<a header-buttons [routerLink]="" (click)="showAll()">Show all</a> <a header-buttons [routerLink]="" (click)="showAll()" i18n>Show all</a>
<table content class="table table-sm table-hover table-borderless"> <table content class="table table-sm table-hover table-borderless">
<thead> <thead>
<tr> <tr>
<th>Created</th> <th i18n>Created</th>
<th scope="col">Title</th> <th scope="col" i18n>Title</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>

View File

@ -23,7 +23,7 @@ export class SavedViewWidgetComponent implements OnInit {
documents: PaperlessDocument[] = [] documents: PaperlessDocument[] = []
ngOnInit(): void { ngOnInit(): void {
this.documentService.list(1,10,this.savedView.sort_field, this.savedView.sort_reverse, this.savedView.filter_rules).subscribe(result => { this.documentService.listFiltered(1,10,this.savedView.sort_field, this.savedView.sort_reverse, this.savedView.filter_rules).subscribe(result => {
this.documents = result.results this.documents = result.results
}) })
} }

View File

@ -1,6 +1,6 @@
<app-widget-frame title="Statistics"> <app-widget-frame title="Statistics" i18n-title>
<ng-container content> <ng-container content>
<p class="card-text">Documents in inbox: {{statistics.documents_inbox}}</p> <p class="card-text" i18n>Documents in inbox: {{statistics.documents_inbox}}</p>
<p class="card-text">Total documents: {{statistics.documents_total}}</p> <p class="card-text" i18n>Total documents: {{statistics.documents_total}}</p>
</ng-container> </ng-container>
</app-widget-frame> </app-widget-frame>

View File

@ -1,16 +1,16 @@
<app-widget-frame title="Upload new documents"> <app-widget-frame title="Upload new documents" i18n-title>
<div content> <div content>
<form> <form>
<ngx-file-drop dropZoneLabel="Drop documents here or" (onFileDrop)="dropped($event)" <ngx-file-drop dropZoneLabel="Drop documents here or" browseBtnLabel="Browse files" (onFileDrop)="dropped($event)"
(onFileOver)="fileOver($event)" (onFileLeave)="fileLeave($event)" dropZoneClassName="bg-light card" (onFileOver)="fileOver($event)" (onFileLeave)="fileLeave($event)" dropZoneClassName="bg-light card"
multiple="true" contentClassName="justify-content-center d-flex align-items-center p-5" [showBrowseBtn]=true multiple="true" contentClassName="justify-content-center d-flex align-items-center p-5" [showBrowseBtn]=true
browseBtnClassName="btn btn-sm btn-outline-primary ml-2"> browseBtnClassName="btn btn-sm btn-outline-primary ml-2" i18n-dropZoneLabel i18n-browseBtnLabel>
</ngx-file-drop> </ngx-file-drop>
</form> </form>
<div *ngIf="uploadVisible" class="mt-3"> <div *ngIf="uploadVisible" class="mt-3">
<p>Uploading {{uploadStatus.length}} file(s)</p> <p i18n>Uploading {{uploadStatus.length}} file(s)</p>
<ngb-progressbar [value]="loadedSum" [max]="totalSum" [striped]="true" [animated]="uploadStatus.length > 0"> <ngb-progressbar [value]="loadedSum" [max]="totalSum" [striped]="true" [animated]="uploadStatus.length > 0">
</ngb-progressbar> </ngb-progressbar>
</div> </div>

View File

@ -60,7 +60,7 @@ export class UploadFileWidgetComponent implements OnInit {
} else if (event.type == HttpEventType.Response) { } else if (event.type == HttpEventType.Response) {
this.uploadStatus.splice(this.uploadStatus.indexOf(uploadStatusObject), 1) this.uploadStatus.splice(this.uploadStatus.indexOf(uploadStatusObject), 1)
this.completedFiles += 1 this.completedFiles += 1
this.toastService.showToast(Toast.make("Information", "The document has been uploaded and will be processed by the consumer shortly.")) this.toastService.showToast(Toast.make("Information", $localize`The document has been uploaded and will be processed by the consumer shortly.`))
} }
}, error => { }, error => {
@ -68,11 +68,11 @@ export class UploadFileWidgetComponent implements OnInit {
this.completedFiles += 1 this.completedFiles += 1
switch (error.status) { switch (error.status) {
case 400: { case 400: {
this.toastService.showToast(Toast.makeError(`There was an error while uploading the document: ${error.error.document}`)) this.toastService.showToast(Toast.makeError($localize`There was an error while uploading the document: ${error.error.document}`))
break; break;
} }
default: { default: {
this.toastService.showToast(Toast.makeError("An error has occurred while uploading the document. Sorry!")) this.toastService.showToast(Toast.makeError($localize`An error has occurred while uploading the document. Sorry!`))
break; break;
} }
} }

View File

@ -1,16 +1,16 @@
<app-widget-frame title="First steps"> <app-widget-frame title="First steps" i18n-title>
<ng-container content> <ng-container content>
<img src="assets/save-filter.png" class="float-right"> <img src="assets/save-filter.png" class="float-right">
<p>Paperless is running! :)</p> <p i18n>Paperless is running! :)</p>
<p>You can start uploading documents by dropping them in the file upload box to the right or by dropping them in the configured consumption folder and they'll start showing up in the documents list. <p i18n>You can start uploading documents by dropping them in the file upload box to the right or by dropping them in the configured consumption folder and they'll start showing up in the documents list.
After you've added some metadata to your documents, use the filtering mechanisms of paperless to create custom views (such as 'Recently added', 'Tagged TODO') and have them displayed on the dashboard instead of this message.</p> After you've added some metadata to your documents, use the filtering mechanisms of paperless to create custom views (such as 'Recently added', 'Tagged TODO') and have them displayed on the dashboard instead of this message.</p>
<p>Paperless offers some more features that try to make your life easier, such as:</p> <p i18n>Paperless offers some more features that try to make your life easier, such as:</p>
<ul> <ul>
<li>Once you've got a couple documents in paperless and added metadata to them, paperless can assign that metadata to new documents automatically.</li> <li i18n>Once you've got a couple documents in paperless and added metadata to them, paperless can assign that metadata to new documents automatically.</li>
<li>You can configure paperless to read your mails and add documents from attached files.</li> <li i18n>You can configure paperless to read your mails and add documents from attached files.</li>
</ul> </ul>
<p>Consult the documentation on how to use these features. The section on basic usage also has some information on how to use paperless in general.</p> <p i18n>Consult the documentation on how to use these features. The section on basic usage also has some information on how to use paperless in general.</p>
</ng-container> </ng-container>
</app-widget-frame> </app-widget-frame>

View File

@ -1,19 +1,18 @@
<app-page-header [(title)]="title"> <app-page-header [(title)]="title">
<div class="input-group input-group-sm mr-5" *ngIf="getContentType() == 'application/pdf'"> <div class="input-group input-group-sm mr-5" *ngIf="getContentType() == 'application/pdf'">
<div class="input-group-prepend"> <div class="input-group-prepend">
<div class="input-group-text">Page </div> <div class="input-group-text" i18n>Page</div>
</div> </div>
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages" [(ngModel)]="previewCurrentPage" /> <input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages" [(ngModel)]="previewCurrentPage" />
<div class="input-group-append"> <div class="input-group-append">
<div class="input-group-text">of {{previewNumPages}}</div> <div class="input-group-text" i18n>of {{previewNumPages}}</div>
</div> </div>
</div> </div>
<button type="button" class="btn btn-sm btn-outline-danger mr-2" (click)="delete()"> <button type="button" class="btn btn-sm btn-outline-danger mr-2" (click)="delete()">
<svg class="buttonicon" fill="currentColor"> <svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#trash" /> <use xlink:href="assets/bootstrap-icons.svg#trash" />
</svg> </svg>&nbsp;<span class="d-none d-lg-inline" i18n>Delete</span>
<span class="d-none d-lg-inline"> Delete</span>
</button> </button>
<div class="btn-group mr-2"> <div class="btn-group mr-2">
@ -21,14 +20,13 @@
<a [href]="downloadUrl" class="btn btn-sm btn-outline-primary"> <a [href]="downloadUrl" class="btn btn-sm btn-outline-primary">
<svg class="buttonicon" fill="currentColor"> <svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#download" /> <use xlink:href="assets/bootstrap-icons.svg#download" />
</svg> </svg>&nbsp;<span class="d-none d-lg-inline" i18n>Download</span>
<span class="d-none d-lg-inline"> Download</span>
</a> </a>
<div class="btn-group" ngbDropdown role="group" *ngIf="metadata?.has_archive_version"> <div class="btn-group" ngbDropdown role="group" *ngIf="metadata?.has_archive_version">
<button class="btn btn-sm btn-outline-primary dropdown-toggle-split" ngbDropdownToggle></button> <button class="btn btn-sm btn-outline-primary dropdown-toggle-split" ngbDropdownToggle></button>
<div class="dropdown-menu shadow" ngbDropdownMenu> <div class="dropdown-menu shadow" ngbDropdownMenu>
<a ngbDropdownItem [href]="downloadOriginalUrl">Download original</a> <a ngbDropdownItem [href]="downloadOriginalUrl" i18n>Download original</a>
</div> </div>
</div> </div>
@ -37,15 +35,13 @@
<button type="button" class="btn btn-sm btn-outline-primary mr-2" (click)="moreLike()"> <button type="button" class="btn btn-sm btn-outline-primary mr-2" (click)="moreLike()">
<svg class="buttonicon" fill="currentColor"> <svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#three-dots" /> <use xlink:href="assets/bootstrap-icons.svg#three-dots" />
</svg> </svg>&nbsp;<span class="d-none d-lg-inline" i18n>More like this</span>
<span class="d-none d-lg-inline"> More like this</span>
</button> </button>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="close()"> <button type="button" class="btn btn-sm btn-outline-primary" (click)="close()">
<svg class="buttonicon" fill="currentColor"> <svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#x" /> <use xlink:href="assets/bootstrap-icons.svg#x" />
</svg> </svg>&nbsp;<span class="d-none d-lg-inline" i18n>Close</span>
<span class="d-none d-lg-inline"> Close</span>
</button> </button>
</app-page-header> </app-page-header>
@ -57,27 +53,27 @@
<ul ngbNav #nav="ngbNav" class="nav-tabs"> <ul ngbNav #nav="ngbNav" class="nav-tabs">
<li [ngbNavItem]="1"> <li [ngbNavItem]="1">
<a ngbNavLink>Details</a> <a ngbNavLink i18n>Details</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<app-input-text title="Title" formControlName="title"></app-input-text> <app-input-text i18n-title title="Title" formControlName="title"></app-input-text>
<div class="form-group"> <div class="form-group">
<label for="archive_serial_number">Archive Serial Number</label> <label for="archive_serial_number" i18n>Archive Serial Number</label>
<input type="number" class="form-control" id="archive_serial_number" <input type="number" class="form-control" id="archive_serial_number"
formControlName='archive_serial_number'> formControlName='archive_serial_number'>
</div> </div>
<app-input-date-time titleDate="Date created" formControlName="created"></app-input-date-time> <app-input-date-time i18n-titleDate titleDate="Date created" formControlName="created"></app-input-date-time>
<app-input-select [items]="correspondents" title="Correspondent" formControlName="correspondent" <app-input-select [items]="correspondents" i18n-title title="Correspondent" formControlName="correspondent" [allowNull]="true"
(createNew)="createCorrespondent()"></app-input-select> (createNew)="createCorrespondent()"></app-input-select>
<app-input-select [items]="documentTypes" title="Document type" formControlName="document_type" <app-input-select [items]="documentTypes" i18n-title title="Document type" formControlName="document_type" [allowNull]="true"
(createNew)="createDocumentType()"></app-input-select> (createNew)="createDocumentType()"></app-input-select>
<app-input-tags formControlName="tags" title="Tags"></app-input-tags> <app-input-tags formControlName="tags" i18n-title title="Tags"></app-input-tags>
</ng-template> </ng-template>
</li> </li>
<li [ngbNavItem]="2"> <li [ngbNavItem]="2">
<a ngbNavLink>Content</a> <a ngbNavLink i18n>Content</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<div class="form-group"> <div class="form-group">
<textarea class="form-control" id="content" rows="20" formControlName='content'></textarea> <textarea class="form-control" id="content" rows="20" formControlName='content'></textarea>
@ -86,48 +82,48 @@
</li> </li>
<li [ngbNavItem]="3"> <li [ngbNavItem]="3">
<a ngbNavLink>Metadata</a> <a ngbNavLink i18n>Metadata</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<table class="table table-borderless"> <table class="table table-borderless">
<tbody> <tbody>
<tr> <tr>
<td>Date modified</td> <td i18n>Date modified</td>
<td>{{document.modified | date:'medium'}}</td> <td>{{document.modified | date:'medium'}}</td>
</tr> </tr>
<tr> <tr>
<td>Date added</td> <td i18n>Date added</td>
<td>{{document.added | date:'medium'}}</td> <td>{{document.added | date:'medium'}}</td>
</tr> </tr>
<tr> <tr>
<td>Media filename</td> <td i18n>Media filename</td>
<td>{{metadata?.media_filename}}</td> <td>{{metadata?.media_filename}}</td>
</tr> </tr>
<tr> <tr>
<td>Original MD5 Checksum</td> <td i18n>Original MD5 checksum</td>
<td>{{metadata?.original_checksum}}</td> <td>{{metadata?.original_checksum}}</td>
</tr> </tr>
<tr> <tr>
<td>Original file size</td> <td i18n>Original file size</td>
<td>{{metadata?.original_size | fileSize}}</td> <td>{{metadata?.original_size | fileSize}}</td>
</tr> </tr>
<tr> <tr>
<td>Original mime type</td> <td i18n>Original mime type</td>
<td>{{metadata?.original_mime_type}}</td> <td>{{metadata?.original_mime_type}}</td>
</tr> </tr>
<tr *ngIf="metadata?.has_archive_version"> <tr *ngIf="metadata?.has_archive_version">
<td>Archive MD5 Checksum</td> <td i18n>Archive MD5 checksum</td>
<td>{{metadata?.archive_checksum}}</td> <td>{{metadata?.archive_checksum}}</td>
</tr> </tr>
<tr *ngIf="metadata?.has_archive_version"> <tr *ngIf="metadata?.has_archive_version">
<td>Archive file size</td> <td i18n>Archive file size</td>
<td>{{metadata?.archive_size | fileSize}}</td> <td>{{metadata?.archive_size | fileSize}}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<app-metadata-collapse title="Original document metadata" [metadata]="metadata.original_metadata" *ngIf="metadata?.original_metadata?.length > 0"></app-metadata-collapse> <app-metadata-collapse i18n-title title="Original document metadata" [metadata]="metadata.original_metadata" *ngIf="metadata?.original_metadata?.length > 0"></app-metadata-collapse>
<app-metadata-collapse title="Archived document metadata" [metadata]="metadata.archive_metadata" *ngIf="metadata?.archive_metadata?.length > 0"></app-metadata-collapse> <app-metadata-collapse i18n-title title="Archived document metadata" [metadata]="metadata.archive_metadata" *ngIf="metadata?.archive_metadata?.length > 0"></app-metadata-collapse>
</ng-template> </ng-template>
</li> </li>
@ -135,10 +131,9 @@
<div [ngbNavOutlet]="nav" class="mt-2"></div> <div [ngbNavOutlet]="nav" class="mt-2"></div>
<button type="button" class="btn btn-outline-secondary" (click)="discard()">Discard</button>&nbsp; <button type="button" class="btn btn-outline-secondary" (click)="discard()" i18n>Discard</button>&nbsp;
<button type="button" class="btn btn-outline-primary" (click)="saveEditNext()" *ngIf="hasNext()">Save & edit <button type="button" class="btn btn-outline-primary" (click)="saveEditNext()" *ngIf="hasNext()" i18n>Save & edit next</button>&nbsp;
next</button>&nbsp; <button type="submit" class="btn btn-primary" i18n>Save</button>&nbsp;
<button type="submit" class="btn btn-primary">Save</button>&nbsp;
</form> </form>
</div> </div>

View File

@ -158,11 +158,11 @@ export class DocumentDetailComponent implements OnInit {
delete() { delete() {
let modal = this.modalService.open(ConfirmDialogComponent, {backdrop: 'static'}) let modal = this.modalService.open(ConfirmDialogComponent, {backdrop: 'static'})
modal.componentInstance.title = "Confirm delete" modal.componentInstance.title = $localize`Confirm delete`
modal.componentInstance.messageBold = `Do you really want to delete document '${this.document.title}'?` modal.componentInstance.messageBold = $localize`Do you really want to delete document '${this.document.title}'?`
modal.componentInstance.message = `The files for this document will be deleted permanently. This operation cannot be undone.` modal.componentInstance.message = $localize`The files for this document will be deleted permanently. This operation cannot be undone.`
modal.componentInstance.btnClass = "btn-danger" modal.componentInstance.btnClass = "btn-danger"
modal.componentInstance.btnCaption = "Delete document" modal.componentInstance.btnCaption = $localize`Delete document`
modal.componentInstance.confirmClicked.subscribe(() => { modal.componentInstance.confirmClicked.subscribe(() => {
this.documentsService.delete(this.document).subscribe(() => { this.documentsService.delete(this.document).subscribe(() => {
modal.close() modal.close()

View File

@ -1,7 +1,15 @@
<div class="card mb-3 bg-light shadow-sm"> <div class="card mb-3 bg-light shadow-sm" [class.card-selected]="selected" [class.document-card]="selectable">
<div class="row no-gutters"> <div class="row no-gutters">
<div class="col-md-2 d-none d-lg-block"> <div class="col-md-2 d-none d-lg-block doc-img-background" [class.doc-img-background-selected]="selected">
<img [src]="getThumbUrl()" class="card-img doc-img border-right"> <img [src]="getThumbUrl()" class="card-img doc-img border-right" (click)="selected = selectable ? !selected : false">
<div style="top: 0; left: 0" class="position-absolute border-right border-bottom bg-light p-1" [class.document-card-check]="!selected">
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input" id="smallCardCheck{{document.id}}" [checked]="selected" (change)="selected = $event.target.checked">
<label class="custom-control-label" for="smallCardCheck{{document.id}}"></label>
</div>
</div>
</div> </div>
<div class="col"> <div class="col">
<div class="card-body"> <div class="card-body">
@ -9,11 +17,11 @@
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<h5 class="card-title"> <h5 class="card-title">
<ng-container *ngIf="document.correspondent"> <ng-container *ngIf="document.correspondent">
<a *ngIf="clickCorrespondent.observers.length ; else nolink" [routerLink]="" title="Filter by correspondent" (click)="clickCorrespondent.emit(document.correspondent)" class="font-weight-bold">{{(document.correspondent$ | async)?.name}}</a> <a *ngIf="clickCorrespondent.observers.length ; else nolink" [routerLink]="" title="Filter by correspondent" i18n-title (click)="clickCorrespondent.emit(document.correspondent)" class="font-weight-bold">{{(document.correspondent$ | async)?.name}}</a>
<ng-template #nolink>{{(document.correspondent$ | async)?.name}}</ng-template>: <ng-template #nolink>{{(document.correspondent$ | async)?.name}}</ng-template>:
</ng-container> </ng-container>
{{document.title | documentTitle}} {{document.title | documentTitle}}
<app-tag [tag]="t" linkTitle="Filter by tag" *ngFor="let t of document.tags$ | async" class="ml-1" (click)="clickTag.emit(t.id)" [clickable]="clickTag.observers.length"></app-tag> <app-tag [tag]="t" linkTitle="Filter by tag" i18n-linkTitle *ngFor="let t of document.tags$ | async" class="ml-1" (click)="clickTag.emit(t.id)" [clickable]="clickTag.observers.length"></app-tag>
</h5> </h5>
<h5 class="card-title" *ngIf="document.archive_serial_number">#{{document.archive_serial_number}}</h5> <h5 class="card-title" *ngIf="document.archive_serial_number">#{{document.archive_serial_number}}</h5>
</div> </div>
@ -28,37 +36,33 @@
<a routerLink="/search" [queryParams]="{'more_like': document.id}" class="btn btn-sm btn-outline-secondary" *ngIf="moreLikeThis"> <a routerLink="/search" [queryParams]="{'more_like': document.id}" class="btn btn-sm btn-outline-secondary" *ngIf="moreLikeThis">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-three-dots" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-three-dots" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M3 9.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z"/> <path fill-rule="evenodd" d="M3 9.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z"/>
</svg> </svg>&nbsp;<ng-container i18n>More like this</ng-container>
More like this
</a> </a>
<a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary"> <a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary">
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-pencil" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-pencil" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/> <path fill-rule="evenodd" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
</svg> </svg>&nbsp;<ng-container i18n>Edit</ng-container>
Edit
</a> </a>
<a type="button" class="btn btn-sm btn-outline-secondary" [href]="getPreviewUrl()"> <a type="button" class="btn btn-sm btn-outline-secondary" [href]="getPreviewUrl()">
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-search" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-search" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M10.442 10.442a1 1 0 0 1 1.415 0l3.85 3.85a1 1 0 0 1-1.414 1.415l-3.85-3.85a1 1 0 0 1 0-1.415z"/> <path fill-rule="evenodd" d="M10.442 10.442a1 1 0 0 1 1.415 0l3.85 3.85a1 1 0 0 1-1.414 1.415l-3.85-3.85a1 1 0 0 1 0-1.415z"/>
<path fill-rule="evenodd" d="M6.5 12a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zM13 6.5a6.5 6.5 0 1 1-13 0 6.5 6.5 0 0 1 13 0z"/> <path fill-rule="evenodd" d="M6.5 12a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zM13 6.5a6.5 6.5 0 1 1-13 0 6.5 6.5 0 0 1 13 0z"/>
</svg> </svg>&nbsp;<ng-container i18n>View</ng-container>
View
</a> </a>
<a type="button" class="btn btn-sm btn-outline-secondary" [href]="getDownloadUrl()"> <a type="button" class="btn btn-sm btn-outline-secondary" [href]="getDownloadUrl()">
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-download" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-download" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/> <path fill-rule="evenodd" d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
<path fill-rule="evenodd" d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/> <path fill-rule="evenodd" d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/>
</svg> </svg>&nbsp;<ng-container i18n>Download</ng-container>
Download
</a> </a>
</div> </div>
<small class="text-muted ml-auto">Score:</small> <small class="text-muted ml-auto" i18n>Score:</small>
<ngb-progressbar *ngIf="searchScore" [type]="searchScoreClass" [value]="searchScore" class="search-score-bar mx-2" [max]="1"></ngb-progressbar> <ngb-progressbar *ngIf="searchScore" [type]="searchScoreClass" [value]="searchScore" class="search-score-bar mx-2" [max]="1"></ngb-progressbar>
<small class="text-muted">Created: {{document.created | date}}</small> <small class="text-muted" i18n>Created: {{document.created | date}}</small>
</div> </div>
</div> </div>

View File

@ -1,5 +1,6 @@
@import "/src/theme";
.result-content { .result-content {
color: darkgray;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
@ -8,11 +9,31 @@
object-position: top; object-position: top;
height: 100%; height: 100%;
position: absolute; position: absolute;
mix-blend-mode: multiply;
} }
.search-score-bar { .search-score-bar {
width: 100px; width: 100px;
height: 5px; height: 5px;
margin-top: 2px; margin-top: 2px;
}
.document-card-check {
display: none
}
.document-card:hover .document-card-check {
display: block;
}
.card-selected {
border-color: $primary;
}
.doc-img-background {
background-color: white;
}
.doc-img-background-selected {
background-color: $primaryFaded;
} }

View File

@ -12,6 +12,25 @@ export class DocumentCardLargeComponent implements OnInit {
constructor(private documentService: DocumentService, private sanitizer: DomSanitizer) { } constructor(private documentService: DocumentService, private sanitizer: DomSanitizer) { }
_selected = false
get selected() {
return this._selected
}
@Input()
set selected(value: boolean) {
this._selected = value
this.selectedChange.emit(value)
}
@Output()
selectedChange = new EventEmitter<boolean>()
get selectable() {
return this.selectedChange.observers.length > 0
}
@Input() @Input()
moreLikeThis: boolean = false moreLikeThis: boolean = false

View File

@ -1,10 +1,18 @@
<div class="col p-2 h-100"> <div class="col p-2 h-100 document-card">
<div class="card h-100 shadow-sm"> <div class="card h-100 shadow-sm" [class.card-selected]="selected">
<div class="border-bottom"> <div class="border-bottom" [class.doc-img-background-selected]="selected">
<img class="card-img doc-img" [src]="getThumbUrl()"> <img class="card-img doc-img" [src]="getThumbUrl()" (click)="selected = !selected">
<div style="top: 0; left: 0" class="position-absolute border-right border-bottom bg-light p-1" [class.document-card-check]="!selected">
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input" id="smallCardCheck{{document.id}}" [checked]="selected" (change)="selected = $event.target.checked">
<label class="custom-control-label" for="smallCardCheck{{document.id}}"></label>
</div>
</div>
<div style="top: 0; right: 0; font-size: large" class="text-right position-absolute mr-1"> <div style="top: 0; right: 0; font-size: large" class="text-right position-absolute mr-1">
<div *ngFor="let t of getTagsLimited$() | async"> <div *ngFor="let t of getTagsLimited$() | async">
<app-tag [tag]="t" (click)="clickTag.emit(t.id)" [clickable]="true" linkTitle="Filter by tag"></app-tag> <app-tag [tag]="t" (click)="clickTag.emit(t.id)" [clickable]="true" linkTitle="Filter by tag" i18n-linkTitle></app-tag>
</div> </div>
<div *ngIf="moreTags"> <div *ngIf="moreTags">
<span class="badge badge-secondary">+ {{moreTags}}</span> <span class="badge badge-secondary">+ {{moreTags}}</span>
@ -15,7 +23,7 @@
<div class="card-body p-2"> <div class="card-body p-2">
<p class="card-text"> <p class="card-text">
<ng-container *ngIf="document.correspondent"> <ng-container *ngIf="document.correspondent">
<a [routerLink]="" title="Filter by correspondent" (click)="clickCorrespondent.emit(document.correspondent)" class="font-weight-bold">{{(document.correspondent$ | async)?.name}}</a>: <a [routerLink]="" title="Filter by correspondent" i18n-title (click)="clickCorrespondent.emit(document.correspondent)" class="font-weight-bold">{{(document.correspondent$ | async)?.name}}</a>:
</ng-container> </ng-container>
{{document.title | documentTitle}} {{document.title | documentTitle}}
</p> </p>
@ -24,18 +32,18 @@
<div class="d-flex justify-content-between align-items-center mx-n2"> <div class="d-flex justify-content-between align-items-center mx-n2">
<div class="btn-group"> <div class="btn-group">
<a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary" title="Edit"> <a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary" title="Edit" i18n-title>
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-pencil" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-pencil" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/> <path fill-rule="evenodd" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
</svg> </svg>
</a> </a>
<a [href]="getPreviewUrl()" class="btn btn-sm btn-outline-secondary" title="View in browser"> <a [href]="getPreviewUrl()" class="btn btn-sm btn-outline-secondary" title="View in browser" i18n-title>
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-search" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-search" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M10.442 10.442a1 1 0 0 1 1.415 0l3.85 3.85a1 1 0 0 1-1.414 1.415l-3.85-3.85a1 1 0 0 1 0-1.415z"/> <path fill-rule="evenodd" d="M10.442 10.442a1 1 0 0 1 1.415 0l3.85 3.85a1 1 0 0 1-1.414 1.415l-3.85-3.85a1 1 0 0 1 0-1.415z"/>
<path fill-rule="evenodd" d="M6.5 12a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zM13 6.5a6.5 6.5 0 1 1-13 0 6.5 6.5 0 0 1 13 0z"/> <path fill-rule="evenodd" d="M6.5 12a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zM13 6.5a6.5 6.5 0 1 1-13 0 6.5 6.5 0 0 1 13 0z"/>
</svg> </svg>
</a> </a>
<a [href]="getDownloadUrl()" class="btn btn-sm btn-outline-secondary" title="Download"> <a [href]="getDownloadUrl()" class="btn btn-sm btn-outline-secondary" title="Download" i18n-title>
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-download" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-download" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/> <path fill-rule="evenodd" d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
<path fill-rule="evenodd" d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/> <path fill-rule="evenodd" d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/>

View File

@ -1,5 +1,24 @@
@import "/src/theme";
.doc-img { .doc-img {
object-fit: cover; object-fit: cover;
object-position: top; object-position: top;
height: 200px; height: 200px;
mix-blend-mode: multiply;
}
.document-card-check {
display: none
}
.document-card:hover .document-card-check {
display: block;
}
.card-selected {
border-color: $primary;
}
.doc-img-background-selected {
background-color: $primaryFaded;
} }

View File

@ -12,6 +12,21 @@ export class DocumentCardSmallComponent implements OnInit {
constructor(private documentService: DocumentService) { } constructor(private documentService: DocumentService) { }
_selected = false
get selected() {
return this._selected
}
@Input()
set selected(value: boolean) {
this._selected = value
this.selectedChange.emit(value)
}
@Output()
selectedChange = new EventEmitter<boolean>()
@Input() @Input()
document: PaperlessDocument document: PaperlessDocument

View File

@ -1,4 +1,28 @@
<app-page-header [title]="getTitle()"> <app-page-header [title]="getTitle()">
<div ngbDropdown class="d-inline-block mr-2">
<button class="btn btn-sm btn-outline-primary" id="dropdownBasic1" ngbDropdownToggle>
<svg class="toolbaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#text-indent-left" />
</svg>
Bulk edit
</button>
<div ngbDropdownMenu aria-labelledby="dropdownBasic1" class="shadow">
<button ngbDropdownItem (click)="list.selectPage()">Select page</button>
<button ngbDropdownItem (click)="list.selectAll()">Select all</button>
<button ngbDropdownItem (click)="list.selectNone()">Select none</button>
<div class="dropdown-divider"></div>
<button ngbDropdownItem [disabled]="list.selected.size == 0" (click)="bulkSetCorrespondent()">Set correspondent</button>
<button ngbDropdownItem [disabled]="list.selected.size == 0" (click)="bulkRemoveCorrespondent()">Remove correspondent</button>
<button ngbDropdownItem [disabled]="list.selected.size == 0" (click)="bulkSetDocumentType()">Set document type</button>
<button ngbDropdownItem [disabled]="list.selected.size == 0" (click)="bulkRemoveDocumentType()">Remove document type</button>
<button ngbDropdownItem [disabled]="list.selected.size == 0" (click)="bulkAddTag()">Add tag</button>
<button ngbDropdownItem [disabled]="list.selected.size == 0" (click)="bulkRemoveTag()">Remove tag</button>
<div class="dropdown-divider"></div>
<button ngbDropdownItem [disabled]="list.selected.size == 0" (click)="bulkDelete()">Delete</button>
</div>
</div>
<div class="btn-group btn-group-toggle" ngbRadioGroup [(ngModel)]="displayMode" <div class="btn-group btn-group-toggle" ngbRadioGroup [(ngModel)]="displayMode"
(ngModelChange)="saveDisplayMode()"> (ngModelChange)="saveDisplayMode()">
<label ngbButtonLabel class="btn-outline-primary btn-sm"> <label ngbButtonLabel class="btn-outline-primary btn-sm">
@ -23,7 +47,7 @@
<div class="btn-group btn-group-toggle ml-2" ngbRadioGroup [(ngModel)]="list.sortReverse"> <div class="btn-group btn-group-toggle ml-2" ngbRadioGroup [(ngModel)]="list.sortReverse">
<div ngbDropdown class="btn-group"> <div ngbDropdown class="btn-group">
<button class="btn btn-outline-primary btn-sm" id="dropdownBasic1" ngbDropdownToggle>Sort by</button> <button class="btn btn-outline-primary btn-sm" id="dropdownBasic1" ngbDropdownToggle i18n>Sort by</button>
<div ngbDropdownMenu aria-labelledby="dropdownBasic1" class="shadow"> <div ngbDropdownMenu aria-labelledby="dropdownBasic1" class="shadow">
<button *ngFor="let f of getSortFields()" ngbDropdownItem (click)="list.sortField = f.field" <button *ngFor="let f of getSortFields()" ngbDropdownItem (click)="list.sortField = f.field"
[class.active]="list.sortField == f.field">{{f.name}}</button> [class.active]="list.sortField == f.field">{{f.name}}</button>
@ -46,15 +70,15 @@
<div class="btn-group ml-2"> <div class="btn-group ml-2">
<div class="btn-group" ngbDropdown role="group"> <div class="btn-group" ngbDropdown role="group">
<button class="btn btn-sm btn-outline-primary dropdown-toggle" ngbDropdownToggle>Views</button> <button class="btn btn-sm btn-outline-primary dropdown-toggle" ngbDropdownToggle i18n>Views</button>
<div class="dropdown-menu shadow" ngbDropdownMenu> <div class="dropdown-menu shadow" ngbDropdownMenu>
<ng-container *ngIf="!list.savedViewId"> <ng-container *ngIf="!list.savedViewId">
<button ngbDropdownItem *ngFor="let view of savedViewService.allViews" (click)="loadViewConfig(view)">{{view.name}}</button> <button ngbDropdownItem *ngFor="let view of savedViewService.allViews" (click)="loadViewConfig(view)">{{view.name}}</button>
<div class="dropdown-divider" *ngIf="savedViewService.allViews.length > 0"></div> <div class="dropdown-divider" *ngIf="savedViewService.allViews.length > 0"></div>
</ng-container> </ng-container>
<button ngbDropdownItem (click)="saveViewConfig()" *ngIf="list.savedViewId">Save "{{list.savedViewTitle}}"</button> <button ngbDropdownItem (click)="saveViewConfig()" *ngIf="list.savedViewId" i18n>Save "{{list.savedViewTitle}}"</button>
<button ngbDropdownItem (click)="saveViewConfigAs()">Save as...</button> <button ngbDropdownItem (click)="saveViewConfigAs()" i18n>Save as...</button>
</div> </div>
</div> </div>
@ -67,27 +91,34 @@
</div> </div>
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<p>{{list.collectionSize || 0}} document(s) <span *ngIf="isFiltered">(filtered)</span></p> <p><span *ngIf="list.selected.size > 0">Selected {{list.selected.size}} of </span>{{list.collectionSize || 0}} document(s) <span *ngIf="isFiltered">(filtered)</span></p>
<ngb-pagination [pageSize]="list.currentPageSize" [collectionSize]="list.collectionSize" [(page)]="list.currentPage" [maxSize]="5" <ngb-pagination [pageSize]="list.currentPageSize" [collectionSize]="list.collectionSize" [(page)]="list.currentPage" [maxSize]="5"
[rotate]="true" (pageChange)="list.reload()" aria-label="Default pagination"></ngb-pagination> [rotate]="true" (pageChange)="list.reload()" aria-label="Default pagination"></ngb-pagination>
</div> </div>
<div *ngIf="displayMode == 'largeCards'"> <div *ngIf="displayMode == 'largeCards'">
<app-document-card-large *ngFor="let d of list.documents" [document]="d" [details]="d.content" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)"> <app-document-card-large [selected]="list.isSelected(d)" (selectedChange)="list.setSelected(d, $event)" *ngFor="let d of list.documents; trackBy: trackByDocumentId" [document]="d" [details]="d.content" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)">
</app-document-card-large> </app-document-card-large>
</div> </div>
<table class="table table-sm border shadow-sm" *ngIf="displayMode == 'details'"> <table class="table table-sm border shadow-sm" *ngIf="displayMode == 'details'">
<thead> <thead>
<th class="d-none d-lg-table-cell">ASN</th> <th></th>
<th class="d-none d-md-table-cell">Correspondent</th> <th class="d-none d-lg-table-cell" i18n>ASN</th>
<th>Title</th> <th class="d-none d-md-table-cell" i18n>Correspondent</th>
<th class="d-none d-xl-table-cell">Document type</th> <th i18n>Title</th>
<th>Created</th> <th class="d-none d-xl-table-cell" i18n>Document type</th>
<th class="d-none d-xl-table-cell">Added</th> <th i18n>Created</th>
<th class="d-none d-xl-table-cell" i18n>Added</th>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let d of list.documents"> <tr *ngFor="let d of list.documents; trackBy: trackByDocumentId" [ngClass]="list.isSelected(d) ? 'table-row-selected' : ''">
<td>
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input" id="docCheck{{d.id}}" [checked]="list.isSelected(d)" (change)="list.setSelected(d, $event.target.checked)">
<label class="custom-control-label" for="docCheck{{d.id}}"></label>
</div>
</td>
<td class="d-none d-lg-table-cell"> <td class="d-none d-lg-table-cell">
{{d.archive_serial_number}} {{d.archive_serial_number}}
</td> </td>
@ -117,5 +148,5 @@
<div class="m-n2 row row-cols-paperless-cards" *ngIf="displayMode == 'smallCards'"> <div class="m-n2 row row-cols-paperless-cards" *ngIf="displayMode == 'smallCards'">
<app-document-card-small [document]="d" *ngFor="let d of list.documents" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)"></app-document-card-small> <app-document-card-small [selected]="list.isSelected(d)" (selectedChange)="list.setSelected(d, $event)" [document]="d" *ngFor="let d of list.documents; trackBy: trackByDocumentId" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)"></app-document-card-small>
</div> </div>

View File

@ -1,3 +1,9 @@
@import "/src/theme";
.table-row-selected {
background-color: $primaryFaded;
}
$paperless-card-breakpoints: ( $paperless-card-breakpoints: (
0: 2, // xs 0: 2, // xs
768px: 3, //md 768px: 3, //md

View File

@ -1,14 +1,22 @@
import { Component, OnInit, ViewChild } from '@angular/core'; import { Component, OnInit, ViewChild } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { FILTER_CORRESPONDENT } from 'src/app/data/filter-rule-type'; import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { PaperlessDocument } from 'src/app/data/paperless-document';
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'; import { PaperlessSavedView } from 'src/app/data/paperless-saved-view';
import { DocumentListViewService } from 'src/app/services/document-list-view.service'; import { DocumentListViewService } from 'src/app/services/document-list-view.service';
import { DOCUMENT_SORT_FIELDS } from 'src/app/services/rest/document.service'; import { CorrespondentService } from 'src/app/services/rest/correspondent.service';
import { DocumentTypeService } from 'src/app/services/rest/document-type.service';
import { DocumentService, DOCUMENT_SORT_FIELDS } from 'src/app/services/rest/document.service';
import { TagService } from 'src/app/services/rest/tag.service';
import { SavedViewService } from 'src/app/services/rest/saved-view.service'; import { SavedViewService } from 'src/app/services/rest/saved-view.service';
import { Toast, ToastService } from 'src/app/services/toast.service'; import { Toast, ToastService } from 'src/app/services/toast.service';
import { FilterEditorComponent } from '../filter-editor/filter-editor.component'; import { FilterEditorComponent } from '../filter-editor/filter-editor.component';
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component';
import { SelectDialogComponent } from '../common/select-dialog/select-dialog.component';
import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-view-config-dialog.component'; import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-view-config-dialog.component';
import { OpenDocumentsService } from 'src/app/services/open-documents.service';
@Component({ @Component({
selector: 'app-document-list', selector: 'app-document-list',
@ -23,7 +31,12 @@ export class DocumentListComponent implements OnInit {
public route: ActivatedRoute, public route: ActivatedRoute,
private router: Router, private router: Router,
private toastService: ToastService, private toastService: ToastService,
public modalService: NgbModal) { } public modalService: NgbModal,
private correspondentService: CorrespondentService,
private documentTypeService: DocumentTypeService,
private tagService: TagService,
private documentService: DocumentService,
private openDocumentService: OpenDocumentsService) { }
@ViewChild("filterEditor") @ViewChild("filterEditor")
private filterEditor: FilterEditorComponent private filterEditor: FilterEditorComponent
@ -35,7 +48,7 @@ export class DocumentListComponent implements OnInit {
} }
getTitle() { getTitle() {
return this.list.savedViewTitle || "Documents" return this.list.savedViewTitle || $localize`Documents`
} }
getSortFields() { getSortFields() {
@ -77,7 +90,7 @@ export class DocumentListComponent implements OnInit {
saveViewConfig() { saveViewConfig() {
this.savedViewService.update(this.list.savedView).subscribe(result => { this.savedViewService.update(this.list.savedView).subscribe(result => {
this.toastService.showToast(Toast.make("Information", `View "${this.list.savedView.name}" saved successfully.`)) this.toastService.showToast(Toast.make("Information", $localize`View "${this.list.savedView.name}" saved successfully.`))
}) })
} }
@ -96,7 +109,7 @@ export class DocumentListComponent implements OnInit {
} }
this.savedViewService.create(savedView).subscribe(() => { this.savedViewService.create(savedView).subscribe(() => {
modal.close() modal.close()
this.toastService.showToast(Toast.make("Information", `View "${savedView.name}" created successfully.`)) this.toastService.showToast(Toast.make("Information", $localize`View "${savedView.name}" created successfully.`))
}) })
}) })
} }
@ -113,4 +126,122 @@ export class DocumentListComponent implements OnInit {
this.filterEditor.toggleDocumentType(documentTypeID) this.filterEditor.toggleDocumentType(documentTypeID)
} }
trackByDocumentId(index, item: PaperlessDocument) {
return item.id
}
private executeBulkOperation(method: string, args): Observable<any> {
return this.documentService.bulkEdit(Array.from(this.list.selected), method, args).pipe(
tap(() => {
this.list.reload()
this.list.selected.forEach(id => {
this.openDocumentService.refreshDocument(id)
})
this.list.selectNone()
})
)
}
bulkSetCorrespondent() {
let modal = this.modalService.open(SelectDialogComponent, {backdrop: 'static'})
modal.componentInstance.title = "Select correspondent"
modal.componentInstance.message = `Select the correspondent you wish to assign to ${this.list.selected.size} selected document(s):`
this.correspondentService.listAll().subscribe(response => {
modal.componentInstance.objects = response.results
})
modal.componentInstance.selectClicked.subscribe(selectedId => {
this.executeBulkOperation('set_correspondent', {"correspondent": selectedId}).subscribe(
response => {
modal.close()
}
)
})
}
bulkRemoveCorrespondent() {
let modal = this.modalService.open(ConfirmDialogComponent, {backdrop: 'static'})
modal.componentInstance.title = "Remove correspondent"
modal.componentInstance.message = `This operation will remove the correspondent from all ${this.list.selected.size} selected document(s).`
modal.componentInstance.confirmClicked.subscribe(() => {
this.executeBulkOperation('set_correspondent', {"correspondent": null}).subscribe(r => {
modal.close()
})
})
}
bulkSetDocumentType() {
let modal = this.modalService.open(SelectDialogComponent, {backdrop: 'static'})
modal.componentInstance.title = "Select document type"
modal.componentInstance.message = `Select the document type you wish to assign to ${this.list.selected.size} selected document(s):`
this.documentTypeService.listAll().subscribe(response => {
modal.componentInstance.objects = response.results
})
modal.componentInstance.selectClicked.subscribe(selectedId => {
this.executeBulkOperation('set_document_type', {"document_type": selectedId}).subscribe(
response => {
modal.close()
}
)
})
}
bulkRemoveDocumentType() {
let modal = this.modalService.open(ConfirmDialogComponent, {backdrop: 'static'})
modal.componentInstance.title = "Remove document type"
modal.componentInstance.message = `This operation will remove the document type from all ${this.list.selected.size} selected document(s).`
modal.componentInstance.confirmClicked.subscribe(() => {
this.executeBulkOperation('set_document_type', {"document_type": null}).subscribe(r => {
modal.close()
})
})
}
bulkAddTag() {
let modal = this.modalService.open(SelectDialogComponent, {backdrop: 'static'})
modal.componentInstance.title = "Select tag"
modal.componentInstance.message = `Select the tag you wish to assign to ${this.list.selected.size} selected document(s):`
this.tagService.listAll().subscribe(response => {
modal.componentInstance.objects = response.results
})
modal.componentInstance.selectClicked.subscribe(selectedId => {
this.executeBulkOperation('add_tag', {"tag": selectedId}).subscribe(
response => {
modal.close()
}
)
})
}
bulkRemoveTag() {
let modal = this.modalService.open(SelectDialogComponent, {backdrop: 'static'})
modal.componentInstance.title = "Select tag"
modal.componentInstance.message = `Select the tag you wish to remove from ${this.list.selected.size} selected document(s):`
this.tagService.listAll().subscribe(response => {
modal.componentInstance.objects = response.results
})
modal.componentInstance.selectClicked.subscribe(selectedId => {
this.executeBulkOperation('remove_tag', {"tag": selectedId}).subscribe(
response => {
modal.close()
}
)
})
}
bulkDelete() {
let modal = this.modalService.open(ConfirmDialogComponent, {backdrop: 'static'})
modal.componentInstance.delayConfirm(5)
modal.componentInstance.title = "Delete confirm"
modal.componentInstance.messageBold = `This operation will permanently delete all ${this.list.selected.size} selected document(s).`
modal.componentInstance.message = `This operation cannot be undone.`
modal.componentInstance.btnClass = "btn-danger"
modal.componentInstance.btnCaption = "Delete document(s)"
modal.componentInstance.confirmClicked.subscribe(() => {
this.executeBulkOperation("delete", {}).subscribe(
response => {
modal.close()
}
)
})
}
} }

View File

@ -1,17 +1,17 @@
<form [formGroup]="saveViewConfigForm" class="needs-validation" novalidate (ngSubmit)="save()"> <form [formGroup]="saveViewConfigForm" class="needs-validation" novalidate (ngSubmit)="save()">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Save current view</h4> <h4 class="modal-title" id="modal-basic-title" i18n>Save current view</h4>
<button type="button" class="close" aria-label="Close" (click)="cancel()"> <button type="button" class="close" aria-label="Close" (click)="cancel()">
<span aria-hidden="true">&times;</span> <span aria-hidden="true">&times;</span>
</button> </button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<app-input-text title="Name" formControlName="name"></app-input-text> <app-input-text i18n-title title="Name" formControlName="name"></app-input-text>
<app-input-check title="Show in side bar" formControlName="showInSideBar"></app-input-check> <app-input-check i18n-title title="Show in side bar" formControlName="showInSideBar"></app-input-check>
<app-input-check title="Show on dashboard" formControlName="showOnDashboard"></app-input-check> <app-input-check i18n-title title="Show on dashboard" formControlName="showOnDashboard"></app-input-check>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-dark" (click)="cancel()">Cancel</button> <button type="button" class="btn btn-outline-dark" (click)="cancel()" i18n>Cancel</button>
<button type="submit" class="btn btn-primary">Save</button> <button type="submit" class="btn btn-primary" i18n>Save</button>
</div> </div>
</form> </form>

View File

@ -7,13 +7,13 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<app-input-text title="Name" formControlName="name"></app-input-text> <app-input-text i18n-title title="Name" formControlName="name"></app-input-text>
<app-input-select title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></app-input-select> <app-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></app-input-select>
<app-input-text title="Match" formControlName="match" hint="Auto matching does not require you to fill in this field."></app-input-text> <app-input-text i18n-title title="Match" formControlName="match" hint="Auto matching does not require you to fill in this field."></app-input-text>
<app-input-check title="Case insensitive" formControlName="is_insensitive" hint="Auto matching ignores this option."></app-input-check> <app-input-check i18n-title title="Case insensitive" formControlName="is_insensitive" hint="Auto matching ignores this option."></app-input-check>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-dark" (click)="cancel()">Cancel</button> <button type="button" class="btn btn-outline-dark" (click)="cancel()" i18n>Cancel</button>
<button type="submit" class="btn btn-primary">Save</button> <button type="submit" class="btn btn-primary" i18n>Save</button>
</div> </div>
</form> </form>

View File

@ -1,7 +1,5 @@
<app-page-header title="Correspondents"> <app-page-header title="Correspondents" i18n-title>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="openCreateDialog()"> <button type="button" class="btn btn-sm btn-outline-primary" (click)="openCreateDialog()" i18n>Create</button>
Create
</button>
</app-page-header> </app-page-header>
<div class="row m-0 justify-content-end"> <div class="row m-0 justify-content-end">
@ -11,11 +9,11 @@
<table class="table table-striped border shadow"> <table class="table table-striped border shadow">
<thead> <thead>
<tr> <tr>
<th scope="col" sortable="name" (sort)="onSort($event)">Name</th> <th scope="col" sortable="name" (sort)="onSort($event)" i18n>Name</th>
<th scope="col" sortable="matching_algorithm" (sort)="onSort($event)">Matching</th> <th scope="col" sortable="matching_algorithm" (sort)="onSort($event)" i18n>Matching</th>
<th scope="col" sortable="document_count" (sort)="onSort($event)">Document count</th> <th scope="col" sortable="document_count" (sort)="onSort($event)" i18n>Document count</th>
<th scope="col" sortable="last_correspondence" (sort)="onSort($event)">Last correspondence</th> <th scope="col" sortable="last_correspondence" (sort)="onSort($event)" i18n>Last correspondence</th>
<th scope="col">Actions</th> <th scope="col" i18n>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -29,21 +27,18 @@
<button class="btn btn-sm btn-outline-secondary" (click)="filterDocuments(correspondent)"> <button class="btn btn-sm btn-outline-secondary" (click)="filterDocuments(correspondent)">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-funnel" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-funnel" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M1.5 1.5A.5.5 0 0 1 2 1h12a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.128.334L10 8.692V13.5a.5.5 0 0 1-.342.474l-3 1A.5.5 0 0 1 6 14.5V8.692L1.628 3.834A.5.5 0 0 1 1.5 3.5v-2zm1 .5v1.308l4.372 4.858A.5.5 0 0 1 7 8.5v5.306l2-.666V8.5a.5.5 0 0 1 .128-.334L13.5 3.308V2h-11z"/> <path fill-rule="evenodd" d="M1.5 1.5A.5.5 0 0 1 2 1h12a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.128.334L10 8.692V13.5a.5.5 0 0 1-.342.474l-3 1A.5.5 0 0 1 6 14.5V8.692L1.628 3.834A.5.5 0 0 1 1.5 3.5v-2zm1 .5v1.308l4.372 4.858A.5.5 0 0 1 7 8.5v5.306l2-.666V8.5a.5.5 0 0 1 .128-.334L13.5 3.308V2h-11z"/>
</svg> </svg>&nbsp;<ng-container i18n>Documents</ng-container>
Documents
</button> </button>
<button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(correspondent)"> <button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(correspondent)">
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-pencil" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-pencil" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/> <path fill-rule="evenodd" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
</svg> </svg>&nbsp;<ng-container i18n>Edit</ng-container>
Edit
</button> </button>
<button class="btn btn-sm btn-outline-danger" (click)="openDeleteDialog(correspondent)"> <button class="btn btn-sm btn-outline-danger" (click)="openDeleteDialog(correspondent)">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-trash" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-trash" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/> <path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4L4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/> <path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4L4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
</svg> </svg>&nbsp;<ng-container i18n>Delete</ng-container>
Delete
</button> </button>
</div> </div>
</td> </td>

View File

@ -7,14 +7,14 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<app-input-text title="Name" formControlName="name"></app-input-text> <app-input-text i18n-title title="Name" formControlName="name"></app-input-text>
<app-input-select title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></app-input-select> <app-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></app-input-select>
<app-input-text title="Match" formControlName="match" hint="Auto matching does not require you to fill in this field."></app-input-text> <app-input-text i18n-title title="Match" formControlName="match" hint="Auto matching does not require you to fill in this field."></app-input-text>
<app-input-check title="Case insensitive" formControlName="is_insensitive" hint="Auto matching ignores this option."></app-input-check> <app-input-check i18n-title title="Case insensitive" formControlName="is_insensitive" hint="Auto matching ignores this option."></app-input-check>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-dark" (click)="cancel()">Cancel</button> <button type="button" class="btn btn-outline-dark" (click)="cancel()" i18n>Cancel</button>
<button type="submit" class="btn btn-primary">Save</button> <button type="submit" class="btn btn-primary" i18n>Save</button>
</div> </div>
</form> </form>

View File

@ -1,7 +1,5 @@
<app-page-header title="Document types"> <app-page-header title="Document types">
<button type="button" class="btn btn-sm btn-outline-primary" (click)="openCreateDialog()"> <button type="button" class="btn btn-sm btn-outline-primary" (click)="openCreateDialog()" i18n>Create</button>
Create
</button>
</app-page-header> </app-page-header>
<div class="row m-0 justify-content-end"> <div class="row m-0 justify-content-end">
@ -12,10 +10,10 @@
<table class="table table-striped border shadow"> <table class="table table-striped border shadow">
<thead> <thead>
<tr> <tr>
<th scope="col" sortable="name" (sort)="onSort($event)">Name</th> <th scope="col" sortable="name" (sort)="onSort($event)" i18n>Name</th>
<th scope="col" sortable="matching_algorithm" (sort)="onSort($event)">Matching</th> <th scope="col" sortable="matching_algorithm" (sort)="onSort($event)" i18n>Matching</th>
<th scope="col" sortable="document_count" (sort)="onSort($event)">Document count</th> <th scope="col" sortable="document_count" (sort)="onSort($event)" i18n>Document count</th>
<th scope="col">Actions</th> <th scope="col" i18n>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -28,21 +26,18 @@
<button class="btn btn-sm btn-outline-secondary" (click)="filterDocuments(document_type)"> <button class="btn btn-sm btn-outline-secondary" (click)="filterDocuments(document_type)">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-funnel" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-funnel" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M1.5 1.5A.5.5 0 0 1 2 1h12a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.128.334L10 8.692V13.5a.5.5 0 0 1-.342.474l-3 1A.5.5 0 0 1 6 14.5V8.692L1.628 3.834A.5.5 0 0 1 1.5 3.5v-2zm1 .5v1.308l4.372 4.858A.5.5 0 0 1 7 8.5v5.306l2-.666V8.5a.5.5 0 0 1 .128-.334L13.5 3.308V2h-11z"/> <path fill-rule="evenodd" d="M1.5 1.5A.5.5 0 0 1 2 1h12a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.128.334L10 8.692V13.5a.5.5 0 0 1-.342.474l-3 1A.5.5 0 0 1 6 14.5V8.692L1.628 3.834A.5.5 0 0 1 1.5 3.5v-2zm1 .5v1.308l4.372 4.858A.5.5 0 0 1 7 8.5v5.306l2-.666V8.5a.5.5 0 0 1 .128-.334L13.5 3.308V2h-11z"/>
</svg> </svg>&nbsp;<ng-container i18n>Documents</ng-container>
Documents
</button> </button>
<button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(document_type)"> <button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(document_type)">
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-pencil" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-pencil" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/> <path fill-rule="evenodd" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
</svg> </svg>&nbsp;<ng-container i18n>Edit</ng-container>
Edit
</button> </button>
<button class="btn btn-sm btn-outline-danger" (click)="openDeleteDialog(document_type)"> <button class="btn btn-sm btn-outline-danger" (click)="openDeleteDialog(document_type)">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-trash" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-trash" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/> <path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4L4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/> <path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4L4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
</svg> </svg>&nbsp;<ng-container i18n>Delete</ng-container>
Delete
</button> </button>
</div> </div>
</td> </td>

View File

@ -28,7 +28,7 @@ export abstract class GenericListComponent<T extends ObjectWithId> implements On
getMatching(o: MatchingModel) { getMatching(o: MatchingModel) {
if (o.matching_algorithm == MATCH_AUTO) { if (o.matching_algorithm == MATCH_AUTO) {
return "Automatic" return $localize`Automatic`
} else if (o.match && o.match.length > 0) { } else if (o.match && o.match.length > 0) {
return `${o.match} (${MATCHING_ALGORITHMS.find(a => a.id == o.matching_algorithm).name})` return `${o.match} (${MATCHING_ALGORITHMS.find(a => a.id == o.matching_algorithm).name})`
} else { } else {
@ -90,11 +90,11 @@ export abstract class GenericListComponent<T extends ObjectWithId> implements On
openDeleteDialog(object: T) { openDeleteDialog(object: T) {
var activeModal = this.modalService.open(ConfirmDialogComponent, {backdrop: 'static'}) var activeModal = this.modalService.open(ConfirmDialogComponent, {backdrop: 'static'})
activeModal.componentInstance.title = "Confirm delete" activeModal.componentInstance.title = $localize`Confirm delete`
activeModal.componentInstance.messageBold = `Do you really want to delete ${this.getObjectName(object)}?` activeModal.componentInstance.messageBold = $localize`Do you really want to delete ${this.getObjectName(object)}?`
activeModal.componentInstance.message = "Associated documents will not be deleted." activeModal.componentInstance.message = $localize`Associated documents will not be deleted.`
activeModal.componentInstance.btnClass = "btn-danger" activeModal.componentInstance.btnClass = "btn-danger"
activeModal.componentInstance.btnCaption = "Delete" activeModal.componentInstance.btnCaption = $localize`Delete`
activeModal.componentInstance.confirmClicked.subscribe(() => { activeModal.componentInstance.confirmClicked.subscribe(() => {
this.service.delete(object).subscribe(_ => { this.service.delete(object).subscribe(_ => {
activeModal.close() activeModal.close()

View File

@ -1,11 +1,12 @@
<app-page-header title="Logs"> <app-page-header title="Logs" i18n-title>
<div ngbDropdown class="btn-group"> <div ngbDropdown class="btn-group">
<button class="btn btn-outline-primary btn-sm" id="dropdownBasic1" ngbDropdownToggle> <button class="btn btn-outline-primary btn-sm" id="dropdownBasic1" ngbDropdownToggle>
<svg class="toolbaricon" fill="currentColor"> <svg class="toolbaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#funnel" /> <use xlink:href="assets/bootstrap-icons.svg#funnel" />
</svg> </svg>&nbsp;<ng-container i18n>Filter</ng-container>
Filter
</button> </button>
<div ngbDropdownMenu aria-labelledby="dropdownBasic1"> <div ngbDropdownMenu aria-labelledby="dropdownBasic1">
<button *ngFor="let f of getLevels()" ngbDropdownItem (click)="setLevel(f.id)" <button *ngFor="let f of getLevels()" ngbDropdownItem (click)="setLevel(f.id)"

View File

@ -7,14 +7,14 @@
<ul ngbNav #nav="ngbNav" class="nav-tabs"> <ul ngbNav #nav="ngbNav" class="nav-tabs">
<li [ngbNavItem]="1"> <li [ngbNavItem]="1">
<a ngbNavLink>General settings</a> <a ngbNavLink i18n>General settings</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<h4>Document list</h4> <h4 i18n>Document list</h4>
<div class="form-row form-group"> <div class="form-row form-group">
<div class="col-md-3 col-form-label"> <div class="col-md-3 col-form-label">
<span>Items per page</span> <span i18n>Items per page</span>
</div> </div>
<div class="col"> <div class="col">
@ -31,36 +31,36 @@
</ng-template> </ng-template>
</li> </li>
<li [ngbNavItem]="2"> <li [ngbNavItem]="2">
<a ngbNavLink>Saved views</a> <a ngbNavLink i18n>Saved views</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<div formGroupName="savedViews"> <div formGroupName="savedViews">
<div *ngFor="let view of savedViews" [formGroupName]="view.id" class="form-row"> <div *ngFor="let view of savedViews" [formGroupName]="view.id" class="form-row">
<div class="form-group col-4 mr-3"> <div class="form-group col-4 mr-3">
<label for="name_{{view.id}}">Name</label> <label for="name_{{view.id}}" i18n>Name</label>
<input type="text" class="form-control" formControlName="name" id="name_{{view.id}}"> <input type="text" class="form-control" formControlName="name" id="name_{{view.id}}">
</div> </div>
<div class="form-group col-auto mr-3"> <div class="form-group col-auto mr-3">
<label for="show_on_dashboard_{{view.id}}">Appears on</label> <label for="show_on_dashboard_{{view.id}}" i18n>Appears on</label>
<div class="custom-control custom-switch"> <div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input" id="show_on_dashboard_{{view.id}}" formControlName="show_on_dashboard"> <input type="checkbox" class="custom-control-input" id="show_on_dashboard_{{view.id}}" formControlName="show_on_dashboard">
<label class="custom-control-label" for="show_on_dashboard_{{view.id}}">Show on dashboard</label> <label class="custom-control-label" for="show_on_dashboard_{{view.id}}" i18n>Show on dashboard</label>
</div> </div>
<div class="custom-control custom-switch"> <div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input" id="show_in_sidebar_{{view.id}}" formControlName="show_in_sidebar"> <input type="checkbox" class="custom-control-input" id="show_in_sidebar_{{view.id}}" formControlName="show_in_sidebar">
<label class="custom-control-label" for="show_in_sidebar_{{view.id}}">Show in sidebar</label> <label class="custom-control-label" for="show_in_sidebar_{{view.id}}" i18n>Show in sidebar</label>
</div> </div>
</div> </div>
<div class="form-group col-auto"> <div class="form-group col-auto">
<label for="name_{{view.id}}">Actions</label> <label for="name_{{view.id}}" i18n>Actions</label>
<button type="button" class="btn btn-sm btn-outline-danger form-control" (click)="deleteSavedView(view)">Delete</button> <button type="button" class="btn btn-sm btn-outline-danger form-control" (click)="deleteSavedView(view)" i18n>Delete</button>
</div> </div>
</div> </div>
<div *ngIf="savedViews.length == 0">No saved views defined.</div> <div *ngIf="savedViews.length == 0" i18n>No saved views defined.</div>
</div> </div>

View File

@ -46,14 +46,14 @@ export class SettingsComponent implements OnInit {
this.savedViewService.delete(savedView).subscribe(() => { this.savedViewService.delete(savedView).subscribe(() => {
this.savedViewGroup.removeControl(savedView.id.toString()) this.savedViewGroup.removeControl(savedView.id.toString())
this.savedViews.splice(this.savedViews.indexOf(savedView), 1) this.savedViews.splice(this.savedViews.indexOf(savedView), 1)
this.toastService.showToast(Toast.make("Information", `Saved view "${savedView.name} deleted.`)) this.toastService.showToast(Toast.make("Information", $localize`Saved view "${savedView.name} deleted.`))
}) })
} }
private saveLocalSettings() { private saveLocalSettings() {
localStorage.setItem(GENERAL_SETTINGS.DOCUMENT_LIST_SIZE, this.settingsForm.value.documentListItemPerPage) localStorage.setItem(GENERAL_SETTINGS.DOCUMENT_LIST_SIZE, this.settingsForm.value.documentListItemPerPage)
this.documentListViewService.updatePageSize() this.documentListViewService.updatePageSize()
this.toastService.showToast(Toast.make("Information", "Settings saved successfully.")) this.toastService.showToast(Toast.make("Information", $localize`Settings saved successfully.`))
} }
saveSettings() { saveSettings() {
@ -65,7 +65,7 @@ export class SettingsComponent implements OnInit {
this.savedViewService.patchMany(x).subscribe(s => { this.savedViewService.patchMany(x).subscribe(s => {
this.saveLocalSettings() this.saveLocalSettings()
}, error => { }, error => {
this.toastService.showToast(Toast.makeError(`Error while storing settings on server: ${JSON.stringify(error.error)}`)) this.toastService.showToast(Toast.makeError($localize`Error while storing settings on server: ${JSON.stringify(error.error)}`))
}) })
} else { } else {
this.saveLocalSettings() this.saveLocalSettings()

View File

@ -4,5 +4,5 @@
<path fill-rule="evenodd" d="M4.285 12.433a.5.5 0 0 0 .683-.183A3.498 3.498 0 0 1 8 10.5c1.295 0 2.426.703 3.032 1.75a.5.5 0 0 0 .866-.5A4.498 4.498 0 0 0 8 9.5a4.5 4.5 0 0 0-3.898 2.25.5.5 0 0 0 .183.683z"/> <path fill-rule="evenodd" d="M4.285 12.433a.5.5 0 0 0 .683-.183A3.498 3.498 0 0 1 8 10.5c1.295 0 2.426.703 3.032 1.75a.5.5 0 0 0 .866-.5A4.498 4.498 0 0 0 8 9.5a4.5 4.5 0 0 0-3.898 2.25.5.5 0 0 0 .183.683z"/>
<path d="M7 6.5C7 7.328 6.552 8 6 8s-1-.672-1-1.5S5.448 5 6 5s1 .672 1 1.5zm4 0c0 .828-.448 1.5-1 1.5s-1-.672-1-1.5S9.448 5 10 5s1 .672 1 1.5z"/> <path d="M7 6.5C7 7.328 6.552 8 6 8s-1-.672-1-1.5S5.448 5 6 5s1 .672 1 1.5zm4 0c0 .828-.448 1.5-1 1.5s-1-.672-1-1.5S9.448 5 10 5s1 .672 1 1.5z"/>
</svg> </svg>
<h1>404 Not Found</h1> <h1 i18n>404 Not Found</h1>
</div> </div>

View File

@ -1,7 +1,7 @@
<app-page-header title="Search results"> <app-page-header title="Search results">
</app-page-header> </app-page-header>
<div *ngIf="errorMessage" class="alert alert-danger">Invalid search query: {{errorMessage}}</div> <div *ngIf="errorMessage" class="alert alert-danger" i18n>Invalid search query: {{errorMessage}}</div>
<p *ngIf="more_like"> <p *ngIf="more_like">
Showing documents similar to Showing documents similar to

View File

@ -9,12 +9,12 @@ export const MATCH_FUZZY = 5
export const MATCH_AUTO = 6 export const MATCH_AUTO = 6
export const MATCHING_ALGORITHMS = [ export const MATCHING_ALGORITHMS = [
{id: MATCH_ANY, name: "Any"}, {id: MATCH_ANY, name: $localize`Any`},
{id: MATCH_ALL, name: "All"}, {id: MATCH_ALL, name: $localize`All`},
{id: MATCH_LITERAL, name: "Literal"}, {id: MATCH_LITERAL, name: $localize`Literal`},
{id: MATCH_REGEX, name: "Regular Expression"}, {id: MATCH_REGEX, name: $localize`Regular expression`},
{id: MATCH_FUZZY, name: "Fuzzy Match"}, {id: MATCH_FUZZY, name: $localize`Fuzzy match`},
{id: MATCH_AUTO, name: "Auto"}, {id: MATCH_AUTO, name: $localize`Auto`},
] ]
export interface MatchingModel extends ObjectWithId { export interface MatchingModel extends ObjectWithId {

View File

@ -40,10 +40,14 @@ export class DocumentListViewService {
} }
set savedView(value: PaperlessSavedView) { set savedView(value: PaperlessSavedView) {
if (value) { if (value && !this._savedViewConfig || value && value.id != this._savedViewConfig.id) {
//saved view inactive and should be active now, or saved view active, but a different view is requested
//this is here so that we don't modify value, which might be the actual instance of the saved view. //this is here so that we don't modify value, which might be the actual instance of the saved view.
this.selectNone()
this._savedViewConfig = Object.assign({}, value) this._savedViewConfig = Object.assign({}, value)
} else { } else if (this._savedViewConfig && !value) {
//saved view active, but document list requested
this.selectNone()
this._savedViewConfig = null this._savedViewConfig = null
} }
} }
@ -90,7 +94,7 @@ export class DocumentListViewService {
reload(onFinish?) { reload(onFinish?) {
this.isReloading = true this.isReloading = true
this.documentService.list( this.documentService.listFiltered(
this.currentPage, this.currentPage,
this.currentPageSize, this.currentPageSize,
this.view.sort_field, this.view.sort_field,
@ -118,6 +122,7 @@ export class DocumentListViewService {
//want changes in the filter editor to propagate into here right away. //want changes in the filter editor to propagate into here right away.
this.view.filter_rules = filterRules this.view.filter_rules = filterRules
this.reload() this.reload()
this.reduceSelectionToFilter()
this.saveDocumentListView() this.saveDocumentListView()
} }
@ -191,6 +196,49 @@ export class DocumentListViewService {
} }
} }
selected = new Set<number>()
selectNone() {
this.selected.clear()
}
private reduceSelectionToFilter() {
if (this.selected.size > 0) {
this.documentService.listAllFilteredIds(this.filterRules).subscribe(ids => {
let subset = new Set<number>()
for (let id of ids) {
if (this.selected.has(id)) {
subset.add(id)
}
}
this.selected = subset
})
}
}
selectAll() {
this.documentService.listAllFilteredIds(this.filterRules).subscribe(ids => ids.forEach(id => this.selected.add(id)))
}
selectPage() {
this.selected.clear()
this.documents.forEach(doc => {
this.selected.add(doc.id)
})
}
isSelected(d: PaperlessDocument) {
return this.selected.has(d.id)
}
setSelected(d: PaperlessDocument, value: boolean) {
if (value) {
this.selected.add(d.id)
} else if (!value) {
this.selected.delete(d.id)
}
}
constructor(private documentService: DocumentService) { constructor(private documentService: DocumentService) {
let documentListViewConfigJson = sessionStorage.getItem(DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG) let documentListViewConfigJson = sessionStorage.getItem(DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG)
if (documentListViewConfigJson) { if (documentListViewConfigJson) {

View File

@ -1,6 +1,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { PaperlessDocument } from '../data/paperless-document'; import { PaperlessDocument } from '../data/paperless-document';
import { OPEN_DOCUMENT_SERVICE } from '../data/storage-keys'; import { OPEN_DOCUMENT_SERVICE } from '../data/storage-keys';
import { DocumentService } from './rest/document.service';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -9,7 +10,7 @@ export class OpenDocumentsService {
private MAX_OPEN_DOCUMENTS = 5 private MAX_OPEN_DOCUMENTS = 5
constructor() { constructor(private documentService: DocumentService) {
if (sessionStorage.getItem(OPEN_DOCUMENT_SERVICE.DOCUMENTS)) { if (sessionStorage.getItem(OPEN_DOCUMENT_SERVICE.DOCUMENTS)) {
try { try {
this.openDocuments = JSON.parse(sessionStorage.getItem(OPEN_DOCUMENT_SERVICE.DOCUMENTS)) this.openDocuments = JSON.parse(sessionStorage.getItem(OPEN_DOCUMENT_SERVICE.DOCUMENTS))
@ -22,6 +23,15 @@ export class OpenDocumentsService {
private openDocuments: PaperlessDocument[] = [] private openDocuments: PaperlessDocument[] = []
refreshDocument(id: number) {
let index = this.openDocuments.findIndex(doc => doc.id == id)
if (index > -1) {
this.documentService.get(id).subscribe(doc => {
this.openDocuments[index] = doc
})
}
}
getOpenDocuments(): PaperlessDocument[] { getOpenDocuments(): PaperlessDocument[] {
return this.openDocuments return this.openDocuments
} }

View File

@ -52,9 +52,9 @@ export abstract class AbstractPaperlessService<T extends ObjectWithId> {
private _listAll: Observable<Results<T>> private _listAll: Observable<Results<T>>
listAll(ordering?: string, extraParams?): Observable<Results<T>> { listAll(sortField?: string, sortReverse?: boolean, extraParams?): Observable<Results<T>> {
if (!this._listAll) { if (!this._listAll) {
this._listAll = this.list(1, 100000, ordering, extraParams).pipe( this._listAll = this.list(1, 100000, sortField, sortReverse, extraParams).pipe(
publishReplay(1), publishReplay(1),
refCount() refCount()
) )

View File

@ -13,13 +13,13 @@ import { TagService } from './tag.service';
import { FILTER_RULE_TYPES } from 'src/app/data/filter-rule-type'; import { FILTER_RULE_TYPES } from 'src/app/data/filter-rule-type';
export const DOCUMENT_SORT_FIELDS = [ export const DOCUMENT_SORT_FIELDS = [
{ field: "correspondent__name", name: "Correspondent" }, { field: "correspondent__name", name: $localize`Correspondent` },
{ field: "document_type__name", name: "Document type" }, { field: "document_type__name", name: $localize`Document type` },
{ field: 'title', name: 'Title' }, { field: 'title', name: $localize`Title` },
{ field: 'archive_serial_number', name: 'ASN' }, { field: 'archive_serial_number', name: $localize`ASN` },
{ field: 'created', name: 'Created' }, { field: 'created', name: $localize`Created` },
{ field: 'added', name: 'Added' }, { field: 'added', name: $localize`Added` },
{ field: 'modified', name: 'Modified' } { field: 'modified', name: $localize`Modified` }
] ]
@Injectable({ @Injectable({
@ -61,8 +61,8 @@ export class DocumentService extends AbstractPaperlessService<PaperlessDocument>
return doc return doc
} }
list(page?: number, pageSize?: number, sortField?: string, sortReverse?: boolean, filterRules?: FilterRule[]): Observable<Results<PaperlessDocument>> { listFiltered(page?: number, pageSize?: number, sortField?: string, sortReverse?: boolean, filterRules?: FilterRule[], extraParams = {}): Observable<Results<PaperlessDocument>> {
return super.list(page, pageSize, sortField, sortReverse, this.filterRulesToQueryParams(filterRules)).pipe( return this.list(page, pageSize, sortField, sortReverse, Object.assign(extraParams, this.filterRulesToQueryParams(filterRules))).pipe(
map(results => { map(results => {
results.results.forEach(doc => this.addObservablesToDocument(doc)) results.results.forEach(doc => this.addObservablesToDocument(doc))
return results return results
@ -70,6 +70,12 @@ export class DocumentService extends AbstractPaperlessService<PaperlessDocument>
) )
} }
listAllFilteredIds(filterRules?: FilterRule[]): Observable<number[]> {
return this.listFiltered(1, 100000, null, null, filterRules, {"fields": "id"}).pipe(
map(response => response.results.map(doc => doc.id))
)
}
getPreviewUrl(id: number, original: boolean = false): string { getPreviewUrl(id: number, original: boolean = false): string {
let url = this.getResourceUrl(id, 'preview') let url = this.getResourceUrl(id, 'preview')
if (original) { if (original) {
@ -98,4 +104,12 @@ export class DocumentService extends AbstractPaperlessService<PaperlessDocument>
return this.http.get<PaperlessDocumentMetadata>(this.getResourceUrl(id, 'metadata')) return this.http.get<PaperlessDocumentMetadata>(this.getResourceUrl(id, 'metadata'))
} }
bulkEdit(ids: number[], method: string, args: any) {
return this.http.post(this.getResourceUrl(null, 'bulk_edit'), {
'documents': ids,
'method': method,
'parameters': args
})
}
} }

View File

@ -2,5 +2,5 @@ export const environment = {
production: true, production: true,
apiBaseUrl: "/api/", apiBaseUrl: "/api/",
appTitle: "Paperless-ng", appTitle: "Paperless-ng",
version: "0.9.8" version: "0.9.9"
}; };

View File

@ -1,5 +1,6 @@
$paperless-green: #17541f; $paperless-green: #17541f;
$primary: #17541f; $primary: #17541f;
$primaryFaded: #d1ddd2;
$theme-colors: ( $theme-colors: (
"primary": $primary "primary": $primary

View File

@ -0,0 +1,72 @@
from django.db.models import Q
from django_q.tasks import async_task
from documents.models import Document, Correspondent, DocumentType
def set_correspondent(doc_ids, correspondent):
if correspondent:
correspondent = Correspondent.objects.get(id=correspondent)
qs = Document.objects.filter(
Q(id__in=doc_ids) & ~Q(correspondent=correspondent))
affected_docs = [doc.id for doc in qs]
qs.update(correspondent=correspondent)
async_task("documents.tasks.bulk_rename_files", document_ids=affected_docs)
return "OK"
def set_document_type(doc_ids, document_type):
if document_type:
document_type = DocumentType.objects.get(id=document_type)
qs = Document.objects.filter(
Q(id__in=doc_ids) & ~Q(document_type=document_type))
affected_docs = [doc.id for doc in qs]
qs.update(document_type=document_type)
async_task("documents.tasks.bulk_rename_files", document_ids=affected_docs)
return "OK"
def add_tag(doc_ids, tag):
qs = Document.objects.filter(Q(id__in=doc_ids) & ~Q(tags__id=tag))
affected_docs = [doc.id for doc in qs]
DocumentTagRelationship = Document.tags.through
DocumentTagRelationship.objects.bulk_create([
DocumentTagRelationship(
document_id=doc, tag_id=tag) for doc in affected_docs
])
async_task("documents.tasks.bulk_rename_files", document_ids=affected_docs)
return "OK"
def remove_tag(doc_ids, tag):
qs = Document.objects.filter(Q(id__in=doc_ids) & Q(tags__id=tag))
affected_docs = [doc.id for doc in qs]
DocumentTagRelationship = Document.tags.through
DocumentTagRelationship.objects.filter(
Q(document_id__in=affected_docs) &
Q(tag_id=tag)
).delete()
async_task("documents.tasks.bulk_rename_files", document_ids=affected_docs)
return "OK"
def delete(doc_ids):
Document.objects.filter(id__in=doc_ids).delete()
return "OK"

View File

@ -51,6 +51,6 @@ def parser_check(app_configs, **kwargs):
if len(parsers) == 0: if len(parsers) == 0:
return [Error("No parsers found. This is a bug. The consumer won't be " return [Error("No parsers found. This is a bug. The consumer won't be "
"able to onsume any documents without parsers.")] "able to consume any documents without parsers.")]
else: else:
return [] return []

View File

@ -95,19 +95,20 @@ class Consumer(LoggingMixin):
self.pre_check_directories() self.pre_check_directories()
self.pre_check_duplicate() self.pre_check_duplicate()
self.log("info", "Consuming {}".format(self.filename)) self.log("info", f"Consuming {self.filename}")
# Determine the parser class. # Determine the parser class.
mime_type = magic.from_file(self.path, mime=True) mime_type = magic.from_file(self.path, mime=True)
self.log("debug", f"Detected mime type: {mime_type}")
parser_class = get_parser_class_for_mime_type(mime_type) parser_class = get_parser_class_for_mime_type(mime_type)
if not parser_class: if not parser_class:
raise ConsumerError(f"No parsers abvailable for {self.filename}") raise ConsumerError(f"No parsers available for {self.filename}")
else: else:
self.log("debug", self.log("debug",
f"Parser: {parser_class.__name__} " f"Parser: {parser_class.__name__}")
f"based on mime type {mime_type}")
# Notify all listeners that we're going to do some work. # Notify all listeners that we're going to do some work.

View File

@ -1,8 +1,10 @@
import json import json
import logging
import os import os
import shutil import shutil
from contextlib import contextmanager from contextlib import contextmanager
import tqdm
from django.conf import settings from django.conf import settings
from django.core.management import call_command from django.core.management import call_command
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
@ -43,6 +45,8 @@ class Command(Renderable, BaseCommand):
def handle(self, *args, **options): def handle(self, *args, **options):
logging.getLogger().handlers[0].level = logging.ERROR
self.source = options["source"] self.source = options["source"]
if not os.path.exists(self.source): if not os.path.exists(self.source):
@ -69,6 +73,9 @@ class Command(Renderable, BaseCommand):
self._import_files_from_manifest() self._import_files_from_manifest()
print("Updating search index...")
call_command('document_index', 'reindex')
@staticmethod @staticmethod
def _check_manifest_exists(path): def _check_manifest_exists(path):
if not os.path.exists(path): if not os.path.exists(path):
@ -111,10 +118,13 @@ class Command(Renderable, BaseCommand):
os.makedirs(settings.THUMBNAIL_DIR, exist_ok=True) os.makedirs(settings.THUMBNAIL_DIR, exist_ok=True)
os.makedirs(settings.ARCHIVE_DIR, exist_ok=True) os.makedirs(settings.ARCHIVE_DIR, exist_ok=True)
for record in self.manifest: print("Copy files into paperless...")
if not record["model"] == "documents.document": manifest_documents = list(filter(
continue lambda r: r["model"] == "documents.document",
self.manifest))
for record in tqdm.tqdm(manifest_documents):
document = Document.objects.get(pk=record["pk"]) document = Document.objects.get(pk=record["pk"])
@ -138,7 +148,6 @@ class Command(Renderable, BaseCommand):
create_source_path_directory(document.source_path) create_source_path_directory(document.source_path)
print(f"Moving {document_path} to {document.source_path}")
shutil.copy(document_path, document.source_path) shutil.copy(document_path, document.source_path)
shutil.copy(thumbnail_path, document.thumbnail_path) shutil.copy(thumbnail_path, document.thumbnail_path)
if archive_path: if archive_path:

View File

@ -1,4 +1,5 @@
from django.core.management import BaseCommand from django.core.management import BaseCommand
from django.db import transaction
from documents.mixins import Renderable from documents.mixins import Renderable
from documents.tasks import index_reindex, index_optimize from documents.tasks import index_reindex, index_optimize
@ -18,8 +19,8 @@ class Command(Renderable, BaseCommand):
def handle(self, *args, **options): def handle(self, *args, **options):
self.verbosity = options["verbosity"] self.verbosity = options["verbosity"]
with transaction.atomic():
if options['command'] == 'reindex': if options['command'] == 'reindex':
index_reindex() index_reindex()
elif options['command'] == 'optimize': elif options['command'] == 'optimize':
index_optimize() index_optimize()

View File

@ -3,11 +3,34 @@ from django.utils.text import slugify
from rest_framework import serializers from rest_framework import serializers
from rest_framework.fields import SerializerMethodField from rest_framework.fields import SerializerMethodField
from . import bulk_edit
from .models import Correspondent, Tag, Document, Log, DocumentType, \ from .models import Correspondent, Tag, Document, Log, DocumentType, \
SavedView, SavedViewFilterRule SavedView, SavedViewFilterRule
from .parsers import is_mime_type_supported from .parsers import is_mime_type_supported
# https://www.django-rest-framework.org/api-guide/serializers/#example
class DynamicFieldsModelSerializer(serializers.ModelSerializer):
"""
A ModelSerializer that takes an additional `fields` argument that
controls which fields should be displayed.
"""
def __init__(self, *args, **kwargs):
# Don't pass the 'fields' arg up to the superclass
fields = kwargs.pop('fields', None)
# Instantiate the superclass normally
super(DynamicFieldsModelSerializer, self).__init__(*args, **kwargs)
if fields is not None:
# Drop any fields that are not specified in the `fields` argument.
allowed = set(fields)
existing = set(self.fields)
for field_name in existing - allowed:
self.fields.pop(field_name)
class CorrespondentSerializer(serializers.ModelSerializer): class CorrespondentSerializer(serializers.ModelSerializer):
document_count = serializers.IntegerField(read_only=True) document_count = serializers.IntegerField(read_only=True)
@ -91,7 +114,7 @@ class DocumentTypeField(serializers.PrimaryKeyRelatedField):
return DocumentType.objects.all() return DocumentType.objects.all()
class DocumentSerializer(serializers.ModelSerializer): class DocumentSerializer(DynamicFieldsModelSerializer):
correspondent = CorrespondentField(allow_null=True) correspondent = CorrespondentField(allow_null=True)
tags = TagsField(many=True) tags = TagsField(many=True)
@ -180,6 +203,101 @@ class SavedViewSerializer(serializers.ModelSerializer):
return saved_view return saved_view
class BulkEditSerializer(serializers.Serializer):
documents = serializers.ListField(
child=serializers.IntegerField(),
label="Documents",
write_only=True
)
method = serializers.ChoiceField(
choices=[
"set_correspondent",
"set_document_type",
"add_tag",
"remove_tag",
"delete"
],
label="Method",
write_only=True,
)
parameters = serializers.DictField(allow_empty=True)
def validate_documents(self, documents):
count = Document.objects.filter(id__in=documents).count()
if not count == len(documents):
raise serializers.ValidationError(
"Some documents don't exist or were specified twice.")
return documents
def validate_method(self, method):
if method == "set_correspondent":
return bulk_edit.set_correspondent
elif method == "set_document_type":
return bulk_edit.set_document_type
elif method == "add_tag":
return bulk_edit.add_tag
elif method == "remove_tag":
return bulk_edit.remove_tag
elif method == "delete":
return bulk_edit.delete
else:
raise serializers.ValidationError("Unsupported method.")
def _validate_parameters_tags(self, parameters):
if 'tag' in parameters:
tag_id = parameters['tag']
try:
Tag.objects.get(id=tag_id)
except Tag.DoesNotExist:
raise serializers.ValidationError("Tag does not exist")
else:
raise serializers.ValidationError("tag not specified")
def _validate_parameters_document_type(self, parameters):
if 'document_type' in parameters:
document_type_id = parameters['document_type']
if document_type_id is None:
# None is ok
return
try:
DocumentType.objects.get(id=document_type_id)
except DocumentType.DoesNotExist:
raise serializers.ValidationError(
"Document type does not exist")
else:
raise serializers.ValidationError("document_type not specified")
def _validate_parameters_correspondent(self, parameters):
if 'correspondent' in parameters:
correspondent_id = parameters['correspondent']
if correspondent_id is None:
return
try:
Correspondent.objects.get(id=correspondent_id)
except Correspondent.DoesNotExist:
raise serializers.ValidationError(
"Correspondent does not exist")
else:
raise serializers.ValidationError("correspondent not specified")
def validate(self, attrs):
method = attrs['method']
parameters = attrs['parameters']
if method == bulk_edit.set_correspondent:
self._validate_parameters_correspondent(parameters)
elif method == bulk_edit.set_document_type:
self._validate_parameters_document_type(parameters)
elif method == bulk_edit.add_tag or method == bulk_edit.remove_tag:
self._validate_parameters_tags(parameters)
return attrs
class PostDocumentSerializer(serializers.Serializer): class PostDocumentSerializer(serializers.Serializer):
document = serializers.FileField( document = serializers.FileField(

View File

@ -2,6 +2,7 @@ import logging
import tqdm import tqdm
from django.conf import settings from django.conf import settings
from django.db.models.signals import post_save
from whoosh.writing import AsyncWriter from whoosh.writing import AsyncWriter
from documents import index, sanity_checker from documents import index, sanity_checker
@ -87,3 +88,9 @@ def sanity_check():
raise SanityFailedError(messages) raise SanityFailedError(messages)
else: else:
return "No issues detected." return "No issues detected."
def bulk_rename_files(document_ids):
qs = Document.objects.filter(id__in=document_ids)
for doc in qs:
post_save.send(Document, instance=doc, created=False)

View File

@ -1,3 +1,4 @@
import json
import os import os
import shutil import shutil
import tempfile import tempfile
@ -7,7 +8,7 @@ from django.contrib.auth.models import User
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from whoosh.writing import AsyncWriter from whoosh.writing import AsyncWriter
from documents import index from documents import index, bulk_edit
from documents.models import Document, Correspondent, DocumentType, Tag, SavedView from documents.models import Document, Correspondent, DocumentType, Tag, SavedView
from documents.tests.utils import DirectoriesMixin from documents.tests.utils import DirectoriesMixin
@ -63,6 +64,58 @@ class TestDocumentApi(DirectoriesMixin, APITestCase):
self.assertEqual(len(Document.objects.all()), 0) self.assertEqual(len(Document.objects.all()), 0)
def test_document_fields(self):
c = Correspondent.objects.create(name="c", pk=41)
dt = DocumentType.objects.create(name="dt", pk=63)
tag = Tag.objects.create(name="t", pk=85)
doc = Document.objects.create(title="WOW", content="the content", correspondent=c, document_type=dt, checksum="123", mime_type="application/pdf")
response = self.client.get("/api/documents/", format='json')
self.assertEqual(response.status_code, 200)
results_full = response.data['results']
self.assertTrue("content" in results_full[0])
self.assertTrue("id" in results_full[0])
response = self.client.get("/api/documents/?fields=id", format='json')
self.assertEqual(response.status_code, 200)
results = response.data['results']
self.assertFalse("content" in results[0])
self.assertTrue("id" in results[0])
self.assertEqual(len(results[0]), 1)
response = self.client.get("/api/documents/?fields=content", format='json')
self.assertEqual(response.status_code, 200)
results = response.data['results']
self.assertTrue("content" in results[0])
self.assertFalse("id" in results[0])
self.assertEqual(len(results[0]), 1)
response = self.client.get("/api/documents/?fields=id,content", format='json')
self.assertEqual(response.status_code, 200)
results = response.data['results']
self.assertTrue("content" in results[0])
self.assertTrue("id" in results[0])
self.assertEqual(len(results[0]), 2)
response = self.client.get("/api/documents/?fields=id,conteasdnt", format='json')
self.assertEqual(response.status_code, 200)
results = response.data['results']
self.assertFalse("content" in results[0])
self.assertTrue("id" in results[0])
self.assertEqual(len(results[0]), 1)
response = self.client.get("/api/documents/?fields=", format='json')
self.assertEqual(response.status_code, 200)
results = response.data['results']
self.assertEqual(results_full, results)
response = self.client.get("/api/documents/?fields=dgfhs", format='json')
self.assertEqual(response.status_code, 200)
results = response.data['results']
self.assertEqual(len(results[0]), 0)
def test_document_actions(self): def test_document_actions(self):
_, filename = tempfile.mkstemp(dir=self.dirs.originals_dir) _, filename = tempfile.mkstemp(dir=self.dirs.originals_dir)
@ -614,3 +667,273 @@ class TestDocumentApi(DirectoriesMixin, APITestCase):
v1 = SavedView.objects.get(id=v1.id) v1 = SavedView.objects.get(id=v1.id)
self.assertEqual(v1.filter_rules.count(), 0) self.assertEqual(v1.filter_rules.count(), 0)
class TestBulkEdit(DirectoriesMixin, APITestCase):
def setUp(self):
super(TestBulkEdit, self).setUp()
user = User.objects.create_superuser(username="temp_admin")
self.client.force_login(user=user)
patcher = mock.patch('documents.bulk_edit.async_task')
self.async_task = patcher.start()
self.addCleanup(patcher.stop)
self.c1 = Correspondent.objects.create(name="c1")
self.c2 = Correspondent.objects.create(name="c2")
self.dt1 = DocumentType.objects.create(name="dt1")
self.dt2 = DocumentType.objects.create(name="dt2")
self.t1 = Tag.objects.create(name="t1")
self.t2 = Tag.objects.create(name="t2")
self.doc1 = Document.objects.create(checksum="A", title="A")
self.doc2 = Document.objects.create(checksum="B", title="B", correspondent=self.c1, document_type=self.dt1)
self.doc3 = Document.objects.create(checksum="C", title="C", correspondent=self.c2, document_type=self.dt2)
self.doc4 = Document.objects.create(checksum="D", title="D")
self.doc5 = Document.objects.create(checksum="E", title="E")
self.doc2.tags.add(self.t1)
self.doc3.tags.add(self.t2)
self.doc4.tags.add(self.t1, self.t2)
def test_set_correspondent(self):
self.assertEqual(Document.objects.filter(correspondent=self.c2).count(), 1)
bulk_edit.set_correspondent([self.doc1.id, self.doc2.id, self.doc3.id], self.c2.id)
self.assertEqual(Document.objects.filter(correspondent=self.c2).count(), 3)
self.async_task.assert_called_once()
args, kwargs = self.async_task.call_args
self.assertCountEqual(kwargs['document_ids'], [self.doc1.id, self.doc2.id])
def test_unset_correspondent(self):
self.assertEqual(Document.objects.filter(correspondent=self.c2).count(), 1)
bulk_edit.set_correspondent([self.doc1.id, self.doc2.id, self.doc3.id], None)
self.assertEqual(Document.objects.filter(correspondent=self.c2).count(), 0)
self.async_task.assert_called_once()
args, kwargs = self.async_task.call_args
self.assertCountEqual(kwargs['document_ids'], [self.doc2.id, self.doc3.id])
def test_set_document_type(self):
self.assertEqual(Document.objects.filter(document_type=self.dt2).count(), 1)
bulk_edit.set_document_type([self.doc1.id, self.doc2.id, self.doc3.id], self.dt2.id)
self.assertEqual(Document.objects.filter(document_type=self.dt2).count(), 3)
self.async_task.assert_called_once()
args, kwargs = self.async_task.call_args
self.assertCountEqual(kwargs['document_ids'], [self.doc1.id, self.doc2.id])
def test_unset_document_type(self):
self.assertEqual(Document.objects.filter(document_type=self.dt2).count(), 1)
bulk_edit.set_document_type([self.doc1.id, self.doc2.id, self.doc3.id], None)
self.assertEqual(Document.objects.filter(document_type=self.dt2).count(), 0)
self.async_task.assert_called_once()
args, kwargs = self.async_task.call_args
self.assertCountEqual(kwargs['document_ids'], [self.doc2.id, self.doc3.id])
def test_add_tag(self):
self.assertEqual(Document.objects.filter(tags__id=self.t1.id).count(), 2)
bulk_edit.add_tag([self.doc1.id, self.doc2.id, self.doc3.id, self.doc4.id], self.t1.id)
self.assertEqual(Document.objects.filter(tags__id=self.t1.id).count(), 4)
self.async_task.assert_called_once()
args, kwargs = self.async_task.call_args
self.assertCountEqual(kwargs['document_ids'], [self.doc1.id, self.doc3.id])
def test_remove_tag(self):
self.assertEqual(Document.objects.filter(tags__id=self.t1.id).count(), 2)
bulk_edit.remove_tag([self.doc1.id, self.doc3.id, self.doc4.id], self.t1.id)
self.assertEqual(Document.objects.filter(tags__id=self.t1.id).count(), 1)
self.async_task.assert_called_once()
args, kwargs = self.async_task.call_args
self.assertCountEqual(kwargs['document_ids'], [self.doc4.id])
def test_delete(self):
self.assertEqual(Document.objects.count(), 5)
bulk_edit.delete([self.doc1.id, self.doc2.id])
self.assertEqual(Document.objects.count(), 3)
self.assertCountEqual([doc.id for doc in Document.objects.all()], [self.doc3.id, self.doc4.id, self.doc5.id])
@mock.patch("documents.serialisers.bulk_edit.set_correspondent")
def test_api_set_correspondent(self, m):
m.return_value = "OK"
response = self.client.post("/api/documents/bulk_edit/", json.dumps({
"documents": [self.doc1.id],
"method": "set_correspondent",
"parameters": {"correspondent": self.c1.id}
}), content_type='application/json')
self.assertEqual(response.status_code, 200)
m.assert_called_once()
args, kwargs = m.call_args
self.assertEqual(args[0], [self.doc1.id])
self.assertEqual(kwargs['correspondent'], self.c1.id)
@mock.patch("documents.serialisers.bulk_edit.set_correspondent")
def test_api_unset_correspondent(self, m):
m.return_value = "OK"
response = self.client.post("/api/documents/bulk_edit/", json.dumps({
"documents": [self.doc1.id],
"method": "set_correspondent",
"parameters": {"correspondent": None}
}), content_type='application/json')
self.assertEqual(response.status_code, 200)
m.assert_called_once()
args, kwargs = m.call_args
self.assertEqual(args[0], [self.doc1.id])
self.assertIsNone(kwargs['correspondent'])
@mock.patch("documents.serialisers.bulk_edit.set_document_type")
def test_api_set_type(self, m):
m.return_value = "OK"
response = self.client.post("/api/documents/bulk_edit/", json.dumps({
"documents": [self.doc1.id],
"method": "set_document_type",
"parameters": {"document_type": self.dt1.id}
}), content_type='application/json')
self.assertEqual(response.status_code, 200)
m.assert_called_once()
args, kwargs = m.call_args
self.assertEqual(args[0], [self.doc1.id])
self.assertEqual(kwargs['document_type'], self.dt1.id)
@mock.patch("documents.serialisers.bulk_edit.set_document_type")
def test_api_unset_type(self, m):
m.return_value = "OK"
response = self.client.post("/api/documents/bulk_edit/", json.dumps({
"documents": [self.doc1.id],
"method": "set_document_type",
"parameters": {"document_type": None}
}), content_type='application/json')
self.assertEqual(response.status_code, 200)
m.assert_called_once()
args, kwargs = m.call_args
self.assertEqual(args[0], [self.doc1.id])
self.assertIsNone(kwargs['document_type'])
@mock.patch("documents.serialisers.bulk_edit.add_tag")
def test_api_add_tag(self, m):
m.return_value = "OK"
response = self.client.post("/api/documents/bulk_edit/", json.dumps({
"documents": [self.doc1.id],
"method": "add_tag",
"parameters": {"tag": self.t1.id}
}), content_type='application/json')
self.assertEqual(response.status_code, 200)
m.assert_called_once()
args, kwargs = m.call_args
self.assertEqual(args[0], [self.doc1.id])
self.assertEqual(kwargs['tag'], self.t1.id)
@mock.patch("documents.serialisers.bulk_edit.remove_tag")
def test_api_remove_tag(self, m):
m.return_value = "OK"
response = self.client.post("/api/documents/bulk_edit/", json.dumps({
"documents": [self.doc1.id],
"method": "remove_tag",
"parameters": {"tag": self.t1.id}
}), content_type='application/json')
self.assertEqual(response.status_code, 200)
m.assert_called_once()
args, kwargs = m.call_args
self.assertEqual(args[0], [self.doc1.id])
self.assertEqual(kwargs['tag'], self.t1.id)
@mock.patch("documents.serialisers.bulk_edit.delete")
def test_api_delete(self, m):
m.return_value = "OK"
response = self.client.post("/api/documents/bulk_edit/", json.dumps({
"documents": [self.doc1.id],
"method": "delete",
"parameters": {}
}), content_type='application/json')
self.assertEqual(response.status_code, 200)
m.assert_called_once()
args, kwargs = m.call_args
self.assertEqual(args[0], [self.doc1.id])
self.assertEqual(len(kwargs), 0)
def test_api_invalid_doc(self):
self.assertEqual(Document.objects.count(), 5)
response = self.client.post("/api/documents/bulk_edit/", json.dumps({
"documents": [-235],
"method": "delete",
"parameters": {}
}), content_type='application/json')
self.assertEqual(response.status_code, 400)
self.assertEqual(Document.objects.count(), 5)
def test_api_invalid_method(self):
self.assertEqual(Document.objects.count(), 5)
response = self.client.post("/api/documents/bulk_edit/", json.dumps({
"documents": [self.doc2.id],
"method": "exterminate",
"parameters": {}
}), content_type='application/json')
self.assertEqual(response.status_code, 400)
self.assertEqual(Document.objects.count(), 5)
def test_api_invalid_correspondent(self):
self.assertEqual(self.doc2.correspondent, self.c1)
response = self.client.post("/api/documents/bulk_edit/", json.dumps({
"documents": [self.doc2.id],
"method": "set_correspondent",
"parameters": {'correspondent': 345657}
}), content_type='application/json')
self.assertEqual(response.status_code, 400)
doc2 = Document.objects.get(id=self.doc2.id)
self.assertEqual(doc2.correspondent, self.c1)
def test_api_invalid_document_type(self):
self.assertEqual(self.doc2.document_type, self.dt1)
response = self.client.post("/api/documents/bulk_edit/", json.dumps({
"documents": [self.doc2.id],
"method": "set_document_type",
"parameters": {'document_type': 345657}
}), content_type='application/json')
self.assertEqual(response.status_code, 400)
doc2 = Document.objects.get(id=self.doc2.id)
self.assertEqual(doc2.document_type, self.dt1)
def test_api_add_invalid_tag(self):
self.assertEqual(list(self.doc2.tags.all()), [self.t1])
response = self.client.post("/api/documents/bulk_edit/", json.dumps({
"documents": [self.doc2.id],
"method": "add_tag",
"parameters": {'tag': 345657}
}), content_type='application/json')
self.assertEqual(response.status_code, 400)
self.assertEqual(list(self.doc2.tags.all()), [self.t1])
def test_api_delete_invalid_tag(self):
self.assertEqual(list(self.doc2.tags.all()), [self.t1])
response = self.client.post("/api/documents/bulk_edit/", json.dumps({
"documents": [self.doc2.id],
"method": "remove_tag",
"parameters": {'tag': 345657}
}), content_type='application/json')
self.assertEqual(response.status_code, 400)
self.assertEqual(list(self.doc2.tags.all()), [self.t1])
class TestApiAuth(APITestCase):
def test_auth_required(self):
d = Document.objects.create(title="Test")
self.assertEqual(self.client.get("/api/documents/").status_code, 401)
self.assertEqual(self.client.get(f"/api/documents/{d.id}/").status_code, 401)
self.assertEqual(self.client.get(f"/api/documents/{d.id}/download/").status_code, 401)
self.assertEqual(self.client.get(f"/api/documents/{d.id}/preview/").status_code, 401)
self.assertEqual(self.client.get(f"/api/documents/{d.id}/thumb/").status_code, 401)
self.assertEqual(self.client.get("/api/tags/").status_code, 401)
self.assertEqual(self.client.get("/api/correspondents/").status_code, 401)
self.assertEqual(self.client.get("/api/document_types/").status_code, 401)
self.assertEqual(self.client.get("/api/logs/").status_code, 401)
self.assertEqual(self.client.get("/api/saved_views/").status_code, 401)
self.assertEqual(self.client.get("/api/search/").status_code, 401)
self.assertEqual(self.client.get("/api/search/auto_complete/").status_code, 401)
self.assertEqual(self.client.get("/api/documents/bulk_edit/").status_code, 401)

View File

@ -1,9 +1,12 @@
import unittest import unittest
from unittest import mock
from django.core.checks import Error
from django.test import TestCase from django.test import TestCase
from .factories import DocumentFactory from .factories import DocumentFactory
from ..checks import changed_password_check from .. import document_consumer_declaration
from ..checks import changed_password_check, parser_check
from ..models import Document from ..models import Document
@ -15,3 +18,13 @@ class ChecksTestCase(TestCase):
def test_changed_password_check_no_encryption(self): def test_changed_password_check_no_encryption(self):
DocumentFactory.create(storage_type=Document.STORAGE_TYPE_UNENCRYPTED) DocumentFactory.create(storage_type=Document.STORAGE_TYPE_UNENCRYPTED)
self.assertEqual(changed_password_check(None), []) self.assertEqual(changed_password_check(None), [])
def test_parser_check(self):
self.assertEqual(parser_check(None), [])
with mock.patch('documents.checks.document_consumer_declaration.send') as m:
m.return_value = []
self.assertEqual(parser_check(None), [Error("No parsers found. This is a bug. The consumer won't be "
"able to consume any documents without parsers.")])

View File

@ -47,7 +47,8 @@ from .serialisers import (
TagSerializer, TagSerializer,
DocumentTypeSerializer, DocumentTypeSerializer,
PostDocumentSerializer, PostDocumentSerializer,
SavedViewSerializer SavedViewSerializer,
BulkEditSerializer
) )
@ -110,6 +111,10 @@ class DocumentTypeViewSet(ModelViewSet):
ordering_fields = ("name", "matching_algorithm", "match", "document_count") ordering_fields = ("name", "matching_algorithm", "match", "document_count")
class BulkEditForm(object):
pass
class DocumentViewSet(RetrieveModelMixin, class DocumentViewSet(RetrieveModelMixin,
UpdateModelMixin, UpdateModelMixin,
DestroyModelMixin, DestroyModelMixin,
@ -133,6 +138,17 @@ class DocumentViewSet(RetrieveModelMixin,
"added", "added",
"archive_serial_number") "archive_serial_number")
def get_serializer(self, *args, **kwargs):
fields_param = self.request.query_params.get('fields', None)
if fields_param:
fields = fields_param.split(",")
else:
fields = None
serializer_class = self.get_serializer_class()
kwargs.setdefault('context', self.get_serializer_context())
kwargs.setdefault('fields', fields)
return serializer_class(*args, **kwargs)
def update(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):
response = super(DocumentViewSet, self).update( response = super(DocumentViewSet, self).update(
request, *args, **kwargs) request, *args, **kwargs)
@ -274,6 +290,39 @@ class SavedViewViewSet(ModelViewSet):
serializer.save(user=self.request.user) serializer.save(user=self.request.user)
class BulkEditView(APIView):
permission_classes = (IsAuthenticated,)
serializer_class = BulkEditSerializer
parser_classes = (parsers.JSONParser,)
def get_serializer_context(self):
return {
'request': self.request,
'format': self.format_kwarg,
'view': self
}
def get_serializer(self, *args, **kwargs):
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
method = serializer.validated_data.get("method")
parameters = serializer.validated_data.get("parameters")
documents = serializer.validated_data.get("documents")
try:
# TODO: parameter validation
result = method(documents, **parameters)
return Response({"result": result})
except Exception as e:
return HttpResponseBadRequest(str(e))
class PostDocumentView(APIView): class PostDocumentView(APIView):
permission_classes = (IsAuthenticated,) permission_classes = (IsAuthenticated,)

View File

@ -18,7 +18,8 @@ from documents.views import (
SearchAutoCompleteView, SearchAutoCompleteView,
StatisticsView, StatisticsView,
PostDocumentView, PostDocumentView,
SavedViewViewSet SavedViewViewSet,
BulkEditView
) )
from paperless.views import FaviconView from paperless.views import FaviconView
@ -52,6 +53,10 @@ urlpatterns = [
re_path(r"^documents/post_document/", PostDocumentView.as_view(), re_path(r"^documents/post_document/", PostDocumentView.as_view(),
name="post_document"), name="post_document"),
re_path(r"^documents/bulk_edit/", BulkEditView.as_view(),
name="bulk_edit"),
path('token/', views.obtain_auth_token) path('token/', views.obtain_auth_token)
] + api_router.urls)), ] + api_router.urls)),

View File

@ -1 +1 @@
__version__ = (0, 9, 8) __version__ = (0, 9, 9)