mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-04-02 13:45:10 -05:00
Merge branch 'dev' into travis-multiarch-builds
This commit is contained in:
commit
85cf931d0a
11
README.md
11
README.md
@ -35,7 +35,10 @@ Here's what you get:
|
||||
* Includes a dashboard that shows basic statistics and has document upload.
|
||||
* Filtering by tags, correspondents, types, and more.
|
||||
* 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.
|
||||
* 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.
|
||||
@ -51,6 +54,8 @@ For a complete list of changes from paperless, check out the [changelog](https:/
|
||||
|
||||
# Roadmap for 1.0
|
||||
|
||||
- **Bulk editing**. Add/remove metadata from multiple documents at once.
|
||||
|
||||
- Make the front end nice (except mobile).
|
||||
- Test coverage at 90%.
|
||||
- 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.
|
||||
|
||||
- **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:
|
||||
- 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
|
||||
@ -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.
|
||||
- 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.
|
||||
- **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.
|
||||
|
||||
|
@ -15,7 +15,7 @@ services:
|
||||
POSTGRES_PASSWORD: paperless
|
||||
|
||||
webserver:
|
||||
image: jonaswinkler/paperless-ng:0.9.8
|
||||
image: jonaswinkler/paperless-ng:0.9.9
|
||||
restart: always
|
||||
depends_on:
|
||||
- db
|
||||
|
@ -5,7 +5,7 @@ services:
|
||||
restart: always
|
||||
|
||||
webserver:
|
||||
image: jonaswinkler/paperless-ng:0.9.8
|
||||
image: jonaswinkler/paperless-ng:0.9.9
|
||||
restart: always
|
||||
depends_on:
|
||||
- broker
|
||||
|
@ -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
|
||||
##################
|
||||
|
||||
|
@ -25,6 +25,8 @@ that works right for you based on recommendations from other Paperless users.
|
||||
+---------+----------------+-----+-----+-----+----------------+
|
||||
| Fujitsu | `ix500`_ | yes | | yes | `eonist`_ |
|
||||
+---------+----------------+-----+-----+-----+----------------+
|
||||
| Epson | `WF-7710DWF`_ | yes | | yes | `Skylinar`_ |
|
||||
+---------+----------------+-----+-----+-----+----------------+
|
||||
| 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-J5910DW: https://www.brother.co.uk/printers/inkjet-printers/mfcj5910dw
|
||||
.. _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/
|
||||
|
||||
.. _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
|
||||
.. _eonist: https://github.com/eonist
|
||||
.. _REOLDEV: https://github.com/REOLDEV
|
||||
.. _Skylinar: https://github.com/Skylinar
|
||||
.. _jonaswinkler: https://github.com/jonaswinkler
|
||||
|
@ -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
|
||||
run the above mentioned components yourself.
|
||||
|
||||
.. _setup-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
|
||||
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.
|
||||
2. Tell paperless to use PostgreSQL:
|
||||
|
||||
|
@ -39,7 +39,7 @@ Operation not permitted
|
||||
|
||||
You might see errors such as:
|
||||
|
||||
.. code::
|
||||
.. code:: shell-session
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
@ -55,6 +55,7 @@ import { FilterPipe } from './pipes/filter.pipe';
|
||||
import { DocumentTitlePipe } from './pipes/document-title.pipe';
|
||||
import { MetadataCollapseComponent } from './components/document-detail/metadata-collapse/metadata-collapse.component';
|
||||
import { NgSelectModule } from '@ng-select/ng-select';
|
||||
import { SelectDialogComponent } from './components/common/select-dialog/select-dialog.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
@ -100,7 +101,8 @@ import { NgSelectModule } from '@ng-select/ng-select';
|
||||
FileSizePipe,
|
||||
FilterPipe,
|
||||
DocumentTitlePipe,
|
||||
MetadataCollapseComponent
|
||||
MetadataCollapseComponent,
|
||||
SelectDialogComponent
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
|
@ -1,7 +1,7 @@
|
||||
<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="#">
|
||||
<img src="assets/logo-dark-notext.svg" height="18px" class="mr-2">
|
||||
Paperless-ng
|
||||
<ng-container i18n="app title">Paperless-ng</ng-container>
|
||||
</span>
|
||||
<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"
|
||||
@ -9,8 +9,8 @@
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<form (ngSubmit)="search()" class="w-100 m-1">
|
||||
<input class="form-control form-control-dark" type="text" placeholder="Search" aria-label="Search"
|
||||
[formControl]="searchField" [ngbTypeahead]="searchAutoComplete" (selectItem)="itemSelected($event)">
|
||||
<input class="form-control form-control-dark" type="text" placeholder="Search for documents" aria-label="Search"
|
||||
[formControl]="searchField" [ngbTypeahead]="searchAutoComplete" (selectItem)="itemSelected($event)" i18n-placeholder>
|
||||
</form>
|
||||
</nav>
|
||||
|
||||
@ -28,136 +28,122 @@
|
||||
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#house"/>
|
||||
</svg>
|
||||
Dashboard
|
||||
</svg> <ng-container i18n>Dashboard</ng-container>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" routerLink="documents" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" (click)="closeMenu()">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#files"/>
|
||||
</svg>
|
||||
Documents
|
||||
</svg> <ng-container i18n>Documents</ng-container>
|
||||
</a>
|
||||
</li>
|
||||
</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'>
|
||||
<span>Saved views</span>
|
||||
<ng-container i18n>Saved views</ng-container>
|
||||
</h6>
|
||||
<ul class="nav flex-column mb-2">
|
||||
<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()">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#funnel"/>
|
||||
</svg>
|
||||
{{view.name}}
|
||||
</svg> {{view.name}}
|
||||
</a>
|
||||
</li>
|
||||
</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'>
|
||||
<span>Open documents</span>
|
||||
<ng-container i18n>Open documents</ng-container>
|
||||
</h6>
|
||||
<ul class="nav flex-column mb-2">
|
||||
<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()">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#file-text"/>
|
||||
</svg>
|
||||
{{d.title | documentTitle}}
|
||||
</svg> {{d.title | documentTitle}}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item w-100" *ngIf="openDocuments.length > 1">
|
||||
<a class="nav-link text-truncate" [routerLink]="" (click)="closeAll()">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#x"/>
|
||||
</svg>
|
||||
Close all
|
||||
</svg> <ng-container i18n>Close all</ng-container>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<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>
|
||||
<ul class="nav flex-column mb-2">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#person"/>
|
||||
</svg>
|
||||
Correspondents
|
||||
</svg> <ng-container i18n>Correspondents</ng-container>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" routerLink="tags" routerLinkActive="active" (click)="closeMenu()">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#tags"/>
|
||||
</svg>
|
||||
Tags
|
||||
</svg> <ng-container i18n>Tags</ng-container>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" routerLink="documenttypes" routerLinkActive="active" (click)="closeMenu()">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#hash"/>
|
||||
</svg>
|
||||
Document types
|
||||
</svg> <ng-container i18n>Document types</ng-container>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#text-left"/>
|
||||
</svg>
|
||||
Logs
|
||||
</svg> <ng-container i18n>Logs</ng-container>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#gear"/>
|
||||
</svg>
|
||||
Settings
|
||||
</svg> <ng-container i18n>Settings</ng-container>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="admin/">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#toggles"/>
|
||||
</svg>
|
||||
Admin
|
||||
</svg> <ng-container i18n>Admin</ng-container>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<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>
|
||||
<ul class="nav flex-column mb-2">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" target="_blank" rel="noopener noreferrer" href="https://paperless-ng.readthedocs.io/en/latest/">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#question-circle"/>
|
||||
</svg>
|
||||
Documentation
|
||||
</svg> <ng-container i18n>Documentation</ng-container>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" target="_blank" rel="noopener noreferrer" href="https://github.com/jonaswinkler/paperless-ng">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#link"/>
|
||||
</svg>
|
||||
GitHub
|
||||
</svg> <ng-container i18n>GitHub</ng-container>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="accounts/logout/">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#door-open"/>
|
||||
</svg>
|
||||
Logout
|
||||
</svg> <ng-container i18n>Logout</ng-container>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
@ -10,5 +10,8 @@
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-dark" (click)="cancelClicked()">Cancel</button>
|
||||
<button type="button" class="btn" [class]="btnClass" (click)="confirmClicked.emit()">{{btnCaption}}</button>
|
||||
</div>
|
||||
<button type="button" class="btn" [class]="btnClass" (click)="confirmClicked.emit()" [disabled]="!confirmButtonEnabled">
|
||||
{{btnCaption}}
|
||||
<span *ngIf="!confirmButtonEnabled"> ({{seconds}})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
@ -28,6 +28,21 @@ export class ConfirmDialogComponent implements OnInit {
|
||||
@Input()
|
||||
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 {
|
||||
}
|
||||
|
||||
|
@ -5,6 +5,7 @@
|
||||
[disabled]="disabled"
|
||||
[style.color]="textColor"
|
||||
[style.background]="backgroundColor"
|
||||
[clearable]="allowNull"
|
||||
(change)="onChange(value)"
|
||||
(blur)="onTouched()">
|
||||
<ng-option *ngFor="let i of items" [value]="i.id">{{i.name}}</ng-option>
|
||||
|
@ -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">×</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>
|
@ -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();
|
||||
});
|
||||
});
|
@ -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()
|
||||
}
|
||||
}
|
@ -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">
|
||||
</app-page-header>
|
||||
|
||||
|
@ -24,15 +24,15 @@ export class DashboardComponent implements OnInit {
|
||||
} else if (tagUsername && tagUsername.content) {
|
||||
return tagUsername.content
|
||||
} else {
|
||||
return null
|
||||
return "null"
|
||||
}
|
||||
}
|
||||
|
||||
get subtitle() {
|
||||
if (this.displayName) {
|
||||
return `Hello ${this.displayName}, welcome to Paperless-ng!`
|
||||
return $localize`Hello ${this.displayName}, welcome to Paperless-ng!`
|
||||
} else {
|
||||
return `Welcome to Paperless-ng!`
|
||||
return $localize`Welcome to Paperless-ng!`
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,13 +1,13 @@
|
||||
<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">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Created</th>
|
||||
<th scope="col">Title</th>
|
||||
<th i18n>Created</th>
|
||||
<th scope="col" i18n>Title</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
@ -23,7 +23,7 @@ export class SavedViewWidgetComponent implements OnInit {
|
||||
documents: PaperlessDocument[] = []
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
<app-widget-frame title="Statistics">
|
||||
<app-widget-frame title="Statistics" i18n-title>
|
||||
<ng-container content>
|
||||
<p class="card-text">Documents in inbox: {{statistics.documents_inbox}}</p>
|
||||
<p class="card-text">Total documents: {{statistics.documents_total}}</p>
|
||||
<p class="card-text" i18n>Documents in inbox: {{statistics.documents_inbox}}</p>
|
||||
<p class="card-text" i18n>Total documents: {{statistics.documents_total}}</p>
|
||||
</ng-container>
|
||||
</app-widget-frame>
|
@ -1,16 +1,16 @@
|
||||
<app-widget-frame title="Upload new documents">
|
||||
<app-widget-frame title="Upload new documents" i18n-title>
|
||||
|
||||
<div content>
|
||||
<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"
|
||||
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>
|
||||
</form>
|
||||
<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>
|
||||
</div>
|
||||
|
@ -60,7 +60,7 @@ export class UploadFileWidgetComponent implements OnInit {
|
||||
} else if (event.type == HttpEventType.Response) {
|
||||
this.uploadStatus.splice(this.uploadStatus.indexOf(uploadStatusObject), 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 => {
|
||||
@ -68,11 +68,11 @@ export class UploadFileWidgetComponent implements OnInit {
|
||||
this.completedFiles += 1
|
||||
switch (error.status) {
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -1,16 +1,16 @@
|
||||
<app-widget-frame title="First steps">
|
||||
<app-widget-frame title="First steps" i18n-title>
|
||||
|
||||
<ng-container content>
|
||||
<img src="assets/save-filter.png" class="float-right">
|
||||
<p>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>Paperless is running! :)</p>
|
||||
<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>
|
||||
<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>
|
||||
<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>You can configure paperless to read your mails and add documents from attached files.</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 i18n>You can configure paperless to read your mails and add documents from attached files.</li>
|
||||
</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>
|
||||
|
||||
</app-widget-frame>
|
@ -1,19 +1,18 @@
|
||||
<app-page-header [(title)]="title">
|
||||
<div class="input-group input-group-sm mr-5" *ngIf="getContentType() == 'application/pdf'">
|
||||
<div class="input-group-prepend">
|
||||
<div class="input-group-text">Page </div>
|
||||
<div class="input-group-text" i18n>Page</div>
|
||||
</div>
|
||||
<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-text">of {{previewNumPages}}</div>
|
||||
<div class="input-group-text" i18n>of {{previewNumPages}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-sm btn-outline-danger mr-2" (click)="delete()">
|
||||
<svg class="buttonicon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#trash" />
|
||||
</svg>
|
||||
<span class="d-none d-lg-inline"> Delete</span>
|
||||
</svg> <span class="d-none d-lg-inline" i18n>Delete</span>
|
||||
</button>
|
||||
|
||||
<div class="btn-group mr-2">
|
||||
@ -21,14 +20,13 @@
|
||||
<a [href]="downloadUrl" class="btn btn-sm btn-outline-primary">
|
||||
<svg class="buttonicon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#download" />
|
||||
</svg>
|
||||
<span class="d-none d-lg-inline"> Download</span>
|
||||
</svg> <span class="d-none d-lg-inline" i18n>Download</span>
|
||||
</a>
|
||||
|
||||
<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>
|
||||
<div class="dropdown-menu shadow" ngbDropdownMenu>
|
||||
<a ngbDropdownItem [href]="downloadOriginalUrl">Download original</a>
|
||||
<a ngbDropdownItem [href]="downloadOriginalUrl" i18n>Download original</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -37,15 +35,13 @@
|
||||
<button type="button" class="btn btn-sm btn-outline-primary mr-2" (click)="moreLike()">
|
||||
<svg class="buttonicon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#three-dots" />
|
||||
</svg>
|
||||
<span class="d-none d-lg-inline"> More like this</span>
|
||||
</svg> <span class="d-none d-lg-inline" i18n>More like this</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="close()">
|
||||
<svg class="buttonicon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#x" />
|
||||
</svg>
|
||||
<span class="d-none d-lg-inline"> Close</span>
|
||||
</svg> <span class="d-none d-lg-inline" i18n>Close</span>
|
||||
</button>
|
||||
</app-page-header>
|
||||
|
||||
@ -57,27 +53,27 @@
|
||||
|
||||
<ul ngbNav #nav="ngbNav" class="nav-tabs">
|
||||
<li [ngbNavItem]="1">
|
||||
<a ngbNavLink>Details</a>
|
||||
<a ngbNavLink i18n>Details</a>
|
||||
<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">
|
||||
<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"
|
||||
formControlName='archive_serial_number'>
|
||||
</div>
|
||||
<app-input-date-time titleDate="Date created" formControlName="created"></app-input-date-time>
|
||||
<app-input-select [items]="correspondents" title="Correspondent" formControlName="correspondent"
|
||||
<app-input-date-time i18n-titleDate titleDate="Date created" formControlName="created"></app-input-date-time>
|
||||
<app-input-select [items]="correspondents" i18n-title title="Correspondent" formControlName="correspondent" [allowNull]="true"
|
||||
(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>
|
||||
<app-input-tags formControlName="tags" title="Tags"></app-input-tags>
|
||||
<app-input-tags formControlName="tags" i18n-title title="Tags"></app-input-tags>
|
||||
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="2">
|
||||
<a ngbNavLink>Content</a>
|
||||
<a ngbNavLink i18n>Content</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="form-group">
|
||||
<textarea class="form-control" id="content" rows="20" formControlName='content'></textarea>
|
||||
@ -86,48 +82,48 @@
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="3">
|
||||
<a ngbNavLink>Metadata</a>
|
||||
<a ngbNavLink i18n>Metadata</a>
|
||||
<ng-template ngbNavContent>
|
||||
|
||||
<table class="table table-borderless">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Date modified</td>
|
||||
<td i18n>Date modified</td>
|
||||
<td>{{document.modified | date:'medium'}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Date added</td>
|
||||
<td i18n>Date added</td>
|
||||
<td>{{document.added | date:'medium'}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Media filename</td>
|
||||
<td i18n>Media filename</td>
|
||||
<td>{{metadata?.media_filename}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Original MD5 Checksum</td>
|
||||
<td i18n>Original MD5 checksum</td>
|
||||
<td>{{metadata?.original_checksum}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Original file size</td>
|
||||
<td i18n>Original file size</td>
|
||||
<td>{{metadata?.original_size | fileSize}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Original mime type</td>
|
||||
<td i18n>Original mime type</td>
|
||||
<td>{{metadata?.original_mime_type}}</td>
|
||||
</tr>
|
||||
<tr *ngIf="metadata?.has_archive_version">
|
||||
<td>Archive MD5 Checksum</td>
|
||||
<td i18n>Archive MD5 checksum</td>
|
||||
<td>{{metadata?.archive_checksum}}</td>
|
||||
</tr>
|
||||
<tr *ngIf="metadata?.has_archive_version">
|
||||
<td>Archive file size</td>
|
||||
<td i18n>Archive file size</td>
|
||||
<td>{{metadata?.archive_size | fileSize}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<app-metadata-collapse 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="Original document metadata" [metadata]="metadata.original_metadata" *ngIf="metadata?.original_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>
|
||||
</li>
|
||||
@ -135,10 +131,9 @@
|
||||
|
||||
<div [ngbNavOutlet]="nav" class="mt-2"></div>
|
||||
|
||||
<button type="button" class="btn btn-outline-secondary" (click)="discard()">Discard</button>
|
||||
<button type="button" class="btn btn-outline-primary" (click)="saveEditNext()" *ngIf="hasNext()">Save & edit
|
||||
next</button>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
<button type="button" class="btn btn-outline-secondary" (click)="discard()" i18n>Discard</button>
|
||||
<button type="button" class="btn btn-outline-primary" (click)="saveEditNext()" *ngIf="hasNext()" i18n>Save & edit next</button>
|
||||
<button type="submit" class="btn btn-primary" i18n>Save</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
@ -158,11 +158,11 @@ export class DocumentDetailComponent implements OnInit {
|
||||
|
||||
delete() {
|
||||
let modal = this.modalService.open(ConfirmDialogComponent, {backdrop: 'static'})
|
||||
modal.componentInstance.title = "Confirm delete"
|
||||
modal.componentInstance.messageBold = `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.title = $localize`Confirm delete`
|
||||
modal.componentInstance.messageBold = $localize`Do you really want to delete document '${this.document.title}'?`
|
||||
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.btnCaption = "Delete document"
|
||||
modal.componentInstance.btnCaption = $localize`Delete document`
|
||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||
this.documentsService.delete(this.document).subscribe(() => {
|
||||
modal.close()
|
||||
|
@ -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="col-md-2 d-none d-lg-block">
|
||||
<img [src]="getThumbUrl()" class="card-img doc-img border-right">
|
||||
<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" (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 class="col">
|
||||
<div class="card-body">
|
||||
@ -9,11 +17,11 @@
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title">
|
||||
<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-container>
|
||||
{{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 class="card-title" *ngIf="document.archive_serial_number">#{{document.archive_serial_number}}</h5>
|
||||
</div>
|
||||
@ -28,37 +36,33 @@
|
||||
<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">
|
||||
<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>
|
||||
More like this
|
||||
</svg> <ng-container i18n>More like this</ng-container>
|
||||
</a>
|
||||
<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">
|
||||
<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>
|
||||
Edit
|
||||
</svg> <ng-container i18n>Edit</ng-container>
|
||||
</a>
|
||||
<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">
|
||||
<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"/>
|
||||
</svg>
|
||||
View
|
||||
</svg> <ng-container i18n>View</ng-container>
|
||||
</a>
|
||||
<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">
|
||||
<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"/>
|
||||
</svg>
|
||||
Download
|
||||
</svg> <ng-container i18n>Download</ng-container>
|
||||
</a>
|
||||
|
||||
</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>
|
||||
|
||||
<small class="text-muted">Created: {{document.created | date}}</small>
|
||||
<small class="text-muted" i18n>Created: {{document.created | date}}</small>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
@ -1,5 +1,6 @@
|
||||
@import "/src/theme";
|
||||
|
||||
.result-content {
|
||||
color: darkgray;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@ -8,11 +9,31 @@
|
||||
object-position: top;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
|
||||
.search-score-bar {
|
||||
width: 100px;
|
||||
height: 5px;
|
||||
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;
|
||||
}
|
@ -12,6 +12,25 @@ export class DocumentCardLargeComponent implements OnInit {
|
||||
|
||||
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()
|
||||
moreLikeThis: boolean = false
|
||||
|
||||
|
@ -1,10 +1,18 @@
|
||||
<div class="col p-2 h-100">
|
||||
<div class="card h-100 shadow-sm">
|
||||
<div class="border-bottom">
|
||||
<img class="card-img doc-img" [src]="getThumbUrl()">
|
||||
<div class="col p-2 h-100 document-card">
|
||||
<div class="card h-100 shadow-sm" [class.card-selected]="selected">
|
||||
<div class="border-bottom" [class.doc-img-background-selected]="selected">
|
||||
<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 *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 *ngIf="moreTags">
|
||||
<span class="badge badge-secondary">+ {{moreTags}}</span>
|
||||
@ -15,7 +23,7 @@
|
||||
<div class="card-body p-2">
|
||||
<p class="card-text">
|
||||
<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>
|
||||
{{document.title | documentTitle}}
|
||||
</p>
|
||||
@ -24,18 +32,18 @@
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mx-n2">
|
||||
<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">
|
||||
<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>
|
||||
</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">
|
||||
<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"/>
|
||||
</svg>
|
||||
</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">
|
||||
<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"/>
|
||||
|
@ -1,5 +1,24 @@
|
||||
@import "/src/theme";
|
||||
|
||||
.doc-img {
|
||||
object-fit: cover;
|
||||
object-position: top;
|
||||
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;
|
||||
}
|
@ -12,6 +12,21 @@ export class DocumentCardSmallComponent implements OnInit {
|
||||
|
||||
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()
|
||||
document: PaperlessDocument
|
||||
|
||||
|
@ -1,4 +1,28 @@
|
||||
<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"
|
||||
(ngModelChange)="saveDisplayMode()">
|
||||
<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 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">
|
||||
<button *ngFor="let f of getSortFields()" ngbDropdownItem (click)="list.sortField = f.field"
|
||||
[class.active]="list.sortField == f.field">{{f.name}}</button>
|
||||
@ -46,15 +70,15 @@
|
||||
<div class="btn-group ml-2">
|
||||
|
||||
<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>
|
||||
<ng-container *ngIf="!list.savedViewId">
|
||||
<button ngbDropdownItem *ngFor="let view of savedViewService.allViews" (click)="loadViewConfig(view)">{{view.name}}</button>
|
||||
<div class="dropdown-divider" *ngIf="savedViewService.allViews.length > 0"></div>
|
||||
</ng-container>
|
||||
|
||||
<button ngbDropdownItem (click)="saveViewConfig()" *ngIf="list.savedViewId">Save "{{list.savedViewTitle}}"</button>
|
||||
<button ngbDropdownItem (click)="saveViewConfigAs()">Save as...</button>
|
||||
<button ngbDropdownItem (click)="saveViewConfig()" *ngIf="list.savedViewId" i18n>Save "{{list.savedViewTitle}}"</button>
|
||||
<button ngbDropdownItem (click)="saveViewConfigAs()" i18n>Save as...</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -67,27 +91,34 @@
|
||||
</div>
|
||||
|
||||
<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"
|
||||
[rotate]="true" (pageChange)="list.reload()" aria-label="Default pagination"></ngb-pagination>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<table class="table table-sm border shadow-sm" *ngIf="displayMode == 'details'">
|
||||
<thead>
|
||||
<th class="d-none d-lg-table-cell">ASN</th>
|
||||
<th class="d-none d-md-table-cell">Correspondent</th>
|
||||
<th>Title</th>
|
||||
<th class="d-none d-xl-table-cell">Document type</th>
|
||||
<th>Created</th>
|
||||
<th class="d-none d-xl-table-cell">Added</th>
|
||||
<th></th>
|
||||
<th class="d-none d-lg-table-cell" i18n>ASN</th>
|
||||
<th class="d-none d-md-table-cell" i18n>Correspondent</th>
|
||||
<th i18n>Title</th>
|
||||
<th class="d-none d-xl-table-cell" i18n>Document type</th>
|
||||
<th i18n>Created</th>
|
||||
<th class="d-none d-xl-table-cell" i18n>Added</th>
|
||||
</thead>
|
||||
<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">
|
||||
{{d.archive_serial_number}}
|
||||
</td>
|
||||
@ -117,5 +148,5 @@
|
||||
|
||||
|
||||
<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>
|
||||
|
@ -1,3 +1,9 @@
|
||||
@import "/src/theme";
|
||||
|
||||
.table-row-selected {
|
||||
background-color: $primaryFaded;
|
||||
}
|
||||
|
||||
$paperless-card-breakpoints: (
|
||||
0: 2, // xs
|
||||
768px: 3, //md
|
||||
|
@ -1,14 +1,22 @@
|
||||
import { Component, OnInit, ViewChild } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
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 { 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 { Toast, ToastService } from 'src/app/services/toast.service';
|
||||
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 { OpenDocumentsService } from 'src/app/services/open-documents.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-document-list',
|
||||
@ -23,7 +31,12 @@ export class DocumentListComponent implements OnInit {
|
||||
public route: ActivatedRoute,
|
||||
private router: Router,
|
||||
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")
|
||||
private filterEditor: FilterEditorComponent
|
||||
@ -35,7 +48,7 @@ export class DocumentListComponent implements OnInit {
|
||||
}
|
||||
|
||||
getTitle() {
|
||||
return this.list.savedViewTitle || "Documents"
|
||||
return this.list.savedViewTitle || $localize`Documents`
|
||||
}
|
||||
|
||||
getSortFields() {
|
||||
@ -77,7 +90,7 @@ export class DocumentListComponent implements OnInit {
|
||||
|
||||
saveViewConfig() {
|
||||
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(() => {
|
||||
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)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -1,17 +1,17 @@
|
||||
<form [formGroup]="saveViewConfigForm" class="needs-validation" novalidate (ngSubmit)="save()">
|
||||
<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()">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<app-input-text title="Name" formControlName="name"></app-input-text>
|
||||
<app-input-check title="Show in side bar" formControlName="showInSideBar"></app-input-check>
|
||||
<app-input-check title="Show on dashboard" formControlName="showOnDashboard"></app-input-check>
|
||||
<app-input-text i18n-title title="Name" formControlName="name"></app-input-text>
|
||||
<app-input-check i18n-title title="Show in side bar" formControlName="showInSideBar"></app-input-check>
|
||||
<app-input-check i18n-title title="Show on dashboard" formControlName="showOnDashboard"></app-input-check>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-dark" (click)="cancel()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
<button type="button" class="btn btn-outline-dark" (click)="cancel()" i18n>Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" i18n>Save</button>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -7,13 +7,13 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
||||
<app-input-text title="Name" formControlName="name"></app-input-text>
|
||||
<app-input-select 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-check title="Case insensitive" formControlName="is_insensitive" hint="Auto matching ignores this option."></app-input-check>
|
||||
<app-input-text i18n-title title="Name" formControlName="name"></app-input-text>
|
||||
<app-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></app-input-select>
|
||||
<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 i18n-title title="Case insensitive" formControlName="is_insensitive" hint="Auto matching ignores this option."></app-input-check>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-dark" (click)="cancel()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
<button type="button" class="btn btn-outline-dark" (click)="cancel()" i18n>Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" i18n>Save</button>
|
||||
</div>
|
||||
</form>
|
@ -1,7 +1,5 @@
|
||||
<app-page-header title="Correspondents">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="openCreateDialog()">
|
||||
Create
|
||||
</button>
|
||||
<app-page-header title="Correspondents" i18n-title>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="openCreateDialog()" i18n>Create</button>
|
||||
</app-page-header>
|
||||
|
||||
<div class="row m-0 justify-content-end">
|
||||
@ -11,11 +9,11 @@
|
||||
<table class="table table-striped border shadow">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" sortable="name" (sort)="onSort($event)">Name</th>
|
||||
<th scope="col" sortable="matching_algorithm" (sort)="onSort($event)">Matching</th>
|
||||
<th scope="col" sortable="document_count" (sort)="onSort($event)">Document count</th>
|
||||
<th scope="col" sortable="last_correspondence" (sort)="onSort($event)">Last correspondence</th>
|
||||
<th scope="col">Actions</th>
|
||||
<th scope="col" sortable="name" (sort)="onSort($event)" i18n>Name</th>
|
||||
<th scope="col" sortable="matching_algorithm" (sort)="onSort($event)" i18n>Matching</th>
|
||||
<th scope="col" sortable="document_count" (sort)="onSort($event)" i18n>Document count</th>
|
||||
<th scope="col" sortable="last_correspondence" (sort)="onSort($event)" i18n>Last correspondence</th>
|
||||
<th scope="col" i18n>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -29,21 +27,18 @@
|
||||
<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">
|
||||
<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>
|
||||
Documents
|
||||
</svg> <ng-container i18n>Documents</ng-container>
|
||||
</button>
|
||||
<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">
|
||||
<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>
|
||||
Edit
|
||||
</svg> <ng-container i18n>Edit</ng-container>
|
||||
</button>
|
||||
<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">
|
||||
<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"/>
|
||||
</svg>
|
||||
Delete
|
||||
</svg> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
|
@ -7,14 +7,14 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
||||
<app-input-text title="Name" formControlName="name"></app-input-text>
|
||||
<app-input-select 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-check title="Case insensitive" formControlName="is_insensitive" hint="Auto matching ignores this option."></app-input-check>
|
||||
<app-input-text i18n-title title="Name" formControlName="name"></app-input-text>
|
||||
<app-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></app-input-select>
|
||||
<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 i18n-title title="Case insensitive" formControlName="is_insensitive" hint="Auto matching ignores this option."></app-input-check>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-dark" (click)="cancel()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
<button type="button" class="btn btn-outline-dark" (click)="cancel()" i18n>Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" i18n>Save</button>
|
||||
</div>
|
||||
</form>
|
@ -1,7 +1,5 @@
|
||||
<app-page-header title="Document types">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="openCreateDialog()">
|
||||
Create
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="openCreateDialog()" i18n>Create</button>
|
||||
</app-page-header>
|
||||
|
||||
<div class="row m-0 justify-content-end">
|
||||
@ -12,10 +10,10 @@
|
||||
<table class="table table-striped border shadow">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" sortable="name" (sort)="onSort($event)">Name</th>
|
||||
<th scope="col" sortable="matching_algorithm" (sort)="onSort($event)">Matching</th>
|
||||
<th scope="col" sortable="document_count" (sort)="onSort($event)">Document count</th>
|
||||
<th scope="col">Actions</th>
|
||||
<th scope="col" sortable="name" (sort)="onSort($event)" i18n>Name</th>
|
||||
<th scope="col" sortable="matching_algorithm" (sort)="onSort($event)" i18n>Matching</th>
|
||||
<th scope="col" sortable="document_count" (sort)="onSort($event)" i18n>Document count</th>
|
||||
<th scope="col" i18n>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -28,21 +26,18 @@
|
||||
<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">
|
||||
<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>
|
||||
Documents
|
||||
</svg> <ng-container i18n>Documents</ng-container>
|
||||
</button>
|
||||
<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">
|
||||
<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>
|
||||
Edit
|
||||
</svg> <ng-container i18n>Edit</ng-container>
|
||||
</button>
|
||||
<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">
|
||||
<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"/>
|
||||
</svg>
|
||||
Delete
|
||||
</svg> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
|
@ -28,7 +28,7 @@ export abstract class GenericListComponent<T extends ObjectWithId> implements On
|
||||
|
||||
getMatching(o: MatchingModel) {
|
||||
if (o.matching_algorithm == MATCH_AUTO) {
|
||||
return "Automatic"
|
||||
return $localize`Automatic`
|
||||
} else if (o.match && o.match.length > 0) {
|
||||
return `${o.match} (${MATCHING_ALGORITHMS.find(a => a.id == o.matching_algorithm).name})`
|
||||
} else {
|
||||
@ -90,11 +90,11 @@ export abstract class GenericListComponent<T extends ObjectWithId> implements On
|
||||
|
||||
openDeleteDialog(object: T) {
|
||||
var activeModal = this.modalService.open(ConfirmDialogComponent, {backdrop: 'static'})
|
||||
activeModal.componentInstance.title = "Confirm delete"
|
||||
activeModal.componentInstance.messageBold = `Do you really want to delete ${this.getObjectName(object)}?`
|
||||
activeModal.componentInstance.message = "Associated documents will not be deleted."
|
||||
activeModal.componentInstance.title = $localize`Confirm delete`
|
||||
activeModal.componentInstance.messageBold = $localize`Do you really want to delete ${this.getObjectName(object)}?`
|
||||
activeModal.componentInstance.message = $localize`Associated documents will not be deleted.`
|
||||
activeModal.componentInstance.btnClass = "btn-danger"
|
||||
activeModal.componentInstance.btnCaption = "Delete"
|
||||
activeModal.componentInstance.btnCaption = $localize`Delete`
|
||||
activeModal.componentInstance.confirmClicked.subscribe(() => {
|
||||
this.service.delete(object).subscribe(_ => {
|
||||
activeModal.close()
|
||||
|
@ -1,11 +1,12 @@
|
||||
<app-page-header title="Logs">
|
||||
<app-page-header title="Logs" i18n-title>
|
||||
|
||||
<div ngbDropdown class="btn-group">
|
||||
<button class="btn btn-outline-primary btn-sm" id="dropdownBasic1" ngbDropdownToggle>
|
||||
<svg class="toolbaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#funnel" />
|
||||
</svg>
|
||||
Filter
|
||||
</svg> <ng-container i18n>Filter</ng-container>
|
||||
|
||||
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="dropdownBasic1">
|
||||
<button *ngFor="let f of getLevels()" ngbDropdownItem (click)="setLevel(f.id)"
|
||||
|
@ -7,14 +7,14 @@
|
||||
|
||||
<ul ngbNav #nav="ngbNav" class="nav-tabs">
|
||||
<li [ngbNavItem]="1">
|
||||
<a ngbNavLink>General settings</a>
|
||||
<a ngbNavLink i18n>General settings</a>
|
||||
<ng-template ngbNavContent>
|
||||
|
||||
<h4>Document list</h4>
|
||||
<h4 i18n>Document list</h4>
|
||||
|
||||
<div class="form-row form-group">
|
||||
<div class="col-md-3 col-form-label">
|
||||
<span>Items per page</span>
|
||||
<span i18n>Items per page</span>
|
||||
</div>
|
||||
<div class="col">
|
||||
|
||||
@ -31,36 +31,36 @@
|
||||
</ng-template>
|
||||
</li>
|
||||
<li [ngbNavItem]="2">
|
||||
<a ngbNavLink>Saved views</a>
|
||||
<a ngbNavLink i18n>Saved views</a>
|
||||
<ng-template ngbNavContent>
|
||||
|
||||
<div formGroupName="savedViews">
|
||||
|
||||
<div *ngFor="let view of savedViews" [formGroupName]="view.id" class="form-row">
|
||||
<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}}">
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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 class="custom-control custom-switch">
|
||||
<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 class="form-group col-auto">
|
||||
<label for="name_{{view.id}}">Actions</label>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger form-control" (click)="deleteSavedView(view)">Delete</button>
|
||||
<label for="name_{{view.id}}" i18n>Actions</label>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger form-control" (click)="deleteSavedView(view)" i18n>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="savedViews.length == 0">No saved views defined.</div>
|
||||
<div *ngIf="savedViews.length == 0" i18n>No saved views defined.</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
@ -46,14 +46,14 @@ export class SettingsComponent implements OnInit {
|
||||
this.savedViewService.delete(savedView).subscribe(() => {
|
||||
this.savedViewGroup.removeControl(savedView.id.toString())
|
||||
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() {
|
||||
localStorage.setItem(GENERAL_SETTINGS.DOCUMENT_LIST_SIZE, this.settingsForm.value.documentListItemPerPage)
|
||||
this.documentListViewService.updatePageSize()
|
||||
this.toastService.showToast(Toast.make("Information", "Settings saved successfully."))
|
||||
this.toastService.showToast(Toast.make("Information", $localize`Settings saved successfully.`))
|
||||
}
|
||||
|
||||
saveSettings() {
|
||||
@ -65,7 +65,7 @@ export class SettingsComponent implements OnInit {
|
||||
this.savedViewService.patchMany(x).subscribe(s => {
|
||||
this.saveLocalSettings()
|
||||
}, 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 {
|
||||
this.saveLocalSettings()
|
||||
|
@ -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 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>
|
||||
<h1>404 Not Found</h1>
|
||||
<h1 i18n>404 Not Found</h1>
|
||||
</div>
|
@ -1,7 +1,7 @@
|
||||
<app-page-header title="Search results">
|
||||
</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">
|
||||
Showing documents similar to
|
||||
|
@ -9,12 +9,12 @@ export const MATCH_FUZZY = 5
|
||||
export const MATCH_AUTO = 6
|
||||
|
||||
export const MATCHING_ALGORITHMS = [
|
||||
{id: MATCH_ANY, name: "Any"},
|
||||
{id: MATCH_ALL, name: "All"},
|
||||
{id: MATCH_LITERAL, name: "Literal"},
|
||||
{id: MATCH_REGEX, name: "Regular Expression"},
|
||||
{id: MATCH_FUZZY, name: "Fuzzy Match"},
|
||||
{id: MATCH_AUTO, name: "Auto"},
|
||||
{id: MATCH_ANY, name: $localize`Any`},
|
||||
{id: MATCH_ALL, name: $localize`All`},
|
||||
{id: MATCH_LITERAL, name: $localize`Literal`},
|
||||
{id: MATCH_REGEX, name: $localize`Regular expression`},
|
||||
{id: MATCH_FUZZY, name: $localize`Fuzzy match`},
|
||||
{id: MATCH_AUTO, name: $localize`Auto`},
|
||||
]
|
||||
|
||||
export interface MatchingModel extends ObjectWithId {
|
||||
|
@ -40,10 +40,14 @@ export class DocumentListViewService {
|
||||
}
|
||||
|
||||
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.selectNone()
|
||||
this._savedViewConfig = Object.assign({}, value)
|
||||
} else {
|
||||
} else if (this._savedViewConfig && !value) {
|
||||
//saved view active, but document list requested
|
||||
this.selectNone()
|
||||
this._savedViewConfig = null
|
||||
}
|
||||
}
|
||||
@ -90,7 +94,7 @@ export class DocumentListViewService {
|
||||
|
||||
reload(onFinish?) {
|
||||
this.isReloading = true
|
||||
this.documentService.list(
|
||||
this.documentService.listFiltered(
|
||||
this.currentPage,
|
||||
this.currentPageSize,
|
||||
this.view.sort_field,
|
||||
@ -118,6 +122,7 @@ export class DocumentListViewService {
|
||||
//want changes in the filter editor to propagate into here right away.
|
||||
this.view.filter_rules = filterRules
|
||||
this.reload()
|
||||
this.reduceSelectionToFilter()
|
||||
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) {
|
||||
let documentListViewConfigJson = sessionStorage.getItem(DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG)
|
||||
if (documentListViewConfigJson) {
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { PaperlessDocument } from '../data/paperless-document';
|
||||
import { OPEN_DOCUMENT_SERVICE } from '../data/storage-keys';
|
||||
import { DocumentService } from './rest/document.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@ -9,7 +10,7 @@ export class OpenDocumentsService {
|
||||
|
||||
private MAX_OPEN_DOCUMENTS = 5
|
||||
|
||||
constructor() {
|
||||
constructor(private documentService: DocumentService) {
|
||||
if (sessionStorage.getItem(OPEN_DOCUMENT_SERVICE.DOCUMENTS)) {
|
||||
try {
|
||||
this.openDocuments = JSON.parse(sessionStorage.getItem(OPEN_DOCUMENT_SERVICE.DOCUMENTS))
|
||||
@ -22,6 +23,15 @@ export class OpenDocumentsService {
|
||||
|
||||
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[] {
|
||||
return this.openDocuments
|
||||
}
|
||||
|
@ -52,9 +52,9 @@ export abstract class AbstractPaperlessService<T extends ObjectWithId> {
|
||||
|
||||
private _listAll: Observable<Results<T>>
|
||||
|
||||
listAll(ordering?: string, extraParams?): Observable<Results<T>> {
|
||||
listAll(sortField?: string, sortReverse?: boolean, extraParams?): Observable<Results<T>> {
|
||||
if (!this._listAll) {
|
||||
this._listAll = this.list(1, 100000, ordering, extraParams).pipe(
|
||||
this._listAll = this.list(1, 100000, sortField, sortReverse, extraParams).pipe(
|
||||
publishReplay(1),
|
||||
refCount()
|
||||
)
|
||||
|
@ -13,13 +13,13 @@ import { TagService } from './tag.service';
|
||||
import { FILTER_RULE_TYPES } from 'src/app/data/filter-rule-type';
|
||||
|
||||
export const DOCUMENT_SORT_FIELDS = [
|
||||
{ field: "correspondent__name", name: "Correspondent" },
|
||||
{ field: "document_type__name", name: "Document type" },
|
||||
{ field: 'title', name: 'Title' },
|
||||
{ field: 'archive_serial_number', name: 'ASN' },
|
||||
{ field: 'created', name: 'Created' },
|
||||
{ field: 'added', name: 'Added' },
|
||||
{ field: 'modified', name: 'Modified' }
|
||||
{ field: "correspondent__name", name: $localize`Correspondent` },
|
||||
{ field: "document_type__name", name: $localize`Document type` },
|
||||
{ field: 'title', name: $localize`Title` },
|
||||
{ field: 'archive_serial_number', name: $localize`ASN` },
|
||||
{ field: 'created', name: $localize`Created` },
|
||||
{ field: 'added', name: $localize`Added` },
|
||||
{ field: 'modified', name: $localize`Modified` }
|
||||
]
|
||||
|
||||
@Injectable({
|
||||
@ -61,8 +61,8 @@ export class DocumentService extends AbstractPaperlessService<PaperlessDocument>
|
||||
return doc
|
||||
}
|
||||
|
||||
list(page?: number, pageSize?: number, sortField?: string, sortReverse?: boolean, filterRules?: FilterRule[]): Observable<Results<PaperlessDocument>> {
|
||||
return super.list(page, pageSize, sortField, sortReverse, this.filterRulesToQueryParams(filterRules)).pipe(
|
||||
listFiltered(page?: number, pageSize?: number, sortField?: string, sortReverse?: boolean, filterRules?: FilterRule[], extraParams = {}): Observable<Results<PaperlessDocument>> {
|
||||
return this.list(page, pageSize, sortField, sortReverse, Object.assign(extraParams, this.filterRulesToQueryParams(filterRules))).pipe(
|
||||
map(results => {
|
||||
results.results.forEach(doc => this.addObservablesToDocument(doc))
|
||||
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 {
|
||||
let url = this.getResourceUrl(id, 'preview')
|
||||
if (original) {
|
||||
@ -98,4 +104,12 @@ export class DocumentService extends AbstractPaperlessService<PaperlessDocument>
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -2,5 +2,5 @@ export const environment = {
|
||||
production: true,
|
||||
apiBaseUrl: "/api/",
|
||||
appTitle: "Paperless-ng",
|
||||
version: "0.9.8"
|
||||
version: "0.9.9"
|
||||
};
|
||||
|
@ -1,5 +1,6 @@
|
||||
$paperless-green: #17541f;
|
||||
$primary: #17541f;
|
||||
$primaryFaded: #d1ddd2;
|
||||
|
||||
$theme-colors: (
|
||||
"primary": $primary
|
||||
|
72
src/documents/bulk_edit.py
Normal file
72
src/documents/bulk_edit.py
Normal 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"
|
@ -51,6 +51,6 @@ def parser_check(app_configs, **kwargs):
|
||||
|
||||
if len(parsers) == 0:
|
||||
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:
|
||||
return []
|
||||
|
@ -95,19 +95,20 @@ class Consumer(LoggingMixin):
|
||||
self.pre_check_directories()
|
||||
self.pre_check_duplicate()
|
||||
|
||||
self.log("info", "Consuming {}".format(self.filename))
|
||||
self.log("info", f"Consuming {self.filename}")
|
||||
|
||||
# Determine the parser class.
|
||||
|
||||
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)
|
||||
if not parser_class:
|
||||
raise ConsumerError(f"No parsers abvailable for {self.filename}")
|
||||
raise ConsumerError(f"No parsers available for {self.filename}")
|
||||
else:
|
||||
self.log("debug",
|
||||
f"Parser: {parser_class.__name__} "
|
||||
f"based on mime type {mime_type}")
|
||||
f"Parser: {parser_class.__name__}")
|
||||
|
||||
# Notify all listeners that we're going to do some work.
|
||||
|
||||
|
@ -1,8 +1,10 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from contextlib import contextmanager
|
||||
|
||||
import tqdm
|
||||
from django.conf import settings
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
@ -43,6 +45,8 @@ class Command(Renderable, BaseCommand):
|
||||
|
||||
def handle(self, *args, **options):
|
||||
|
||||
logging.getLogger().handlers[0].level = logging.ERROR
|
||||
|
||||
self.source = options["source"]
|
||||
|
||||
if not os.path.exists(self.source):
|
||||
@ -69,6 +73,9 @@ class Command(Renderable, BaseCommand):
|
||||
|
||||
self._import_files_from_manifest()
|
||||
|
||||
print("Updating search index...")
|
||||
call_command('document_index', 'reindex')
|
||||
|
||||
@staticmethod
|
||||
def _check_manifest_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.ARCHIVE_DIR, exist_ok=True)
|
||||
|
||||
for record in self.manifest:
|
||||
print("Copy files into paperless...")
|
||||
|
||||
if not record["model"] == "documents.document":
|
||||
continue
|
||||
manifest_documents = list(filter(
|
||||
lambda r: r["model"] == "documents.document",
|
||||
self.manifest))
|
||||
|
||||
for record in tqdm.tqdm(manifest_documents):
|
||||
|
||||
document = Document.objects.get(pk=record["pk"])
|
||||
|
||||
@ -138,7 +148,6 @@ class Command(Renderable, BaseCommand):
|
||||
|
||||
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(thumbnail_path, document.thumbnail_path)
|
||||
if archive_path:
|
||||
|
@ -1,4 +1,5 @@
|
||||
from django.core.management import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from documents.mixins import Renderable
|
||||
from documents.tasks import index_reindex, index_optimize
|
||||
@ -18,8 +19,8 @@ class Command(Renderable, BaseCommand):
|
||||
def handle(self, *args, **options):
|
||||
|
||||
self.verbosity = options["verbosity"]
|
||||
|
||||
if options['command'] == 'reindex':
|
||||
index_reindex()
|
||||
elif options['command'] == 'optimize':
|
||||
index_optimize()
|
||||
with transaction.atomic():
|
||||
if options['command'] == 'reindex':
|
||||
index_reindex()
|
||||
elif options['command'] == 'optimize':
|
||||
index_optimize()
|
||||
|
@ -3,11 +3,34 @@ from django.utils.text import slugify
|
||||
from rest_framework import serializers
|
||||
from rest_framework.fields import SerializerMethodField
|
||||
|
||||
from . import bulk_edit
|
||||
from .models import Correspondent, Tag, Document, Log, DocumentType, \
|
||||
SavedView, SavedViewFilterRule
|
||||
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):
|
||||
|
||||
document_count = serializers.IntegerField(read_only=True)
|
||||
@ -91,7 +114,7 @@ class DocumentTypeField(serializers.PrimaryKeyRelatedField):
|
||||
return DocumentType.objects.all()
|
||||
|
||||
|
||||
class DocumentSerializer(serializers.ModelSerializer):
|
||||
class DocumentSerializer(DynamicFieldsModelSerializer):
|
||||
|
||||
correspondent = CorrespondentField(allow_null=True)
|
||||
tags = TagsField(many=True)
|
||||
@ -180,6 +203,101 @@ class SavedViewSerializer(serializers.ModelSerializer):
|
||||
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):
|
||||
|
||||
document = serializers.FileField(
|
||||
|
@ -2,6 +2,7 @@ import logging
|
||||
|
||||
import tqdm
|
||||
from django.conf import settings
|
||||
from django.db.models.signals import post_save
|
||||
from whoosh.writing import AsyncWriter
|
||||
|
||||
from documents import index, sanity_checker
|
||||
@ -87,3 +88,9 @@ def sanity_check():
|
||||
raise SanityFailedError(messages)
|
||||
else:
|
||||
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)
|
||||
|
@ -1,3 +1,4 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
@ -7,7 +8,7 @@ from django.contrib.auth.models import User
|
||||
from rest_framework.test import APITestCase
|
||||
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.tests.utils import DirectoriesMixin
|
||||
|
||||
@ -63,6 +64,58 @@ class TestDocumentApi(DirectoriesMixin, APITestCase):
|
||||
|
||||
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):
|
||||
|
||||
_, filename = tempfile.mkstemp(dir=self.dirs.originals_dir)
|
||||
@ -614,3 +667,273 @@ class TestDocumentApi(DirectoriesMixin, APITestCase):
|
||||
|
||||
v1 = SavedView.objects.get(id=v1.id)
|
||||
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)
|
||||
|
@ -1,9 +1,12 @@
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from django.core.checks import Error
|
||||
from django.test import TestCase
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -15,3 +18,13 @@ class ChecksTestCase(TestCase):
|
||||
def test_changed_password_check_no_encryption(self):
|
||||
DocumentFactory.create(storage_type=Document.STORAGE_TYPE_UNENCRYPTED)
|
||||
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.")])
|
||||
|
@ -47,7 +47,8 @@ from .serialisers import (
|
||||
TagSerializer,
|
||||
DocumentTypeSerializer,
|
||||
PostDocumentSerializer,
|
||||
SavedViewSerializer
|
||||
SavedViewSerializer,
|
||||
BulkEditSerializer
|
||||
)
|
||||
|
||||
|
||||
@ -110,6 +111,10 @@ class DocumentTypeViewSet(ModelViewSet):
|
||||
ordering_fields = ("name", "matching_algorithm", "match", "document_count")
|
||||
|
||||
|
||||
class BulkEditForm(object):
|
||||
pass
|
||||
|
||||
|
||||
class DocumentViewSet(RetrieveModelMixin,
|
||||
UpdateModelMixin,
|
||||
DestroyModelMixin,
|
||||
@ -133,6 +138,17 @@ class DocumentViewSet(RetrieveModelMixin,
|
||||
"added",
|
||||
"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):
|
||||
response = super(DocumentViewSet, self).update(
|
||||
request, *args, **kwargs)
|
||||
@ -274,6 +290,39 @@ class SavedViewViewSet(ModelViewSet):
|
||||
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):
|
||||
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
@ -18,7 +18,8 @@ from documents.views import (
|
||||
SearchAutoCompleteView,
|
||||
StatisticsView,
|
||||
PostDocumentView,
|
||||
SavedViewViewSet
|
||||
SavedViewViewSet,
|
||||
BulkEditView
|
||||
)
|
||||
from paperless.views import FaviconView
|
||||
|
||||
@ -52,6 +53,10 @@ urlpatterns = [
|
||||
re_path(r"^documents/post_document/", PostDocumentView.as_view(),
|
||||
name="post_document"),
|
||||
|
||||
|
||||
re_path(r"^documents/bulk_edit/", BulkEditView.as_view(),
|
||||
name="bulk_edit"),
|
||||
|
||||
path('token/', views.obtain_auth_token)
|
||||
|
||||
] + api_router.urls)),
|
||||
|
@ -1 +1 @@
|
||||
__version__ = (0, 9, 8)
|
||||
__version__ = (0, 9, 9)
|
||||
|
Loading…
x
Reference in New Issue
Block a user