Merge branch 'dev' into feature-chore-angular-17

This commit is contained in:
shamoon
2023-12-19 12:53:09 -08:00
59 changed files with 2573 additions and 2247 deletions

View File

@@ -102,3 +102,14 @@ body:
attributes:
label: Other
description: Any other relevant details.
- type: checkboxes
id: required-checks
attributes:
label: Please confirm the following
options:
- label: I believe this issue is a bug that affects all users of Paperless-ngx, not something specific to my installation.
required: true
- label: I have already searched for relevant existing issues and discussions before opening this report.
required: true
- label: I have updated the title field above with a concise description.
required: true

View File

@@ -16,7 +16,7 @@ on:
env:
# This is the version of pipenv all the steps will use
# If changing this, change Dockerfile
DEFAULT_PIP_ENV_VERSION: "2023.10.24"
DEFAULT_PIP_ENV_VERSION: "2023.11.15"
# This is the default version of Python to use in most steps which aren't specific
DEFAULT_PYTHON_VERSION: "3.10"

View File

@@ -29,7 +29,7 @@ COPY Pipfile* ./
RUN set -eux \
&& echo "Installing pipenv" \
&& python3 -m pip install --no-cache-dir --upgrade pipenv==2023.10.24 \
&& python3 -m pip install --no-cache-dir --upgrade pipenv==2023.11.15 \
&& echo "Generating requirement.txt" \
&& pipenv requirements > requirements.txt
@@ -39,6 +39,8 @@ RUN set -eux \
# - Don't leave anything extra in here
FROM docker.io/python:3.11-slim-bookworm as main-app
ENV PYTHONWARNINGS="ignore:::django.http.response:517"
LABEL org.opencontainers.image.authors="paperless-ngx team <hello@paperless-ngx.com>"
LABEL org.opencontainers.image.documentation="https://docs.paperless-ngx.com/"
LABEL org.opencontainers.image.source="https://github.com/paperless-ngx/paperless-ngx"
@@ -52,8 +54,8 @@ ARG TARGETARCH
# Can be workflow provided, defaults set for manual building
ARG JBIG2ENC_VERSION=0.29
ARG QPDF_VERSION=11.6.3
ARG GS_VERSION=10.02.0
ARG QPDF_VERSION=11.6.4
ARG GS_VERSION=10.02.1
#
# Begin installation and configuration
@@ -123,13 +125,13 @@ RUN set -eux \
&& echo "Installing Ghostscript ${GS_VERSION}" \
&& curl --fail --silent --show-error --location \
--output libgs10_${GS_VERSION}.dfsg-2_${TARGETARCH}.deb \
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/libgs10_${GS_VERSION}.dfsg-2_${TARGETARCH}.deb \
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/libgs10_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
&& curl --fail --silent --show-error --location \
--output ghostscript_${GS_VERSION}.dfsg-2_${TARGETARCH}.deb \
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/ghostscript_${GS_VERSION}.dfsg-2_${TARGETARCH}.deb \
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/ghostscript_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
&& curl --fail --silent --show-error --location \
--output libgs10-common_${GS_VERSION}.dfsg-2_all.deb \
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/libgs10-common_${GS_VERSION}.dfsg-2_all.deb \
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/libgs10-common_${GS_VERSION}.dfsg-1_all.deb \
&& dpkg --install ./libgs10-common_${GS_VERSION}.dfsg-2_all.deb \
&& dpkg --install ./libgs10_${GS_VERSION}.dfsg-2_${TARGETARCH}.deb \
&& dpkg --install ./ghostscript_${GS_VERSION}.dfsg-2_${TARGETARCH}.deb \

10
Pipfile
View File

@@ -4,16 +4,16 @@ verify_ssl = true
name = "pypi"
[packages]
dateparser = "~=1.1"
dateparser = "~=1.2"
# WARNING: django does not use semver.
# Only patch versions are guaranteed to not introduce breaking changes.
django = "~=4.2.7"
django = "~=4.2.8"
django-auditlog = "*"
django-celery-results = "*"
django-compression-middleware = "*"
django-cors-headers = "*"
django-extensions = "*"
django-filter = "~=23.3"
django-filter = "~=23.5"
django-guardian = "*"
django-multiselectfield = "*"
djangorestframework = "~=3.14"
@@ -33,7 +33,7 @@ inotifyrecursive = "~=0.3"
langdetect = "*"
mysqlclient = "*"
nltk = "*"
ocrmypdf = "~=15.0"
ocrmypdf = "~=15.4"
pathvalidate = "*"
pdf2image = "*"
psycopg2 = "*"
@@ -57,7 +57,7 @@ zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"}
[dev-packages]
# Linting
black = "*"
black = "==23.11.0"
pre-commit = "*"
ruff = "*"
# Testing

1032
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -607,3 +607,10 @@ document_fuzzy_match [--ratio] [--processes N]
| ----------- | -------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| --ratio | No | 85.0 | a number between 0 and 100, setting how similar a document must be for it to be reported. Higher numbers mean more similarity. |
| --processes | No | 1/4 of system cores | Number of processes to use for matching. Setting 1 disables multiple processes |
| --delete | No | False | If provided, one document of a matched pair above the ratio will be deleted. |
!!! warning
If providing the `--delete` option, it is highly recommended to have a backup.
While every effort has been taken to ensure proper operation, there is always the
chance of deletion of a file you want to keep.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 550 KiB

After

Width:  |  Height:  |  Size: 559 KiB

View File

