mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-06 08:10:35 +00:00
Feature: store barcode contents, list and search them (#14276)
* Feature: store barcode contents, list and search them New setting PAPERLESS_CONSUMER_STORE_BARCODE_VALUES (off by default) stores all barcodes found during consumption with the document: page, type and content. They are listed on the metadata tab with a copy button, returned by the documents API and searchable with barcodes: in the advanced search. Versions keep their own barcodes, reprocessing reads them again. Refs #9898 * Tests: cover the remaining barcode branches Covers unchanged and failing barcode reads on reprocessing, unsupported files and DocumentBarcode.__str__, and uses toHaveLength in the barcode list spec as suggested by SonarCloud. * Address review: keep barcodes when they can't be read, format choices - Reprocessing and new versions keep the stored barcodes when the file can't be scanned or the scan fails, and replace them atomically. - Format is a TextChoices of the zxing-cpp formats, with a test. - Shared scan code, latest_version helper, TypedDict, serializer reuse. - Barcodes in the split manifest, export/import tests, pytest-style tests. * Barcode tests: TIFF reprocess, format check both ways, fixtures - Reprocessing a TIFF with TIFF support off keeps the stored barcodes. - The format test also fails when zxing-cpp drops a format. - Fixtures in place of the sample dir mixin, plugin-level disabled test. - Format labels aren't translated, OpenAPI enum named BarcodeFormatEnum. * Review: module-level zxing reader, shorter barcode docs - read_barcodes_zxing is a module-level function used by scan_pdf. - Drop the trivial __str__ test, mark it no cover. - Shorten the barcode docs and remove the duplicate in configuration.md. * Fix header * Use utility class * Return barcodes from the metadata endpoint only Drop the barcodes field and its prefetches from the document serializer, as agreed in the review. Also remove the now empty component stylesheet. --------- Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
This commit is contained in:
co-authored by
shamoon
parent
312684aef3
commit
62cc31fbdb
+46
@@ -0,0 +1,46 @@
|
||||
<table class="table table-borderless align-baseline">
|
||||
<thead>
|
||||
<tr>
|
||||
<th i18n>Page</th>
|
||||
<th i18n>Type</th>
|
||||
<th i18n>Content</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (barcode of barcodes(); track $index) {
|
||||
<tr>
|
||||
<td>{{ barcode.page }}</td>
|
||||
<td class="text-nowrap">{{ barcode.format }}</td>
|
||||
<td class="text-break">
|
||||
@if (isLink(barcode.value)) {
|
||||
<a
|
||||
[href]="barcode.value"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>{{ barcode.value }}</a
|
||||
>
|
||||
} @else {
|
||||
{{ barcode.value }}
|
||||
}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
(click)="copy($index)"
|
||||
title="Copy content"
|
||||
i18n-title
|
||||
>
|
||||
@if (copiedIndex() === $index) {
|
||||
<i-bs name="clipboard-check"></i-bs>
|
||||
} @else {
|
||||
<i-bs name="clipboard"></i-bs>
|
||||
}
|
||||
<span class="visually-hidden" i18n>Copy content</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import { Clipboard } from '@angular/cdk/clipboard'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { DocumentBarcodesComponent } from './document-barcodes.component'
|
||||
|
||||
const barcodes = [
|
||||
{ page: 1, value: 'ASN00123', format: 'Code128' },
|
||||
{ page: 2, value: 'https://example.com/invoice/4711', format: 'QRCode' },
|
||||
{ page: 2, value: 'javascript:alert(1)', format: 'QRCode' },
|
||||
]
|
||||
|
||||
describe('DocumentBarcodesComponent', () => {
|
||||
let component: DocumentBarcodesComponent
|
||||
let fixture: ComponentFixture<DocumentBarcodesComponent>
|
||||
let clipboard: Clipboard
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
DocumentBarcodesComponent,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(DocumentBarcodesComponent)
|
||||
component = fixture.componentInstance
|
||||
clipboard = TestBed.inject(Clipboard)
|
||||
fixture.componentRef.setInput('barcodes', barcodes)
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should display all barcodes', () => {
|
||||
const rows = fixture.nativeElement.querySelectorAll('tbody tr')
|
||||
expect(rows).toHaveLength(3)
|
||||
expect(rows[0].textContent).toContain('ASN00123')
|
||||
expect(rows[0].textContent).toContain('Code128')
|
||||
})
|
||||
|
||||
it('should only link http(s) values', () => {
|
||||
const links = fixture.nativeElement.querySelectorAll('tbody a')
|
||||
expect(links).toHaveLength(1)
|
||||
expect(links[0].getAttribute('href')).toEqual(
|
||||
'https://example.com/invoice/4711'
|
||||
)
|
||||
expect(links[0].getAttribute('target')).toEqual('_blank')
|
||||
})
|
||||
|
||||
it('should copy a value and show feedback', () => {
|
||||
jest.useFakeTimers()
|
||||
const copySpy = jest.spyOn(clipboard, 'copy').mockReturnValue(true)
|
||||
const buttons = fixture.nativeElement.querySelectorAll('tbody button')
|
||||
buttons[0].click()
|
||||
fixture.detectChanges()
|
||||
expect(copySpy).toHaveBeenCalledWith('ASN00123')
|
||||
expect(component.copiedIndex()).toEqual(0)
|
||||
expect(buttons[0].querySelector('i-bs').getAttribute('name')).toEqual(
|
||||
'clipboard-check'
|
||||
)
|
||||
jest.advanceTimersByTime(3000)
|
||||
fixture.detectChanges()
|
||||
expect(component.copiedIndex()).toBeNull()
|
||||
expect(buttons[0].querySelector('i-bs').getAttribute('name')).toEqual(
|
||||
'clipboard'
|
||||
)
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
it('should not show feedback if copying failed', () => {
|
||||
jest.spyOn(clipboard, 'copy').mockReturnValue(false)
|
||||
component.copy(1)
|
||||
expect(component.copiedIndex()).toBeNull()
|
||||
})
|
||||
})
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { Clipboard } from '@angular/cdk/clipboard'
|
||||
import { Component, inject, input, OnDestroy, signal } from '@angular/core'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { DocumentBarcode } from 'src/app/data/document-barcode'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-document-barcodes',
|
||||
templateUrl: './document-barcodes.component.html',
|
||||
imports: [NgxBootstrapIconsModule],
|
||||
})
|
||||
export class DocumentBarcodesComponent implements OnDestroy {
|
||||
private readonly clipboard = inject(Clipboard)
|
||||
|
||||
readonly barcodes = input<DocumentBarcode[]>([])
|
||||
|
||||
readonly copiedIndex = signal<number>(null)
|
||||
private copyTimeout: ReturnType<typeof setTimeout>
|
||||
|
||||
public isLink(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value.trim())
|
||||
return ['http:', 'https:'].includes(url.protocol) && !!url.host
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public copy(index: number) {
|
||||
if (!this.clipboard.copy(this.barcodes()[index].value)) return
|
||||
this.copiedIndex.set(index)
|
||||
clearTimeout(this.copyTimeout)
|
||||
this.copyTimeout = setTimeout(() => this.copiedIndex.set(null), 3000)
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
clearTimeout(this.copyTimeout)
|
||||
}
|
||||
}
|
||||
@@ -354,6 +354,10 @@
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (metadata()?.barcodes?.length > 0) {
|
||||
<h6 i18n>Barcodes</h6>
|
||||
<pngx-document-barcodes [barcodes]="metadata().barcodes"></pngx-document-barcodes>
|
||||
}
|
||||
@if (metadata()?.original_metadata?.length > 0) {
|
||||
<pngx-metadata-collapse i18n-title title="Original document metadata" [metadata]="metadata()?.original_metadata"></pngx-metadata-collapse>
|
||||
}
|
||||
|
||||
@@ -135,6 +135,7 @@ import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-li
|
||||
import { SuggestionsDropdownComponent } from '../common/suggestions-dropdown/suggestions-dropdown.component'
|
||||
import { DocumentNotesComponent } from '../document-notes/document-notes.component'
|
||||
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
||||
import { DocumentBarcodesComponent } from './document-barcodes/document-barcodes.component'
|
||||
import { DocumentHistoryComponent } from './document-history/document-history.component'
|
||||
import { DocumentVersionDropdownComponent } from './document-version-dropdown/document-version-dropdown.component'
|
||||
import { MetadataCollapseComponent } from './metadata-collapse/metadata-collapse.component'
|
||||
@@ -177,6 +178,7 @@ interface IncomingDocumentUpdate {
|
||||
DateComponent,
|
||||
DocumentLinkComponent,
|
||||
MetadataCollapseComponent,
|
||||
DocumentBarcodesComponent,
|
||||
PermissionsFormComponent,
|
||||
SelectComponent,
|
||||
TagsComponent,
|
||||
|
||||
Reference in New Issue
Block a user