Merge branch 'dev' into feature-chore-angular-17
This commit is contained in:
11
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
11
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -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"
|
||||
|
||||
|
||||
14
Dockerfile
14
Dockerfile
@@ -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
10
Pipfile
@@ -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
1032
Pipfile.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
1196
src-ui/messages.xlf
1196
src-ui/messages.xlf
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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 }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,9 @@
|
||||
.preview-popup-container > * {
|
||||
width: 30rem !important;
|
||||
height: 22rem !important;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
::ng-deep .popover.popover-preview {
|
||||
max-width: 32rem;
|
||||
}
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 & 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 & 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 & 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 & 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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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> <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> <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> <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> <span class="d-none d-md-inline" i18n>Download</span>
|
||||
</a>
|
||||
</div>
|
||||
</svg> <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> <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> <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> <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>
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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/',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -555,6 +555,11 @@ table.table {
|
||||
}
|
||||
}
|
||||
|
||||
.popover-hidden .popover {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// Tour
|
||||
.tour-active .popover {
|
||||
min-width: 360px;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user