@@ -1,5 +1,38 @@
# Changelog
## paperless-ngx 2.1.2
### Bug Fixes
- Fix: sort consumption templates by order by default [@shamoon](https://github.com/shamoon) ([#4956](https://github.com/paperless-ngx/paperless-ngx/pull/4956))
- Fix: Updates gotenberg-client, including workaround for Gotenberg non-latin handling [@stumpylog](https://github.com/stumpylog) ([#4944](https://github.com/paperless-ngx/paperless-ngx/pull/4944))
- Fix: allow text copy in pngx pdf viewer [@shamoon](https://github.com/shamoon) ([#4938](https://github.com/paperless-ngx/paperless-ngx/pull/4938))
- Fix: Don't allow autocomplete searches to fail on schema field matches [@stumpylog](https://github.com/stumpylog) ([#4934](https://github.com/paperless-ngx/paperless-ngx/pull/4934))
- Fix: Convert search dates to UTC in advanced search [@bogdal](https://github.com/bogdal) ([#4891](https://github.com/paperless-ngx/paperless-ngx/pull/4891))
- Fix: Use the attachment filename so downstream template matching works [@stumpylog](https://github.com/stumpylog) ([#4931](https://github.com/paperless-ngx/paperless-ngx/pull/4931))
- Fix: frontend handle autocomplete failure gracefully [@shamoon](https://github.com/shamoon) ([#4903](https://github.com/paperless-ngx/paperless-ngx/pull/4903))
### Dependencies
- Chore(deps-dev): Bump the small-changes group with 2 updates [@dependabot](https://github.com/dependabot) ([#4942](https://github.com/paperless-ngx/paperless-ngx/pull/4942))
- Chore(deps-dev): Bump the development group with 1 update [@dependabot](https://github.com/dependabot) ([#4939](https://github.com/paperless-ngx/paperless-ngx/pull/4939))
### All App Changes
<details>
<summary>9 changes</summary>
- Fix: sort consumption templates by order by default [@shamoon](https://github.com/shamoon) ([#4956](https://github.com/paperless-ngx/paperless-ngx/pull/4956))
- Chore: reorganize api tests [@shamoon](https://github.com/shamoon) ([#4935](https://github.com/paperless-ngx/paperless-ngx/pull/4935))
- Chore(deps-dev): Bump the small-changes group with 2 updates [@dependabot](https://github.com/dependabot) ([#4942](https://github.com/paperless-ngx/paperless-ngx/pull/4942))
- Fix: allow text copy in pngx pdf viewer [@shamoon](https://github.com/shamoon) ([#4938](https://github.com/paperless-ngx/paperless-ngx/pull/4938))
- Chore(deps-dev): Bump the development group with 1 update [@dependabot](https://github.com/dependabot) ([#4939](https://github.com/paperless-ngx/paperless-ngx/pull/4939))
- Fix: Don't allow autocomplete searches to fail on schema field matches [@stumpylog](https://github.com/stumpylog) ([#4934](https://github.com/paperless-ngx/paperless-ngx/pull/4934))
- Fix: Convert search dates to UTC in advanced search [@bogdal](https://github.com/bogdal) ([#4891](https://github.com/paperless-ngx/paperless-ngx/pull/4891))
- Fix: Use the attachment filename so downstream template matching works [@stumpylog](https://github.com/stumpylog) ([#4931](https://github.com/paperless-ngx/paperless-ngx/pull/4931))
- Fix: frontend handle autocomplete failure gracefully [@shamoon](https://github.com/shamoon) ([#4903](https://github.com/paperless-ngx/paperless-ngx/pull/4903))
</details>
## paperless-ngx 2.1.1
### Bug Fixes

View File

@@ -311,6 +311,8 @@ applied. You can use the following placeholders:
- `{added_month_name}`: added month name
- `{added_month_name_short}`: added month short name
- `{added_day}`: added day
- `{added_time}`: added time in HH:MM format
- `{original_filename}`: original file name without extension
## Custom Fields {#custom-fields}

View File

@@ -12,13 +12,9 @@ test('should activate / deactivate save button when changes are saved', async ({
await expect(page.getByTitle('Storage path', { exact: true })).toHaveText(
/\w+/
)
await expect(
page.getByRole('button', { name: 'Save', exact: true })
).toBeDisabled()
await expect(page.getByRole('button', { name: 'Save' }).nth(1)).toBeDisabled()
await page.getByTitle('Storage path').getByTitle('Clear all').click()
await expect(
page.getByRole('button', { name: 'Save', exact: true })
).toBeEnabled()
await expect(page.getByRole('button', { name: 'Save' }).nth(1)).toBeEnabled()
})
test('should warn on unsaved changes', async ({ page }) => {
@@ -27,16 +23,12 @@ test('should warn on unsaved changes', async ({ page }) => {
await expect(page.getByTitle('Correspondent', { exact: true })).toHaveText(
/\w+/
)
await expect(
page.getByRole('button', { name: 'Save', exact: true })
).toBeDisabled()
await expect(page.getByRole('button', { name: 'Save' }).nth(1)).toBeDisabled()
await page
.getByTitle('Storage path', { exact: true })
.getByTitle('Clear all')
.click()
await expect(
page.getByRole('button', { name: 'Save', exact: true })
).toBeEnabled()
await expect(page.getByRole('button', { name: 'Save' }).nth(1)).toBeEnabled()
await page.getByRole('button', { name: 'Close', exact: true }).click()
await expect(page.getByRole('dialog')).toHaveText(/unsaved changes/)
await page.getByRole('button', { name: 'Cancel' }).click()

File diff suppressed because it is too large Load Diff

View File

@@ -107,6 +107,7 @@ import { CustomFieldsDropdownComponent } from './components/common/custom-fields
import { ProfileEditDialogComponent } from './components/common/profile-edit-dialog/profile-edit-dialog.component'
import { PdfViewerComponent } from './components/common/pdf-viewer/pdf-viewer.component'
import { DocumentLinkComponent } from './components/common/input/document-link/document-link.component'
import { PreviewPopupComponent } from './components/common/preview-popup/preview-popup.component'
import localeAf from '@angular/common/locales/af'
import localeAr from '@angular/common/locales/ar'
@@ -261,6 +262,7 @@ function initializeApp(settings: SettingsService) {
ProfileEditDialogComponent,
PdfViewerComponent,
DocumentLinkComponent,
PreviewPopupComponent,
],
imports: [
BrowserModule,

View File

@@ -81,6 +81,16 @@ describe('DateComponent', () => {
expect(eventSpy).toHaveBeenCalled()
})
it('should show allow system keyboard events', () => {
let event: KeyboardEvent = new KeyboardEvent('keypress', {
key: '9',
altKey: true,
})
let preventDefaultSpy = jest.spyOn(event, 'preventDefault')
input.dispatchEvent(event)
expect(preventDefaultSpy).not.toHaveBeenCalled()
})
it('should support paste', () => {
expect(component.value).toBeUndefined()
const date = '5/4/20'
@@ -99,5 +109,25 @@ describe('DateComponent', () => {
event['clipboardData'] = clipboardData
input.dispatchEvent(event)
expect(component.value).toEqual({ day: 4, month: 5, year: 2020 })
// coverage
window['clipboardData'] = {
getData: (type) => '',
}
component.onPaste(new Event('foo') as any)
})
it('should set filter button title', () => {
component.title = 'foo'
expect(component.filterButtonTitle).toEqual(
'Filter documents with this foo'
)
})
it('should emit date on filter', () => {
let dateReceived
component.value = '12/16/2023'
component.filterDocuments.subscribe((date) => (dateReceived = date))
component.onFilterDocuments()
expect(dateReceived).toEqual([{ day: 16, month: 12, year: 2023 }])
})
})

View File

@@ -90,7 +90,11 @@ export class DateComponent
}
onKeyPress(event: KeyboardEvent) {
if ('Enter' !== event.key && !/[0-9,\.\/-]+/.test(event.key)) {
if (
'Enter' !== event.key &&
!(event.altKey || event.metaKey || event.ctrlKey) &&
!/[0-9,\.\/-]+/.test(event.key)
) {
event.preventDefault()
}
}

View File

@@ -1,6 +1,10 @@
::ng-deep .ng-select-container .ng-value-container .ng-value {
background-color: transparent !important;
border-color: transparent;
::ng-deep .ng-select-container .ng-value-container {
overflow: hidden;
.ng-value {
background-color: transparent !important;
border-color: transparent;
}
}
.sidebaricon {
@@ -9,6 +13,4 @@
.badge {
font-size: .75rem;
// --bs-primary: var(--pngx-bg-alt);
// color: var(--pngx-primary-text-contrast);
}

View File

@@ -20,6 +20,10 @@ const documents = [
id: 12,
title: 'Document 12 bar',
},
{
id: 16,
title: 'Document 16 bar',
},
{
id: 23,
title: 'Document 23 bar',
@@ -48,10 +52,15 @@ describe('DocumentLinkComponent', () => {
fixture.detectChanges()
})
it('should retrieve selected documents from APIs', () => {
const getSpy = jest.spyOn(documentService, 'getCachedMany')
it('should retrieve selected documents from API', () => {
const getSpy = jest.spyOn(documentService, 'getFew')
getSpy.mockImplementation((ids) => {
return of(documents.filter((d) => ids.includes(d.id)))
const docs = documents.filter((d) => ids.includes(d.id))
return of({
count: docs.length,
all: docs.map((d) => d.id),
results: docs,
})
})
component.writeValue([1])
expect(getSpy).toHaveBeenCalled()
@@ -85,12 +94,18 @@ describe('DocumentLinkComponent', () => {
})
it('should load values correctly', () => {
jest.spyOn(documentService, 'getCachedMany').mockImplementation((ids) => {
return of(documents.filter((d) => ids.includes(d.id)))
const getSpy = jest.spyOn(documentService, 'getFew')
getSpy.mockImplementation((ids) => {
const docs = documents.filter((d) => ids.includes(d.id))
return of({
count: docs.length,
all: docs.map((d) => d.id),
results: docs,
})
})
component.writeValue([12, 23])
expect(component.value).toEqual([12, 23])
expect(component.selectedDocuments).toEqual([documents[1], documents[2]])
expect(component.selectedDocuments).toEqual([documents[1], documents[3]])
component.writeValue(null)
expect(component.value).toEqual([])
expect(component.selectedDocuments).toEqual([])
@@ -100,9 +115,14 @@ describe('DocumentLinkComponent', () => {
})
it('should support unselect', () => {
const getSpy = jest.spyOn(documentService, 'getCachedMany')
const getSpy = jest.spyOn(documentService, 'getFew')
getSpy.mockImplementation((ids) => {
return of(documents.filter((d) => ids.includes(d.id)))
const docs = documents.filter((d) => ids.includes(d.id))
return of({
count: docs.length,
all: docs.map((d) => d.id),
results: docs,
})
})
component.writeValue([12, 23])
component.unselect({ id: 23 })
@@ -115,4 +135,26 @@ describe('DocumentLinkComponent', () => {
expect(component.compareDocuments(documents[0], { id: 2 })).toBeFalsy()
expect(component.trackByFn(documents[1])).toEqual(12)
})
it('should not include the current document or already selected documents in results', () => {
let foundDocs
component.foundDocuments$.subscribe((found) => (foundDocs = found))
component.parentDocumentID = 23
component.selectedDocuments = [documents[2]]
const listSpy = jest.spyOn(documentService, 'listFiltered')
listSpy.mockImplementation(
(page, pageSize, sortField, sortReverse, filterRules, extraParams) => {
const docs = documents.filter((d) =>
d.title.includes(filterRules[0].value)
)
return of({
count: docs.length,
results: docs,
all: docs.map((d) => d.id),
})
}
)
component.documentsInput$.next('bar')
expect(foundDocs).toEqual([documents[1]])
})
})

View File

@@ -43,6 +43,9 @@ export class DocumentLinkComponent
@Input()
notFoundText: string = $localize`No documents found`
@Input()
parentDocumentID: number
constructor(private documentsService: DocumentService) {
super()
}
@@ -58,11 +61,11 @@ export class DocumentLinkComponent
} else {
this.loading = true
this.documentsService
.getCachedMany(documentIDs)
.getFew(documentIDs, { fields: 'id,title' })
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((documents) => {
.subscribe((documentResults) => {
this.loading = false
this.selectedDocuments = documents
this.selectedDocuments = documentResults.results
super.writeValue(documentIDs)
})
}
@@ -86,7 +89,13 @@ export class DocumentLinkComponent
{ truncate_content: true }
)
.pipe(
map((results) => results.results),
map((results) =>
results.results.filter(
(d) =>
d.id !== this.parentDocumentID &&
!this.selectedDocuments.find((sd) => sd.id === d.id)
)
),
catchError(() => of([])), // empty on error
tap(() => (this.loading = false))
)

View File

@@ -0,0 +1,22 @@
<div class="preview-popup-container">
<div *ngIf="error; else noError" class="w-100 h-100 position-relative">
<p class="fst-italic position-absolute top-50 start-50 translate-middle" i18n>Error loading preview</p>
</div>
<ng-template #noError>
<object *ngIf="renderAsObject; else pngxViewer" [data]="previewURL | safeUrl" width="100%" class="bg-light" [class.p-2]="!isPdf"></object>
<ng-template #pngxViewer>
<div *ngIf="requiresPassword" class="w-100 h-100 position-relative">
<svg width="2em" height="2em" fill="currentColor" class="position-absolute top-50 start-50 translate-middle">
<use xlink:href="assets/bootstrap-icons.svg#file-earmark-lock"/>
</svg>
</div>
<pngx-pdf-viewer *ngIf="!requiresPassword"
[src]="previewURL"
[original-size]="false"
[show-borders]="true"
[show-all]="true"
(error)="onError($event)">
</pngx-pdf-viewer>
</ng-template>
</ng-template>
</div>

View File

@@ -0,0 +1,9 @@
.preview-popup-container > * {
width: 30rem !important;
height: 22rem !important;
overflow-y: scroll;
}
::ng-deep .popover.popover-preview {
max-width: 32rem;
}

View File

@@ -0,0 +1,92 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { PreviewPopupComponent } from './preview-popup.component'
import { PdfViewerComponent } from '../pdf-viewer/pdf-viewer.component'
import { By } from '@angular/platform-browser'
import { SafeUrlPipe } from 'src/app/pipes/safeurl.pipe'
import { SettingsService } from 'src/app/services/settings.service'
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { DocumentService } from 'src/app/services/rest/document.service'
const doc = {
id: 10,
title: 'Document 10',
content: 'Cupcake ipsum dolor sit amet ice cream.',
original_file_name: 'sample.pdf',
}
describe('PreviewPopupComponent', () => {
let component: PreviewPopupComponent
let fixture: ComponentFixture<PreviewPopupComponent>
let settingsService: SettingsService
let documentService: DocumentService
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [PreviewPopupComponent, PdfViewerComponent, SafeUrlPipe],
imports: [HttpClientTestingModule],
})
settingsService = TestBed.inject(SettingsService)
documentService = TestBed.inject(DocumentService)
jest
.spyOn(documentService, 'getPreviewUrl')
.mockImplementation((id) => doc.original_file_name)
fixture = TestBed.createComponent(PreviewPopupComponent)
component = fixture.componentInstance
component.document = doc
fixture.detectChanges()
})
it('should guess if file is pdf by file name', () => {
expect(component.isPdf).toBeTruthy()
component.document.archived_file_name = 'sample.pdf'
expect(component.isPdf).toBeTruthy()
component.document.archived_file_name = undefined
component.document.original_file_name = 'sample.txt'
expect(component.isPdf).toBeFalsy()
component.document.original_file_name = 'sample.pdf'
})
it('should return settings for native PDF viewer', () => {
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
expect(component.useNativePdfViewer).toBeFalsy()
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, true)
expect(component.useNativePdfViewer).toBeTruthy()
})
it('should render object if native PDF viewer enabled', () => {
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, true)
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('object'))).not.toBeNull()
})
it('should render pngx viewer if native PDF viewer disabled', () => {
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('object'))).toBeNull()
expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull()
})
it('should show lock icon on password error', () => {
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
component.onError({ name: 'PasswordException' })
fixture.detectChanges()
expect(component.requiresPassword).toBeTruthy()
expect(fixture.debugElement.query(By.css('svg'))).not.toBeNull()
})
it('should fall back to object for non-pdf', () => {
component.document.original_file_name = 'sample.png'
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('object'))).not.toBeNull()
})
it('should show message on error', () => {
component.onError({})
fixture.detectChanges()
expect(fixture.debugElement.nativeElement.textContent).toContain(
'Error loading preview'
)
})
})

View File

@@ -0,0 +1,52 @@
import { Component, Input } from '@angular/core'
import { PaperlessDocument } from 'src/app/data/paperless-document'
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
import { DocumentService } from 'src/app/services/rest/document.service'
import { SettingsService } from 'src/app/services/settings.service'
@Component({
selector: 'pngx-preview-popup',
templateUrl: './preview-popup.component.html',
styleUrls: ['./preview-popup.component.scss'],
})
export class PreviewPopupComponent {
@Input()
document: PaperlessDocument
error = false
requiresPassword: boolean = false
get renderAsObject(): boolean {
return (this.isPdf && this.useNativePdfViewer) || !this.isPdf
}
get previewURL() {
return this.documentService.getPreviewUrl(this.document.id)
}
get useNativePdfViewer(): boolean {
return this.settingsService.get(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER)
}
get isPdf(): boolean {
// We dont have time to retrieve metadata, make a best guess by file name
return (
this.document?.original_file_name?.endsWith('.pdf') ||
this.document?.archived_file_name?.endsWith('.pdf')
)
}
constructor(
private settingsService: SettingsService,
private documentService: DocumentService
) {}
onError(event: any) {
if (event.name == 'PasswordException') {
this.requiresPassword = true
} else {
this.error = true
}
}
}

View File

@@ -11,7 +11,6 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import {
NgbAccordionModule,
NgbActiveModal,
NgbModal,
NgbModalModule,
} from '@ng-bootstrap/ng-bootstrap'
import { HttpClientModule } from '@angular/common/http'

View File

@@ -130,7 +130,8 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
}
save(): void {
const passwordChanged = this.currentPassword !== this.newPassword
const passwordChanged =
this.newPassword && this.currentPassword !== this.newPassword
const profile = Object.assign({}, this.form.value)
this.networkActive = true
this.profileService

View File

@@ -3,63 +3,54 @@
[title]="savedView.name"
[loading]="loading"
[draggable]="savedView"
>
>
@if (documents.length) {
<a class="btn-link text-decoration-none" header-buttons [routerLink]="[]" (click)="showAll()" i18n>Show all</a>
}
<a *ngIf="documents.length" class="btn-link text-decoration-none" header-buttons [routerLink]="[]" (click)="showAll()" i18n>Show all</a>
@if (documents.length) {
<table content class="table table-hover mb-0 align-middle">
<thead>
<tr>
<th scope="col" i18n>Created</th>
<th scope="col" i18n>Title</th>
<th scope="col" class="d-none d-md-table-cell" i18n>Tags</th>
<th scope="col" class="d-none d-md-table-cell" i18n>Correspondent</th>
</tr>
</thead>
<tbody>
@for (doc of documents; track doc) {
<tr (mouseleave)="mouseLeaveCard()">
<td class="py-2 py-md-3"><a routerLink="/documents/{{doc.id}}" class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.created_date | customDate}}</a></td>
<td class="py-2 py-md-3">
<a routerLink="/documents/{{doc.id}}" title="Edit" i18n-title class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.title | documentTitle}}</a>
</td>
<td class="py-2 py-md-3 d-none d-md-table-cell">
@for (t of doc.tags$ | async; track t) {
<pngx-tag [tag]="t" class="ms-1" (click)="clickTag(t, $event)"></pngx-tag>
}
</td>
<td class="position-relative py-2 py-md-3 d-none d-md-table-cell">
@if (doc.correspondent !== null) {
<a class="btn-link text-dark text-decoration-none py-2 py-md-3" routerLink="/documents" [queryParams]="getCorrespondentQueryParams(doc.correspondent)">{{(doc.correspondent$ | async)?.name}}</a>
}
<div class="btn-group position-absolute top-50 end-0 translate-middle-y">
<a [href]="getPreviewUrl(doc)" title="View Preview" i18n-title target="_blank" class="btn px-4 btn-dark border-dark-subtle"
[ngbPopover]="previewContent" [popoverTitle]="doc.title | documentTitle"
autoClose="true" popoverClass="shadow popover-preview" container="body" (mouseenter)="mouseEnterPreview(doc)" (mouseleave)="mouseLeavePreview()" #popover="ngbPopover">
<svg class="buttonicon-xs" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#eye"/>
</svg>
</a>
<ng-template #previewContent>
<object [data]="getPreviewUrl(doc) | safeUrl" class="preview" width="100%"></object>
</ng-template>
<a [href]="getDownloadUrl(doc)" class="btn px-4 btn-dark border-dark-subtle" title="Download" i18n-title (click)="$event.stopPropagation()">
<svg class="buttonicon-xs" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#download"/>
</svg>
</a>
</div>
</td>
</tr>
}
</tbody>
</table>
} @else {
<table *ngIf="documents.length; else empty" content class="table table-hover mb-0 align-middle">
<thead>
<tr>
<th scope="col" i18n>Created</th>
<th scope="col" i18n>Title</th>
<th scope="col" class="d-none d-md-table-cell" i18n>Tags</th>
<th scope="col" class="d-none d-md-table-cell" i18n>Correspondent</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let doc of documents" (mouseleave)="maybeClosePopover()">
<td class="py-2 py-md-3"><a routerLink="/documents/{{doc.id}}" class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.created_date | customDate}}</a></td>
<td class="py-2 py-md-3">
<a routerLink="/documents/{{doc.id}}" title="Edit" i18n-title class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.title | documentTitle}}</a>
</td>
<td class="py-2 py-md-3 d-none d-md-table-cell">
<pngx-tag [tag]="t" *ngFor="let t of doc.tags$ | async" class="ms-1" (click)="clickTag(t, $event)"></pngx-tag>
</td>
<td class="position-relative py-2 py-md-3 d-none d-md-table-cell">
<a *ngIf="doc.correspondent !== null" class="btn-link text-dark text-decoration-none py-2 py-md-3" routerLink="/documents" [queryParams]="getCorrespondentQueryParams(doc.correspondent)">{{(doc.correspondent$ | async)?.name}}</a>
<div class="btn-group position-absolute top-50 end-0 translate-middle-y">
<a [href]="getPreviewUrl(doc)" title="View Preview" i18n-title target="_blank" class="btn px-4 btn-dark border-dark-subtle"
[ngbPopover]="previewContent" [popoverTitle]="doc.title | documentTitle"
autoClose="true" popoverClass="shadow popover-preview" container="body" (mouseenter)="mouseEnterPreviewButton(doc)" (mouseleave)="mouseLeavePreviewButton()" #popover="ngbPopover">
<svg class="buttonicon-xs" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#eye"/>
</svg>
</a>
<ng-template #previewContent>
<pngx-preview-popup [document]="doc" (mouseenter)="mouseEnterPreview()" (mouseleave)="mouseLeavePreview()"></pngx-preview-popup>
</ng-template>
<a [href]="getDownloadUrl(doc)" class="btn px-4 btn-dark border-dark-subtle" title="Download" i18n-title (click)="$event.stopPropagation()">
<svg class="buttonicon-xs" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#download"/>
</svg>
</a>
</div>
</td>
</tr>
</tbody>
</table>
<ng-template #empty>
<p i18n class="text-center text-muted mb-0 fst-italic">No documents</p>
}
</ng-template>
</pngx-widget-frame>

View File

@@ -29,6 +29,7 @@ import { SavedViewWidgetComponent } from './saved-view-widget.component'
import { By } from '@angular/platform-browser'
import { SafeUrlPipe } from 'src/app/pipes/safeurl.pipe'
import { DragDropModule } from '@angular/cdk/drag-drop'
import { PreviewPopupComponent } from 'src/app/components/common/preview-popup/preview-popup.component'
const savedView: PaperlessSavedView = {
id: 1,
@@ -74,6 +75,7 @@ describe('SavedViewWidgetComponent', () => {
CustomDatePipe,
DocumentTitlePipe,
SafeUrlPipe,
PreviewPopupComponent,
],
providers: [
PermissionsGuard,
@@ -137,15 +139,18 @@ describe('SavedViewWidgetComponent', () => {
)
component.ngOnInit()
fixture.detectChanges()
component.mouseEnterPreview(documentResults[0])
component.mouseEnterPreviewButton(documentResults[0])
expect(component.popover.isOpen()).toBeTruthy()
expect(component.popoverHidden).toBeTruthy()
tick(600)
expect(component.popoverHidden).toBeFalsy()
component.mouseLeaveCard()
component.maybeClosePopover()
component.mouseEnterPreview(documentResults[1])
component.mouseEnterPreviewButton(documentResults[1])
tick(100)
component.mouseLeavePreviewButton()
component.mouseEnterPreview()
expect(component.popover.isOpen()).toBeTruthy()
component.mouseLeavePreview()
tick(600)
expect(component.popover.isOpen()).toBeFalsy()

View File

@@ -26,10 +26,7 @@ import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
@Component({
selector: 'pngx-saved-view-widget',
templateUrl: './saved-view-widget.component.html',
styleUrls: [
'./saved-view-widget.component.scss',
'../../../document-list/popover-preview/popover-preview.scss',
],
styleUrls: ['./saved-view-widget.component.scss'],
})
export class SavedViewWidgetComponent
extends ComponentWithPermissions
@@ -121,8 +118,11 @@ export class SavedViewWidgetComponent
return this.documentService.getDownloadUrl(document.id)
}
mouseEnterPreview(doc: PaperlessDocument) {
this.popover = this.popovers.get(this.documents.indexOf(doc))
mouseEnterPreviewButton(doc: PaperlessDocument) {
const newPopover = this.popovers.get(this.documents.indexOf(doc))
if (this.popover !== newPopover && this.popover?.isOpen())
this.popover.close()
this.popover = newPopover
this.mouseOnPreview = true
if (!this.popover.isOpen()) {
// we're going to open but hide to pre-load content during hover delay
@@ -139,12 +139,24 @@ export class SavedViewWidgetComponent
}
}
mouseLeavePreview() {
this.mouseOnPreview = false
mouseEnterPreview() {
this.mouseOnPreview = true
}
mouseLeaveCard() {
this.popover?.close()
mouseLeavePreview() {
this.mouseOnPreview = false
this.maybeClosePopover()
}
mouseLeavePreviewButton() {
this.mouseOnPreview = false
this.maybeClosePopover()
}
maybeClosePopover() {
setTimeout(() => {
if (!this.mouseOnPreview) this.popover?.close()
}, 300)
}
getCorrespondentQueryParams(correspondentId: number): Params {

View File

@@ -1,47 +1,43 @@
<pngx-page-header [(title)]="title">
@if (getContentType() === 'application/pdf' && !useNativePdfViewer) {
<div class="input-group input-group-sm me-2 d-none d-md-flex">
<div class="input-group-text" i18n>Page</div>
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages" [(ngModel)]="previewCurrentPage" />
<div class="input-group-text" i18n>of {{previewNumPages}}</div>
</div>
<div class="input-group input-group-sm me-5 d-none d-md-flex">
<button class="btn btn-outline-secondary" (click)="decreaseZoom()" i18n>-</button>
<select class="form-select" (change)="onZoomSelect($event)">
@for (setting of zoomSettings; track setting) {
<option [value]="setting" [selected]="previewZoomSetting === setting">
{{ getZoomSettingTitle(setting) }}
</option>
}
</select>
<button class="btn btn-outline-secondary" (click)="increaseZoom()" i18n>+</button>
</div>
}
<ng-container *ngIf="getContentType() === 'application/pdf' && !useNativePdfViewer">
<div class="input-group input-group-sm me-2 d-none d-md-flex">
<div class="input-group-text" i18n>Page</div>
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages" [(ngModel)]="previewCurrentPage" />
<div class="input-group-text" i18n>of {{previewNumPages}}</div>
</div>
<div class="input-group input-group-sm me-5 d-none d-md-flex">
<button class="btn btn-outline-secondary" (click)="decreaseZoom()" i18n>-</button>
<select class="form-select" (change)="onZoomSelect($event)">
<option *ngFor="let setting of zoomSettings" [value]="setting" [selected]="previewZoomSetting === setting">
{{ getZoomSettingTitle(setting) }}
</option>
</select>
<button class="btn btn-outline-secondary" (click)="increaseZoom()" i18n>+</button>
</div>
</ng-container>
<button type="button" class="btn btn-sm btn-outline-danger me-4" (click)="delete()" [disabled]="!userIsOwner" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#trash" />
</svg><span class="d-none d-lg-inline ps-1" i18n>Delete</span>
<button type="button" class="btn btn-sm btn-outline-danger me-4" (click)="delete()" [disabled]="!userIsOwner" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#trash" />
</svg><span class="d-none d-lg-inline ps-1" i18n>Delete</span>
</button>
<div class="btn-group me-2">
<a [href]="downloadUrl" class="btn btn-sm btn-outline-primary">
<svg class="buttonicon me-md-1" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#download" />
</svg><span class="d-none d-lg-inline ps-1" i18n>Download</span>
<a [href]="downloadUrl" class="btn btn-sm btn-outline-primary">
<svg class="buttonicon me-md-1" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#download" />
</svg><span class="d-none d-lg-inline ps-1" i18n>Download</span>
</a>
@if (metadata?.has_archive_version) {
<div class="btn-group" ngbDropdown role="group">
<div class="btn-group" ngbDropdown role="group" *ngIf="metadata?.has_archive_version">
<button class="btn btn-sm btn-outline-primary dropdown-toggle" ngbDropdownToggle></button>
<div class="dropdown-menu shadow" ngbDropdownMenu>
<a ngbDropdownItem [href]="downloadOriginalUrl" i18n>Download original</a>
<a ngbDropdownItem [href]="downloadOriginalUrl" i18n>Download original</a>
</div>
</div>
}
</div>
</div>
</div>
<div class="ms-auto" ngbDropdown>
<div class="ms-auto" ngbDropdown>
<button class="btn btn-sm btn-outline-primary me-2" id="actionsDropdown" ngbDropdownToggle>
<svg class="toolbaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#three-dots" />
@@ -50,279 +46,240 @@
</button>
<div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow">
<button ngbDropdownItem (click)="redoOcr()" [disabled]="!userCanEdit">
<svg class="buttonicon-sm" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#arrow-counterclockwise" />
</svg><span class="ps-1" i18n>Redo OCR</span>
</button>
<button ngbDropdownItem (click)="moreLike()">
<svg class="buttonicon-sm" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#diagram-3" />
</svg><span class="ps-1" i18n>More like this</span>
</button>
</div>
</div>
<use xlink:href="assets/bootstrap-icons.svg#arrow-counterclockwise" />
</svg><span class="ps-1" i18n>Redo OCR</span>
</button>
<pngx-custom-fields-dropdown
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"
class="me-2"
[documentId]="documentId"
[disabled]="!userIsOwner"
[existingFields]="document?.custom_fields"
(created)="refreshCustomFields()"
(added)="addField($event)">
</pngx-custom-fields-dropdown>
<button ngbDropdownItem (click)="moreLike()">
<svg class="buttonicon-sm" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#diagram-3" />
</svg><span class="ps-1" i18n>More like this</span>
</button>
</div>
</div>
<pngx-share-links-dropdown [documentId]="documentId" [hasArchiveVersion]="!!document?.archived_file_name" [disabled]="!userIsOwner" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ShareLink }"></pngx-share-links-dropdown>
</pngx-page-header>
<pngx-custom-fields-dropdown
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"
class="me-2"
[documentId]="documentId"
[disabled]="!userIsOwner"
[existingFields]="document?.custom_fields"
(created)="refreshCustomFields()"
(added)="addField($event)">
</pngx-custom-fields-dropdown>
<div class="row">
<div class="col-md-6 col-xl-4 mb-4">
<pngx-share-links-dropdown [documentId]="documentId" [hasArchiveVersion]="!!document?.archived_file_name" [disabled]="!userIsOwner" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ShareLink }"></pngx-share-links-dropdown>
</pngx-page-header>
<form [formGroup]='documentForm' (ngSubmit)="save()">
<div class="row">
<div class="col-md-6 col-xl-4 mb-4">
<div class="btn-toolbar mb-1 pb-3 border-bottom">
<form [formGroup]='documentForm' (ngSubmit)="save()">
<div class="btn-toolbar mb-1 pb-3 border-bottom">
<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Close" (click)="close()">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#x" />
</svg>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Previous" (click)="previousDoc()" [disabled]="!hasPrevious()">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#arrow-left" />
</svg>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Next" (click)="nextDoc()" [disabled]="!hasNext()">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#arrow-right" />
</svg>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Close" (click)="close()">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#x" />
</svg>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Previous" (click)="previousDoc()" [disabled]="!hasPrevious()">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#arrow-left" />
</svg>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Next" (click)="nextDoc()" [disabled]="!hasNext()">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#arrow-right" />
</svg>
</button>
</div>
<div class="btn-group ms-auto">
<button type="button" class="btn btn-sm btn-outline-secondary" (click)="discard()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Discard</button>
<ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
@if (hasNext()) {
<button type="button" class="btn btn-sm btn-outline-primary" (click)="saveEditNext()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save &amp; next</button>
}
@if (!hasNext()) {
<button type="button" class="btn btn-sm btn-outline-primary" (click)="save(true)" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save &amp; close</button>
}
<button type="submit" class="btn btn-sm btn-primary" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save</button>
</ng-container>
</div>
</div>
<ng-container *ngTemplateOutlet="saveButtons"></ng-container>
</div>
<ul ngbNav #nav="ngbNav" class="nav-underline flex-nowrap flex-md-wrap overflow-auto" (navChange)="onNavChange($event)" [(activeId)]="activeNavID">
<ul ngbNav #nav="ngbNav" class="nav-underline flex-nowrap flex-md-wrap overflow-auto" (navChange)="onNavChange($event)" [(activeId)]="activeNavID">
<li [ngbNavItem]="DocumentDetailNavIDs.Details">
<a ngbNavLink i18n>Details</a>
<ng-template ngbNavContent>
<div>
<pngx-input-text #inputTitle i18n-title title="Title" formControlName="title" [horizontal]="true" (keyup)="titleKeyUp($event)" [error]="error?.title"></pngx-input-text>
<pngx-input-number i18n-title title="Archive serial number" [error]="error?.archive_serial_number" [horizontal]="true" formControlName='archive_serial_number'></pngx-input-number>
<pngx-input-date i18n-title title="Date created" formControlName="created_date" [suggestions]="suggestions?.dates" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
[error]="error?.created_date"></pngx-input-date>
<pngx-input-select [items]="correspondents" i18n-title title="Correspondent" formControlName="correspondent" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
(createNew)="createCorrespondent($event)" [suggestions]="suggestions?.correspondents" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }"></pngx-input-select>
<pngx-input-select [items]="documentTypes" i18n-title title="Document type" formControlName="document_type" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
(createNew)="createDocumentType($event)" [suggestions]="suggestions?.document_types" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }"></pngx-input-select>
<pngx-input-select [items]="storagePaths" i18n-title title="Storage path" formControlName="storage_path" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
(createNew)="createStoragePath($event)" [suggestions]="suggestions?.storage_paths" i18n-placeholder placeholder="Default" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }"></pngx-input-select>
<pngx-input-tags formControlName="tags" [suggestions]="suggestions?.tags" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"></pngx-input-tags>
@for (fieldInstance of document?.custom_fields; track fieldInstance; let i = $index) {
<div [formGroup]="customFieldFormFields.controls[i]">
@switch (getCustomFieldFromInstance(fieldInstance)?.data_type) {
@case (PaperlessCustomFieldDataType.String) {
<pngx-input-text formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [error]="getCustomFieldError(i)"></pngx-input-text>
}
@case (PaperlessCustomFieldDataType.Date) {
<pngx-input-date formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [error]="getCustomFieldError(i)"></pngx-input-date>
}
@case (PaperlessCustomFieldDataType.Integer) {
<pngx-input-number formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [showAdd]="false" [error]="getCustomFieldError(i)"></pngx-input-number>
}
@case (PaperlessCustomFieldDataType.Float) {
<pngx-input-number formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [showAdd]="false" [step]=".1" [error]="getCustomFieldError(i)"></pngx-input-number>
}
@case (PaperlessCustomFieldDataType.Monetary) {
<pngx-input-number formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [showAdd]="false" [step]=".01" [error]="getCustomFieldError(i)"></pngx-input-number>
}
@case (PaperlessCustomFieldDataType.Boolean) {
<pngx-input-check formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true"></pngx-input-check>
}
@case (PaperlessCustomFieldDataType.Url) {
<pngx-input-url formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [error]="getCustomFieldError(i)"></pngx-input-url>
}
@case (PaperlessCustomFieldDataType.DocumentLink) {
<pngx-input-document-link formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [error]="getCustomFieldError(i)"></pngx-input-document-link>
}
}
<a ngbNavLink i18n>Details</a>
<ng-template ngbNavContent>
<div>
<pngx-input-text #inputTitle i18n-title title="Title" formControlName="title" [horizontal]="true" (keyup)="titleKeyUp($event)" [error]="error?.title"></pngx-input-text>
<pngx-input-number i18n-title title="Archive serial number" [error]="error?.archive_serial_number" [horizontal]="true" formControlName='archive_serial_number'></pngx-input-number>
<pngx-input-date i18n-title title="Date created" formControlName="created_date" [suggestions]="suggestions?.dates" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
[error]="error?.created_date"></pngx-input-date>
<pngx-input-select [items]="correspondents" i18n-title title="Correspondent" formControlName="correspondent" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
(createNew)="createCorrespondent($event)" [suggestions]="suggestions?.correspondents" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }"></pngx-input-select>
<pngx-input-select [items]="documentTypes" i18n-title title="Document type" formControlName="document_type" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
(createNew)="createDocumentType($event)" [suggestions]="suggestions?.document_types" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }"></pngx-input-select>
<pngx-input-select [items]="storagePaths" i18n-title title="Storage path" formControlName="storage_path" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
(createNew)="createStoragePath($event)" [suggestions]="suggestions?.storage_paths" i18n-placeholder placeholder="Default" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }"></pngx-input-select>
<pngx-input-tags formControlName="tags" [suggestions]="suggestions?.tags" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"></pngx-input-tags>
<ng-container *ngFor="let fieldInstance of document?.custom_fields; let i = index">
<div [formGroup]="customFieldFormFields.controls[i]" [ngSwitch]="getCustomFieldFromInstance(fieldInstance)?.data_type">
<pngx-input-text *ngSwitchCase="PaperlessCustomFieldDataType.String" formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [error]="getCustomFieldError(i)"></pngx-input-text>
<pngx-input-date *ngSwitchCase="PaperlessCustomFieldDataType.Date" formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [error]="getCustomFieldError(i)"></pngx-input-date>
<pngx-input-number *ngSwitchCase="PaperlessCustomFieldDataType.Integer" formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [showAdd]="false" [error]="getCustomFieldError(i)"></pngx-input-number>
<pngx-input-number *ngSwitchCase="PaperlessCustomFieldDataType.Float" formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [showAdd]="false" [step]=".1" [error]="getCustomFieldError(i)"></pngx-input-number>
<pngx-input-number *ngSwitchCase="PaperlessCustomFieldDataType.Monetary" formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [showAdd]="false" [step]=".01" [error]="getCustomFieldError(i)"></pngx-input-number>
<pngx-input-check *ngSwitchCase="PaperlessCustomFieldDataType.Boolean" formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true"></pngx-input-check>
<pngx-input-url *ngSwitchCase="PaperlessCustomFieldDataType.Url" formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [error]="getCustomFieldError(i)"></pngx-input-url>
<pngx-input-document-link *ngSwitchCase="PaperlessCustomFieldDataType.DocumentLink" formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [parentDocumentID]="documentId" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [error]="getCustomFieldError(i)"></pngx-input-document-link>
</div>
</ng-container>
</div>
}
</div>
</ng-template>
<div class="d-flex border-top pt-3">
<ng-container *ngTemplateOutlet="saveButtons"></ng-container>
</div>
</ng-template>
</li>
<li [ngbNavItem]="DocumentDetailNavIDs.Content">
<a ngbNavLink i18n>Content</a>
<ng-template ngbNavContent>
<div>
<textarea class="form-control" id="content" rows="20" formControlName='content' [class.rtl]="isRTL"></textarea>
</div>
</ng-template>
<a ngbNavLink i18n>Content</a>
<ng-template ngbNavContent>
<div>
<textarea class="form-control" id="content" rows="20" formControlName='content' [class.rtl]="isRTL"></textarea>
</div>
</ng-template>
</li>
<li [ngbNavItem]="DocumentDetailNavIDs.Metadata">
<a ngbNavLink i18n>Metadata</a>
<ng-template ngbNavContent>
<a ngbNavLink i18n>Metadata</a>
<ng-template ngbNavContent>
@if (document) {
<table class="table table-borderless">
<tbody>
<tr>
<td i18n>Date modified</td>
<td>{{document.modified | customDate}}</td>
</tr>
<tr>
<td i18n>Date added</td>
<td>{{document.added | customDate}}</td>
</tr>
<tr>
<td i18n>Media filename</td>
<td>{{metadata?.media_filename}}</td>
</tr>
<tr>
<td i18n>Original filename</td>
<td>{{metadata?.original_filename}}</td>
</tr>
<tr>
<td i18n>Original MD5 checksum</td>
<td>{{metadata?.original_checksum}}</td>
</tr>
<tr>
<td i18n>Original file size</td>
<td>{{metadata?.original_size | fileSize}}</td>
</tr>
<tr>
<td i18n>Original mime type</td>
<td>{{metadata?.original_mime_type}}</td>
</tr>
@if (metadata?.has_archive_version) {
<tr>
<td i18n>Archive MD5 checksum</td>
<td>{{metadata?.archive_checksum}}</td>
</tr>
}
@if (metadata?.has_archive_version) {
<tr>
<td i18n>Archive file size</td>
<td>{{metadata?.archive_size | fileSize}}</td>
</tr>
}
</tbody>
</table>
}
<table class="table table-borderless" *ngIf="document">
<tbody>
<tr>
<td i18n>Date modified</td>
<td>{{document.modified | customDate}}</td>
</tr>
<tr>
<td i18n>Date added</td>
<td>{{document.added | customDate}}</td>
</tr>
<tr>
<td i18n>Media filename</td>
<td>{{metadata?.media_filename}}</td>
</tr>
<tr>
<td i18n>Original filename</td>
<td>{{metadata?.original_filename}}</td>
</tr>
<tr>
<td i18n>Original MD5 checksum</td>
<td>{{metadata?.original_checksum}}</td>
</tr>
<tr>
<td i18n>Original file size</td>
<td>{{metadata?.original_size | fileSize}}</td>
</tr>
<tr>
<td i18n>Original mime type</td>
<td>{{metadata?.original_mime_type}}</td>
</tr>
<tr *ngIf="metadata?.has_archive_version">
<td i18n>Archive MD5 checksum</td>
<td>{{metadata?.archive_checksum}}</td>
</tr>
<tr *ngIf="metadata?.has_archive_version">
<td i18n>Archive file size</td>
<td>{{metadata?.archive_size | fileSize}}</td>
</tr>
</tbody>
</table>
@if (metadata?.original_metadata?.length > 0) {
<pngx-metadata-collapse i18n-title title="Original document metadata" [metadata]="metadata.original_metadata"></pngx-metadata-collapse>
}
@if (metadata?.archive_metadata?.length > 0) {
<pngx-metadata-collapse i18n-title title="Archived document metadata" [metadata]="metadata.archive_metadata"></pngx-metadata-collapse>
}
<pngx-metadata-collapse i18n-title title="Original document metadata" [metadata]="metadata.original_metadata" *ngIf="metadata?.original_metadata?.length > 0"></pngx-metadata-collapse>
<pngx-metadata-collapse i18n-title title="Archived document metadata" [metadata]="metadata.archive_metadata" *ngIf="metadata?.archive_metadata?.length > 0"></pngx-metadata-collapse>
</ng-template>
</ng-template>
</li>
<li [ngbNavItem]="DocumentDetailNavIDs.Preview" class="d-md-none">
<a ngbNavLink i18n>Preview</a>
@if (!pdfPreview.offsetParent) {
<ng-template ngbNavContent>
<ng-container *ngTemplateOutlet="previewContent"></ng-container>
<a ngbNavLink i18n>Preview</a>
<ng-template ngbNavContent *ngIf="!pdfPreview.offsetParent">
<ng-container *ngTemplateOutlet="previewContent"></ng-container>
</ng-template>
}
</li>
@if (notesEnabled) {
<li [ngbNavItem]="DocumentDetailNavIDs.Notes">
<a class="text-nowrap" ngbNavLink i18n>Notes @if (document?.notes.length) {
<span class="badge text-bg-secondary ms-1">{{document.notes.length}}</span>
}</a>
<li [ngbNavItem]="DocumentDetailNavIDs.Notes" *ngIf="notesEnabled">
<a class="text-nowrap" ngbNavLink i18n>Notes <span *ngIf="document?.notes.length" class="badge text-bg-secondary ms-1">{{document.notes.length}}</span></a>
<ng-template ngbNavContent>
<pngx-document-notes [documentId]="documentId" [notes]="document?.notes" [addDisabled]="!userCanEdit" (updated)="notesUpdated($event)"></pngx-document-notes>
<pngx-document-notes [documentId]="documentId" [notes]="document?.notes" [addDisabled]="!userCanEdit" (updated)="notesUpdated($event)"></pngx-document-notes>
</ng-template>
</li>
}
</li>
@if (showPermissions) {
<li [ngbNavItem]="DocumentDetailNavIDs.Permissions">
<li [ngbNavItem]="DocumentDetailNavIDs.Permissions" *ngIf="showPermissions">
<a ngbNavLink i18n>Permissions</a>
<ng-template ngbNavContent>
<div class="mb-3">
<pngx-permissions-form [users]="users" formControlName="permissions_form"></pngx-permissions-form>
</div>
<div class="mb-3">
<pngx-permissions-form [users]="users" formControlName="permissions_form"></pngx-permissions-form>
</div>
</ng-template>
</li>
}
</ul>
</li>
</ul>
<div [ngbNavOutlet]="nav" class="mt-3"></div>
<div [ngbNavOutlet]="nav" class="mt-3"></div>
</form>
</div>
</form>
</div>
<div class="col-md-6 col-xl-8 mb-3 d-none d-md-block position-relative" #pdfPreview>
<ng-container *ngTemplateOutlet="previewContent"></ng-container>
@if (renderAsPlainText) {
<div [innerText]="previewText" class="preview-sticky bg-light p-3 overflow-auto" width="100%"></div>
}
@if (requiresPassword) {
<div class="password-prompt">
<form>
<input autocomplete="" class="form-control" i18n-placeholder placeholder="Enter Password" type="password" (keyup)="onPasswordKeyUp($event)" />
</form>
</div>
}
</div>
</div>
<ng-template #previewContent>
@if (!metadata) {
<div class="w-100 h-100 d-flex align-items-center justify-content-center">
<div>
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<ng-container i18n>Loading...</ng-container>
</div>
</div>
}
@if (getContentType() === 'application/pdf') {
@if (!useNativePdfViewer ) {
<div class="preview-sticky pdf-viewer-container">
<pngx-pdf-viewer
[src]="{ url: previewUrl, password: password }"
[original-size]="false"
[show-borders]="true"
[show-all]="true"
[(page)]="previewCurrentPage"
[zoom-scale]="previewZoomScale"
[zoom]="previewZoomSetting"
(error)="onError($event)"
(after-load-complete)="pdfPreviewLoaded($event)">
</pngx-pdf-viewer>
</div>
} @else {
<object [data]="previewUrl | safeUrl" class="preview-sticky" width="100%"></object>
}
}
@if (renderAsPlainText) {
<div class="col-md-6 col-xl-8 mb-3 d-none d-md-block position-relative" #pdfPreview>
<ng-container *ngTemplateOutlet="previewContent"></ng-container>
<ng-container *ngIf="renderAsPlainText">
<div [innerText]="previewText" class="preview-sticky bg-light p-3 overflow-auto" width="100%"></div>
}
@if (showPasswordField) {
<div class="password-prompt">
<form>
<input autocomplete="" autofocus="true" class="form-control" i18n-placeholder placeholder="Enter Password" type="password" (keyup)="onPasswordKeyUp($event)" />
</form>
</div>
}
</ng-template>
</ng-container>
<div *ngIf="requiresPassword" class="password-prompt">
<form>
<input autocomplete="" class="form-control" i18n-placeholder placeholder="Enter Password" type="password" (keyup)="onPasswordKeyUp($event)" />
</form>
</div>
</div>
</div>
<ng-template #saveButtons>
<div class="btn-group ms-auto">
<ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
<button type="submit" class="order-3 btn btn-sm btn-primary" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save</button>
<button *ngIf="hasNext()" type="button" class="order-1 btn btn-sm btn-outline-primary" (click)="saveEditNext()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save &amp; next</button>
<button *ngIf="!hasNext()" type="button" class="order-2 btn btn-sm btn-outline-primary" (click)="save(true)" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save &amp; close</button>
</ng-container>
<button type="button" class="order-0 btn btn-sm btn-outline-secondary" (click)="discard()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Discard</button>
</div>
</ng-template>
<ng-template #previewContent>
<div *ngIf="!metadata" class="w-100 h-100 d-flex align-items-center justify-content-center">
<div>
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<ng-container i18n>Loading...</ng-container>
</div>
</div>
<ng-container *ngIf="getContentType() === 'application/pdf'">
<div class="preview-sticky pdf-viewer-container" *ngIf="!useNativePdfViewer ; else nativePdfViewer">
<pngx-pdf-viewer
[src]="{ url: previewUrl, password: password }"
[original-size]="false"
[show-borders]="true"
[show-all]="true"
[(page)]="previewCurrentPage"
[zoom-scale]="previewZoomScale"
[zoom]="previewZoomSetting"
(error)="onError($event)"
(after-load-complete)="pdfPreviewLoaded($event)">
</pngx-pdf-viewer>
</div>
<ng-template #nativePdfViewer>
<object [data]="previewUrl | safeUrl" class="preview-sticky" width="100%"></object>
</ng-template>
</ng-container>
<ng-container *ngIf="renderAsPlainText">
<div [innerText]="previewText" class="preview-sticky bg-light p-3 overflow-auto" width="100%"></div>
</ng-container>
<div *ngIf="showPasswordField" class="password-prompt">
<form>
<input autocomplete="" autofocus="true" class="form-control" i18n-placeholder placeholder="Enter Password" type="password" (keyup)="onPasswordKeyUp($event)" />
</form>
</div>
</ng-template>

View File

@@ -44,3 +44,17 @@ textarea.rtl {
.input-group .btn-outline-secondary {
border-color: var(--bs-border-color);
}
.btn-group .btn.order-0 {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-top-left-radius: var(--bs-border-radius-sm);
border-bottom-left-radius: var(--bs-border-radius-sm);
}
.btn-group .btn.order-3 {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-top-right-radius: var(--bs-border-radius-sm);
border-bottom-right-radius: var(--bs-border-radius-sm);
}

View File

@@ -16,35 +16,23 @@
<div class="d-flex justify-content-between align-items-center">
<h5 class="card-title">
@if (document.correspondent) {
@if (clickCorrespondent.observers.length ) {
<a title="Filter by correspondent" i18n-title (click)="clickCorrespondent.emit(document.correspondent);$event.stopPropagation()" class="fw-bold btn-link">{{(document.correspondent$ | async)?.name}}</a>
} @else {
{{(document.correspondent$ | async)?.name}}
}
:
}
<ng-container *ngIf="document.correspondent">
<a *ngIf="clickCorrespondent.observers.length ; else nolink" title="Filter by correspondent" i18n-title (click)="clickCorrespondent.emit(document.correspondent);$event.stopPropagation()" class="fw-bold btn-link">{{(document.correspondent$ | async)?.name}}</a>
<ng-template #nolink>{{(document.correspondent$ | async)?.name}}</ng-template>:
</ng-container>
{{document.title | documentTitle}}
@for (t of document.tags$ | async; track t) {
<pngx-tag [tag]="t" linkTitle="Filter by tag" i18n-linkTitle class="ms-1" (click)="clickTag.emit(t.id);$event.stopPropagation()" [clickable]="clickTag.observers.length"></pngx-tag>
}
<pngx-tag [tag]="t" linkTitle="Filter by tag" i18n-linkTitle *ngFor="let t of document.tags$ | async" class="ms-1" (click)="clickTag.emit(t.id);$event.stopPropagation()" [clickable]="clickTag.observers.length"></pngx-tag>
</h5>
</div>
<p class="card-text">
@if (document.__search_hit__ && document.__search_hit__.highlights) {
<span [innerHtml]="document.__search_hit__.highlights"></span>
}
@for (highlight of searchNoteHighlights; track highlight) {
<span class="d-block">
<svg width="1em" height="1em" fill="currentColor" class="me-2">
<use xlink:href="assets/bootstrap-icons.svg#chat-left-text"/>
</svg>
<span [innerHtml]="highlight"></span>
</span>
}
@if (!document.__search_hit__) {
<span class="result-content">{{contentTrimmed}}</span>
}
<span *ngIf="document.__search_hit__ && document.__search_hit__.highlights" [innerHtml]="document.__search_hit__.highlights"></span>
<span *ngFor="let highlight of searchNoteHighlights" class="d-block">
<svg width="1em" height="1em" fill="currentColor" class="me-2">
<use xlink:href="assets/bootstrap-icons.svg#chat-left-text"/>
</svg>
<span [innerHtml]="highlight"></span>
</span>
<span *ngIf="!document.__search_hit__" class="result-content">{{contentTrimmed}}</span>
</p>
@@ -53,97 +41,85 @@
<a class="btn btn-sm btn-outline-secondary" (click)="clickMoreLike.emit()">
<svg class="sidebaricon" fill="currentColor" class="sidebaricon">
<use xlink:href="assets/bootstrap-icons.svg#diagram-3"/>
</svg>&nbsp;<span class="d-none d-md-inline" i18n>More like this</span>
</a>
<a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
<svg class="sidebaricon" fill="currentColor" class="sidebaricon">
<use xlink:href="assets/bootstrap-icons.svg#pencil"/>
</svg>&nbsp;<span class="d-none d-md-inline" i18n>Edit</span>
</a>
<a class="btn btn-sm btn-outline-secondary" target="_blank" [href]="previewUrl"
[ngbPopover]="previewContent" [popoverTitle]="document.title | documentTitle"
autoClose="true" popoverClass="shadow popover-preview" (mouseenter)="mouseEnterPreview()" (mouseleave)="mouseLeavePreview()" #popover="ngbPopover">
<svg class="sidebaricon" fill="currentColor" class="sidebaricon">
<use xlink:href="assets/bootstrap-icons.svg#eye"/>
</svg>&nbsp;<span class="d-none d-md-inline" i18n>View</span>
</a>
<ng-template #previewContent>
<object [data]="previewUrl | safeUrl" class="preview" width="100%"></object>
</ng-template>
<a class="btn btn-sm btn-outline-secondary" [href]="getDownloadUrl()">
<svg class="sidebaricon" fill="currentColor" class="sidebaricon">
<use xlink:href="assets/bootstrap-icons.svg#download"/>
</svg>&nbsp;<span class="d-none d-md-inline" i18n>Download</span>
</a>
</div>
</svg>&nbsp;<span class="d-none d-md-inline" i18n>More like this</span>
</a>
<a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
<svg class="sidebaricon" fill="currentColor" class="sidebaricon">
<use xlink:href="assets/bootstrap-icons.svg#pencil"/>
</svg>&nbsp;<span class="d-none d-md-inline" i18n>Edit</span>
</a>
<a class="btn btn-sm btn-outline-secondary" target="_blank" [href]="previewUrl"
[ngbPopover]="previewContent" [popoverTitle]="document.title | documentTitle"
autoClose="true" popoverClass="shadow popover-preview" (mouseenter)="mouseEnterPreview()" (mouseleave)="mouseLeavePreview()" #popover="ngbPopover">
<svg class="sidebaricon" fill="currentColor" class="sidebaricon">
<use xlink:href="assets/bootstrap-icons.svg#eye"/>
</svg>&nbsp;<span class="d-none d-md-inline" i18n>View</span>
</a>
<ng-template #previewContent>
<pngx-preview-popup [document]="document"></pngx-preview-popup>
</ng-template>
<a class="btn btn-sm btn-outline-secondary" [href]="getDownloadUrl()">
<svg class="sidebaricon" fill="currentColor" class="sidebaricon">
<use xlink:href="assets/bootstrap-icons.svg#download"/>
</svg>&nbsp;<span class="d-none d-md-inline" i18n>Download</span>
</a>
</div>
<div class="list-group list-group-horizontal border-0 card-info ms-md-auto mt-2 mt-md-0">
@if (notesEnabled && document.notes.length) {
<button routerLink="/documents/{{document.id}}/notes" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2" title="View notes" i18n-title>
<svg class="metadata-icon me-2 text-muted" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#chat-left-text"/>
</svg>
<small i18n>{{document.notes.length}} Notes</small>
</button>
}
@if (document.document_type) {
<button type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2" title="Filter by document type" i18n-title
(click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()">
<svg class="metadata-icon me-2 text-muted" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#file-earmark"/>
</svg>
<small>{{(document.document_type$ | async)?.name}}</small>
</button>
}
@if (document.storage_path) {
<button type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2" title="Filter by storage path" i18n-title
(click)="clickStoragePath.emit(document.storage_path);$event.stopPropagation()">
<svg class="metadata-icon me-2 text-muted" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#archive"/>
</svg>
<small>{{(document.storage_path$ | async)?.name}}</small>
</button>
}
@if (document.archive_serial_number | isNumber) {
<div class="list-group-item me-2 bg-light text-dark p-1 border-0">
<svg class="metadata-icon me-2 text-muted" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#upc-scan"/>
</svg>
<small>#{{document.archive_serial_number}}</small>
</div>
}
<ng-template #dateTooltip>
<div class="d-flex flex-column text-light">
<span i18n>Created: {{ document.created | customDate }}</span>
<span i18n>Added: {{ document.added | customDate }}</span>
<span i18n>Modified: {{ document.modified | customDate }}</span>
</div>
</ng-template>
<div class="list-group-item bg-light text-dark p-1 border-0" [ngbTooltip]="dateTooltip">
<svg class="metadata-icon me-2 text-muted" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#calendar-event"/>
</svg>
<small>{{document.created_date | customDate:'mediumDate'}}</small>
</div>
@if (document.owner && document.owner !== settingsService.currentUser.id) {
<div class="list-group-item bg-light text-dark p-1 border-0">
<svg class="metadata-icon me-2 text-muted" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#person-fill-lock"/>
</svg>
<small>{{document.owner | username}}</small>
</div>
}
@if (document.__search_hit__?.score) {
<div class="list-group-item bg-light text-dark border-0 d-flex p-0 ps-4 search-score">
<small class="text-muted" i18n>Score:</small>
<ngb-progressbar [type]="searchScoreClass" [value]="document.__search_hit__.score" class="search-score-bar mx-2 mt-1" [max]="1"></ngb-progressbar>
</div>
}
</div>
</div>
<div class="list-group list-group-horizontal border-0 card-info ms-md-auto mt-2 mt-md-0">
<button *ngIf="notesEnabled && document.notes.length" routerLink="/documents/{{document.id}}/notes" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2" title="View notes" i18n-title>
<svg class="metadata-icon me-2 text-muted" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#chat-left-text"/>
</svg>
<small i18n>{{document.notes.length}} Notes</small>
</button>
<button *ngIf="document.document_type" type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2" title="Filter by document type" i18n-title
(click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()">
<svg class="metadata-icon me-2 text-muted" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#file-earmark"/>
</svg>
<small>{{(document.document_type$ | async)?.name}}</small>
</button>
<button *ngIf="document.storage_path" type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2" title="Filter by storage path" i18n-title
(click)="clickStoragePath.emit(document.storage_path);$event.stopPropagation()">
<svg class="metadata-icon me-2 text-muted" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#archive"/>
</svg>
<small>{{(document.storage_path$ | async)?.name}}</small>
</button>
<div *ngIf="document.archive_serial_number | isNumber" class="list-group-item me-2 bg-light text-dark p-1 border-0">
<svg class="metadata-icon me-2 text-muted" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#upc-scan"/>
</svg>
<small>#{{document.archive_serial_number}}</small>
</div>
<ng-template #dateTooltip>
<div class="d-flex flex-column text-light">
<span i18n>Created: {{ document.created | customDate }}</span>
<span i18n>Added: {{ document.added | customDate }}</span>
<span i18n>Modified: {{ document.modified | customDate }}</span>
</div>
</ng-template>
<div class="list-group-item bg-light text-dark p-1 border-0" [ngbTooltip]="dateTooltip">
<svg class="metadata-icon me-2 text-muted" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#calendar-event"/>
</svg>
<small>{{document.created_date | customDate:'mediumDate'}}</small>
</div>
<div *ngIf="document.owner && document.owner !== settingsService.currentUser.id" class="list-group-item bg-light text-dark p-1 border-0">
<svg class="metadata-icon me-2 text-muted" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#person-fill-lock"/>
</svg>
<small>{{document.owner | username}}</small>
</div>
<div *ngIf="document.__search_hit__?.score" class="list-group-item bg-light text-dark border-0 d-flex p-0 ps-4 search-score">
<small class="text-muted" i18n>Score:</small>
<ngb-progressbar [type]="searchScoreClass" [value]="document.__search_hit__.score" class="search-score-bar mx-2 mt-1" [max]="1"></ngb-progressbar>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -19,6 +19,7 @@ import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
import { SafeUrlPipe } from 'src/app/pipes/safeurl.pipe'
import { DocumentCardLargeComponent } from './document-card-large.component'
import { IsNumberPipe } from 'src/app/pipes/is-number.pipe'
import { PreviewPopupComponent } from '../../common/preview-popup/preview-popup.component'
const doc = {
id: 10,
@@ -50,6 +51,7 @@ describe('DocumentCardLargeComponent', () => {
IfPermissionsDirective,
SafeUrlPipe,
IsNumberPipe,
PreviewPopupComponent,
],
providers: [DatePipe],
imports: [

View File

@@ -15,10 +15,7 @@ import { ComponentWithPermissions } from '../../with-permissions/with-permission
@Component({
selector: 'pngx-document-card-large',
templateUrl: './document-card-large.component.html',
styleUrls: [
'./document-card-large.component.scss',
'../popover-preview/popover-preview.scss',
],
styleUrls: ['./document-card-large.component.scss'],
})
export class DocumentCardLargeComponent extends ComponentWithPermissions {
constructor(

View File

@@ -108,7 +108,7 @@
</svg>
</a>
<ng-template #previewContent>
<object [data]="previewUrl | safeUrl" class="preview" width="100%"></object>
<pngx-preview-popup [document]="document"></pngx-preview-popup>
</ng-template>
<a [href]="getDownloadUrl()" class="btn btn-sm btn-outline-secondary" title="Download" i18n-title (click)="$event.stopPropagation()">
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-download" fill="currentColor" xmlns="http://www.w3.org/2000/svg">

View File

@@ -22,6 +22,7 @@ import { By } from '@angular/platform-browser'
import { TagComponent } from '../../common/tag/tag.component'
import { PaperlessTag } from 'src/app/data/paperless-tag'
import { IsNumberPipe } from 'src/app/pipes/is-number.pipe'
import { PreviewPopupComponent } from '../../common/preview-popup/preview-popup.component'
const doc = {
id: 10,
@@ -64,6 +65,7 @@ describe('DocumentCardSmallComponent', () => {
SafeUrlPipe,
TagComponent,
IsNumberPipe,
PreviewPopupComponent,
],
providers: [DatePipe],
imports: [

View File

@@ -16,10 +16,7 @@ import { ComponentWithPermissions } from '../../with-permissions/with-permission
@Component({
selector: 'pngx-document-card-small',
templateUrl: './document-card-small.component.html',
styleUrls: [
'./document-card-small.component.scss',
'../popover-preview/popover-preview.scss',
],
styleUrls: ['./document-card-small.component.scss'],
})
export class DocumentCardSmallComponent extends ComponentWithPermissions {
constructor(

View File

@@ -1,22 +0,0 @@
::ng-deep .popover.popover-preview {
max-width: 40rem;
.preview {
min-width: 30rem;
min-height: 18rem;
max-height: 35rem;
overflow-y: scroll;
}
.spinner-border {
position: absolute;
top: 4rem;
left: calc(50% - 0.5rem);
z-index: 0;
}
}
::ng-deep .popover-hidden .popover {
opacity: 0;
pointer-events: none;
}

View File

@@ -96,6 +96,21 @@ export const commonAbstractPaperlessServiceTests = (endpoint, ServiceClass) => {
expect(req.request.method).toEqual('PATCH')
req.flush([])
})
test('should call appropriate api endpoint for get a few objects', () => {
subscription = service.getFew([1, 2, 3]).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/?id__in=1,2,3`
)
expect(req.request.method).toEqual('GET')
req.flush([])
subscription = service.getFew([4, 5, 6], { foo: 'bar' }).subscribe()
const req2 = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/?id__in=4,5,6&foo=bar`
)
expect(req2.request.method).toEqual('GET')
req2.flush([])
})
})
beforeEach(() => {

View File

@@ -91,6 +91,19 @@ export abstract class AbstractPaperlessService<T extends ObjectWithId> {
)
}
getFew(ids: number[], extraParams?): Observable<Results<T>> {
let httpParams = new HttpParams()
httpParams = httpParams.set('id__in', ids.join(','))
for (let extraParamKey in extraParams) {
if (extraParams[extraParamKey] != null) {
httpParams = httpParams.set(extraParamKey, extraParams[extraParamKey])
}
}
return this.http.get<Results<T>>(this.getResourceUrl(), {
params: httpParams,
})
}
clearCache() {
this._listAll = null
}

View File

@@ -5,7 +5,7 @@ export const environment = {
apiBaseUrl: document.baseURI + 'api/',
apiVersion: '3',
appTitle: 'Paperless-ngx',
version: '2.1.2-dev',
version: '2.1.3-dev',
webSocketHost: window.location.host,
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
webSocketBaseUrl: base_url.pathname + 'ws/',

View File

@@ -10,13 +10,13 @@
</context-group>
<target state="final">Fermer</target>
</trans-unit>
<trans-unit id="ngb.timepicker.HH" datatype="html">
<trans-unit id="ngb.timepicker.HH" datatype="html" approved="yes">
<source>HH</source>
<context-group purpose="location">
<context context-type="sourcefile">node_modules/src/ngb-config.ts</context>
<context context-type="linenumber">13</context>
</context-group>
<target state="translated">HH</target>
<target state="final">HH</target>
</trans-unit>
<trans-unit id="ngb.toast.close-aria" datatype="html" approved="yes">
<source>Close</source>
@@ -100,13 +100,13 @@
</context-group>
<target state="final">Précédent</target>
</trans-unit>
<trans-unit id="ngb.timepicker.MM" datatype="html">
<trans-unit id="ngb.timepicker.MM" datatype="html" approved="yes">
<source>MM</source>
<context-group purpose="location">
<context context-type="sourcefile">node_modules/src/ngb-config.ts</context>
<context context-type="linenumber">13</context>
</context-group>
<target state="translated">MM</target>
<target state="final">MM</target>
</trans-unit>
<trans-unit id="ngb.pagination.next" datatype="html" approved="yes">
<source>»</source>

View File

@@ -288,7 +288,7 @@
<context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">90</context>
</context-group>
<target state="needs-translation">Document <x id="PH" equiv-text="status.filename"/> was added to Paperless-ngx.</target>
<target state="translated">Dokument <x id="PH" equiv-text="status.filename"/> je dodan u Paperless-ngx.</target>
</trans-unit>
<trans-unit id="1931214133925051574" datatype="html">
<source>Open document</source>
@@ -316,7 +316,7 @@
<context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">120</context>
</context-group>
<target state="needs-translation">Document <x id="PH" equiv-text="status.filename"/> is being processed by Paperless-ngx.</target>
<target state="translated">Dokument <x id="PH" equiv-text="status.filename"/> je u fazi obrade.</target>
</trans-unit>
<trans-unit id="2501522447884928778" datatype="html">
<source>Prev</source>
@@ -476,7 +476,7 @@
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
<context context-type="linenumber">15</context>
</context-group>
<target state="needs-translation">Auto refresh</target>
<target state="translated">Automatsko osvježavanje</target>
</trans-unit>
<trans-unit id="3894950702316166331" datatype="html">
<source>Loading...</source>
@@ -596,7 +596,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">15</context>
</context-group>
<target state="needs-translation">General</target>
<target state="translated">Općenito</target>
</trans-unit>
<trans-unit id="8671234314555525900" datatype="html">
<source>Appearance</source>
@@ -916,7 +916,7 @@
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">178,180</context>
</context-group>
<target state="needs-translation"> Settings apply to this user account for objects (Tags, Mail Rules, etc.) created via the web UI </target>
<target state="translated"> Postavke ovog korisničkog računa za objekte (Oznake, Pravila za e-poštu, itd.) stvorene putem web sučelja </target>
</trans-unit>
<trans-unit id="4292903881380648974" datatype="html">
<source>Default Owner</source>
@@ -2074,7 +2074,7 @@
<context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context>
<context context-type="linenumber">124</context>
</context-group>
<target state="needs-translation">Deleted user</target>
<target state="translated">Izbrisani korisnik</target>
</trans-unit>
<trans-unit id="1942566571910298572" datatype="html">
<source>Error deleting user.</source>
@@ -2479,7 +2479,7 @@
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
<context context-type="linenumber">276</context>
</context-group>
<target state="needs-translation">An error occurred while saving update checking settings.</target>
<target state="translated">Došlo je do pogreške prilikom spremanja postavki ažuriranja.</target>
</trans-unit>
<trans-unit id="8700121026680200191" datatype="html">
<source>Clear</source>
@@ -3039,7 +3039,7 @@
<context context-type="sourcefile">src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html</context>
<context context-type="linenumber">9</context>
</context-group>
<target state="needs-translation">Data type</target>
<target state="translated">Tip podataka</target>
</trans-unit>
<trans-unit id="5933665691581884232" datatype="html">
<source>Data type cannot be changed after a field is created</source>
@@ -3047,7 +3047,7 @@
<context context-type="sourcefile">src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html</context>
<context context-type="linenumber">10</context>
</context-group>
<target state="needs-translation">Data type cannot be changed after a field is created</target>
<target state="translated">Tip podataka ne može se promijeniti nakon što je polje stvoreno</target>
</trans-unit>
<trans-unit id="528950215505228201" datatype="html">
<source>Create new custom field</source>
@@ -3175,7 +3175,7 @@
<context context-type="sourcefile">src/app/components/common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component.html</context>
<context context-type="linenumber">19</context>
</context-group>
<target state="needs-translation">Character Set</target>
<target state="translated">Skup znakova</target>
</trans-unit>
<trans-unit id="6563391987554512024" datatype="html">
<source>Test</source>

File diff suppressed because it is too large Load Diff

View File

@@ -555,6 +555,11 @@ table.table {
}
}
.popover-hidden .popover {
opacity: 0;
pointer-events: none;
}
// Tour
.tour-active .popover {
min-width: 360px;

View File

@@ -14,6 +14,8 @@ from pikepdf import Pdf
from PIL import Image
from documents.converters import convert_from_tiff_to_pdf
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides
from documents.data_models import DocumentSource
from documents.utils import copy_basic_file_stats
from documents.utils import copy_file_with_basic_stats
@@ -53,6 +55,7 @@ class BarcodeReader:
self.mime: Final[str] = mime_type
self.pdf_file: Path = self.file
self.barcodes: list[Barcode] = []
self._tiff_conversion_done = False
self.temp_dir: Optional[tempfile.TemporaryDirectory] = None
if settings.CONSUMER_BARCODE_TIFF_SUPPORT:
@@ -150,12 +153,14 @@ class BarcodeReader:
def convert_from_tiff_to_pdf(self):
"""
May convert a TIFF image into a PDF, if the input is a TIFF
May convert a TIFF image into a PDF, if the input is a TIFF and
the TIFF has not been made into a PDF
"""
# Nothing to do, pdf_file is already assigned correctly
if self.mime != "image/tiff":
if self.mime != "image/tiff" or self._tiff_conversion_done:
return
self._tiff_conversion_done = True
self.pdf_file = convert_from_tiff_to_pdf(self.file, Path(self.temp_dir.name))
def detect(self) -> None:
@@ -167,6 +172,9 @@ class BarcodeReader:
if self.barcodes:
return
# No op if not a TIFF
self.convert_from_tiff_to_pdf()
# Choose the library for reading
if settings.CONSUMER_BARCODE_SCANNER == "PYZBAR":
reader = self.read_barcodes_pyzbar
@@ -240,7 +248,7 @@ class BarcodeReader:
"""
document_paths = []
fname = self.file.with_suffix("").name
fname = self.file.stem
with Pdf.open(self.pdf_file) as input_pdf:
# Start with an empty document
current_document: list[Page] = []
@@ -290,7 +298,7 @@ class BarcodeReader:
def separate(
self,
source: DocumentSource,
override_name: Optional[str] = None,
overrides: DocumentMetadataOverrides,
) -> bool:
"""
Separates the document, based on barcodes and configuration, creating new
@@ -316,27 +324,23 @@ class BarcodeReader:
logger.warning("No pages to split on!")
return False
# Create the split documents
doc_paths = self.separate_pages(separator_pages)
tmp_dir = Path(tempfile.mkdtemp(prefix="paperless-barcode-split-")).resolve()
# Save the new documents to correct folder
if source != DocumentSource.ConsumeFolder:
# The given file is somewhere in SCRATCH_DIR,
# and new documents must be moved to the CONSUMPTION_DIR
# for the consumer to notice them
save_to_dir = settings.CONSUMPTION_DIR
else:
# The given file is somewhere in CONSUMPTION_DIR,
# and may be some levels down for recursive tagging
# so use the file's parent to preserve any metadata
save_to_dir = self.file.parent
from documents import tasks
for idx, document_path in enumerate(doc_paths):
if override_name is not None:
newname = f"{idx}_{override_name}"
dest = save_to_dir / newname
else:
dest = save_to_dir
logger.info(f"Saving {document_path} to {dest}")
copy_file_with_basic_stats(document_path, dest)
# Create the split document tasks
for new_document in self.separate_pages(separator_pages):
copy_file_with_basic_stats(new_document, tmp_dir / new_document.name)
tasks.consume_file.delay(
ConsumableDocument(
# Same source, for templates
source=source,
# Can't use same folder or the consume might grab it again
original_file=(tmp_dir / new_document.name).resolve(),
),
# All the same metadata
overrides,
)
logger.info("Barcode splitting complete!")
return True

View File

@@ -691,6 +691,8 @@ class Consumer(LoggingMixin):
added_month_name_short=local_added.strftime("%b"),
added_day=local_added.strftime("%d"),
owner_username=owner_username,
original_filename=Path(self.filename).stem,
added_time=local_added.strftime("%H:%M"),
).strip()
def _store(

View File

@@ -238,18 +238,6 @@ class Command(BaseCommand):
serializers.serialize("json", StoragePath.objects.all()),
)
notes = json.loads(
serializers.serialize("json", Note.objects.all()),
)
if not self.split_manifest:
manifest += notes
documents = Document.objects.order_by("id")
document_map = {d.pk: d for d in documents}
document_manifest = json.loads(serializers.serialize("json", documents))
if not self.split_manifest:
manifest += document_manifest
manifest += json.loads(
serializers.serialize("json", MailAccount.objects.all()),
)
@@ -303,10 +291,24 @@ class Command(BaseCommand):
serializers.serialize("json", CustomField.objects.all()),
)
# These are treated specially and included in the per-document manifest
# if that setting is enabled. Otherwise, they are just exported to the bulk
# manifest
documents = Document.objects.order_by("id")
document_map: dict[int, Document] = {d.pk: d for d in documents}
document_manifest = json.loads(serializers.serialize("json", documents))
notes = json.loads(
serializers.serialize("json", Note.objects.all()),
)
custom_field_instances = json.loads(
serializers.serialize("json", CustomFieldInstance.objects.all()),
)
if not self.split_manifest:
manifest += json.loads(
serializers.serialize("json", CustomFieldInstance.objects.all()),
)
manifest += document_manifest
manifest += notes
manifest += custom_field_instances
# 3. Export files from each document
for index, document_dict in tqdm.tqdm(
@@ -412,6 +414,12 @@ class Command(BaseCommand):
notes,
),
)
content += list(
filter(
lambda d: d["fields"]["document"] == document_dict["pk"],
custom_field_instances,
),
)
manifest_name.write_text(
json.dumps(content, indent=2, ensure_ascii=False),
encoding="utf-8",

View File

@@ -53,6 +53,12 @@ class Command(MultiProcessMixin, ProgressBarMixin, BaseCommand):
type=float,
help="Ratio to consider documents a match",
)
parser.add_argument(
"--delete",
default=False,
action="store_true",
help="If set, one document of matches above the ratio WILL BE DELETED",
)
self.add_argument_progress_bar_mixin(parser)
self.add_argument_processes_mixin(parser)
@@ -63,6 +69,13 @@ class Command(MultiProcessMixin, ProgressBarMixin, BaseCommand):
self.handle_processes_mixin(**options)
self.handle_progress_bar_mixin(**options)
if options["delete"]:
self.stdout.write(
self.style.WARNING(
"The command is configured to delete documents. Use with caution",
),
)
opt_ratio = options["ratio"]
checked_pairs: set[tuple[int, int]] = set()
work_pkgs: list[_WorkPackage] = []
@@ -81,15 +94,12 @@ class Command(MultiProcessMixin, ProgressBarMixin, BaseCommand):
continue
# Skip matching which have already been matched together
# doc 1 to doc 2 is the same as doc 2 to doc 1
if (first_doc.pk, second_doc.pk) in checked_pairs or (
second_doc.pk,
first_doc.pk,
) in checked_pairs:
doc_1_to_doc_2 = (first_doc.pk, second_doc.pk)
doc_2_to_doc_1 = doc_1_to_doc_2[::-1]
if doc_1_to_doc_2 in checked_pairs or doc_2_to_doc_1 in checked_pairs:
continue
checked_pairs.update(
[(first_doc.pk, second_doc.pk), (second_doc.pk, first_doc.pk)],
)
checked_pairs.update([doc_1_to_doc_2, doc_2_to_doc_1])
# Actually something useful to work on now
work_pkgs.append(_WorkPackage(first_doc, second_doc))
# Don't spin up a pool of 1 process
@@ -109,6 +119,7 @@ class Command(MultiProcessMixin, ProgressBarMixin, BaseCommand):
# Check results
messages = []
maybe_delete_ids = []
for result in sorted(results):
if result.ratio >= opt_ratio:
messages.append(
@@ -117,6 +128,7 @@ class Command(MultiProcessMixin, ProgressBarMixin, BaseCommand):
f" to {result.doc_two_pk} (confidence {result.ratio:.3f})",
),
)
maybe_delete_ids.append(result.doc_two_pk)
if len(messages) == 0:
messages.append(
@@ -125,3 +137,10 @@ class Command(MultiProcessMixin, ProgressBarMixin, BaseCommand):
self.stdout.writelines(
messages,
)
if options["delete"]:
self.stdout.write(
self.style.NOTICE(
f"Deleting {len(maybe_delete_ids)} documents based on ratio matches",
),
)
Document.objects.filter(pk__in=maybe_delete_ids).delete()

View File

@@ -140,7 +140,7 @@ def consume_file(
with BarcodeReader(input_doc.original_file, input_doc.mime_type) as reader:
if settings.CONSUMER_ENABLE_BARCODES and reader.separate(
input_doc.source,
overrides.filename,
overrides,
):
# notify the sender, otherwise the progress bar
# in the UI stays stuck

View File

@@ -1,5 +1,4 @@
import shutil
from pathlib import Path
from unittest import mock
import pytest
@@ -11,10 +10,13 @@ from documents import tasks
from documents.barcodes import BarcodeReader
from documents.consumer import ConsumerError
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides
from documents.data_models import DocumentSource
from documents.models import Document
from documents.tests.utils import DirectoriesMixin
from documents.tests.utils import DocumentConsumeDelayMixin
from documents.tests.utils import FileSystemAssertsMixin
from documents.tests.utils import SampleDirMixin
try:
import zxingcpp # noqa: F401
@@ -25,11 +27,7 @@ except ImportError:
@override_settings(CONSUMER_BARCODE_SCANNER="PYZBAR")
class TestBarcode(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
SAMPLE_DIR = Path(__file__).parent / "samples"
BARCODE_SAMPLE_DIR = SAMPLE_DIR / "barcodes"
class TestBarcode(DirectoriesMixin, FileSystemAssertsMixin, SampleDirMixin, TestCase):
def test_scan_file_for_separating_barcodes(self):
"""
GIVEN:
@@ -48,6 +46,46 @@ class TestBarcode(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
self.assertEqual(reader.pdf_file, test_file)
self.assertDictEqual(separator_page_numbers, {0: False})
@override_settings(
CONSUMER_BARCODE_TIFF_SUPPORT=True,
)
def test_scan_tiff_for_separating_barcodes(self):
"""
GIVEN:
- TIFF image containing barcodes
WHEN:
- Consume task returns
THEN:
- The file was split
"""
test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t-middle.tiff"
with BarcodeReader(test_file, "image/tiff") as reader:
reader.detect()
separator_page_numbers = reader.get_separation_pages()
self.assertDictEqual(separator_page_numbers, {1: False})
@override_settings(
CONSUMER_BARCODE_TIFF_SUPPORT=True,
)
def test_scan_tiff_with_alpha_for_separating_barcodes(self):
"""
GIVEN:
- TIFF image containing barcodes
WHEN:
- Consume task returns
THEN:
- The file was split
"""
test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t-middle-alpha.tiff"
with BarcodeReader(test_file, "image/tiff") as reader:
reader.detect()
separator_page_numbers = reader.get_separation_pages()
self.assertDictEqual(separator_page_numbers, {1: False})
def test_scan_file_for_separating_barcodes_none_present(self):
"""
GIVEN:
@@ -285,6 +323,28 @@ class TestBarcode(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
self.assertGreater(len(reader.barcodes), 0)
self.assertDictEqual(separator_page_numbers, {1: False})
def test_scan_file_for_separating_barcodes_password(self):
"""
GIVEN:
- Password protected PDF
WHEN:
- File is scanned for barcode
THEN:
- Scanning handles the exception without crashing
"""
test_file = self.SAMPLE_DIR / "password-is-test.pdf"
with self.assertLogs("paperless.barcodes", level="WARNING") as cm:
with BarcodeReader(test_file, "application/pdf") as reader:
reader.detect()
warning = cm.output[0]
expected_str = "WARNING:paperless.barcodes:File is likely password protected, not checking for barcodes"
self.assertTrue(warning.startswith(expected_str))
separator_page_numbers = reader.get_separation_pages()
self.assertEqual(reader.pdf_file, test_file)
self.assertDictEqual(separator_page_numbers, {})
def test_separate_pages(self):
"""
GIVEN:
@@ -332,8 +392,12 @@ class TestBarcode(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
with self.assertLogs("paperless.barcodes", level="WARNING") as cm:
with BarcodeReader(test_file, "application/pdf") as reader:
success = reader.separate(DocumentSource.ApiUpload)
self.assertFalse(success)
self.assertFalse(
reader.separate(
DocumentSource.ApiUpload,
DocumentMetadataOverrides(),
),
)
self.assertEqual(
cm.output,
[
@@ -341,215 +405,6 @@ class TestBarcode(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
],
)
def test_save_to_dir_given_name(self):
"""
GIVEN:
- File to save to a directory
- There is a name override
WHEN:
- The file is saved
THEN:
- The file exists
"""
test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t-middle.pdf"
with BarcodeReader(test_file, "application/pdf") as reader:
reader.separate(DocumentSource.ApiUpload, "newname.pdf")
self.assertEqual(reader.pdf_file, test_file)
target_file1 = settings.CONSUMPTION_DIR / "0_newname.pdf"
target_file2 = settings.CONSUMPTION_DIR / "1_newname.pdf"
self.assertIsFile(target_file1)
self.assertIsFile(target_file2)
def test_barcode_splitter_api_upload(self):
"""
GIVEN:
- Input file containing barcodes
WHEN:
- Input file is split on barcodes
THEN:
- Correct number of files produced
"""
sample_file = self.BARCODE_SAMPLE_DIR / "patch-code-t-middle.pdf"
test_file = settings.SCRATCH_DIR / "patch-code-t-middle.pdf"
shutil.copy(sample_file, test_file)
with BarcodeReader(test_file, "application/pdf") as reader:
reader.separate(DocumentSource.ApiUpload)
self.assertEqual(reader.pdf_file, test_file)
target_file1 = (
settings.CONSUMPTION_DIR / "patch-code-t-middle_document_0.pdf"
)
target_file2 = (
settings.CONSUMPTION_DIR / "patch-code-t-middle_document_1.pdf"
)
self.assertIsFile(target_file1)
self.assertIsFile(target_file2)
def test_barcode_splitter_consume_dir(self):
"""
GIVEN:
- Input file containing barcodes
WHEN:
- Input file is split on barcodes
THEN:
- Correct number of files produced
"""
sample_file = self.BARCODE_SAMPLE_DIR / "patch-code-t-middle.pdf"
test_file = settings.CONSUMPTION_DIR / "patch-code-t-middle.pdf"
shutil.copy(sample_file, test_file)
with BarcodeReader(test_file, "application/pdf") as reader:
reader.detect()
reader.separate(DocumentSource.ConsumeFolder)
self.assertEqual(reader.pdf_file, test_file)
target_file1 = (
settings.CONSUMPTION_DIR / "patch-code-t-middle_document_0.pdf"
)
target_file2 = (
settings.CONSUMPTION_DIR / "patch-code-t-middle_document_1.pdf"
)
self.assertIsFile(target_file1)
self.assertIsFile(target_file2)
def test_barcode_splitter_consume_dir_recursive(self):
"""
GIVEN:
- Input file containing barcodes
- Input file is within a directory structure of the consume folder
WHEN:
- Input file is split on barcodes
THEN:
- Correct number of files produced
- Output files are within the same directory structure
"""
sample_file = self.BARCODE_SAMPLE_DIR / "patch-code-t-middle.pdf"
test_file = (
settings.CONSUMPTION_DIR / "tag1" / "tag2" / "patch-code-t-middle.pdf"
)
test_file.parent.mkdir(parents=True)
shutil.copy(sample_file, test_file)
with BarcodeReader(test_file, "application/pdf") as reader:
reader.separate(DocumentSource.ConsumeFolder)
self.assertEqual(reader.pdf_file, test_file)
target_file1 = (
settings.CONSUMPTION_DIR
/ "tag1"
/ "tag2"
/ "patch-code-t-middle_document_0.pdf"
)
target_file2 = (
settings.CONSUMPTION_DIR
/ "tag1"
/ "tag2"
/ "patch-code-t-middle_document_1.pdf"
)
self.assertIsFile(target_file1)
self.assertIsFile(target_file2)
@override_settings(CONSUMER_ENABLE_BARCODES=True)
def test_consume_barcode_file(self):
"""
GIVEN:
- Input file with barcodes given to consume task
WHEN:
- Consume task returns
THEN:
- The file was split
"""
test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t-middle.pdf"
dst = settings.SCRATCH_DIR / "patch-code-t-middle.pdf"
shutil.copy(test_file, dst)
with mock.patch("documents.tasks.async_to_sync"):
self.assertEqual(
tasks.consume_file(
ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=dst,
),
None,
),
"File successfully split",
)
@override_settings(
CONSUMER_ENABLE_BARCODES=True,
CONSUMER_BARCODE_TIFF_SUPPORT=True,
)
def test_consume_barcode_tiff_file(self):
"""
GIVEN:
- TIFF image containing barcodes
WHEN:
- Consume task returns
THEN:
- The file was split
"""
test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t-middle.tiff"
dst = settings.SCRATCH_DIR / "patch-code-t-middle.tiff"
shutil.copy(test_file, dst)
with mock.patch("documents.tasks.async_to_sync"):
self.assertEqual(
tasks.consume_file(
ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=dst,
),
None,
),
"File successfully split",
)
self.assertIsNotFile(dst)
@override_settings(
CONSUMER_ENABLE_BARCODES=True,
CONSUMER_BARCODE_TIFF_SUPPORT=True,
)
def test_consume_barcode_tiff_file_with_alpha(self):
"""
GIVEN:
- TIFF image containing barcodes
- TIFF image has an alpha layer
WHEN:
- Consume task handles the alpha layer and returns
THEN:
- The file was split without issue
"""
test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t-middle-alpha.tiff"
dst = settings.SCRATCH_DIR / "patch-code-t-middle.tiff"
shutil.copy(test_file, dst)
with mock.patch("documents.tasks.async_to_sync"):
self.assertEqual(
tasks.consume_file(
ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=dst,
),
None,
),
"File successfully split",
)
self.assertIsNotFile(dst)
@override_settings(
CONSUMER_ENABLE_BARCODES=True,
CONSUMER_BARCODE_TIFF_SUPPORT=True,
@@ -597,60 +452,6 @@ class TestBarcode(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
self.assertIsNone(kwargs["override_document_type_id"])
self.assertIsNone(kwargs["override_tag_ids"])
@override_settings(
CONSUMER_ENABLE_BARCODES=True,
CONSUMER_BARCODE_TIFF_SUPPORT=True,
)
def test_consume_barcode_supported_no_extension_file(self):
"""
GIVEN:
- TIFF image containing barcodes
- TIFF file is given without extension
WHEN:
- Consume task returns
THEN:
- The file was split
"""
test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t-middle.tiff"
dst = settings.SCRATCH_DIR / "patch-code-t-middle"
shutil.copy(test_file, dst)
with mock.patch("documents.tasks.async_to_sync"):
self.assertEqual(
tasks.consume_file(
ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=dst,
),
None,
),
"File successfully split",
)
self.assertIsNotFile(dst)
def test_scan_file_for_separating_barcodes_password(self):
"""
GIVEN:
- Password protected PDF
WHEN:
- File is scanned for barcode
THEN:
- Scanning handles the exception without crashing
"""
test_file = self.SAMPLE_DIR / "password-is-test.pdf"
with self.assertLogs("paperless.barcodes", level="WARNING") as cm:
with BarcodeReader(test_file, "application/pdf") as reader:
reader.detect()
warning = cm.output[0]
expected_str = "WARNING:paperless.barcodes:File is likely password protected, not checking for barcodes"
self.assertTrue(warning.startswith(expected_str))
separator_page_numbers = reader.get_separation_pages()
self.assertEqual(reader.pdf_file, test_file)
self.assertDictEqual(separator_page_numbers, {})
@override_settings(
CONSUMER_ENABLE_BARCODES=True,
CONSUMER_ENABLE_ASN_BARCODE=True,
@@ -722,11 +523,64 @@ class TestBarcode(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
self.assertEqual(len(document_list), 5)
class TestAsnBarcode(DirectoriesMixin, TestCase):
SAMPLE_DIR = Path(__file__).parent / "samples"
@override_settings(CONSUMER_BARCODE_SCANNER="PYZBAR")
class TestBarcodeNewConsume(
DirectoriesMixin,
FileSystemAssertsMixin,
SampleDirMixin,
DocumentConsumeDelayMixin,
TestCase,
):
@override_settings(CONSUMER_ENABLE_BARCODES=True)
def test_consume_barcode_file(self):
"""
GIVEN:
- Incoming file with at 1 barcode producing 2 documents
- Document includes metadata override information
WHEN:
- The document is split
THEN:
- Two new consume tasks are created
- Metadata overrides are preserved for the new consume
- The document source is unchanged (for consume templates)
"""
test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t-middle.pdf"
temp_copy = self.dirs.scratch_dir / test_file.name
shutil.copy(test_file, temp_copy)
BARCODE_SAMPLE_DIR = SAMPLE_DIR / "barcodes"
overrides = DocumentMetadataOverrides(tag_ids=[1, 2, 9])
with mock.patch("documents.tasks.async_to_sync") as progress_mocker:
self.assertEqual(
tasks.consume_file(
ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=temp_copy,
),
overrides,
),
"File successfully split",
)
# We let the consumer know progress is done
progress_mocker.assert_called_once()
# 2 new document consume tasks created
self.assertEqual(self.consume_file_mock.call_count, 2)
self.assertIsNotFile(temp_copy)
# Check the split files exist
# Check the source is unchanged
# Check the overrides are unchanged
for (
new_input_doc,
new_doc_overrides,
) in self.get_all_consume_delay_call_args():
self.assertEqual(new_input_doc.source, DocumentSource.ConsumeFolder)
self.assertIsFile(new_input_doc.original_file)
self.assertEqual(overrides, new_doc_overrides)
class TestAsnBarcode(DirectoriesMixin, SampleDirMixin, TestCase):
@override_settings(CONSUMER_ASN_BARCODE_PREFIX="CUSTOM-PREFIX-")
def test_scan_file_for_asn_custom_prefix(self):
"""

View File

@@ -646,10 +646,13 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
with paperless_environment():
self.assertEqual(Document.objects.count(), 4)
self.assertEqual(CustomFieldInstance.objects.count(), 1)
Document.objects.all().delete()
CustomFieldInstance.objects.all().delete()
self.assertEqual(Document.objects.count(), 0)
call_command("document_importer", "--no-progress-bar", self.target)
self.assertEqual(Document.objects.count(), 4)
self.assertEqual(CustomFieldInstance.objects.count(), 1)
def test_folder_prefix(self):
"""

View File

@@ -157,3 +157,55 @@ class TestFuzzyMatchCommand(TestCase):
self.assertRegex(lines[0], self.MSG_REGEX)
self.assertRegex(lines[1], self.MSG_REGEX)
self.assertRegex(lines[2], self.MSG_REGEX)
def test_document_deletion(self):
"""
GIVEN:
- 3 documents exist
- Document 1 to document 3 has a similarity over 85.0
WHEN:
- Command is called with the --delete option
THEN:
- User is warned about the deletion flag
- Document 3 is deleted
- Documents 1 and 2 remain
"""
# Content similarity is 86.667
Document.objects.create(
checksum="BEEFCAFE",
title="A",
content="first document scanned by bob",
mime_type="application/pdf",
filename="test.pdf",
)
Document.objects.create(
checksum="DEADBEAF",
title="A",
content="second document scanned by alice",
mime_type="application/pdf",
filename="other_test.pdf",
)
Document.objects.create(
checksum="CATTLE",
title="A",
content="first document scanned by pete",
mime_type="application/pdf",
filename="final_test.pdf",
)
self.assertEqual(Document.objects.count(), 3)
stdout, _ = self.call_command("--delete")
print(stdout)
lines = [x.strip() for x in stdout.split("\n") if len(x.strip())]
self.assertEqual(len(lines), 3)
self.assertEqual(
lines[0],
"The command is configured to delete documents. Use with caution",
)
self.assertRegex(lines[1], self.MSG_REGEX)
self.assertEqual(lines[2], "Deleting 1 documents based on ratio matches")
self.assertEqual(Document.objects.count(), 2)
self.assertIsNotNone(Document.objects.get(pk=1))
self.assertIsNotNone(Document.objects.get(pk=2))

View File

@@ -235,8 +235,10 @@ class DocumentConsumeDelayMixin:
"""
Iterates over all calls to the async task and returns the arguments
"""
# Must be at least 1 call
self.consume_file_mock.assert_called()
for args, _ in self.consume_file_mock.call_args_list:
for args, kwargs in self.consume_file_mock.call_args_list:
input_doc, overrides = args
yield (input_doc, overrides)
@@ -244,7 +246,7 @@ class DocumentConsumeDelayMixin:
def get_specific_consume_delay_call_args(
self,
index: int,
) -> Iterator[tuple[ConsumableDocument, DocumentMetadataOverrides]]:
) -> tuple[ConsumableDocument, DocumentMetadataOverrides]:
"""
Returns the arguments of a specific call to the async task
"""
@@ -299,3 +301,9 @@ class TestMigrations(TransactionTestCase):
def setUpBeforeMigration(self, apps):
pass
class SampleDirMixin:
SAMPLE_DIR = Path(__file__).parent / "samples"
BARCODE_SAMPLE_DIR = SAMPLE_DIR / "barcodes"

View File

@@ -182,10 +182,14 @@ class PassUserMixin(CreateModelMixin):
class CorrespondentViewSet(ModelViewSet, PassUserMixin):
model = Correspondent
queryset = Correspondent.objects.annotate(
document_count=Count("documents"),
last_correspondence=Max("documents__created"),
).order_by(Lower("name"))
queryset = (
Correspondent.objects.annotate(
document_count=Count("documents"),
last_correspondence=Max("documents__created"),
)
.select_related("owner")
.order_by(Lower("name"))
)
serializer_class = CorrespondentSerializer
pagination_class = StandardPagination
@@ -208,8 +212,12 @@ class CorrespondentViewSet(ModelViewSet, PassUserMixin):
class TagViewSet(ModelViewSet, PassUserMixin):
model = Tag
queryset = Tag.objects.annotate(document_count=Count("documents")).order_by(
Lower("name"),
queryset = (
Tag.objects.annotate(document_count=Count("documents"))
.select_related("owner")
.order_by(
Lower("name"),
)
)
def get_serializer_class(self, *args, **kwargs):
@@ -232,9 +240,13 @@ class TagViewSet(ModelViewSet, PassUserMixin):
class DocumentTypeViewSet(ModelViewSet, PassUserMixin):
model = DocumentType
queryset = DocumentType.objects.annotate(
document_count=Count("documents"),
).order_by(Lower("name"))
queryset = (
DocumentType.objects.annotate(
document_count=Count("documents"),
)
.select_related("owner")
.order_by(Lower("name"))
)
serializer_class = DocumentTypeSerializer
pagination_class = StandardPagination
@@ -283,7 +295,12 @@ class DocumentViewSet(
)
def get_queryset(self):
return Document.objects.distinct().annotate(num_notes=Count("notes"))
return (
Document.objects.distinct()
.annotate(num_notes=Count("notes"))
.select_related("correspondent", "storage_path", "document_type", "owner")
.prefetch_related("tags", "custom_fields", "notes")
)
def get_serializer(self, *args, **kwargs):
fields_param = self.request.query_params.get("fields", None)
@@ -627,9 +644,18 @@ class DocumentViewSet(
class SearchResultSerializer(DocumentSerializer, PassUserMixin):
def to_representation(self, instance):
doc = Document.objects.get(id=instance["id"])
doc = (
Document.objects.select_related(
"correspondent",
"storage_path",
"document_type",
"owner",
)
.prefetch_related("tags", "custom_fields", "notes")
.get(id=instance["id"])
)
notes = ",".join(
[str(c.note) for c in Note.objects.filter(document=instance["id"])],
[str(c.note) for c in doc.notes.all()],
)
r = super().to_representation(doc)
r["__search_hit__"] = {
@@ -752,7 +778,11 @@ class SavedViewViewSet(ModelViewSet, PassUserMixin):
def get_queryset(self):
user = self.request.user
return SavedView.objects.filter(owner=user)
return (
SavedView.objects.filter(owner=user)
.select_related("owner")
.prefetch_related("filter_rules")
)
def perform_create(self, serializer):
serializer.save(owner=self.request.user)
@@ -1080,8 +1110,12 @@ class BulkDownloadView(GenericAPIView):
class StoragePathViewSet(ModelViewSet, PassUserMixin):
model = StoragePath
queryset = StoragePath.objects.annotate(document_count=Count("documents")).order_by(
Lower("name"),
queryset = (
StoragePath.objects.annotate(document_count=Count("documents"))
.select_related("owner")
.order_by(
Lower("name"),
)
)
serializer_class = StoragePathSerializer
@@ -1347,7 +1381,18 @@ class ConsumptionTemplateViewSet(ModelViewSet):
model = ConsumptionTemplate
queryset = ConsumptionTemplate.objects.all().order_by("order")
queryset = (
ConsumptionTemplate.objects.prefetch_related(
"assign_tags",
"assign_view_users",
"assign_view_groups",
"assign_change_users",
"assign_change_groups",
"assign_custom_fields",
)
.all()
.order_by("order")
)
class CustomFieldViewSet(ModelViewSet):

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-05 08:26-0800\n"
"PO-Revision-Date: 2023-12-12 00:24\n"
"PO-Revision-Date: 2023-12-14 00:23\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-05 08:26-0800\n"
"PO-Revision-Date: 2023-12-05 16:27\n"
"PO-Revision-Date: 2023-12-13 12:09\n"
"Last-Translator: \n"
"Language-Team: Croatian\n"
"Language: hr_HR\n"

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-05 08:26-0800\n"
"PO-Revision-Date: 2023-12-05 16:27\n"
"PO-Revision-Date: 2023-12-16 00:23\n"
"Last-Translator: \n"
"Language-Team: Romanian\n"
"Language: ro_RO\n"
@@ -23,11 +23,11 @@ msgstr "Documente"
#: documents/models.py:36 documents/models.py:734
msgid "owner"
msgstr ""
msgstr "proprietar"
#: documents/models.py:53
msgid "None"
msgstr ""
msgstr "Nimic"
#: documents/models.py:54
msgid "Any word"
@@ -108,15 +108,15 @@ msgstr "tipuri de document"
#: documents/models.py:124
msgid "path"
msgstr ""
msgstr "cale"
#: documents/models.py:129 documents/models.py:156
msgid "storage path"
msgstr ""
msgstr "cale de stocare"
#: documents/models.py:130
msgid "storage paths"
msgstr ""
msgstr "căi de stocare"
#: documents/models.py:137
msgid "Unencrypted"
@@ -193,11 +193,11 @@ msgstr "Numele curent al arhivei stocate"
#: documents/models.py:250
msgid "original filename"
msgstr ""
msgstr "numele original al fișierului"
#: documents/models.py:256
msgid "The original name of the file when it was uploaded"
msgstr ""
msgstr "Numele original al fișierului când a fost încărcat"
#: documents/models.py:263
msgid "archive serial number"
@@ -381,47 +381,47 @@ msgstr ""
#: documents/models.py:447
msgid "storage path is"
msgstr ""
msgstr "calea de stocare este"
#: documents/models.py:448
msgid "has correspondent in"
msgstr ""
msgstr "are corespondent în"
#: documents/models.py:449
msgid "does not have correspondent in"
msgstr ""
msgstr "nu are corespondent în"
#: documents/models.py:450
msgid "has document type in"
msgstr ""
msgstr "are tip de document în"
#: documents/models.py:451
msgid "does not have document type in"
msgstr ""
msgstr "nu are tip document în"
#: documents/models.py:452
msgid "has storage path in"
msgstr ""
msgstr "are cale de stocare în"
#: documents/models.py:453
msgid "does not have storage path in"
msgstr ""
msgstr "nu are cale de stocare în"
#: documents/models.py:454
msgid "owner is"
msgstr ""
msgstr "proprietarul este"
#: documents/models.py:455
msgid "has owner in"
msgstr ""
msgstr "are proprietar în"
#: documents/models.py:456
msgid "does not have owner"
msgstr ""
msgstr "nu are proprietar"
#: documents/models.py:457
msgid "does not have owner in"
msgstr ""
msgstr "nu are proprietar în"
#: documents/models.py:467
msgid "rule type"
@@ -441,47 +441,47 @@ msgstr "reguli de filtrare"
#: documents/models.py:584
msgid "Task ID"
msgstr ""
msgstr "ID Sarcină"
#: documents/models.py:585
msgid "Celery ID for the Task that was run"
msgstr ""
msgstr "ID-ul sarcinii Celery care a fost rulată"
#: documents/models.py:590
msgid "Acknowledged"
msgstr ""
msgstr "Confirmat"
#: documents/models.py:591
msgid "If the task is acknowledged via the frontend or API"
msgstr ""
msgstr "Dacă sarcina este confirmată prin frontend sau API"
#: documents/models.py:597
msgid "Task Filename"
msgstr ""
msgstr "Numele fișierului sarcină"
#: documents/models.py:598
msgid "Name of the file which the Task was run for"
msgstr ""
msgstr "Numele fișierului pentru care sarcina a fost executată"
#: documents/models.py:604
msgid "Task Name"
msgstr ""
msgstr "Nume sarcină"
#: documents/models.py:605
msgid "Name of the Task which was run"
msgstr ""
msgstr "Numele sarcinii care a fost executată"
#: documents/models.py:612
msgid "Task State"
msgstr ""
msgstr "Stare sarcină"
#: documents/models.py:613
msgid "Current state of the task being run"
msgstr ""
msgstr "Stadiul actual al sarcinii în curs de desfășurare"
#: documents/models.py:618
msgid "Created DateTime"
msgstr ""
msgstr "Data creării"
#: documents/models.py:619
msgid "Datetime field when the task result was created in UTC"
@@ -489,7 +489,7 @@ msgstr ""
#: documents/models.py:624
msgid "Started DateTime"
msgstr ""
msgstr "Data începerii"
#: documents/models.py:625
msgid "Datetime field when the task was started in UTC"
@@ -497,7 +497,7 @@ msgstr ""
#: documents/models.py:630
msgid "Completed DateTime"
msgstr ""
msgstr "Data finalizării"
#: documents/models.py:631
msgid "Datetime field when the task was completed in UTC"
@@ -505,15 +505,15 @@ msgstr ""
#: documents/models.py:636
msgid "Result Data"
msgstr ""
msgstr "Datele rezultatului"
#: documents/models.py:638
msgid "The data returned by the task"
msgstr ""
msgstr "Datele returnate de sarcină"
#: documents/models.py:650
msgid "Note for the document"
msgstr ""
msgstr "Notă pentru document"
#: documents/models.py:674
msgid "user"
@@ -521,23 +521,23 @@ msgstr "utilizator"
#: documents/models.py:679
msgid "note"
msgstr ""
msgstr "notă"
#: documents/models.py:680
msgid "notes"
msgstr ""
msgstr "note"
#: documents/models.py:688
msgid "Archive"
msgstr ""
msgstr "Arhivă"
#: documents/models.py:689
msgid "Original"
msgstr ""
msgstr "Original"
#: documents/models.py:700
msgid "expiration"
msgstr ""
msgstr "expirare"
#: documents/models.py:707
msgid "slug"
@@ -545,35 +545,35 @@ msgstr ""
#: documents/models.py:739
msgid "share link"
msgstr ""
msgstr "link de partajare"
#: documents/models.py:740
msgid "share links"
msgstr ""
msgstr "link-uri de partajare"
#: documents/models.py:752
msgid "String"
msgstr ""
msgstr "Şir de caractere"
#: documents/models.py:753
msgid "URL"
msgstr ""
msgstr "Adresă URL"
#: documents/models.py:754
msgid "Date"
msgstr ""
msgstr "Dată"
#: documents/models.py:755
msgid "Boolean"
msgstr ""
msgstr "Boolean"
#: documents/models.py:756
msgid "Integer"
msgstr ""
msgstr "Număr întreg"
#: documents/models.py:757
msgid "Float"
msgstr ""
msgstr "Număr zecimal"
#: documents/models.py:758
msgid "Monetary"
@@ -581,11 +581,11 @@ msgstr ""
#: documents/models.py:759
msgid "Document Link"
msgstr ""
msgstr "Link document"
#: documents/models.py:771
msgid "data type"
msgstr ""
msgstr "tip date"
#: documents/models.py:779
msgid "custom field"

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-05 08:26-0800\n"
"PO-Revision-Date: 2023-12-05 16:27\n"
"PO-Revision-Date: 2023-12-14 00:23\n"
"Last-Translator: \n"
"Language-Team: Chinese Traditional\n"
"Language: zh_TW\n"
@@ -47,7 +47,7 @@ msgstr ""
#: documents/models.py:58
msgid "Fuzzy word"
msgstr ""
msgstr "模糊詞"
#: documents/models.py:59
msgid "Automatic"
@@ -68,15 +68,15 @@ msgstr "比對演算法"
#: documents/models.py:72
msgid "is insensitive"
msgstr ""
msgstr "不區分大小寫"
#: documents/models.py:95 documents/models.py:147
msgid "correspondent"
msgstr ""
msgstr "聯繫者"
#: documents/models.py:96
msgid "correspondents"
msgstr ""
msgstr "聯繫者"
#: documents/models.py:100
msgid "color"
@@ -84,47 +84,47 @@ msgstr "顏色"
#: documents/models.py:103
msgid "is inbox tag"
msgstr ""
msgstr "收件匣標籤"
#: documents/models.py:106
msgid "Marks this tag as an inbox tag: All newly consumed documents will be tagged with inbox tags."
msgstr ""
msgstr "標記此標籤為收件匣標籤:所有新處理的文件將會以此收件匣標籤作標記。"
#: documents/models.py:112
msgid "tag"
msgstr ""
msgstr "標籤"
#: documents/models.py:113 documents/models.py:185
msgid "tags"
msgstr ""
msgstr "標籤"
#: documents/models.py:118 documents/models.py:167
msgid "document type"
msgstr ""
msgstr "文件類型"
#: documents/models.py:119
msgid "document types"
msgstr ""
msgstr "文件類型"
#: documents/models.py:124
msgid "path"
msgstr ""
msgstr "位址"
#: documents/models.py:129 documents/models.py:156
msgid "storage path"
msgstr ""
msgstr "儲存位址"
#: documents/models.py:130
msgid "storage paths"
msgstr ""
msgstr "儲存位址"
#: documents/models.py:137
msgid "Unencrypted"
msgstr ""
msgstr "未加密"
#: documents/models.py:138
msgid "Encrypted with GNU Privacy Guard"
msgstr ""
msgstr "已使用 GNU Privacy Guard 進行加密"
#: documents/models.py:159
msgid "title"
@@ -189,27 +189,27 @@ msgstr "存檔檔案名稱"
#: documents/models.py:246
msgid "Current archive filename in storage"
msgstr ""
msgstr "現時儲存空間封存的檔案名稱"
#: documents/models.py:250
msgid "original filename"
msgstr ""
msgstr "原先檔案名稱"
#: documents/models.py:256
msgid "The original name of the file when it was uploaded"
msgstr ""
msgstr "檔案上傳時的檔案名稱"
#: documents/models.py:263
msgid "archive serial number"
msgstr ""
msgstr "封存編號"
#: documents/models.py:273
msgid "The position of this document in your physical document archive."
msgstr ""
msgstr "此檔案在你實體儲存空間的位置。"
#: documents/models.py:279 documents/models.py:665 documents/models.py:719
msgid "document"
msgstr ""
msgstr "文件"
#: documents/models.py:280
msgid "documents"
@@ -217,47 +217,47 @@ msgstr "文件"
#: documents/models.py:368
msgid "debug"
msgstr ""
msgstr "偵錯"
#: documents/models.py:369
msgid "information"
msgstr ""
msgstr "資訊"
#: documents/models.py:370
msgid "warning"
msgstr ""
msgstr "警告"
#: documents/models.py:371 paperless_mail/models.py:305
msgid "error"
msgstr ""
msgstr "錯誤"
#: documents/models.py:372
msgid "critical"
msgstr ""
msgstr "嚴重"
#: documents/models.py:375
msgid "group"
msgstr ""
msgstr "群組"
#: documents/models.py:377
msgid "message"
msgstr ""
msgstr "訊息"
#: documents/models.py:380
msgid "level"
msgstr ""
msgstr "程度"
#: documents/models.py:389
msgid "log"
msgstr ""
msgstr "記錄"
#: documents/models.py:390
msgid "logs"
msgstr ""
msgstr "記錄"
#: documents/models.py:399 documents/models.py:464
msgid "saved view"
msgstr ""
msgstr "已儲存的檢視表"
#: documents/models.py:400
msgid "saved views"
@@ -265,207 +265,207 @@ msgstr "保存視圖"
#: documents/models.py:405
msgid "show on dashboard"
msgstr ""
msgstr "顯示在概覽"
#: documents/models.py:408
msgid "show in sidebar"
msgstr ""
msgstr "顯示在側邊欄"
#: documents/models.py:412
msgid "sort field"
msgstr ""
msgstr "排序欄位"
#: documents/models.py:417
msgid "sort reverse"
msgstr ""
msgstr "倒轉排序"
#: documents/models.py:422
msgid "title contains"
msgstr ""
msgstr "標題包含"
#: documents/models.py:423
msgid "content contains"
msgstr ""
msgstr "內容包含"
#: documents/models.py:424
msgid "ASN is"
msgstr ""
msgstr "ASN 為"
#: documents/models.py:425
msgid "correspondent is"
msgstr ""
msgstr "聯繫者為"
#: documents/models.py:426
msgid "document type is"
msgstr ""
msgstr "文件類型為"
#: documents/models.py:427
msgid "is in inbox"
msgstr ""
msgstr "在收件匣內"
#: documents/models.py:428
msgid "has tag"
msgstr ""
msgstr "包含標籤"
#: documents/models.py:429
msgid "has any tag"
msgstr ""
msgstr "包含任何標籤"
#: documents/models.py:430
msgid "created before"
msgstr ""
msgstr "建立時間之前"
#: documents/models.py:431
msgid "created after"
msgstr ""
msgstr "建立時間之後"
#: documents/models.py:432
msgid "created year is"
msgstr ""
msgstr "建立年份為"
#: documents/models.py:433
msgid "created month is"
msgstr ""
msgstr "建立月份為"
#: documents/models.py:434
msgid "created day is"
msgstr ""
msgstr "建立日期為"
#: documents/models.py:435
msgid "added before"
msgstr ""
msgstr "加入時間之前"
#: documents/models.py:436
msgid "added after"
msgstr ""
msgstr "加入時間之後"
#: documents/models.py:437
msgid "modified before"
msgstr ""
msgstr "修改之前"
#: documents/models.py:438
msgid "modified after"
msgstr ""
msgstr "修改之後"
#: documents/models.py:439
msgid "does not have tag"
msgstr ""
msgstr "沒有包含標籤"
#: documents/models.py:440
msgid "does not have ASN"
msgstr ""
msgstr "沒有包含 ASN"
#: documents/models.py:441
msgid "title or content contains"
msgstr ""
msgstr "標題或內容包含"
#: documents/models.py:442
msgid "fulltext query"
msgstr ""
msgstr "全文搜索"
#: documents/models.py:443
msgid "more like this"
msgstr ""
msgstr "其他類似內容"
#: documents/models.py:444
msgid "has tags in"
msgstr ""
msgstr "含有這個標籤"
#: documents/models.py:445
msgid "ASN greater than"
msgstr ""
msgstr "ASN 大於"
#: documents/models.py:446
msgid "ASN less than"
msgstr ""
msgstr "ASN 小於"
#: documents/models.py:447
msgid "storage path is"
msgstr ""
msgstr "儲存位址為"
#: documents/models.py:448
msgid "has correspondent in"
msgstr ""
msgstr "包含聯繫者"
#: documents/models.py:449
msgid "does not have correspondent in"
msgstr ""
msgstr "沒有包含聯繫者"
#: documents/models.py:450
msgid "has document type in"
msgstr ""
msgstr "文件類型包含"
#: documents/models.py:451
msgid "does not have document type in"
msgstr ""
msgstr "沒有包含的文件類型"
#: documents/models.py:452
msgid "has storage path in"
msgstr ""
msgstr "儲存位址包含"
#: documents/models.py:453
msgid "does not have storage path in"
msgstr ""
msgstr "沒有包含的儲存位址"
#: documents/models.py:454
msgid "owner is"
msgstr ""
msgstr "擁有者為"
#: documents/models.py:455
msgid "has owner in"
msgstr ""
msgstr "擁有者包含"
#: documents/models.py:456
msgid "does not have owner"
msgstr ""
msgstr "沒有包含的擁有者"
#: documents/models.py:457
msgid "does not have owner in"
msgstr ""
msgstr "沒有包含的擁有者"
#: documents/models.py:467
msgid "rule type"
msgstr ""
msgstr "規則類型"
#: documents/models.py:469
msgid "value"
msgstr ""
msgstr "數值"
#: documents/models.py:472
msgid "filter rule"
msgstr ""
msgstr "過濾規則"
#: documents/models.py:473
msgid "filter rules"
msgstr ""
msgstr "過濾規則"
#: documents/models.py:584
msgid "Task ID"
msgstr ""
msgstr "任務 ID"
#: documents/models.py:585
msgid "Celery ID for the Task that was run"
msgstr ""
msgstr "已執行任務的 Celery ID"
#: documents/models.py:590
msgid "Acknowledged"
msgstr ""
msgstr "已確認"
#: documents/models.py:591
msgid "If the task is acknowledged via the frontend or API"
msgstr ""
msgstr "如果任務已由前端 / API 確認"
#: documents/models.py:597
msgid "Task Filename"
msgstr ""
msgstr "任務檔案名稱"
#: documents/models.py:598
msgid "Name of the file which the Task was run for"
msgstr ""
msgstr "執行任務的目標檔案名稱"
#: documents/models.py:604
msgid "Task Name"
msgstr ""
msgstr "任務名稱"
#: documents/models.py:605
msgid "Name of the Task which was run"
@@ -473,7 +473,7 @@ msgstr ""
#: documents/models.py:612
msgid "Task State"
msgstr ""
msgstr "任務狀態"
#: documents/models.py:613
msgid "Current state of the task being run"
@@ -657,7 +657,7 @@ msgstr ""
#: documents/models.py:967 paperless_mail/models.py:238
msgid "assign this correspondent"
msgstr ""
msgstr "指派這個聯繫者"
#: documents/models.py:975
msgid "assign this storage path"
@@ -1128,7 +1128,7 @@ msgstr ""
#: paperless_mail/models.py:88
msgid "Do not assign a correspondent"
msgstr ""
msgstr "不要指派聯繫者"
#: paperless_mail/models.py:89
msgid "Use mail address"
@@ -1140,7 +1140,7 @@ msgstr ""
#: paperless_mail/models.py:91
msgid "Use correspondent selected below"
msgstr ""
msgstr "使用以下已選擇的聯繫者"
#: paperless_mail/models.py:101
msgid "account"
@@ -1220,7 +1220,7 @@ msgstr ""
#: paperless_mail/models.py:228
msgid "assign correspondent from"
msgstr ""
msgstr "指派聯繫者從"
#: paperless_mail/models.py:242
msgid "Assign the rule owner to documents"

View File

@@ -1,6 +1,6 @@
from typing import Final
__version__: Final[tuple[int, int, int]] = (2, 1, 2)
__version__: Final[tuple[int, int, int]] = (2, 1, 3)
# Version string like X.Y.Z
__full_version_str__: Final[str] = ".".join(map(str, __version__))
# Version string like X.Y