mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-08-07 19:08:32 -05:00
Compare commits
30 Commits
dependabot
...
feature-pd
Author | SHA1 | Date | |
---|---|---|---|
![]() |
47d697fb6b | ||
![]() |
232f13f69b | ||
![]() |
92b9c69806 | ||
![]() |
d9e9478d68 | ||
![]() |
ea18fb611f | ||
![]() |
6a13c3d2db | ||
![]() |
57e42c7df2 | ||
![]() |
54868846c2 | ||
![]() |
676f793bcf | ||
![]() |
87d5058ede | ||
![]() |
7a1b9007ff | ||
![]() |
018c6337ff | ||
![]() |
65ca2be2c9 | ||
![]() |
935b03201c | ||
![]() |
ecbd68fb01 | ||
![]() |
7ccbaa4b52 | ||
![]() |
e48eabc3e1 | ||
![]() |
ec3b6c582a | ||
![]() |
3717f2360f | ||
![]() |
4b2e493a7a | ||
![]() |
0909b874b3 | ||
![]() |
0255113ccd | ||
![]() |
4c0381f69d | ||
![]() |
eb3d03b5f9 | ||
![]() |
45f03781b8 | ||
![]() |
7fd6c6d189 | ||
![]() |
e8980e2aa7 | ||
![]() |
21268738aa | ||
![]() |
9bb4404129 | ||
![]() |
1bee1495cf |
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -15,6 +15,7 @@ env:
|
||||
DEFAULT_UV_VERSION: "0.8.x"
|
||||
# This is the default version of Python to use in most steps which aren't specific
|
||||
DEFAULT_PYTHON_VERSION: "3.11"
|
||||
NLTK_DATA: "/usr/share/nltk_data"
|
||||
jobs:
|
||||
pre-commit:
|
||||
# We want to run on external PRs, but not on our own internal PRs as they'll be run
|
||||
@@ -121,8 +122,11 @@ jobs:
|
||||
- name: List installed Python dependencies
|
||||
run: |
|
||||
uv pip list
|
||||
- name: Install or update NLTK dependencies
|
||||
run: uv run python -m nltk.downloader punkt punkt_tab snowball_data stopwords -d ${{ env.NLTK_DATA }}
|
||||
- name: Tests
|
||||
env:
|
||||
NLTK_DATA: ${{ env.NLTK_DATA }}
|
||||
PAPERLESS_CI_TEST: 1
|
||||
# Enable paperless_mail testing against real server
|
||||
PAPERLESS_MAIL_TEST_HOST: ${{ secrets.TEST_MAIL_HOST }}
|
||||
|
@@ -31,7 +31,7 @@ repos:
|
||||
rev: v2.4.1
|
||||
hooks:
|
||||
- id: codespell
|
||||
exclude: "(^src-ui/src/locale/)|(^src-ui/pnpm-lock.yaml)|(^src-ui/e2e/)|(^src/paperless_mail/tests/samples/)"
|
||||
exclude: "(^src-ui/src/locale/)|(^src-ui/pnpm-lock.yaml)|(^src-ui/e2e/)|(^src/paperless_mail/tests/samples/)|(^src/documents/tests/samples/)"
|
||||
exclude_types:
|
||||
- pofile
|
||||
- json
|
||||
|
12
docs/api.md
12
docs/api.md
@@ -282,6 +282,18 @@ The following methods are supported:
|
||||
- `"merge": true or false` (defaults to false)
|
||||
- The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including
|
||||
removing them) or be merged with existing permissions.
|
||||
- `edit_pdf`
|
||||
- Requires `parameters`:
|
||||
- `"doc_ids": [DOCUMENT_ID]` A list of a single document ID to edit.
|
||||
- `"operations": [OPERATION, ...]` A list of operations to perform on the documents. Each operation is a dictionary
|
||||
with the following keys:
|
||||
- `"page": PAGE_NUMBER` The page number to edit (1-based).
|
||||
- `"rotate": DEGREES` Optional rotation in degrees (90, 180, 270).
|
||||
- `"doc": OUTPUT_DOCUMENT_INDEX` Optional index of the output document for split operations.
|
||||
- Optional `parameters`:
|
||||
- `"delete_original": true` to delete the original documents after editing.
|
||||
- `"update_document": true` to update the existing document with the edited PDF.
|
||||
- `"include_metadata": true` to copy metadata from the original document to the edited document.
|
||||
- `merge`
|
||||
- No additional `parameters` required.
|
||||
- The ordering of the merged document is determined by the list of IDs.
|
||||
|
@@ -576,12 +576,14 @@ The following custom field types are supported:
|
||||
|
||||
## PDF Actions
|
||||
|
||||
Paperless-ngx supports four basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files):
|
||||
Paperless-ngx supports basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files). When viewing an individual document you can
|
||||
open the 'PDF Editor' to use a simple UI for re-arranging, rotating, deleting pages and splitting documents.
|
||||
|
||||
- Merging documents: available when selecting multiple documents for 'bulk editing'.
|
||||
- Rotating documents: available when selecting multiple documents for 'bulk editing' and from an individual document's details page.
|
||||
- Splitting documents: available from an individual document's details page.
|
||||
- Deleting pages: available from an individual document's details page.
|
||||
- Rotating documents: available when selecting multiple documents for 'bulk editing' and via the pdf editor on an individual document's details page.
|
||||
- Splitting documents: via the pdf editor on an individual document's details page.
|
||||
- Deleting pages: via the pdf editor on an individual document's details page.
|
||||
- Re-arranging pages: via the pdf editor on an individual document's details page.
|
||||
|
||||
!!! important
|
||||
|
||||
|
@@ -81,7 +81,7 @@ optional-dependencies.postgres = [
|
||||
"psycopg-pool==3.2.6",
|
||||
]
|
||||
optional-dependencies.webserver = [
|
||||
"granian[uvloop]~=2.5.0",
|
||||
"granian[uvloop]~=2.4.1",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
@@ -121,6 +121,26 @@ if (!URL.revokeObjectURL) {
|
||||
}
|
||||
Object.defineProperty(window, 'ResizeObserver', { value: mock() })
|
||||
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
class MockIntersectionObserver {
|
||||
constructor(
|
||||
public callback: IntersectionObserverCallback,
|
||||
public options?: IntersectionObserverInit
|
||||
) {}
|
||||
|
||||
observe = jest.fn()
|
||||
unobserve = jest.fn()
|
||||
disconnect = jest.fn()
|
||||
takeRecords = jest.fn()
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'IntersectionObserver', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: MockIntersectionObserver,
|
||||
})
|
||||
}
|
||||
|
||||
HTMLCanvasElement.prototype.getContext = <
|
||||
typeof HTMLCanvasElement.prototype.getContext
|
||||
>jest.fn()
|
||||
|
@@ -1,54 +0,0 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="btn-toolbar flex-nowrap">
|
||||
<div class="input-group input-group-sm">
|
||||
<div class="input-group-text" i18n>Page</div>
|
||||
<input class="form-control mw-60" type="number" min="1" [(ngModel)]="currentPage" />
|
||||
<div class="input-group-text" i18n>of {{totalPages}}</div>
|
||||
</div>
|
||||
<div class="input-group input-group-sm ms-auto">
|
||||
<span class="input-group-text" i18n>Pages to remove</span>
|
||||
<input [ngModel]="pagesString" class="form-control" disabled />
|
||||
</div>
|
||||
</div>
|
||||
<div class="pdf-viewer-container w-100 mt-3">
|
||||
<pdf-viewer #pdfViewer [src]="pdfSrc" [(page)]="currentPage"
|
||||
[original-size]="false"
|
||||
[zoom]="1"
|
||||
zoom-scale="page-fit"
|
||||
[render-text]="false"
|
||||
(pagerendered)="pageRendered($event)"
|
||||
(after-load-complete)="pdfPreviewLoaded($event)">
|
||||
</pdf-viewer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer flex-nowrap">
|
||||
<div>
|
||||
@if (message) {
|
||||
<p [innerHTML]="message | safeHtml"></p>
|
||||
}
|
||||
@if (messageBold) {
|
||||
<p class="mb-0 small"><b [innerHTML]="messageBold | safeHtml"></b></p>
|
||||
}
|
||||
</div>
|
||||
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
|
||||
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
||||
</button>
|
||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
|
||||
{{btnCaption}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ng-template #pageCheckOverlay let-page="page" let-pages="pages">
|
||||
<div class="position-absolute top-0 start-0 w-100 h-100 p-2" (click)="pageCheckChanged(page)">
|
||||
<input type="checkbox" class="form-check-input" />
|
||||
</div>
|
||||
</ng-template>
|
@@ -1,28 +0,0 @@
|
||||
.pdf-viewer-container {
|
||||
background-color: gray;
|
||||
height: 550px;
|
||||
|
||||
pdf-viewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.mw-60 {
|
||||
max-width: 60px;
|
||||
}
|
||||
|
||||
div.position-absolute:has(.form-check-input:checked) {
|
||||
background-color: rgba(var(--bs-dark-rgb), 0.4);
|
||||
}
|
||||
|
||||
.form-check-input {
|
||||
&:checked {
|
||||
background-color: var(--bs-danger);
|
||||
border-color: var(--bs-danger);
|
||||
}
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 0.25rem rgba(var(--bs-danger-rgb), var(--pngx-focus-alpha));
|
||||
border-color: var(--bs-danger);
|
||||
}
|
||||
}
|
@@ -1,60 +0,0 @@
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
|
||||
import { DeletePagesConfirmDialogComponent } from './delete-pages-confirm-dialog.component'
|
||||
|
||||
describe('DeletePagesConfirmDialogComponent', () => {
|
||||
let component: DeletePagesConfirmDialogComponent
|
||||
let fixture: ComponentFixture<DeletePagesConfirmDialogComponent>
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [],
|
||||
imports: [
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
DeletePagesConfirmDialogComponent,
|
||||
],
|
||||
providers: [
|
||||
NgbActiveModal,
|
||||
SafeHtmlPipe,
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
}).compileComponents()
|
||||
fixture = TestBed.createComponent(DeletePagesConfirmDialogComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should return a string with comma-separated pages', () => {
|
||||
component.pages = [1, 2, 3, 4]
|
||||
expect(component.pagesString).toEqual('1, 2, 3, 4')
|
||||
})
|
||||
|
||||
it('should update totalPages when pdf is loaded', () => {
|
||||
component.pdfPreviewLoaded({ numPages: 5 } as any)
|
||||
expect(component.totalPages).toEqual(5)
|
||||
})
|
||||
|
||||
it('should update checks when page is rendered', () => {
|
||||
const event = {
|
||||
target: document.createElement('div'),
|
||||
detail: { pageNumber: 1 },
|
||||
} as any
|
||||
component.pageRendered(event)
|
||||
expect(component['checks'].length).toEqual(1)
|
||||
})
|
||||
|
||||
it('should update pages when page check is changed', () => {
|
||||
component.pageCheckChanged(1)
|
||||
expect(component.pages).toEqual([1])
|
||||
component.pageCheckChanged(1)
|
||||
expect(component.pages).toEqual([])
|
||||
})
|
||||
})
|
@@ -1,69 +0,0 @@
|
||||
import { Component, TemplateRef, ViewChild, inject } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import {
|
||||
PDFDocumentProxy,
|
||||
PdfViewerComponent,
|
||||
PdfViewerModule,
|
||||
} from 'ng2-pdf-viewer'
|
||||
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-delete-pages-confirm-dialog',
|
||||
templateUrl: './delete-pages-confirm-dialog.component.html',
|
||||
styleUrl: './delete-pages-confirm-dialog.component.scss',
|
||||
imports: [PdfViewerModule, FormsModule, ReactiveFormsModule, SafeHtmlPipe],
|
||||
})
|
||||
export class DeletePagesConfirmDialogComponent extends ConfirmDialogComponent {
|
||||
private documentService = inject(DocumentService)
|
||||
|
||||
public documentID: number
|
||||
public pages: number[] = []
|
||||
public currentPage: number = 1
|
||||
public totalPages: number
|
||||
|
||||
@ViewChild('pdfViewer') pdfViewer: PdfViewerComponent
|
||||
@ViewChild('pageCheckOverlay') pageCheckOverlay!: TemplateRef<any>
|
||||
private checks: HTMLElement[] = []
|
||||
|
||||
public get pagesString(): string {
|
||||
return this.pages.join(', ')
|
||||
}
|
||||
|
||||
public get pdfSrc(): string {
|
||||
return this.documentService.getPreviewUrl(this.documentID)
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
public pdfPreviewLoaded(pdf: PDFDocumentProxy) {
|
||||
this.totalPages = pdf.numPages
|
||||
}
|
||||
|
||||
pageRendered(event: CustomEvent) {
|
||||
const pageDiv = event.target as HTMLDivElement
|
||||
const check = this.pageCheckOverlay.createEmbeddedView({
|
||||
page: event.detail.pageNumber,
|
||||
})
|
||||
this.checks[event.detail.pageNumber - 1] = check.rootNodes[0]
|
||||
pageDiv?.insertBefore(check.rootNodes[0], pageDiv.firstChild)
|
||||
this.updateChecks()
|
||||
}
|
||||
|
||||
pageCheckChanged(pageNumber: number) {
|
||||
if (!this.pages.includes(pageNumber)) this.pages.push(pageNumber)
|
||||
else if (this.pages.includes(pageNumber))
|
||||
this.pages.splice(this.pages.indexOf(pageNumber), 1)
|
||||
this.updateChecks()
|
||||
}
|
||||
|
||||
private updateChecks() {
|
||||
this.checks.forEach((check, i) => {
|
||||
const input = check.getElementsByTagName('input')[0]
|
||||
input.checked = this.pages.includes(i + 1)
|
||||
})
|
||||
}
|
||||
}
|
@@ -1,59 +0,0 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>{{message}}</p>
|
||||
<div class="row mb-2">
|
||||
<div class="col-7">
|
||||
<div class="input-group input-group-sm">
|
||||
<div class="input-group-text" i18n>Page</div>
|
||||
<input class="form-control" type="number" min="1" [(ngModel)]="page" />
|
||||
<div class="input-group-text" i18n>of {{totalPages}}</div>
|
||||
</div>
|
||||
<div class="pdf-viewer-container w-100 mt-3">
|
||||
<pdf-viewer [src]="pdfSrc" [(page)]="page"
|
||||
[original-size]="false"
|
||||
[zoom]="1"
|
||||
zoom-scale="page-fit"
|
||||
(after-load-complete)="pdfPreviewLoaded($event)">
|
||||
</pdf-viewer>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<div class="d-grid">
|
||||
<button class="btn btn-sm btn-primary" (click)="addSplit()" [disabled]="!canSplit">
|
||||
<i-bs name="plus-circle"></i-bs>
|
||||
<span i18n>Add Split</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul class="list-group mt-3">
|
||||
@for (pageStr of pagesString.split(','); track pageStr; let i = $index) {
|
||||
<li class="list-group-item d-flex align-items-center">
|
||||
{{pageStr}}
|
||||
@if (pagesString.split(',').length > 1) {
|
||||
|
||||
<button class="btn btn-sm btn-danger ms-auto" (click)="removeSplit(i)">
|
||||
<i-bs name="trash"></i-bs>
|
||||
</button>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="form-check form-switch me-auto">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="deleteOriginalSwitch" [(ngModel)]="deleteOriginal" [disabled]="!userOwnsDocument">
|
||||
<label class="form-check-label" for="deleteOriginalSwitch" i18n>Delete original document after successful split</label>
|
||||
</div>
|
||||
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
|
||||
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
||||
</button>
|
||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
|
||||
{{btnCaption}}
|
||||
</button>
|
||||
</div>
|
@@ -1,9 +0,0 @@
|
||||
.pdf-viewer-container {
|
||||
background-color: gray;
|
||||
height: 500px;
|
||||
|
||||
pdf-viewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
@@ -1,107 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { PdfViewerModule } from 'ng2-pdf-viewer'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { of } from 'rxjs'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { SplitConfirmDialogComponent } from './split-confirm-dialog.component'
|
||||
|
||||
describe('SplitConfirmDialogComponent', () => {
|
||||
let component: SplitConfirmDialogComponent
|
||||
let fixture: ComponentFixture<SplitConfirmDialogComponent>
|
||||
let documentService: DocumentService
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
ReactiveFormsModule,
|
||||
FormsModule,
|
||||
PdfViewerModule,
|
||||
SplitConfirmDialogComponent,
|
||||
],
|
||||
providers: [
|
||||
NgbActiveModal,
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(SplitConfirmDialogComponent)
|
||||
documentService = TestBed.inject(DocumentService)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should load document on init', () => {
|
||||
const getSpy = jest.spyOn(documentService, 'get')
|
||||
component.documentID = 1
|
||||
getSpy.mockReturnValue(of({ id: 1 } as any))
|
||||
component.ngOnInit()
|
||||
expect(documentService.get).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('should update pagesString when pages are added', () => {
|
||||
component.totalPages = 5
|
||||
component.page = 2
|
||||
component.addSplit()
|
||||
expect(component.pagesString).toEqual('1-2,3-5')
|
||||
component.page = 4
|
||||
component.addSplit()
|
||||
expect(component.pagesString).toEqual('1-2,3-4,5')
|
||||
})
|
||||
|
||||
it('should update pagesString when pages are removed', () => {
|
||||
component.totalPages = 5
|
||||
component.page = 2
|
||||
component.addSplit()
|
||||
component.page = 4
|
||||
component.addSplit()
|
||||
expect(component.pagesString).toEqual('1-2,3-4,5')
|
||||
component.removeSplit(0)
|
||||
expect(component.pagesString).toEqual('1-4,5')
|
||||
})
|
||||
|
||||
it('should enable confirm button when pages are added', () => {
|
||||
component.totalPages = 5
|
||||
component.page = 2
|
||||
component.addSplit()
|
||||
expect(component.confirmButtonEnabled).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should disable confirm button when all pages are removed', () => {
|
||||
component.totalPages = 5
|
||||
component.page = 2
|
||||
component.addSplit()
|
||||
component.removeSplit(0)
|
||||
expect(component.confirmButtonEnabled).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should not add split if page is the last page', () => {
|
||||
component.totalPages = 5
|
||||
component.page = 5
|
||||
component.addSplit()
|
||||
expect(component.pagesString).toEqual('1-5')
|
||||
})
|
||||
|
||||
it('should update totalPages when pdf is loaded', () => {
|
||||
component.pdfPreviewLoaded({ numPages: 5 } as any)
|
||||
expect(component.totalPages).toEqual(5)
|
||||
})
|
||||
|
||||
it('should correctly disable split button', () => {
|
||||
component.totalPages = 5
|
||||
component.page = 1
|
||||
expect(component.canSplit).toBeTruthy()
|
||||
component.page = 5
|
||||
expect(component.canSplit).toBeFalsy()
|
||||
component.page = 4
|
||||
expect(component.canSplit).toBeTruthy()
|
||||
component['pages'] = new Set([1, 2, 3, 4])
|
||||
expect(component.canSplit).toBeFalsy()
|
||||
})
|
||||
})
|
@@ -1,98 +0,0 @@
|
||||
import { Component, OnInit, inject } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { Document } from 'src/app/data/document'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-split-confirm-dialog',
|
||||
templateUrl: './split-confirm-dialog.component.html',
|
||||
styleUrl: './split-confirm-dialog.component.scss',
|
||||
imports: [
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgxBootstrapIconsModule,
|
||||
PdfViewerModule,
|
||||
],
|
||||
})
|
||||
export class SplitConfirmDialogComponent
|
||||
extends ConfirmDialogComponent
|
||||
implements OnInit
|
||||
{
|
||||
private documentService = inject(DocumentService)
|
||||
private permissionService = inject(PermissionsService)
|
||||
|
||||
public get pagesString(): string {
|
||||
let pagesStr = ''
|
||||
|
||||
let lastPage = 1
|
||||
for (let i = 1; i <= this.totalPages; i++) {
|
||||
if (this.pages.has(i) || i === this.totalPages) {
|
||||
if (lastPage === i) {
|
||||
pagesStr += `${i},`
|
||||
lastPage = Math.min(i + 1, this.totalPages)
|
||||
} else {
|
||||
pagesStr += `${lastPage}-${i},`
|
||||
lastPage = Math.min(i + 1, this.totalPages)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pagesStr.replace(/,$/, '')
|
||||
}
|
||||
|
||||
private pages: Set<number> = new Set()
|
||||
|
||||
public documentID: number
|
||||
private document: Document
|
||||
public page: number = 1
|
||||
public totalPages: number
|
||||
public deleteOriginal: boolean = false
|
||||
|
||||
public get canSplit(): boolean {
|
||||
return (
|
||||
this.page < this.totalPages &&
|
||||
this.pages.size < this.totalPages - 1 &&
|
||||
!this.pages.has(this.page)
|
||||
)
|
||||
}
|
||||
|
||||
public get pdfSrc(): string {
|
||||
return this.documentService.getPreviewUrl(this.documentID)
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.confirmButtonEnabled = this.pages.size > 0
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.documentService.get(this.documentID).subscribe((r) => {
|
||||
this.document = r
|
||||
})
|
||||
}
|
||||
|
||||
pdfPreviewLoaded(pdf: PDFDocumentProxy) {
|
||||
this.totalPages = pdf.numPages
|
||||
}
|
||||
|
||||
addSplit() {
|
||||
if (this.page === this.totalPages) return
|
||||
this.pages.add(this.page)
|
||||
this.pages = new Set(Array.from(this.pages).sort((a, b) => a - b))
|
||||
this.confirmButtonEnabled = this.pages.size > 0
|
||||
}
|
||||
|
||||
removeSplit(i: number) {
|
||||
let page = Array.from(this.pages)[Math.min(i, this.pages.size - 1)]
|
||||
this.pages.delete(page)
|
||||
this.confirmButtonEnabled = this.pages.size > 0
|
||||
}
|
||||
|
||||
get userOwnsDocument(): boolean {
|
||||
return this.permissionService.currentUserOwnsObject(this.document)
|
||||
}
|
||||
}
|
@@ -0,0 +1,103 @@
|
||||
<pdf-viewer [src]="pdfSrc" [render-text]="false" zoom="0.4" (after-load-complete)="pdfLoaded($event)"></pdf-viewer>
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">{{ title }}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="btn-toolbar mb-2">
|
||||
<div class="btn-group me-3">
|
||||
<button class="btn btn-sm btn-secondary" (click)="selectAll()" title="Select all pages" i18n-title>
|
||||
<i-bs name="check-all"></i-bs>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary" (click)="deselectAll()" [disabled]="!hasSelection()" title="Deselect all pages" i18n-title>
|
||||
<i-bs name="x"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-secondary" (click)="rotateSelected(-90)" [disabled]="!hasSelection()" title="Rotate selected pages counter-clockwise" i18n-title>
|
||||
<i-bs name="arrow-counterclockwise"></i-bs>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary" (click)="rotateSelected(90)" [disabled]="!hasSelection()" title="Rotate selected pages clockwise" i18n-title>
|
||||
<i-bs name="arrow-clockwise"></i-bs>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" (click)="deleteSelected()" [disabled]="!hasSelection()" title="Delete selected pages" i18n-title>
|
||||
<i-bs name="trash"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div cdkDropList (cdkDropListDropped)="drop($event)" cdkDropListOrientation="mixed" class="d-flex flex-wrap row-cols-5">
|
||||
@for (p of pages; track p.page; let i = $index) {
|
||||
<div class="page-item rounded p-2" cdkDrag (click)="toggleSelection(i)" [class.selected]="p.selected">
|
||||
<div class="btn-toolbar hover-actions z-10">
|
||||
<div class="btn-group me-2">
|
||||
<button class="btn btn-sm btn-dark" (click)="rotate(i); $event.stopPropagation()" title="Rotate page counter-clockwise" i18n-title>
|
||||
<i-bs name="arrow-counterclockwise"></i-bs>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-dark" (click)="rotate(i); $event.stopPropagation()" title="Rotate page clockwise" i18n-title>
|
||||
<i-bs name="arrow-clockwise"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-dark text-danger" (click)="remove(i); $event.stopPropagation()" title="Delete page" i18n-title>
|
||||
<i-bs name="trash"></i-bs>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-dark" (click)="toggleSplit(i); $event.stopPropagation()" title="Add / remove document split here" i18n-title>
|
||||
<i-bs name="scissors"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-end border-bottom bg-light py-1 px-2 document-check z-10">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="page{{i}}" [checked]="p.selected" (click)="toggleSelection(i); $event.stopPropagation()">
|
||||
<label class="form-check-label" for="page{{i}}"></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pdf-viewer-container w-100" [class.selected]="p.selected">
|
||||
@defer (on viewport) {
|
||||
@if (!p.loaded) {
|
||||
<div class="placeholder-glow w-100 h-100 z-10">
|
||||
<span class="placeholder w-100 h-100"></span>
|
||||
</div>
|
||||
}
|
||||
<pdf-viewer class="fade" [class.show]="p.loaded" [src]="pdfSrc" [page]="p.page" [rotation]="p.rotate" [original-size]="false" [show-all]="false" [render-text]="false" (page-rendered)="p.loaded = true"></pdf-viewer>
|
||||
} @placeholder {
|
||||
<div class="placeholder-glow w-100 h-100 z-10">
|
||||
<span class="placeholder w-100 h-100"></span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (p.splitAfter) {
|
||||
<div class="split-after rounded position-absolute top-0 end-0 bg-dark text-uppercase text-center h-100 px-1 small fw-bold">— <span i18n>Split here</span> —</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer flex-column">
|
||||
<div class="d-flex w-100 justify-content-between align-items-center">
|
||||
<div class="btn-group" role="group">
|
||||
<input type="radio" class="btn-check" [(ngModel)]="editMode" [value]="PdfEditorEditMode.Create" id="editModeCreate" name="editmode">
|
||||
<label for="editModeCreate" class="btn btn-outline-primary btn-sm">
|
||||
<i-bs name="plus"></i-bs>
|
||||
<span class="form-check-label ms-1" i18n>Create new document(s)</span>
|
||||
</label>
|
||||
<input type="radio" class="btn-check" [(ngModel)]="editMode" [value]="PdfEditorEditMode.Update" id="editModeUpdate" name="editmode" [disabled]="hasSplit()">
|
||||
<label for="editModeUpdate" class="btn btn-outline-primary btn-sm">
|
||||
<i-bs name="pencil"></i-bs>
|
||||
<span class="form-check-label ms-2" i18n>Update existing document</span>
|
||||
</label>
|
||||
</div>
|
||||
@if (editMode === PdfEditorEditMode.Create) {
|
||||
<div class="form-check ms-3">
|
||||
<input class="form-check-input" type="checkbox" id="copyMeta" [(ngModel)]="includeMetadata">
|
||||
<label class="form-check-label" for="copyMeta" i18n>Copy metadata</label>
|
||||
</div>
|
||||
<div class="form-check ms-3">
|
||||
<input class="form-check-input" type="checkbox" id="deleteOriginal" [(ngModel)]="deleteOriginal">
|
||||
<label class="form-check-label" for="deleteOriginal" i18n>Delete original</label>
|
||||
</div>
|
||||
}
|
||||
<button type="button" class="btn ms-auto me-2" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">{{ cancelBtnCaption }}</button>
|
||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="pages.length === 0">{{ btnCaption }}</button>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,70 @@
|
||||
|
||||
|
||||
.page-item {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
background-origin: border-box;
|
||||
|
||||
&.selected {
|
||||
background-color: var(--pngx-primary-darken-5);
|
||||
}
|
||||
}
|
||||
|
||||
.pdf-viewer-container {
|
||||
background-color: gray;
|
||||
height: 240px;
|
||||
|
||||
pdf-viewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .ng2-pdf-viewer-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hover-actions {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page-item:hover .hover-actions {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.document-check {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 0.5rem;
|
||||
border-top-left-radius: 0.25rem;
|
||||
border-bottom-right-radius: 0.25rem;
|
||||
pointer-events: none;
|
||||
|
||||
.form-check {
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
margin-bottom: 0;
|
||||
|
||||
.form-check-input {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page-item:hover .document-check, .selected .document-check {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.z-10 {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.split-after {
|
||||
writing-mode: vertical-rl;
|
||||
}
|
@@ -0,0 +1,142 @@
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { PDFEditorComponent } from './pdf-editor.component'
|
||||
|
||||
describe('PDFEditorComponent', () => {
|
||||
let component: PDFEditorComponent
|
||||
let fixture: ComponentFixture<PDFEditorComponent>
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PDFEditorComponent, NgxBootstrapIconsModule.pick(allIcons)],
|
||||
providers: [
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: NgbActiveModal, useValue: {} },
|
||||
],
|
||||
}).compileComponents()
|
||||
fixture = TestBed.createComponent(PDFEditorComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should return correct operations with no changes', () => {
|
||||
component.pages = [
|
||||
{ page: 1, rotate: 0, splitAfter: false },
|
||||
{ page: 2, rotate: 0, splitAfter: false },
|
||||
{ page: 3, rotate: 0, splitAfter: false },
|
||||
]
|
||||
const ops = component.getOperations()
|
||||
expect(ops).toEqual([
|
||||
{ page: 1, rotate: 0, doc: 0 },
|
||||
{ page: 2, rotate: 0, doc: 0 },
|
||||
{ page: 3, rotate: 0, doc: 0 },
|
||||
])
|
||||
})
|
||||
|
||||
it('should rotate, delete and reorder pages', () => {
|
||||
component.pages = [
|
||||
{ page: 1, rotate: 0, splitAfter: false, selected: false },
|
||||
{ page: 2, rotate: 0, splitAfter: false, selected: false },
|
||||
]
|
||||
component.toggleSelection(0)
|
||||
component.rotateSelected(90)
|
||||
expect(component.pages[0].rotate).toBe(90)
|
||||
component.toggleSelection(0) // deselect
|
||||
component.toggleSelection(1)
|
||||
component.deleteSelected()
|
||||
expect(component.pages.length).toBe(1)
|
||||
component.pages.push({ page: 2, rotate: 0, splitAfter: false })
|
||||
component.drop({ previousIndex: 0, currentIndex: 1 } as any)
|
||||
expect(component.pages[0].page).toBe(2)
|
||||
component.rotate(0)
|
||||
expect(component.pages[0].rotate).toBe(90)
|
||||
})
|
||||
|
||||
it('should handle empty pages array', () => {
|
||||
component.pages = []
|
||||
expect(component.getOperations()).toEqual([])
|
||||
})
|
||||
|
||||
it('should increment doc index after splitAfter', () => {
|
||||
component.pages = [
|
||||
{ page: 1, rotate: 0, splitAfter: true },
|
||||
{ page: 2, rotate: 0, splitAfter: false },
|
||||
{ page: 3, rotate: 0, splitAfter: true },
|
||||
{ page: 4, rotate: 0, splitAfter: false },
|
||||
]
|
||||
const ops = component.getOperations()
|
||||
expect(ops).toEqual([
|
||||
{ page: 1, rotate: 0, doc: 0 },
|
||||
{ page: 2, rotate: 0, doc: 1 },
|
||||
{ page: 3, rotate: 0, doc: 1 },
|
||||
{ page: 4, rotate: 0, doc: 2 },
|
||||
])
|
||||
})
|
||||
|
||||
it('should include rotations in operations', () => {
|
||||
component.pages = [
|
||||
{ page: 1, rotate: 90, splitAfter: false },
|
||||
{ page: 2, rotate: 180, splitAfter: true },
|
||||
{ page: 3, rotate: 270, splitAfter: false },
|
||||
]
|
||||
const ops = component.getOperations()
|
||||
expect(ops).toEqual([
|
||||
{ page: 1, rotate: 90, doc: 0 },
|
||||
{ page: 2, rotate: 180, doc: 0 },
|
||||
{ page: 3, rotate: 270, doc: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
it('should handle remove operation', () => {
|
||||
component.pages = [
|
||||
{ page: 1, rotate: 0, splitAfter: false, selected: false },
|
||||
{ page: 2, rotate: 0, splitAfter: false, selected: true },
|
||||
{ page: 3, rotate: 0, splitAfter: false, selected: false },
|
||||
]
|
||||
component.remove(1) // remove page 2
|
||||
expect(component.pages.length).toBe(2)
|
||||
expect(component.pages[0].page).toBe(1)
|
||||
expect(component.pages[1].page).toBe(3)
|
||||
})
|
||||
|
||||
it('should toggle splitAfter correctly', () => {
|
||||
component.pages = [
|
||||
{ page: 1, rotate: 0, splitAfter: false },
|
||||
{ page: 2, rotate: 0, splitAfter: false },
|
||||
]
|
||||
component.toggleSplit(0)
|
||||
expect(component.pages[0].splitAfter).toBeTruthy()
|
||||
component.toggleSplit(1)
|
||||
expect(component.pages[1].splitAfter).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should select and deselect all pages', () => {
|
||||
component.pages = [
|
||||
{ page: 1, rotate: 0, splitAfter: false, selected: false },
|
||||
{ page: 2, rotate: 0, splitAfter: false, selected: false },
|
||||
]
|
||||
component.selectAll()
|
||||
expect(component.pages.every((p) => p.selected)).toBeTruthy()
|
||||
expect(component.hasSelection()).toBeTruthy()
|
||||
component.deselectAll()
|
||||
expect(component.pages.every((p) => !p.selected)).toBeTruthy()
|
||||
expect(component.hasSelection()).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should handle pdf loading and page generation', () => {
|
||||
const mockPdf = {
|
||||
numPages: 3,
|
||||
getPage: (pageNum: number) => Promise.resolve({ pageNumber: pageNum }),
|
||||
}
|
||||
component.pdfLoaded(mockPdf as any)
|
||||
expect(component.totalPages).toBe(3)
|
||||
expect(component.pages.length).toBe(3)
|
||||
expect(component.pages[0].page).toBe(1)
|
||||
expect(component.pages[1].page).toBe(2)
|
||||
expect(component.pages[2].page).toBe(3)
|
||||
})
|
||||
})
|
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
CdkDragDrop,
|
||||
DragDropModule,
|
||||
moveItemInArray,
|
||||
} from '@angular/cdk/drag-drop'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component'
|
||||
|
||||
interface PageOperation {
|
||||
page: number
|
||||
rotate: number
|
||||
splitAfter: boolean
|
||||
selected?: boolean
|
||||
loaded?: boolean
|
||||
}
|
||||
|
||||
export enum PdfEditorEditMode {
|
||||
Update = 'update',
|
||||
Create = 'create',
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-pdf-editor',
|
||||
templateUrl: './pdf-editor.component.html',
|
||||
styleUrl: './pdf-editor.component.scss',
|
||||
imports: [
|
||||
DragDropModule,
|
||||
FormsModule,
|
||||
PdfViewerModule,
|
||||
NgxBootstrapIconsModule,
|
||||
],
|
||||
})
|
||||
export class PDFEditorComponent extends ConfirmDialogComponent {
|
||||
public PdfEditorEditMode = PdfEditorEditMode
|
||||
|
||||
private documentService = inject(DocumentService)
|
||||
activeModal: NgbActiveModal = inject(NgbActiveModal)
|
||||
|
||||
documentID: number
|
||||
pages: PageOperation[] = []
|
||||
totalPages = 0
|
||||
editMode: PdfEditorEditMode = PdfEditorEditMode.Create
|
||||
deleteOriginal: boolean = false
|
||||
includeMetadata: boolean = true
|
||||
|
||||
get pdfSrc(): string {
|
||||
return this.documentService.getPreviewUrl(this.documentID)
|
||||
}
|
||||
|
||||
pdfLoaded(pdf: PDFDocumentProxy) {
|
||||
this.totalPages = pdf.numPages
|
||||
this.pages = Array.from({ length: this.totalPages }, (_, i) => ({
|
||||
page: i + 1,
|
||||
rotate: 0,
|
||||
splitAfter: false,
|
||||
selected: false,
|
||||
loaded: false,
|
||||
}))
|
||||
}
|
||||
|
||||
toggleSelection(i: number) {
|
||||
this.pages[i].selected = !this.pages[i].selected
|
||||
}
|
||||
|
||||
rotate(i: number) {
|
||||
this.pages[i].rotate = (this.pages[i].rotate + 90) % 360
|
||||
}
|
||||
|
||||
rotateSelected(dir: number) {
|
||||
for (let p of this.pages) {
|
||||
if (p.selected) {
|
||||
p.rotate = (p.rotate + dir + 360) % 360
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
remove(i: number) {
|
||||
this.pages.splice(i, 1)
|
||||
}
|
||||
|
||||
toggleSplit(i: number) {
|
||||
this.pages[i].splitAfter = !this.pages[i].splitAfter
|
||||
if (this.pages[i].splitAfter) {
|
||||
// force create mode
|
||||
this.editMode = PdfEditorEditMode.Create
|
||||
}
|
||||
}
|
||||
|
||||
selectAll() {
|
||||
this.pages.forEach((p) => (p.selected = true))
|
||||
}
|
||||
|
||||
deselectAll() {
|
||||
this.pages.forEach((p) => (p.selected = false))
|
||||
}
|
||||
|
||||
deleteSelected() {
|
||||
this.pages = this.pages.filter((p) => !p.selected)
|
||||
}
|
||||
|
||||
hasSelection(): boolean {
|
||||
return this.pages.some((p) => p.selected)
|
||||
}
|
||||
|
||||
hasSplit(): boolean {
|
||||
return this.pages.some((p) => p.splitAfter)
|
||||
}
|
||||
|
||||
drop(event: CdkDragDrop<PageOperation[]>) {
|
||||
moveItemInArray(this.pages, event.previousIndex, event.currentIndex)
|
||||
}
|
||||
|
||||
getOperations() {
|
||||
return this.pages.map((p, idx) => ({
|
||||
page: p.page,
|
||||
rotate: p.rotate,
|
||||
doc: this.computeDocIndex(idx),
|
||||
}))
|
||||
}
|
||||
|
||||
private computeDocIndex(index: number): number {
|
||||
let docIndex = 0
|
||||
for (let i = 0; i <= index; i++) {
|
||||
if (this.pages[i].splitAfter && i < index) docIndex++
|
||||
}
|
||||
return docIndex
|
||||
}
|
||||
}
|
@@ -58,16 +58,8 @@
|
||||
<i-bs width="1em" height="1em" name="diagram-3"></i-bs> <span i18n>More like this</span>
|
||||
</button>
|
||||
|
||||
<button ngbDropdownItem (click)="splitDocument()" [disabled]="!userCanAdd || originalContentRenderType !== ContentRenderType.PDF || previewNumPages === 1">
|
||||
<i-bs width="1em" height="1em" name="scissors"></i-bs> <span i18n>Split</span>
|
||||
</button>
|
||||
|
||||
<button ngbDropdownItem (click)="rotateDocument()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF">
|
||||
<i-bs name="arrow-clockwise"></i-bs> <ng-container i18n>Rotate</ng-container>
|
||||
</button>
|
||||
|
||||
<button ngbDropdownItem (click)="deletePages()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF || previewNumPages === 1">
|
||||
<i-bs name="file-earmark-minus"></i-bs> <ng-container i18n>Delete page(s)</ng-container>
|
||||
<button ngbDropdownItem (click)="editPdf()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF">
|
||||
<i-bs name="pencil"></i-bs> <ng-container i18n>PDF Editor</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -1158,81 +1158,40 @@ describe('DocumentDetailComponent', () => {
|
||||
).not.toBeUndefined()
|
||||
})
|
||||
|
||||
it('should support split', () => {
|
||||
it('should support pdf editor, handle error', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||
initNormally()
|
||||
component.splitDocument()
|
||||
component.editPdf()
|
||||
expect(modal).not.toBeUndefined()
|
||||
modal.componentInstance.documentID = doc.id
|
||||
modal.componentInstance.totalPages = 5
|
||||
modal.componentInstance.page = 2
|
||||
modal.componentInstance.addSplit()
|
||||
modal.componentInstance.pages = [{ page: 1, rotate: 0, splitAfter: false }]
|
||||
modal.componentInstance.confirm()
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
)
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [doc.id],
|
||||
method: 'split',
|
||||
parameters: { pages: '1-2,3-5', delete_originals: false },
|
||||
method: 'edit_pdf',
|
||||
parameters: {
|
||||
operations: [{ page: 1, rotate: 0, doc: 0 }],
|
||||
delete_original: false,
|
||||
update_document: false,
|
||||
include_metadata: true,
|
||||
},
|
||||
})
|
||||
req.error(new ProgressEvent('failed'))
|
||||
modal.componentInstance.confirm()
|
||||
req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
)
|
||||
req.flush(true)
|
||||
})
|
||||
|
||||
it('should support rotate', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||
initNormally()
|
||||
component.rotateDocument()
|
||||
expect(modal).not.toBeUndefined()
|
||||
component.editPdf()
|
||||
modal.componentInstance.documentID = doc.id
|
||||
modal.componentInstance.rotate()
|
||||
modal.componentInstance.confirm()
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
)
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [doc.id],
|
||||
method: 'rotate',
|
||||
parameters: { degrees: 90 },
|
||||
})
|
||||
req.error(new ProgressEvent('failed'))
|
||||
modal.componentInstance.pages = [{ page: 1, rotate: 0, splitAfter: true }]
|
||||
modal.componentInstance.confirm()
|
||||
const errorSpy = jest.spyOn(toastService, 'showError')
|
||||
req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
)
|
||||
req.flush(true)
|
||||
})
|
||||
|
||||
it('should support delete pages', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||
initNormally()
|
||||
component.deletePages()
|
||||
expect(modal).not.toBeUndefined()
|
||||
modal.componentInstance.documentID = doc.id
|
||||
modal.componentInstance.pages = [1, 2]
|
||||
modal.componentInstance.confirm()
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
)
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [doc.id],
|
||||
method: 'delete_pages',
|
||||
parameters: { pages: [1, 2] },
|
||||
})
|
||||
req.error(new ProgressEvent('failed'))
|
||||
modal.componentInstance.confirm()
|
||||
req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
)
|
||||
req.flush(true)
|
||||
req.error(new ErrorEvent('failed'))
|
||||
expect(errorSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support keyboard shortcuts', () => {
|
||||
|
@@ -82,9 +82,6 @@ import { getFilenameFromContentDisposition } from 'src/app/utils/http'
|
||||
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
|
||||
import * as UTIF from 'utif'
|
||||
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
||||
import { DeletePagesConfirmDialogComponent } from '../common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component'
|
||||
import { RotateConfirmDialogComponent } from '../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
||||
import { SplitConfirmDialogComponent } from '../common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
|
||||
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
|
||||
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||
@@ -102,6 +99,10 @@ import { TagsComponent } from '../common/input/tags/tags.component'
|
||||
import { TextComponent } from '../common/input/text/text.component'
|
||||
import { UrlComponent } from '../common/input/url/url.component'
|
||||
import { PageHeaderComponent } from '../common/page-header/page-header.component'
|
||||
import {
|
||||
PDFEditorComponent,
|
||||
PdfEditorEditMode,
|
||||
} from '../common/pdf-editor/pdf-editor.component'
|
||||
import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-links-dialog.component'
|
||||
import { DocumentHistoryComponent } from '../document-history/document-history.component'
|
||||
import { DocumentNotesComponent } from '../document-notes/document-notes.component'
|
||||
@@ -1349,13 +1350,13 @@ export class DocumentDetailComponent
|
||||
this.documentForm.updateValueAndValidity()
|
||||
}
|
||||
|
||||
splitDocument() {
|
||||
let modal = this.modalService.open(SplitConfirmDialogComponent, {
|
||||
editPdf() {
|
||||
let modal = this.modalService.open(PDFEditorComponent, {
|
||||
backdrop: 'static',
|
||||
size: 'lg',
|
||||
size: 'xl',
|
||||
scrollable: true,
|
||||
})
|
||||
modal.componentInstance.title = $localize`Split confirm`
|
||||
modal.componentInstance.messageBold = $localize`This operation will split the selected document(s) into new documents.`
|
||||
modal.componentInstance.title = $localize`PDF Editor`
|
||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||
modal.componentInstance.documentID = this.document.id
|
||||
modal.componentInstance.confirmClicked
|
||||
@@ -1363,15 +1364,18 @@ export class DocumentDetailComponent
|
||||
.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
this.documentsService
|
||||
.bulkEdit([this.document.id], 'split', {
|
||||
pages: modal.componentInstance.pagesString,
|
||||
delete_originals: modal.componentInstance.deleteOriginal,
|
||||
.bulkEdit([this.document.id], 'edit_pdf', {
|
||||
operations: modal.componentInstance.getOperations(),
|
||||
delete_original: modal.componentInstance.deleteOriginal,
|
||||
update_document:
|
||||
modal.componentInstance.editMode == PdfEditorEditMode.Update,
|
||||
include_metadata: modal.componentInstance.includeMetadata,
|
||||
})
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastService.showInfo(
|
||||
$localize`Split operation for "${this.document.title}" will begin in the background.`
|
||||
$localize`PDF edit operation for "${this.document.title}" will begin in the background.`
|
||||
)
|
||||
modal.close()
|
||||
},
|
||||
@@ -1380,86 +1384,7 @@ export class DocumentDetailComponent
|
||||
modal.componentInstance.buttonsEnabled = true
|
||||
}
|
||||
this.toastService.showError(
|
||||
$localize`Error executing split operation`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
rotateDocument() {
|
||||
let modal = this.modalService.open(RotateConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
size: 'lg',
|
||||
})
|
||||
modal.componentInstance.title = $localize`Rotate confirm`
|
||||
modal.componentInstance.messageBold = $localize`This operation will permanently rotate the original version of the current document.`
|
||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||
modal.componentInstance.documentID = this.document.id
|
||||
modal.componentInstance.showPDFNote = false
|
||||
modal.componentInstance.confirmClicked
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
this.documentsService
|
||||
.bulkEdit([this.document.id], 'rotate', {
|
||||
degrees: modal.componentInstance.degrees,
|
||||
})
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastService.show({
|
||||
content: $localize`Rotation of "${this.document.title}" will begin in the background. Close and re-open the document after the operation has completed to see the changes.`,
|
||||
delay: 8000,
|
||||
action: this.close.bind(this),
|
||||
actionName: $localize`Close`,
|
||||
})
|
||||
modal.close()
|
||||
},
|
||||
error: (error) => {
|
||||
if (modal) {
|
||||
modal.componentInstance.buttonsEnabled = true
|
||||
}
|
||||
this.toastService.showError(
|
||||
$localize`Error executing rotate operation`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
deletePages() {
|
||||
let modal = this.modalService.open(DeletePagesConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.title = $localize`Delete pages confirm`
|
||||
modal.componentInstance.messageBold = $localize`This operation will permanently delete the selected pages from the original document.`
|
||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||
modal.componentInstance.documentID = this.document.id
|
||||
modal.componentInstance.confirmClicked
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
this.documentsService
|
||||
.bulkEdit([this.document.id], 'delete_pages', {
|
||||
pages: modal.componentInstance.pages,
|
||||
})
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastService.showInfo(
|
||||
$localize`Delete pages operation for "${this.document.title}" will begin in the background. Close and re-open or reload this document after the operation has completed to see the changes.`
|
||||
)
|
||||
modal.close()
|
||||
},
|
||||
error: (error) => {
|
||||
if (modal) {
|
||||
modal.componentInstance.buttonsEnabled = true
|
||||
}
|
||||
this.toastService.showError(
|
||||
$localize`Error executing delete pages operation`,
|
||||
$localize`Error executing PDF edit operation`,
|
||||
error
|
||||
)
|
||||
},
|
||||
|
@@ -497,6 +497,103 @@ def delete_pages(doc_ids: list[int], pages: list[int]) -> Literal["OK"]:
|
||||
return "OK"
|
||||
|
||||
|
||||
def edit_pdf(
|
||||
doc_ids: list[int],
|
||||
operations: list[dict],
|
||||
*,
|
||||
delete_original: bool = False,
|
||||
update_document: bool = False,
|
||||
include_metadata: bool = True,
|
||||
user: User | None = None,
|
||||
) -> Literal["OK"]:
|
||||
"""
|
||||
Operations is a list of dictionaries describing the final PDF pages.
|
||||
Each entry must contain the original page number in `page` and may
|
||||
specify `rotate` in degrees and `doc` indicating the output
|
||||
document index (for splitting). Pages omitted from the list are
|
||||
discarded.
|
||||
"""
|
||||
|
||||
logger.info(
|
||||
f"Editing PDF of document {doc_ids[0]} with {len(operations)} operations",
|
||||
)
|
||||
doc = Document.objects.get(id=doc_ids[0])
|
||||
import pikepdf
|
||||
|
||||
pdf_docs: list[pikepdf.Pdf] = []
|
||||
|
||||
try:
|
||||
with pikepdf.open(doc.source_path) as src:
|
||||
# prepare output documents
|
||||
max_idx = max(op.get("doc", 0) for op in operations)
|
||||
pdf_docs = [pikepdf.new() for _ in range(max_idx + 1)]
|
||||
|
||||
if update_document and len(pdf_docs) > 1:
|
||||
logger.error(
|
||||
"Update requested but multiple output documents specified",
|
||||
)
|
||||
raise ValueError("Multiple output documents specified")
|
||||
|
||||
for op in operations:
|
||||
dst = pdf_docs[op.get("doc", 0)]
|
||||
page = src.pages[op["page"] - 1]
|
||||
dst.pages.append(page)
|
||||
if op.get("rotate"):
|
||||
dst.pages[-1].rotate(op["rotate"], relative=True)
|
||||
|
||||
if update_document:
|
||||
temp_path = doc.source_path.with_suffix(".tmp.pdf")
|
||||
pdf = pdf_docs[0]
|
||||
pdf.remove_unreferenced_resources()
|
||||
# save the edited PDF to a temporary file in case of errors
|
||||
pdf.save(temp_path)
|
||||
# replace the original document with the edited one
|
||||
temp_path.replace(doc.source_path)
|
||||
doc.checksum = hashlib.md5(doc.source_path.read_bytes()).hexdigest()
|
||||
doc.page_count = len(pdf.pages)
|
||||
doc.save()
|
||||
update_document_content_maybe_archive_file.delay(document_id=doc.id)
|
||||
else:
|
||||
consume_tasks = []
|
||||
overrides = (
|
||||
DocumentMetadataOverrides().from_document(doc)
|
||||
if include_metadata
|
||||
else DocumentMetadataOverrides()
|
||||
)
|
||||
if user is not None:
|
||||
overrides.owner_id = user.id
|
||||
|
||||
for idx, pdf in enumerate(pdf_docs, start=1):
|
||||
filepath: Path = (
|
||||
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
||||
/ f"{doc.id}_edit_{idx}.pdf"
|
||||
)
|
||||
pdf.remove_unreferenced_resources()
|
||||
pdf.save(filepath)
|
||||
consume_tasks.append(
|
||||
consume_file.s(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=filepath,
|
||||
),
|
||||
overrides,
|
||||
),
|
||||
)
|
||||
|
||||
if delete_original:
|
||||
chord(header=consume_tasks, body=delete.si([doc.id])).delay()
|
||||
else:
|
||||
group(consume_tasks).delay()
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error editing document {doc.id}: {e}")
|
||||
raise ValueError(
|
||||
f"An error occurred while editing the document: {e}",
|
||||
) from e
|
||||
|
||||
return "OK"
|
||||
|
||||
|
||||
def reflect_doclinks(
|
||||
document: Document,
|
||||
field: CustomField,
|
||||
|
@@ -1,16 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import pickle
|
||||
from binascii import hexlify
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
from typing import Final
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.cache import caches
|
||||
|
||||
from documents.models import Document
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.core.cache.backends.base import BaseCache
|
||||
|
||||
from documents.classifier import DocumentClassifier
|
||||
|
||||
logger = logging.getLogger("paperless.caching")
|
||||
@@ -39,6 +46,80 @@ CACHE_1_MINUTE: Final[int] = 60
|
||||
CACHE_5_MINUTES: Final[int] = 5 * CACHE_1_MINUTE
|
||||
CACHE_50_MINUTES: Final[int] = 50 * CACHE_1_MINUTE
|
||||
|
||||
read_cache = caches["read-cache"]
|
||||
|
||||
|
||||
class LRUCache:
|
||||
def __init__(self, capacity: int = 128):
|
||||
self._data = OrderedDict()
|
||||
self.capacity = capacity
|
||||
|
||||
def get(self, key, default=None) -> Any | None:
|
||||
if key in self._data:
|
||||
self._data.move_to_end(key)
|
||||
return self._data[key]
|
||||
return default
|
||||
|
||||
def set(self, key, value) -> None:
|
||||
self._data[key] = value
|
||||
self._data.move_to_end(key)
|
||||
while len(self._data) > self.capacity:
|
||||
self._data.popitem(last=False)
|
||||
|
||||
|
||||
class StoredLRUCache(LRUCache):
|
||||
"""
|
||||
LRU cache that can persist its entire contents as a single entry in a backend cache.
|
||||
|
||||
Useful for sharing a cache across multiple workers or processes.
|
||||
|
||||
Workflow:
|
||||
1. Load the cache state from the backend using `load()`.
|
||||
2. Use `get()` and `set()` locally as usual.
|
||||
3. Persist changes back to the backend using `save()`.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
backend_key: str,
|
||||
capacity: int = 128,
|
||||
backend: BaseCache = read_cache,
|
||||
backend_ttl=settings.CACHALOT_TIMEOUT,
|
||||
):
|
||||
if backend_key is None:
|
||||
raise ValueError("backend_key is mandatory")
|
||||
super().__init__(capacity)
|
||||
self._backend_key = backend_key
|
||||
self._backend = backend
|
||||
self.backend_ttl = backend_ttl
|
||||
|
||||
def load(self) -> None:
|
||||
"""
|
||||
Load the whole cache content from backend storage.
|
||||
|
||||
If no valid cached data exists in the backend, the local cache is cleared.
|
||||
"""
|
||||
serialized_data = self._backend.get(self._backend_key)
|
||||
try:
|
||||
self._data = (
|
||||
pickle.loads(serialized_data) if serialized_data else OrderedDict()
|
||||
)
|
||||
except pickle.PickleError:
|
||||
logger.warning(
|
||||
"Cache exists in backend but could not be read (possibly invalid format)",
|
||||
)
|
||||
|
||||
def save(self) -> None:
|
||||
"""Save the entire local cache to the backend as a serialized object.
|
||||
|
||||
The backend entry will expire after the configured TTL.
|
||||
"""
|
||||
self._backend.set(
|
||||
self._backend_key,
|
||||
pickle.dumps(self._data),
|
||||
self.backend_ttl,
|
||||
)
|
||||
|
||||
|
||||
def get_suggestion_cache_key(document_id: int) -> str:
|
||||
"""
|
||||
|
@@ -16,16 +16,29 @@ if TYPE_CHECKING:
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.cache import caches
|
||||
|
||||
from documents.caching import CACHE_5_MINUTES
|
||||
from documents.caching import CACHE_50_MINUTES
|
||||
from documents.caching import CLASSIFIER_HASH_KEY
|
||||
from documents.caching import CLASSIFIER_MODIFIED_KEY
|
||||
from documents.caching import CLASSIFIER_VERSION_KEY
|
||||
from documents.caching import StoredLRUCache
|
||||
from documents.models import Document
|
||||
from documents.models import MatchingModel
|
||||
|
||||
logger = logging.getLogger("paperless.classifier")
|
||||
|
||||
ADVANCED_TEXT_PROCESSING_ENABLED = (
|
||||
settings.NLTK_LANGUAGE is not None and settings.NLTK_ENABLED
|
||||
)
|
||||
|
||||
read_cache = caches["read-cache"]
|
||||
|
||||
|
||||
RE_DIGIT = re.compile(r"\d")
|
||||
RE_WORD = re.compile(r"\b[\w]+\b") # words that may contain digits
|
||||
|
||||
|
||||
class IncompatibleClassifierVersionError(Exception):
|
||||
def __init__(self, message: str, *args: object) -> None:
|
||||
@@ -92,15 +105,28 @@ class DocumentClassifier:
|
||||
self.last_auto_type_hash: bytes | None = None
|
||||
|
||||
self.data_vectorizer = None
|
||||
self.data_vectorizer_hash = None
|
||||
self.tags_binarizer = None
|
||||
self.tags_classifier = None
|
||||
self.correspondent_classifier = None
|
||||
self.document_type_classifier = None
|
||||
self.storage_path_classifier = None
|
||||
|
||||
self._stemmer = None
|
||||
# 10,000 elements roughly use 200 to 500 KB per worker,
|
||||
# and also in the shared Redis cache,
|
||||
# Keep this cache small to minimize lookup and I/O latency.
|
||||
if ADVANCED_TEXT_PROCESSING_ENABLED:
|
||||
self._stem_cache = StoredLRUCache(
|
||||
f"stem_cache_v{self.FORMAT_VERSION}",
|
||||
capacity=10000,
|
||||
)
|
||||
self._stop_words = None
|
||||
|
||||
def _update_data_vectorizer_hash(self):
|
||||
self.data_vectorizer_hash = sha256(
|
||||
pickle.dumps(self.data_vectorizer),
|
||||
).hexdigest()
|
||||
|
||||
def load(self) -> None:
|
||||
from sklearn.exceptions import InconsistentVersionWarning
|
||||
|
||||
@@ -119,6 +145,7 @@ class DocumentClassifier:
|
||||
self.last_auto_type_hash = pickle.load(f)
|
||||
|
||||
self.data_vectorizer = pickle.load(f)
|
||||
self._update_data_vectorizer_hash()
|
||||
self.tags_binarizer = pickle.load(f)
|
||||
|
||||
self.tags_classifier = pickle.load(f)
|
||||
@@ -269,7 +296,7 @@ class DocumentClassifier:
|
||||
Generates the content for documents, but once at a time
|
||||
"""
|
||||
for doc in docs_queryset:
|
||||
yield self.preprocess_content(doc.content)
|
||||
yield self.preprocess_content(doc.content, shared_cache=False)
|
||||
|
||||
self.data_vectorizer = CountVectorizer(
|
||||
analyzer="word",
|
||||
@@ -347,6 +374,7 @@ class DocumentClassifier:
|
||||
|
||||
self.last_doc_change_time = latest_doc_change
|
||||
self.last_auto_type_hash = hasher.digest()
|
||||
self._update_data_vectorizer_hash()
|
||||
|
||||
# Set the classifier information into the cache
|
||||
# Caching for 50 minutes, so slightly less than the normal retrain time
|
||||
@@ -356,30 +384,15 @@ class DocumentClassifier:
|
||||
|
||||
return True
|
||||
|
||||
def preprocess_content(self, content: str) -> str: # pragma: no cover
|
||||
"""
|
||||
Process to contents of a document, distilling it down into
|
||||
words which are meaningful to the content
|
||||
"""
|
||||
|
||||
# Lower case the document
|
||||
content = content.lower().strip()
|
||||
# Reduce spaces
|
||||
content = re.sub(r"\s+", " ", content)
|
||||
# Get only the letters
|
||||
content = re.sub(r"[^\w\s]", " ", content)
|
||||
|
||||
# If the NLTK language is supported, do further processing
|
||||
if settings.NLTK_LANGUAGE is not None and settings.NLTK_ENABLED:
|
||||
def _init_advanced_text_processing(self):
|
||||
if self._stop_words is None or self._stemmer is None:
|
||||
import nltk
|
||||
from nltk.corpus import stopwords
|
||||
from nltk.stem import SnowballStemmer
|
||||
from nltk.tokenize import word_tokenize
|
||||
|
||||
# Not really hacky, since it isn't private and is documented, but
|
||||
# set the search path for NLTK data to the single location it should be in
|
||||
nltk.data.path = [settings.NLTK_DIR]
|
||||
|
||||
try:
|
||||
# Preload the corpus early, to force the lazy loader to transform
|
||||
stopwords.ensure_loaded()
|
||||
@@ -387,41 +400,100 @@ class DocumentClassifier:
|
||||
# Do some one time setup
|
||||
# Sometimes, somehow, there's multiple threads loading the corpus
|
||||
# and it's not thread safe, raising an AttributeError
|
||||
if self._stemmer is None:
|
||||
self._stemmer = SnowballStemmer(settings.NLTK_LANGUAGE)
|
||||
if self._stop_words is None:
|
||||
self._stop_words = set(stopwords.words(settings.NLTK_LANGUAGE))
|
||||
|
||||
# Tokenize
|
||||
# This splits the content into tokens, roughly words
|
||||
words: list[str] = word_tokenize(
|
||||
content,
|
||||
language=settings.NLTK_LANGUAGE,
|
||||
)
|
||||
|
||||
meaningful_words = []
|
||||
for word in words:
|
||||
# Skip stop words
|
||||
# These are words like "a", "and", "the" which add little meaning
|
||||
if word in self._stop_words:
|
||||
continue
|
||||
# Stem the words
|
||||
# This reduces the words to their stems.
|
||||
# "amazement" returns "amaz"
|
||||
# "amaze" returns "amaz
|
||||
# "amazed" returns "amaz"
|
||||
meaningful_words.append(self._stemmer.stem(word))
|
||||
|
||||
return " ".join(meaningful_words)
|
||||
|
||||
self._stemmer = SnowballStemmer(settings.NLTK_LANGUAGE)
|
||||
self._stop_words = frozenset(stopwords.words(settings.NLTK_LANGUAGE))
|
||||
except AttributeError:
|
||||
logger.debug("Could not initialize NLTK for advanced text processing.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def stem_and_skip_stop_words(self, words: list[str], *, shared_cache=True):
|
||||
"""
|
||||
Reduce a list of words to their stem. Stop words are converted to empty strings.
|
||||
:param words: the list of words to stem
|
||||
"""
|
||||
|
||||
def _stem_and_skip_stop_word(word: str):
|
||||
"""
|
||||
Reduce a given word to its stem. If it's a stop word, return an empty string.
|
||||
E.g. "amazement", "amaze" and "amazed" all return "amaz".
|
||||
"""
|
||||
cached = self._stem_cache.get(word)
|
||||
if cached is not None:
|
||||
return cached
|
||||
elif word in self._stop_words:
|
||||
return ""
|
||||
# Assumption: words that contain numbers are never stemmed
|
||||
elif RE_DIGIT.search(word):
|
||||
return word
|
||||
else:
|
||||
result = self._stemmer.stem(word)
|
||||
self._stem_cache.set(word, result)
|
||||
return result
|
||||
|
||||
if shared_cache:
|
||||
self._stem_cache.load()
|
||||
|
||||
# Stem the words and skip stop words
|
||||
result = " ".join(
|
||||
filter(None, (_stem_and_skip_stop_word(w) for w in words)),
|
||||
)
|
||||
if shared_cache:
|
||||
self._stem_cache.save()
|
||||
return result
|
||||
|
||||
def preprocess_content(
|
||||
self,
|
||||
content: str,
|
||||
*,
|
||||
shared_cache=True,
|
||||
) -> str:
|
||||
"""
|
||||
Process the contents of a document, distilling it down into
|
||||
words which are meaningful to the content.
|
||||
|
||||
A stemmer cache is shared across workers with the parameter "shared_cache".
|
||||
This is unnecessary when training the classifier.
|
||||
"""
|
||||
|
||||
# Lower case the document, reduce space,
|
||||
# and keep only letters and digits.
|
||||
content = " ".join(match.group().lower() for match in RE_WORD.finditer(content))
|
||||
|
||||
if ADVANCED_TEXT_PROCESSING_ENABLED:
|
||||
from nltk.tokenize import word_tokenize
|
||||
|
||||
if not self._init_advanced_text_processing():
|
||||
return content
|
||||
# Tokenize
|
||||
# This splits the content into tokens, roughly words
|
||||
words = word_tokenize(content, language=settings.NLTK_LANGUAGE)
|
||||
# Stem the words and skip stop words
|
||||
content = self.stem_and_skip_stop_words(words, shared_cache=shared_cache)
|
||||
|
||||
return content
|
||||
|
||||
def _get_vectorizer_cache_key(self, content: str):
|
||||
hash = sha256(content.encode())
|
||||
hash.update(
|
||||
f"|{self.FORMAT_VERSION}|{settings.NLTK_LANGUAGE}|{settings.NLTK_ENABLED}|{self.data_vectorizer_hash}".encode(),
|
||||
)
|
||||
return f"vectorized_content_{hash.hexdigest()}"
|
||||
|
||||
def _vectorize(self, content: str):
|
||||
key = self._get_vectorizer_cache_key(content)
|
||||
serialized_result = read_cache.get(key)
|
||||
if serialized_result is None:
|
||||
result = self.data_vectorizer.transform([self.preprocess_content(content)])
|
||||
read_cache.set(key, pickle.dumps(result), CACHE_5_MINUTES)
|
||||
else:
|
||||
read_cache.touch(key, CACHE_5_MINUTES)
|
||||
result = pickle.loads(serialized_result)
|
||||
return result
|
||||
|
||||
def predict_correspondent(self, content: str) -> int | None:
|
||||
if self.correspondent_classifier:
|
||||
X = self.data_vectorizer.transform([self.preprocess_content(content)])
|
||||
X = self._vectorize(content)
|
||||
correspondent_id = self.correspondent_classifier.predict(X)
|
||||
if correspondent_id != -1:
|
||||
return correspondent_id
|
||||
@@ -432,7 +504,7 @@ class DocumentClassifier:
|
||||
|
||||
def predict_document_type(self, content: str) -> int | None:
|
||||
if self.document_type_classifier:
|
||||
X = self.data_vectorizer.transform([self.preprocess_content(content)])
|
||||
X = self._vectorize(content)
|
||||
document_type_id = self.document_type_classifier.predict(X)
|
||||
if document_type_id != -1:
|
||||
return document_type_id
|
||||
@@ -445,7 +517,7 @@ class DocumentClassifier:
|
||||
from sklearn.utils.multiclass import type_of_target
|
||||
|
||||
if self.tags_classifier:
|
||||
X = self.data_vectorizer.transform([self.preprocess_content(content)])
|
||||
X = self._vectorize(content)
|
||||
y = self.tags_classifier.predict(X)
|
||||
tags_ids = self.tags_binarizer.inverse_transform(y)[0]
|
||||
if type_of_target(y).startswith("multilabel"):
|
||||
@@ -464,7 +536,7 @@ class DocumentClassifier:
|
||||
|
||||
def predict_storage_path(self, content: str) -> int | None:
|
||||
if self.storage_path_classifier:
|
||||
X = self.data_vectorizer.transform([self.preprocess_content(content)])
|
||||
X = self._vectorize(content)
|
||||
storage_path_id = self.storage_path_classifier.predict(X)
|
||||
if storage_path_id != -1:
|
||||
return storage_path_id
|
||||
|
@@ -1293,6 +1293,7 @@ class BulkEditSerializer(
|
||||
"merge",
|
||||
"split",
|
||||
"delete_pages",
|
||||
"edit_pdf",
|
||||
],
|
||||
label="Method",
|
||||
write_only=True,
|
||||
@@ -1366,7 +1367,10 @@ class BulkEditSerializer(
|
||||
return bulk_edit.split
|
||||
elif method == "delete_pages":
|
||||
return bulk_edit.delete_pages
|
||||
else:
|
||||
elif method == "edit_pdf":
|
||||
return bulk_edit.edit_pdf
|
||||
else: # pragma: no cover
|
||||
# This will never happen as it is handled by the ChoiceField
|
||||
raise serializers.ValidationError("Unsupported method.")
|
||||
|
||||
def _validate_parameters_tags(self, parameters):
|
||||
@@ -1520,6 +1524,47 @@ class BulkEditSerializer(
|
||||
else:
|
||||
parameters["archive_fallback"] = False
|
||||
|
||||
def _validate_parameters_edit_pdf(self, parameters, document_id):
|
||||
if "operations" not in parameters:
|
||||
raise serializers.ValidationError("operations not specified")
|
||||
if not isinstance(parameters["operations"], list):
|
||||
raise serializers.ValidationError("operations must be a list")
|
||||
for op in parameters["operations"]:
|
||||
if not isinstance(op, dict):
|
||||
raise serializers.ValidationError("invalid operation entry")
|
||||
if "page" not in op or not isinstance(op["page"], int):
|
||||
raise serializers.ValidationError("page must be an integer")
|
||||
if "rotate" in op and not isinstance(op["rotate"], int):
|
||||
raise serializers.ValidationError("rotate must be an integer")
|
||||
if "doc" in op and not isinstance(op["doc"], int):
|
||||
raise serializers.ValidationError("doc must be an integer")
|
||||
if "update_document" in parameters:
|
||||
if not isinstance(parameters["update_document"], bool):
|
||||
raise serializers.ValidationError("update_document must be a boolean")
|
||||
else:
|
||||
parameters["update_document"] = False
|
||||
if "include_metadata" in parameters:
|
||||
if not isinstance(parameters["include_metadata"], bool):
|
||||
raise serializers.ValidationError("include_metadata must be a boolean")
|
||||
else:
|
||||
parameters["include_metadata"] = True
|
||||
|
||||
if parameters["update_document"]:
|
||||
max_idx = max(op.get("doc", 0) for op in parameters["operations"])
|
||||
if max_idx > 0:
|
||||
raise serializers.ValidationError(
|
||||
"update_document only allowed with a single output document",
|
||||
)
|
||||
|
||||
doc = Document.objects.get(id=document_id)
|
||||
# doc existence is already validated
|
||||
if doc.page_count:
|
||||
for op in parameters["operations"]:
|
||||
if op["page"] < 1 or op["page"] > doc.page_count:
|
||||
raise serializers.ValidationError(
|
||||
f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.",
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
method = attrs["method"]
|
||||
parameters = attrs["parameters"]
|
||||
@@ -1554,6 +1599,12 @@ class BulkEditSerializer(
|
||||
self._validate_parameters_delete_pages(parameters)
|
||||
elif method == bulk_edit.merge:
|
||||
self._validate_parameters_merge(parameters)
|
||||
elif method == bulk_edit.edit_pdf:
|
||||
if len(attrs["documents"]) > 1:
|
||||
raise serializers.ValidationError(
|
||||
"Edit PDF method only supports one document",
|
||||
)
|
||||
self._validate_parameters_edit_pdf(parameters, attrs["documents"][0])
|
||||
|
||||
return attrs
|
||||
|
||||
|
34
src/documents/tests/samples/content.txt
Normal file
34
src/documents/tests/samples/content.txt
Normal file
@@ -0,0 +1,34 @@
|
||||
Sample textual document content.
|
||||
Include as many characters as possible, to check the classifier's vectorization.
|
||||
|
||||
Hey 00, this is "a" test0707 content.
|
||||
This is an example document — created on 2025-06-25.
|
||||
|
||||
Digits: 0123456789
|
||||
Punctuation: . , ; : ! ? ' " ( ) [ ] { } — – …
|
||||
English text: The quick brown fox jumps over the lazy dog.
|
||||
English stop words: We’ve been doing it before.
|
||||
Accented Latin (diacritics): àâäæçéèêëîïôœùûüÿñ
|
||||
Arabic: لقد قام المترجم بعمل جيد
|
||||
Greek: Αλφα, Βήτα, Γάμμα, Δέλτα, Ωμέγα
|
||||
Cyrillic: Привет, как дела? Добро пожаловать!
|
||||
Chinese (Simplified): 你好,世界!今天的天气很好。
|
||||
Chinese (Traditional): 歡迎來到世界,今天天氣很好。
|
||||
Japanese (Kanji, Hiragana, Katakana): 東京へ行きます。カタカナ、ひらがな、漢字。
|
||||
Korean (Hangul): 안녕하세요. 오늘 날씨 어때요?
|
||||
Arabic: مرحبًا، كيف حالك؟
|
||||
Hebrew: שלום, מה שלומך?
|
||||
Emoji: 😀 🐍 📘 ✅ ©️ 🇺🇳
|
||||
Symbols: © ® ™ § ¶ † ‡ ∞ µ ∑ ∆ √
|
||||
Math: ∫₀^∞ x² dx = ∞, π ≈ 3.14159, ∇·E = ρ/ε₀
|
||||
Currency: 1$ € ¥ £ ₹
|
||||
Date formats: 25/06/2025, June 25, 2025, 2025年6月25日
|
||||
Quote in French: « Bonjour, ça va ? »
|
||||
Quote in German: „Guten Tag! Wie geht's?“
|
||||
Newline test:
|
||||
\r\n
|
||||
\r
|
||||
|
||||
Tab\ttest\tspacing
|
||||
/ = +) ( []) ~ * #192 +33601010101 § ¤
|
||||
End of document.
|
1
src/documents/tests/samples/preprocessed_content.txt
Normal file
1
src/documents/tests/samples/preprocessed_content.txt
Normal file
@@ -0,0 +1 @@
|
||||
sample textual document content include as many characters as possible to check the classifier s vectorization hey 00 this is a test0707 content this is an example document created on 2025 06 25 digits 0123456789 punctuation english text the quick brown fox jumps over the lazy dog english stop words we ve been doing it before accented latin diacritics àâäæçéèêëîïôœùûüÿñ arabic لقد قام المترجم بعمل جيد greek αλφα βήτα γάμμα δέλτα ωμέγα cyrillic привет как дела добро пожаловать chinese simplified 你好 世界 今天的天气很好 chinese traditional 歡迎來到世界 今天天氣很好 japanese kanji hiragana katakana 東京へ行きます カタカナ ひらがな 漢字 korean hangul 안녕하세요 오늘 날씨 어때요 arabic مرحب ا كيف حالك hebrew שלום מה שלומך emoji symbols µ math ₀ x² dx π 3 14159 e ρ ε₀ currency 1 date formats 25 06 2025 june 25 2025 2025年6月25日 quote in french bonjour ça va quote in german guten tag wie geht s newline test r n r tab ttest tspacing 192 33601010101 end of document
|
@@ -0,0 +1 @@
|
||||
sampl textual document content includ mani charact possibl check classifi vector hey 00 test0707 content exampl document creat 2025 06 25 digit 0123456789 punctuat english text quick brown fox jump lazi dog english stop word accent latin diacrit àâäæçéèêëîïôœùûüÿñ arab لقد قام المترجم بعمل جيد greek αλφα βήτα γάμμα δέλτα ωμέγα cyril привет как дела добро пожаловать chines simplifi 你好 世界 今天的天气很好 chines tradit 歡迎來到世界 今天天氣很好 japanes kanji hiragana katakana 東京へ行きます カタカナ ひらがな 漢字 korean hangul 안녕하세요 오늘 날씨 어때요 arab مرحب ا كيف حالك hebrew שלום מה שלומך emoji symbol µ math ₀ x² dx π 3 14159 e ρ ε₀ currenc 1 date format 25 06 2025 june 25 2025 2025年6月25日 quot french bonjour ça va quot german guten tag wie geht newlin test r n r tab ttest tspace 192 33601010101 end document
|
@@ -41,6 +41,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
title="B",
|
||||
correspondent=self.c1,
|
||||
document_type=self.dt1,
|
||||
page_count=5,
|
||||
)
|
||||
self.doc3 = Document.objects.create(
|
||||
checksum="C",
|
||||
@@ -1369,6 +1370,218 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn(b"pages must be a list of integers", response.content)
|
||||
|
||||
@mock.patch("documents.serialisers.bulk_edit.edit_pdf")
|
||||
def test_edit_pdf(self, m):
|
||||
self.setup_mock(m, "edit_pdf")
|
||||
response = self.client.post(
|
||||
"/api/documents/bulk_edit/",
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc2.id],
|
||||
"method": "edit_pdf",
|
||||
"parameters": {"operations": [{"page": 1}]},
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
m.assert_called_once()
|
||||
args, kwargs = m.call_args
|
||||
self.assertCountEqual(args[0], [self.doc2.id])
|
||||
self.assertEqual(kwargs["operations"], [{"page": 1}])
|
||||
self.assertEqual(kwargs["user"], self.user)
|
||||
|
||||
def test_edit_pdf_invalid_params(self):
|
||||
# multiple documents
|
||||
response = self.client.post(
|
||||
"/api/documents/bulk_edit/",
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc2.id, self.doc3.id],
|
||||
"method": "edit_pdf",
|
||||
"parameters": {"operations": [{"page": 1}]},
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn(b"Edit PDF method only supports one document", response.content)
|
||||
|
||||
# no operations specified
|
||||
response = self.client.post(
|
||||
"/api/documents/bulk_edit/",
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc2.id],
|
||||
"method": "edit_pdf",
|
||||
"parameters": {},
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn(b"operations not specified", response.content)
|
||||
|
||||
# operations not a list
|
||||
response = self.client.post(
|
||||
"/api/documents/bulk_edit/",
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc2.id],
|
||||
"method": "edit_pdf",
|
||||
"parameters": {"operations": "not_a_list"},
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn(b"operations must be a list", response.content)
|
||||
|
||||
# invalid operation
|
||||
response = self.client.post(
|
||||
"/api/documents/bulk_edit/",
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc2.id],
|
||||
"method": "edit_pdf",
|
||||
"parameters": {"operations": ["invalid_operation"]},
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn(b"invalid operation entry", response.content)
|
||||
|
||||
# page not an int
|
||||
response = self.client.post(
|
||||
"/api/documents/bulk_edit/",
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc2.id],
|
||||
"method": "edit_pdf",
|
||||
"parameters": {"operations": [{"page": "not_an_int"}]},
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn(b"page must be an integer", response.content)
|
||||
|
||||
# rotate not an int
|
||||
response = self.client.post(
|
||||
"/api/documents/bulk_edit/",
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc2.id],
|
||||
"method": "edit_pdf",
|
||||
"parameters": {"operations": [{"page": 1, "rotate": "not_an_int"}]},
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn(b"rotate must be an integer", response.content)
|
||||
|
||||
# doc not an int
|
||||
response = self.client.post(
|
||||
"/api/documents/bulk_edit/",
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc2.id],
|
||||
"method": "edit_pdf",
|
||||
"parameters": {"operations": [{"page": 1, "doc": "not_an_int"}]},
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn(b"doc must be an integer", response.content)
|
||||
|
||||
# update_document not a boolean
|
||||
response = self.client.post(
|
||||
"/api/documents/bulk_edit/",
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc2.id],
|
||||
"method": "edit_pdf",
|
||||
"parameters": {
|
||||
"update_document": "not_a_bool",
|
||||
"operations": [{"page": 1}],
|
||||
},
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn(b"update_document must be a boolean", response.content)
|
||||
|
||||
# include_metadata not a boolean
|
||||
response = self.client.post(
|
||||
"/api/documents/bulk_edit/",
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc2.id],
|
||||
"method": "edit_pdf",
|
||||
"parameters": {
|
||||
"include_metadata": "not_a_bool",
|
||||
"operations": [{"page": 1}],
|
||||
},
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn(b"include_metadata must be a boolean", response.content)
|
||||
|
||||
# update_document True but output would be multiple documents
|
||||
response = self.client.post(
|
||||
"/api/documents/bulk_edit/",
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc2.id],
|
||||
"method": "edit_pdf",
|
||||
"parameters": {
|
||||
"update_document": True,
|
||||
"operations": [{"page": 1, "doc": 1}, {"page": 2, "doc": 2}],
|
||||
},
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn(
|
||||
b"update_document only allowed with a single output document",
|
||||
response.content,
|
||||
)
|
||||
|
||||
@mock.patch("documents.serialisers.bulk_edit.edit_pdf")
|
||||
def test_edit_pdf_page_out_of_bounds(self, m):
|
||||
"""
|
||||
GIVEN:
|
||||
- API data for editing PDF is called
|
||||
- The page number is out of bounds
|
||||
WHEN:
|
||||
- API is called
|
||||
THEN:
|
||||
- The API fails with a correct error code
|
||||
"""
|
||||
self.setup_mock(m, "edit_pdf")
|
||||
response = self.client.post(
|
||||
"/api/documents/bulk_edit/",
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc2.id],
|
||||
"method": "edit_pdf",
|
||||
"parameters": {"operations": [{"page": 99}]},
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn(b"out of bounds", response.content)
|
||||
|
||||
@override_settings(AUDIT_LOG_ENABLED=True)
|
||||
def test_bulk_edit_audit_log_enabled_simple_field(self):
|
||||
"""
|
||||
|
@@ -909,3 +909,156 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
||||
expected_str = "Error deleting pages from document"
|
||||
self.assertIn(expected_str, error_str)
|
||||
mock_update_archive_file.assert_not_called()
|
||||
|
||||
@mock.patch("documents.bulk_edit.group")
|
||||
@mock.patch("documents.tasks.consume_file.s")
|
||||
def test_edit_pdf_basic_operations(self, mock_consume_file, mock_group):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing document
|
||||
WHEN:
|
||||
- edit_pdf is called with two operations to split the doc and rotate pages
|
||||
THEN:
|
||||
- A grouped task is generated and delay() is called
|
||||
"""
|
||||
mock_group.return_value.delay.return_value = None
|
||||
doc_ids = [self.doc2.id]
|
||||
operations = [{"page": 1, "doc": 0}, {"page": 2, "doc": 1, "rotate": 90}]
|
||||
|
||||
result = bulk_edit.edit_pdf(doc_ids, operations)
|
||||
self.assertEqual(result, "OK")
|
||||
mock_group.return_value.delay.assert_called_once()
|
||||
|
||||
@mock.patch("documents.bulk_edit.group")
|
||||
@mock.patch("documents.tasks.consume_file.s")
|
||||
def test_edit_pdf_with_user_override(self, mock_consume_file, mock_group):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing document
|
||||
WHEN:
|
||||
- edit_pdf is called with user override
|
||||
THEN:
|
||||
- Task is created with user context
|
||||
"""
|
||||
mock_group.return_value.delay.return_value = None
|
||||
doc_ids = [self.doc2.id]
|
||||
operations = [{"page": 1, "doc": 0}, {"page": 2, "doc": 1}]
|
||||
user = User.objects.create(username="editor")
|
||||
|
||||
result = bulk_edit.edit_pdf(doc_ids, operations, user=user)
|
||||
self.assertEqual(result, "OK")
|
||||
mock_group.return_value.delay.assert_called_once()
|
||||
|
||||
@mock.patch("documents.bulk_edit.chord")
|
||||
@mock.patch("documents.tasks.consume_file.s")
|
||||
def test_edit_pdf_with_delete_original(self, mock_consume_file, mock_chord):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing document
|
||||
WHEN:
|
||||
- edit_pdf is called with delete_original=True
|
||||
THEN:
|
||||
- Task group is triggered
|
||||
"""
|
||||
mock_chord.return_value.delay.return_value = None
|
||||
doc_ids = [self.doc2.id]
|
||||
operations = [{"page": 1}, {"page": 2}]
|
||||
|
||||
result = bulk_edit.edit_pdf(doc_ids, operations, delete_original=True)
|
||||
self.assertEqual(result, "OK")
|
||||
mock_chord.assert_called_once()
|
||||
|
||||
@mock.patch("documents.tasks.update_document_content_maybe_archive_file.delay")
|
||||
def test_edit_pdf_with_update_document(self, mock_update_document):
|
||||
"""
|
||||
GIVEN:
|
||||
- A single existing PDF document
|
||||
WHEN:
|
||||
- edit_pdf is called with update_document=True and a single output
|
||||
THEN:
|
||||
- The original document is updated in-place
|
||||
- The update_document_content_maybe_archive_file task is triggered
|
||||
"""
|
||||
doc_ids = [self.doc2.id]
|
||||
operations = [{"page": 1}, {"page": 2}]
|
||||
original_checksum = self.doc2.checksum
|
||||
original_page_count = self.doc2.page_count
|
||||
|
||||
result = bulk_edit.edit_pdf(
|
||||
doc_ids,
|
||||
operations=operations,
|
||||
update_document=True,
|
||||
delete_original=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result, "OK")
|
||||
self.doc2.refresh_from_db()
|
||||
self.assertNotEqual(self.doc2.checksum, original_checksum)
|
||||
self.assertNotEqual(self.doc2.page_count, original_page_count)
|
||||
mock_update_document.assert_called_once_with(document_id=self.doc2.id)
|
||||
|
||||
@mock.patch("documents.bulk_edit.group")
|
||||
@mock.patch("documents.tasks.consume_file.s")
|
||||
def test_edit_pdf_without_metadata(self, mock_consume_file, mock_group):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing document
|
||||
WHEN:
|
||||
- edit_pdf is called with include_metadata=False
|
||||
THEN:
|
||||
- Tasks are created with empty metadata
|
||||
"""
|
||||
mock_group.return_value.delay.return_value = None
|
||||
doc_ids = [self.doc2.id]
|
||||
operations = [{"page": 1}]
|
||||
|
||||
result = bulk_edit.edit_pdf(doc_ids, operations, include_metadata=False)
|
||||
self.assertEqual(result, "OK")
|
||||
mock_group.return_value.delay.assert_called_once()
|
||||
|
||||
@mock.patch("documents.bulk_edit.group")
|
||||
@mock.patch("documents.tasks.consume_file.s")
|
||||
def test_edit_pdf_open_failure(self, mock_consume_file, mock_group):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing document
|
||||
WHEN:
|
||||
- edit_pdf fails to open PDF
|
||||
THEN:
|
||||
- Task group is not called
|
||||
"""
|
||||
doc_ids = [self.doc2.id]
|
||||
operations = [
|
||||
{"page": 9999}, # invalid page, forces error during PDF load
|
||||
]
|
||||
with self.assertLogs("paperless.bulk_edit", level="ERROR"):
|
||||
with self.assertRaises(Exception):
|
||||
bulk_edit.edit_pdf(doc_ids, operations)
|
||||
mock_group.assert_not_called()
|
||||
mock_consume_file.assert_not_called()
|
||||
|
||||
@mock.patch("documents.bulk_edit.group")
|
||||
@mock.patch("documents.tasks.consume_file.s")
|
||||
def test_edit_pdf_multiple_outputs_with_update_flag_errors(
|
||||
self,
|
||||
mock_consume_file,
|
||||
mock_group,
|
||||
):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing document
|
||||
WHEN:
|
||||
- edit_pdf is called with multiple outputs and update_document=True
|
||||
THEN:
|
||||
- An error is logged and task group is not called
|
||||
"""
|
||||
doc_ids = [self.doc2.id]
|
||||
operations = [
|
||||
{"page": 1, "doc": 0},
|
||||
{"page": 2, "doc": 1},
|
||||
]
|
||||
with self.assertLogs("paperless.bulk_edit", level="ERROR"):
|
||||
with self.assertRaises(ValueError):
|
||||
bulk_edit.edit_pdf(doc_ids, operations, update_document=True)
|
||||
mock_group.assert_not_called()
|
||||
mock_consume_file.assert_not_called()
|
||||
|
45
src/documents/tests/test_caching.py
Normal file
45
src/documents/tests/test_caching.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import pickle
|
||||
|
||||
from documents.caching import StoredLRUCache
|
||||
|
||||
|
||||
def test_lru_cache_entries():
|
||||
CACHE_TTL = 1
|
||||
# LRU cache with a capacity of 2 elements
|
||||
cache = StoredLRUCache("test_lru_cache_key", 2, backend_ttl=CACHE_TTL)
|
||||
cache.set(1, 1)
|
||||
cache.set(2, 2)
|
||||
assert cache.get(2) == 2
|
||||
assert cache.get(1) == 1
|
||||
|
||||
# The oldest entry (2) should be removed
|
||||
cache.set(3, 3)
|
||||
assert cache.get(3) == 3
|
||||
assert not cache.get(2)
|
||||
assert cache.get(1) == 1
|
||||
|
||||
# Save the cache, restore it and check it overwrites the current cache in memory
|
||||
cache.save()
|
||||
cache.set(4, 4)
|
||||
assert not cache.get(3)
|
||||
cache.load()
|
||||
assert not cache.get(4)
|
||||
assert cache.get(3) == 3
|
||||
assert cache.get(1) == 1
|
||||
|
||||
|
||||
def test_stored_lru_cache_key_ttl(mocker):
|
||||
mock_backend = mocker.Mock()
|
||||
cache = StoredLRUCache("test_key", backend=mock_backend, backend_ttl=321)
|
||||
|
||||
# Simulate storing values
|
||||
cache.set("x", "X")
|
||||
cache.set("y", "Y")
|
||||
cache.save()
|
||||
|
||||
# Assert backend.set was called with pickled data, key and TTL
|
||||
mock_backend.set.assert_called_once()
|
||||
key, data, timeout = mock_backend.set.call_args[0]
|
||||
assert key == "test_key"
|
||||
assert timeout == 321
|
||||
assert pickle.loads(data) == {"x": "X", "y": "Y"}
|
@@ -21,7 +21,7 @@ from documents.models import Tag
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
|
||||
|
||||
def dummy_preprocess(content: str):
|
||||
def dummy_preprocess(content: str, **kwargs):
|
||||
"""
|
||||
Simpler, faster pre-processing for testing purposes
|
||||
"""
|
||||
@@ -223,24 +223,47 @@ class TestClassifier(DirectoriesMixin, TestCase):
|
||||
self.generate_test_data()
|
||||
self.classifier.train()
|
||||
|
||||
self.assertEqual(
|
||||
self.classifier.predict_correspondent(self.doc1.content),
|
||||
self.c1.pk,
|
||||
)
|
||||
self.assertEqual(self.classifier.predict_correspondent(self.doc2.content), None)
|
||||
self.assertListEqual(
|
||||
self.classifier.predict_tags(self.doc1.content),
|
||||
[self.t1.pk],
|
||||
)
|
||||
self.assertListEqual(
|
||||
self.classifier.predict_tags(self.doc2.content),
|
||||
[self.t1.pk, self.t3.pk],
|
||||
)
|
||||
self.assertEqual(
|
||||
self.classifier.predict_document_type(self.doc1.content),
|
||||
self.dt.pk,
|
||||
)
|
||||
self.assertEqual(self.classifier.predict_document_type(self.doc2.content), None)
|
||||
with (
|
||||
mock.patch.object(
|
||||
self.classifier.data_vectorizer,
|
||||
"transform",
|
||||
wraps=self.classifier.data_vectorizer.transform,
|
||||
) as mock_transform,
|
||||
mock.patch.object(
|
||||
self.classifier,
|
||||
"preprocess_content",
|
||||
wraps=self.classifier.preprocess_content,
|
||||
) as mock_preprocess_content,
|
||||
):
|
||||
self.assertEqual(
|
||||
self.classifier.predict_correspondent(self.doc1.content),
|
||||
self.c1.pk,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.classifier.predict_correspondent(self.doc2.content),
|
||||
None,
|
||||
)
|
||||
self.assertListEqual(
|
||||
self.classifier.predict_tags(self.doc1.content),
|
||||
[self.t1.pk],
|
||||
)
|
||||
self.assertListEqual(
|
||||
self.classifier.predict_tags(self.doc2.content),
|
||||
[self.t1.pk, self.t3.pk],
|
||||
)
|
||||
self.assertEqual(
|
||||
self.classifier.predict_document_type(self.doc1.content),
|
||||
self.dt.pk,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.classifier.predict_document_type(self.doc2.content),
|
||||
None,
|
||||
)
|
||||
|
||||
# Check that the classifier vectorized content and text preprocessing has been cached
|
||||
# It should be called once per document (doc1 and doc2)
|
||||
self.assertEqual(mock_preprocess_content.call_count, 2)
|
||||
self.assertEqual(mock_transform.call_count, 2)
|
||||
|
||||
def test_no_retrain_if_no_change(self):
|
||||
"""
|
||||
@@ -694,3 +717,67 @@ class TestClassifier(DirectoriesMixin, TestCase):
|
||||
mock_load.side_effect = Exception()
|
||||
with self.assertRaises(Exception):
|
||||
load_classifier(raise_exception=True)
|
||||
|
||||
|
||||
def test_preprocess_content():
|
||||
"""
|
||||
GIVEN:
|
||||
- Advanced text processing is enabled (default)
|
||||
WHEN:
|
||||
- Classifier preprocesses a document's content
|
||||
THEN:
|
||||
- Processed content matches the expected output (stemmed words)
|
||||
"""
|
||||
with (Path(__file__).parent / "samples" / "content.txt").open("r") as f:
|
||||
content = f.read()
|
||||
with (Path(__file__).parent / "samples" / "preprocessed_content_advanced.txt").open(
|
||||
"r",
|
||||
) as f:
|
||||
expected_preprocess_content = f.read().rstrip()
|
||||
classifier = DocumentClassifier()
|
||||
result = classifier.preprocess_content(content)
|
||||
assert result == expected_preprocess_content
|
||||
|
||||
|
||||
def test_preprocess_content_nltk_disabled():
|
||||
"""
|
||||
GIVEN:
|
||||
- Advanced text processing is disabled
|
||||
WHEN:
|
||||
- Classifier preprocesses a document's content
|
||||
THEN:
|
||||
- Processed content matches the expected output (unstemmed words)
|
||||
"""
|
||||
with (Path(__file__).parent / "samples" / "content.txt").open("r") as f:
|
||||
content = f.read()
|
||||
with (Path(__file__).parent / "samples" / "preprocessed_content.txt").open(
|
||||
"r",
|
||||
) as f:
|
||||
expected_preprocess_content = f.read().rstrip()
|
||||
classifier = DocumentClassifier()
|
||||
with mock.patch("documents.classifier.ADVANCED_TEXT_PROCESSING_ENABLED", new=False):
|
||||
result = classifier.preprocess_content(content)
|
||||
assert result == expected_preprocess_content
|
||||
|
||||
|
||||
def test_preprocess_content_nltk_load_fail(mocker):
|
||||
"""
|
||||
GIVEN:
|
||||
- NLTK stop words fail to load
|
||||
WHEN:
|
||||
- Classifier preprocesses a document's content
|
||||
THEN:
|
||||
- Processed content matches the expected output (unstemmed words)
|
||||
"""
|
||||
_module = mocker.MagicMock(name="nltk_corpus_mock")
|
||||
_module.stopwords.words.side_effect = AttributeError()
|
||||
mocker.patch.dict("sys.modules", {"nltk.corpus": _module})
|
||||
classifier = DocumentClassifier()
|
||||
with (Path(__file__).parent / "samples" / "content.txt").open("r") as f:
|
||||
content = f.read()
|
||||
with (Path(__file__).parent / "samples" / "preprocessed_content.txt").open(
|
||||
"r",
|
||||
) as f:
|
||||
expected_preprocess_content = f.read().rstrip()
|
||||
result = classifier.preprocess_content(content)
|
||||
assert result == expected_preprocess_content
|
||||
|
@@ -1321,6 +1321,7 @@ class BulkEditView(PassUserMixin):
|
||||
"delete_pages": "checksum",
|
||||
"split": None,
|
||||
"merge": None,
|
||||
"edit_pdf": "checksum",
|
||||
"reprocess": "checksum",
|
||||
}
|
||||
|
||||
@@ -1339,6 +1340,7 @@ class BulkEditView(PassUserMixin):
|
||||
if method in [
|
||||
bulk_edit.split,
|
||||
bulk_edit.merge,
|
||||
bulk_edit.edit_pdf,
|
||||
]:
|
||||
parameters["user"] = user
|
||||
|
||||
@@ -1358,27 +1360,36 @@ class BulkEditView(PassUserMixin):
|
||||
|
||||
# check ownership for methods that change original document
|
||||
if (
|
||||
has_perms
|
||||
and method
|
||||
in [
|
||||
bulk_edit.set_permissions,
|
||||
bulk_edit.delete,
|
||||
bulk_edit.rotate,
|
||||
bulk_edit.delete_pages,
|
||||
]
|
||||
) or (
|
||||
method in [bulk_edit.merge, bulk_edit.split]
|
||||
and parameters["delete_originals"]
|
||||
(
|
||||
has_perms
|
||||
and method
|
||||
in [
|
||||
bulk_edit.set_permissions,
|
||||
bulk_edit.delete,
|
||||
bulk_edit.rotate,
|
||||
bulk_edit.delete_pages,
|
||||
bulk_edit.edit_pdf,
|
||||
]
|
||||
)
|
||||
or (
|
||||
method in [bulk_edit.merge, bulk_edit.split]
|
||||
and parameters["delete_originals"]
|
||||
)
|
||||
or (method == bulk_edit.edit_pdf and parameters["update_document"])
|
||||
):
|
||||
has_perms = user_is_owner_of_all_documents
|
||||
|
||||
# check global add permissions for methods that create documents
|
||||
if (
|
||||
has_perms
|
||||
and method in [bulk_edit.split, bulk_edit.merge]
|
||||
and not user.has_perm(
|
||||
"documents.add_document",
|
||||
and (
|
||||
method in [bulk_edit.split, bulk_edit.merge]
|
||||
or (
|
||||
method == bulk_edit.edit_pdf
|
||||
and not parameters["update_document"]
|
||||
)
|
||||
)
|
||||
and not user.has_perm("documents.add_document")
|
||||
):
|
||||
has_perms = False
|
||||
|
||||
@@ -1416,7 +1427,6 @@ class BulkEditView(PassUserMixin):
|
||||
)
|
||||
}
|
||||
|
||||
# TODO: parameter validation
|
||||
result = method(documents, **parameters)
|
||||
|
||||
if settings.AUDIT_LOG_ENABLED and modified_field:
|
||||
|
152
uv.lock
generated
152
uv.lock
generated
@@ -1024,86 +1024,86 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "granian"
|
||||
version = "2.5.0"
|
||||
version = "2.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/91/6b51c5749a58e5d86063b193c15914700464f0d64eda84178bf432dbbcf9/granian-2.5.0.tar.gz", hash = "sha256:bed0d047c9c0c6c6a5a85ee5b3c7e2683fc63e03ac032eaf3d7654fa96bde102", size = 110336, upload-time = "2025-07-30T18:55:15.161Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b6/95/33666bbf579b36562cdfb66293d0b349e9d28a41a5e473ab61ea565e0859/granian-2.4.1.tar.gz", hash = "sha256:31dd5b28373e330506ae3dd4742880317263a54460046e5303585305ed06a793", size = 105802, upload-time = "2025-07-01T21:49:56.81Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/95/31/2d837432708230fc281fce10d5db2eb1755005c9d8f4fdac0707729d442d/granian-2.5.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:49c1c5059b39db0863e3e967ba18f1ab55fa0f86388537d937aa18baf2934f71", size = 3044791, upload-time = "2025-07-30T18:52:31.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/c8/0b48fd67dfe88ce7a21de0fe7030789f71c26223f5523bd5e951dd98afde/granian-2.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:aba86bae39b4e8f770d7e6664490e0abdcdcd742300d16cf1cb370dc159a09c1", size = 2602731, upload-time = "2025-07-30T18:52:34.641Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/21/f40d46af2ce98b7b883d5d34730103afbd5a7bb8fd1af8989cb617275be5/granian-2.5.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0ff75ceeae4c9015c1c5fc5d699bb75b7b10d05f5d69aa091ced11be1e145e43", size = 3347314, upload-time = "2025-07-30T18:52:36.389Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/0d/2d0ab45ba4bd68e70129ebe6d14ab8a41263e66a4b3b25b80c8d60ccf704/granian-2.5.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5f8b1a0b82fa3af2a94572664a3382e3d786981a9cda3bf8b118a0f10338f89", size = 2949913, upload-time = "2025-07-30T18:52:38.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/a4/c11b542bae8d7d02e1f5f07a0925b4bb1ad084fa1438e9ec2f89369154fd/granian-2.5.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c5d01fb578ab52b95c8cc39fe09a01ef5682011f4eea67d0e5e33ab941c9ef38", size = 3233880, upload-time = "2025-07-30T18:52:39.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/ca/c67684afa272dbde2898b6eed6b51679c16b40e0f2562ef36030fc63fb45/granian-2.5.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:92271019ee283cd74d137e6d8e4fda836a80846a7a006d986246d3e300d8229a", size = 3108699, upload-time = "2025-07-30T18:52:40.744Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/e2/13caaaf3a5f8eea1c265567486927035244a8ae8b5f357d697d8c09a7474/granian-2.5.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e08ce1768433327720a754a78a3134100b989663c6e924626ba8ce8cdb3ee0a4", size = 3114257, upload-time = "2025-07-30T18:52:42.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/71/aa85e2ab2d183efcdbc199107d7683629ff5aeabad409cccecc8197c73d7/granian-2.5.0-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d09f33aa20fa0f7f9e48f12e87573b3819b357235cac602ec6cef1b5b3ca33a9", size = 3495152, upload-time = "2025-07-30T18:52:44.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/dd/5e3bab1c332ca2194ef3c58075b1533d944e2cf66b6a242c60c81957e962/granian-2.5.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5aec8050fc01706d27d74fffc32c29eb483530ea3ad383e885e8b7315ae1c699", size = 3273112, upload-time = "2025-07-30T18:52:46.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/f3/7d9cee103d91f4d1b934ebaa0cea944638a7b4940c5af72163e486cd4989/granian-2.5.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0eda4c389c222aa5455b7205640df0207201a86c46e5be98dd0040b6cc45146a", size = 3044837, upload-time = "2025-07-30T18:52:49.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/b8/fcb93a7bddcedc0af11a446094b33dc99af93a338abd8e95747aae3d1112/granian-2.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:58aea28ebb2cdf7545ee3cb1c8593c8b2f857a9fa6219589ecd3f5a4b365262f", size = 2602770, upload-time = "2025-07-30T18:52:51.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/d9/5f94af3cbdbe023774d24616648e428cfd307f18232a64d82caa2ad8113a/granian-2.5.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf9dc480d481ae834a085f1f46213ebf80512b1ace0559f6c0335edb24be0e92", size = 3347657, upload-time = "2025-07-30T18:52:53.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/12/ba9be1b7a9ad28b66735a648a996578b64873c580a3a0681575e60cfa0f8/granian-2.5.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:604c544273b36091b54fdf66d4e9a0f98dc0369b380ba5dd328478ba65cda320", size = 2949970, upload-time = "2025-07-30T18:52:55.359Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/54/f29100152e7dd6f5dfdff2626b040711735aff2ec9f61cba8e7d04614a5e/granian-2.5.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f6d70f6e7edd183afb62468a7fc175348145aec303297a41b1714a9b6d8150d", size = 3233777, upload-time = "2025-07-30T18:52:57.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/e0/988993586ba3d5e80cc87fc464601df55634ec440eb10889c8fdd3b613ac/granian-2.5.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:651cbad3a137b762885b7e57dea77b5d11262b0a2c16d4b61b4812bc8bc5ffa4", size = 3108386, upload-time = "2025-07-30T18:52:58.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/06/815fde5195f40a2a1be4e78ad0c3cdd05727dcca59a5a231d0df95fb6f68/granian-2.5.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:22bac6aace54e4183831a46a4033c076100150527676e666eeb84df9829e0e1c", size = 3114733, upload-time = "2025-07-30T18:53:00.059Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/7f/a8d2fce7f810aa3b7b16550cfbde4756eeb3efcd3646b0adb6c6b7d4bf95/granian-2.5.0-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:0429dd0c4c21c123b06b7f9057ecb4c1fc3d6228f442276e27545a5ad6fc780c", size = 3495857, upload-time = "2025-07-30T18:53:01.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/58/984b53efc3b245f5f9b8d76822061b3fb4c5c1024d526ffe59da55b5405c/granian-2.5.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c6141582757eb2bb8c8390637a3ac29dbba0c10db93192adb360c7060f149782", size = 3273079, upload-time = "2025-07-30T18:53:02.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/31/590b932524f43289aa9f735d0b92ccdd97b2d9e388a5acad171fc01382e4/granian-2.5.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0e7627c3b7e3f9c024c4edd80636e8326fbce0420889e0951da349d13742e503", size = 3026668, upload-time = "2025-07-30T18:53:05.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/94/6bcd3d0cec40994112dfd2b3102f4ff3bd2e62928f6524fb95f38fae6647/granian-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0a4c317f30c227baf16f541f0c93cb08ee45fbf8a2ef5317ba07b6bd6b7c877", size = 2584723, upload-time = "2025-07-30T18:53:06.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/5b/44089c2384ba2e3e5a3cdf08e8a000bff07cf7382fa2d9a0e4e1a9ba6451/granian-2.5.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:35eb58b0f80fc9f55d5210d339ba8f5f8d9c126a2e29f051e8b62353e3f84b1d", size = 3326781, upload-time = "2025-07-30T18:53:08.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/7d/7ca304dde1ce475b83e4add007e36a87284e6838f158db87c11a2c93f379/granian-2.5.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:def250f04c0374069278152bf3e08ecb1f67e0c99d3eb14d902df1e1558b93fa", size = 2937454, upload-time = "2025-07-30T18:53:10.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/6c/858a7cce6ce07adc2f0e7b1809af61d1af2affbc07edaecd127f16207b37/granian-2.5.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eda01a9027f2921f42b4f8bb16e46f8ab67a5345e52b3ffeedd2f921a09c87b6", size = 3229723, upload-time = "2025-07-30T18:53:11.464Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/7b/ded645443ec95921d407e3d277418987a4aed955830f67d6b151c8c095f9/granian-2.5.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c8a51bcb7e533ab75d2d9e13432e9b63c90eeb7fdde700875253efbfb2dcdcbc", size = 3109804, upload-time = "2025-07-30T18:53:13.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/64/837131cd49e17c219e2a04ed711459e0f93bd5c1db7fc243666e1b9d412a/granian-2.5.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:a9c5670171a97c35aeea79fd7100058e461b0271a7e81fdecd586c770cdd2b41", size = 3099711, upload-time = "2025-07-30T18:53:14.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/4e/80578d06426a40a2718b3183c5e32ba570001153cbbb3fe523d3f9e89880/granian-2.5.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:760275d286142775fb21c85d96fc4996e00de9d3b054c424b2c8519679aa14b5", size = 3468897, upload-time = "2025-07-30T18:53:16.477Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/8c/7e5d0187a4e53830cc49231a55f01d3d251eec0cbb09209a0ffde8b33741/granian-2.5.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b1573755288d70f37b55acb14f01cb3a7f7cdca7bf143f908c649ada254e9cb6", size = 3275035, upload-time = "2025-07-30T18:53:17.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/d8/c3c8a452f1b590400bb2cdef1ca61da8e9913762884cf3e6ba801fd3fdad/granian-2.5.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:50d4dc74ab763c1bf396cf85d93a8202bf1bfb74150b03f9fd62b600cd0c777c", size = 3026123, upload-time = "2025-07-30T18:53:20.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/f0/e7038189d4e3b5f1e10bc23547b687bcdecdefbddef87013db64efea6800/granian-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:31705782cd616b9b70536c1b61b7f15815ebc4dcccdb72f58aa806ba7ac5dfa1", size = 2584469, upload-time = "2025-07-30T18:53:22.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/2d/718620f393b6030d4a9ac5d1bc66cc5d159fb7f7c60c5d4483fd43902f7d/granian-2.5.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bbc4ebc727202ad4b3073ca8148c2af49904710d6fce84872191b2dd5cd36916", size = 3326593, upload-time = "2025-07-30T18:53:24.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/57/c8b5f014673717e850db6d551057e74e330aadad5250d3f312cee432b1ec/granian-2.5.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:af272218076663280fdc293b7da3adb716f23d54211cefad92fcf7e01b3eed19", size = 2937464, upload-time = "2025-07-30T18:53:25.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/91/8ae8aa9f0c3bbdf42fada15d623a5ab9fffd38acc243ec5619b6cbd60b9a/granian-2.5.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36493c4f2b672d027eb11b05ca6660f9fd4944452841d213cb0cb64da869539b", size = 3229316, upload-time = "2025-07-30T18:53:27.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/8c/6c294cf3d77dc9524530d30057f9b6e334cc12c5414feb604fb277d030a3/granian-2.5.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:afafac4908d5931e4b2c2a09612e063d7ccd05e531f16b7f11e3bccc4ca8972c", size = 3109818, upload-time = "2025-07-30T18:53:29.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/49/acc3f1e02e35009d9486e4e00d2c951798a8098935d2374f52c7d2728438/granian-2.5.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:fb157c3d66301ffad4113da4c51aed4d56006b9ebe9d0892c682a634b5fff773", size = 3099384, upload-time = "2025-07-30T18:53:30.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/87/7cdd96fbeabbceea3820736e65bd6d8c0021983605cee26ef1bf2e11e24b/granian-2.5.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:879fdeb71fe279175a25d709d95dd2db01eb67cd12d300e51e3dc704ca5e52fd", size = 3468575, upload-time = "2025-07-30T18:53:31.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/64/bd41efc6bfbca0ff871ce28a13b9e687055dd70913dfce92c4a21a264bf7/granian-2.5.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:74601bda3aedb249a3d5059d48108acfa61d6f71686162bda0bedc013a443efb", size = 3274703, upload-time = "2025-07-30T18:53:33.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/43/af71556ea889c28b8c1c74e9f50a64c040a92bae5e4412b8617638a8aa0e/granian-2.5.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:f371dd9eedae26158901fee3eb934e8fa61491cc78d234470ce364b989c78a1f", size = 2955162, upload-time = "2025-07-30T18:53:36.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/35/14c2c050f3df95eb054f2a44b41a02c30c8a04dc8cca888330f55c43a436/granian-2.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f7bf7ed30bcda9bbc9962f187081c5dfa6aa07e06c3a59486bc573b5def35914", size = 2548356, upload-time = "2025-07-30T18:53:38.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/b3/8944acd78ff37a2effcdaf1d6163179e38c46c67c98bb1a68e93a75eb2c2/granian-2.5.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3152037d799ea97e5736de054a48bf75368fb79b7cfee7e6aa46de1076a43882", size = 3091535, upload-time = "2025-07-30T18:53:39.514Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/49/cf0c89eaa41ac81271e3ae33834f71db945cc09dba609f6dc0e75247fd35/granian-2.5.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:9a53151c2d31dbcf1acbe6af89ce0282387614b6401650d511ca4260ba0e03c1", size = 2977716, upload-time = "2025-07-30T18:53:41.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/58/e828bd5a02c412484b4056cef9aa505b014cc0bb1882f5dbdaf26782f147/granian-2.5.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:8f9918bee3c21eb1410f4323440d76eaa0c2d2e6ca4fa3e3a20d07cc54b788f6", size = 3094250, upload-time = "2025-07-30T18:53:42.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/ba/e70f0de5cd6e5bca15ea5e5bc6c5598d34f9d4e9e6707e79e6edb63f1fac/granian-2.5.0-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:c28a34951c1ed8eea97948882bdbc374ce111be5a59293693613d25043ba1313", size = 3458806, upload-time = "2025-07-30T18:53:44.308Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/c5/4d45042c86a924703f0c9617859742e929cdfe31b644bb16f9845c75342c/granian-2.5.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:944ea3bd400a7ccc8129835eda65bd6a37f8fb77828f4e6ded2f06827d6ec25f", size = 3254382, upload-time = "2025-07-30T18:53:45.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/7f/b4bbe3b818ec058afc0b5c19d46d4955b1b94297c503d2d92470f60e7e20/granian-2.5.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:2959482f9907206c11a059ababe2705ab10d2ddd68004aaf6f6977e193ffc3e1", size = 3010491, upload-time = "2025-07-30T18:53:48.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/c1/f83ed5c17e66867f0ff91cee530a8c0bf72a31db5dbfcaa41373f5f4bf3b/granian-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:64327e137b36e648598c595aad4eda92f54e97262cdd3a0ef28dfdaf7998cfed", size = 2569139, upload-time = "2025-07-30T18:53:49.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/f1/841256d8a21af9a7980a8888e9aa15ce2bcf08d314ca3a0878228fa4bca6/granian-2.5.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b0755ee98a021c223df203f6d5bc7a57131422ba579addfc5f3b5f3d2e362c81", size = 3321682, upload-time = "2025-07-30T18:53:51.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/66/476ecee6b8fa9c5f61a71f0b26b15c8230fed9ceb4a0e1f2206e7ffe0a40/granian-2.5.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee2bb9dc2c93aa441175d7cc1e75aa670a757100945844646de1b7cb3777f50b", size = 2929348, upload-time = "2025-07-30T18:53:53.078Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/60/9bd62de29df75911f26672bfed13e05273560c51c76cddda25516f53583c/granian-2.5.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd30fe55ba2f9f6ed85b91d6804d042a426480c504b322d42403bec42c1e303c", size = 3222269, upload-time = "2025-07-30T18:53:55.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/c5/26871d3f8658fdc832768dfe26a005d711c1af358d3b651bab3658220be4/granian-2.5.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:99edb7c9875f91341d40143d14d45919385a29470056f5a2da94e14746cbcf74", size = 3103622, upload-time = "2025-07-30T18:53:57.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/46/ea6c67008ac403fb40d01b8c792827ce8a1625504d1811cd4533219c1e9e/granian-2.5.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:e5142c8dcbe66c9a5c611ba46de47d1ae2cda5f6efdc11d223b586d980158caf", size = 3093618, upload-time = "2025-07-30T18:53:58.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/de/b344e0237e64d7bd1733b8ff418f616e91028f19e6199169cbffa3b203f6/granian-2.5.0-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9a11f339ba11940218c0f8ac2c6da872ca1832789e4e2ce10958e8da931bc0b7", size = 3462674, upload-time = "2025-07-30T18:54:00.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/5b/ec4c8673fba9b240680bf9fbe8c8be049a127a3dbedab926546f3b22e214/granian-2.5.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:846550b371057e3b1e151ef30fe25026e9882a525d0fa66929650eb9ecf75c21", size = 3265050, upload-time = "2025-07-30T18:54:01.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/7e/b6742dab22285aecbdf59169b3098fb2ec7ad5b98a49b40dde45701bfc97/granian-2.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f69e54a136725e7d2911968571fe9a5685282d0097497799b6453d9d4dffee31", size = 2936812, upload-time = "2025-07-30T18:54:04.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/a6/27e2ea416d87b23df767454b7898bc85f184056f4cbfc208a10b56193e79/granian-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d6f209d06723fe20808be7206fc2b86f5fabc015eac1a5d458b1a6e98f005b7a", size = 2531122, upload-time = "2025-07-30T18:54:06.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/a0/46733fbde3f19bcd07b16619113a898ebe0c2762e6a63eb8eed234c48bfc/granian-2.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e5fd23b02bd2c7cc9ca86d780f262b63a53bc5d7846e96a15edf4ac7aa10a79", size = 3085608, upload-time = "2025-07-30T18:54:08.426Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/6f/28e7ddf79de6244a69a0c5e283a2ab7a4089185f9f3007fb9fed86ec11d0/granian-2.5.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:48968902d351f1bfc70ac5fa69157ee86e5f2947f49df5a84c0965213dbb4ee0", size = 2971321, upload-time = "2025-07-30T18:54:09.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/1a/3ba12fd7466741cf3fe7e67d36153261099acd94ff29f6096b8ee62db4f2/granian-2.5.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:000952722eb63bb1811e391142872bf1ebe7f4434b1d39bbf4e91090d4af9429", size = 3088487, upload-time = "2025-07-30T18:54:11.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/1c/ed2d4e3029ffd0d3f3522e38c022b5bbe23d4b27ea919c4737fc307b43dc/granian-2.5.0-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:141bb4aff1c83b51b2d34381b515d9db38d0481603119e4519fdc9ae932e7a2e", size = 3452014, upload-time = "2025-07-30T18:54:12.894Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/24/0575774a57278db4bf49da156e02ad11aa65e564a2070d66c1b0849f9ae7/granian-2.5.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:fe1f870d24c0f9e4a2dfb6f44ef2173fac90425dcc7e35538028fa5ada75876b", size = 3248972, upload-time = "2025-07-30T18:54:14.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/2c/4e51fe7d7539d5629df74735529220bb7f21155151ad50ff346c117b9199/granian-2.5.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:8e0ba96062b56d76311b2ce990065ae5580539e97e1609ab1949ce9f29114b3e", size = 3034058, upload-time = "2025-07-30T18:54:32.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/0f/cb32b490e1aff8dde99b730013e4cc429e9b15b884dfbb95fcf423973cab/granian-2.5.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:88de6d16de826897a83eb971af4ff6b82275771f6504e802e36cb2749836502d", size = 2601562, upload-time = "2025-07-30T18:54:34.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/2f/807528573ddcad450a282133389d83ee4582e3ae0eff1de20e8d70768e6c/granian-2.5.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4df376a99fed695aeadbd239cda670431184a5e69615ee5aab1624b35dbddbdf", size = 3216043, upload-time = "2025-07-30T18:54:35.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/d8/226cc1864814e738fd53971159662cb9d55c5191681a080a4cb73555b760/granian-2.5.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d0c41cc1c58c75f6bedde20e958a54cd09c40c924cb8e07e516c76a308e9e64d", size = 3106601, upload-time = "2025-07-30T18:54:37.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/11/25b0b7ac30af79d4390149cc96aa8e8bf4d5dbe605feeaec7b832c05c156/granian-2.5.0-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:ac08d7f6c44643228220c33c3e697601bd785e970e587ab8c7bac11a661e91b6", size = 3106064, upload-time = "2025-07-30T18:54:39.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/e8/f4f9239f1d932c73a96efb930a8278c36e44d5ec051a08a4d98bd3bc4ce5/granian-2.5.0-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d2258cdc480327a6d0552acdb1005cd2cd65caffc79ebb796c900ece43b66c1d", size = 3533494, upload-time = "2025-07-30T18:54:41.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/f9/b57edf4f5f90c5bc01880a51ae257810bb479048c333532173677fc2212b/granian-2.5.0-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:1db48cf24f3230c748e59d0f6d40616289e549cfe51fa908e1a2a85575b9ca54", size = 3272111, upload-time = "2025-07-30T18:54:43.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/ee/97010d532c4ac48fb2c6dd03e296c8d6ad5829e09f399bfd0a33b6098953/granian-2.5.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a80f23e904f23ca9a90d613444a477a8df8bb2ef1df7bf279ffa6ab7cbbf042a", size = 3034092, upload-time = "2025-07-30T18:54:46.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/f9/263fd8d1d2f0904ead415a19a1b05980180ee647edcb6b0727e2a942e4ad/granian-2.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:53b10f7996c9a732cb3e4cf30890badbac5f9ec4baa2898851a68100767cc754", size = 2601634, upload-time = "2025-07-30T18:54:47.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/a0/2a3348f6a1291a50cc0b6535b6cdf7fbb73998a5ced6536c0026834a781d/granian-2.5.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f1a05836852ff70745ec094940d9edc62bb3f1a1f7ffb8ee692d6727ebc8b95", size = 3216203, upload-time = "2025-07-30T18:54:49.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/5f/f18826ae61861c6e20a1887a359c71074f423e4cd7237faf186618d0f7ee/granian-2.5.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:3ce4c44ebb949980cc02e0c8a823fb4352c94f2443004fe4c39eb0262fcb2e6e", size = 3106525, upload-time = "2025-07-30T18:54:50.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/51/6776580a11e0966af4919735072b7d6fd95feea2907753a77046f1f8fbe3/granian-2.5.0-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:5a8eea72a37c582fe2653587f5b4bb5323bd8882fcbccff3054311b0735d3814", size = 3106320, upload-time = "2025-07-30T18:54:53.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/8f/9910ac8585fe0f8f0d55bb197a08cccea27e7cd778d059302e5d4c78dfdf/granian-2.5.0-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:168762227d94b74dddff066b2f3519f22426e09f8394ed1a2f48072f80be9275", size = 3533584, upload-time = "2025-07-30T18:54:54.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/49/24c811233d11756182ad13dd30e5323dd88faeb76879493dccb484f8d8fe/granian-2.5.0-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:534a0922c460a8bf9c85937edbc7aac00497d05b64bf9ccc5f5b93006882ecd7", size = 3272305, upload-time = "2025-07-30T18:54:56.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/5f/a1a68e68e145979a1387fb27918f057758ed98af7ab71dce865bd8de6200/granian-2.4.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:7a5279a4d6664f1aa60826af6e3588d890732067c8f6266946d9810452e616ea", size = 3051532, upload-time = "2025-07-01T21:47:21.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/9f/1672e33247cfb1128147e38f27e7e226e0e36185a070570480cdd710212b/granian-2.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:42c93f33914d9de8f79ce4bfe50f8b640733865831c4ec020199c9c57bf52cfd", size = 2709147, upload-time = "2025-07-01T21:47:23.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/02/52031944a6c7170ca71c007879ffd6c1ad5e78bd4c9d0ed76b1d3c43916c/granian-2.4.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5468d62131dcc003c944bd4f82cd05e1c3d3c7773e367ef0fd78d197cc7d4d30", size = 3307063, upload-time = "2025-07-01T21:47:25.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/1b/590108fd38356e29b509e32fea25036e1b12ea87e102e08615b01b342e47/granian-2.4.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ab74a8ecb4d94d5dda7b7596fa5e00e10f4d8a22783f7e3b75e73a096bd584f5", size = 3004408, upload-time = "2025-07-01T21:47:26.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/4f/fbf480554a80217af3428e1a6c6dd613e2c4ab4568839ee2473a9c25e297/granian-2.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6a6582b10d7a9d4a4ef03e89469fbfe779309035e956a197ce40f09de68273a", size = 3219653, upload-time = "2025-07-01T21:47:28.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/21/dc0743099e615c87475d10f4e0713de067279243a432aa407c13d14af40e/granian-2.4.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:5f471deb897631e9c9b104ea7d20bffc3a7d31b5d57d4198aa8e41e6c9e38ac6", size = 3102815, upload-time = "2025-07-01T21:47:29.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/90/7df59160facda055050bfcf1987cc43f2d67d6d5ce39e23e3bd927978ba0/granian-2.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:522f7649604cd0c661800992357f4f9af9822279f66931bbe8664968ffd49a2a", size = 3094521, upload-time = "2025-07-01T21:47:30.459Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/8e/72fa602cc07df284beac01ff2eb9ccbeee23914e9790d7b91ca401edf428/granian-2.4.1-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:2a12f6a6a86376e3dc964eaa5a7321cd984c09b0c408d5af379aa2e4cb1ba661", size = 3444340, upload-time = "2025-07-01T21:47:31.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/90/73438d52c1cb68f7e80bbdb90aff066167c6ef97053afc26d74f56635775/granian-2.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1c5c1494b0235cf69dc5cac737dc6b1d3a82833efd5c9ef5a756971b49355988", size = 3246331, upload-time = "2025-07-01T21:47:33.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/36/3189cf0aa085732859355e9f0464e83644920fab71429c79e32807f7be32/granian-2.4.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:dc90c780fc3bb45e653ebab41336d053bc05a85eeb2439540b5d1188b55a44a5", size = 3051270, upload-time = "2025-07-01T21:47:35.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/f2/57311b3c493b3dac84f7bb2d2d2e36bb204efa5963bf64acda2c902165cf/granian-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8303307f26df720b6c9421857478b90b8c404012965f017574bf4ad0baca637b", size = 2709284, upload-time = "2025-07-01T21:47:36.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/c5/a9b9ff4ad4411405a79b18425489b731762a97641b99caddc07577922d12/granian-2.4.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6e6e501eac6acf8ac5bc6247fa67b3eb2cd59b91e683d96028abbf7cb28b0ed", size = 3306997, upload-time = "2025-07-01T21:47:38.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/3a/35f3fc7134bb1b7ea677adf6506b78723f8356ba4230ca1790d7251e421c/granian-2.4.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66b995a12229de0aa30cbe2a338279ac7e720b35db20592fe7fed7a9249649ac", size = 3004758, upload-time = "2025-07-01T21:47:39.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/99/ffb3bba665f81ab7e339afbce2c9da14178e4e85ce20ec599791117557af/granian-2.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdf7529847f9aa3f25d89c132fb238853233bfb8e422f39946ebb651cb9f1e6a", size = 3219788, upload-time = "2025-07-01T21:47:41.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/91/2684c1c29574a39e5436149cc977e092004d0357bca0e03f55264a39299e/granian-2.4.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:6eb47dd316e5e2354e81c514cb58455c37ea84f103756b6f6562181293eee287", size = 3102656, upload-time = "2025-07-01T21:47:42.514Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/cc/64dc5d96c5557f1bda25e52eb74284f295a46b4c1660b95bdd212665d5ae/granian-2.4.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9218b0b4e2c0743444d1a84ba222236efd5d67702b024f8ce9fd2c309f6b147b", size = 3094233, upload-time = "2025-07-01T21:47:43.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/53/f4d30b60b628698bce653196c75d369bdc543e2d31a6811fd3a963b396ef/granian-2.4.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:dd07733183eb291769d0929ec58c6f16293f82d09fbc434bc3474f1c5e185c3c", size = 3444746, upload-time = "2025-07-01T21:47:44.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/0d/737a6185a2db9f662de5b5a06373e1244f354ebc132e6bde5987d34ad169/granian-2.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cf1301875c28bb54d87280473d3b2378fb86339d117913a13df1ab2764a5effe", size = 3246068, upload-time = "2025-07-01T21:47:46.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/d5/c0e6258b8aa18dbb335cd3a886d07ae64bb661ce3fc655d8efa24043cda5/granian-2.4.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:5e05c62d82f14dec1b36b358d766422423f5d610c414a3c83259424174a3658e", size = 3044572, upload-time = "2025-07-01T21:47:49.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/d7/f6b6b5a9d59fc13bcf65554e5cee0ff4e8581fd8af0a69a760e495ab9190/granian-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6166ea4b96cfa2409b70579b1c2609f52fa6035999f7f57975b3b9fc0486f2b1", size = 2698583, upload-time = "2025-07-01T21:47:51.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/b8/714141af2190f49b8aac8f72a55621e1730e104a7afac5f8cb3b6c92ddd2/granian-2.4.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0fc250818064d47c48eb02af7e703bb692ee1d478575fce9659e96cf576f03f3", size = 3303145, upload-time = "2025-07-01T21:47:52.437Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/6e/1b4b25ab3a734c13e7edb3f219df9d27760ce6b2077c3a29e7db1fd9ff66/granian-2.4.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:019464b5f28a9c475cb4b0aa29d3d1e76f115812b63a03b30fb60b40208e5bf2", size = 2994252, upload-time = "2025-07-01T21:47:53.854Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/fc/1be24a6e8c64c47516222e1198e407c134ed1596919debc276fd8ebf35c6/granian-2.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82da2bf26c97fd9bc6663bbeda60b469105f5fb4609a5bdc6d9af5e590b703fe", size = 3216855, upload-time = "2025-07-01T21:47:55.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/86/fe782ee6093c92208d1d5caaf4c0af689c67f1d0ade1b4525c199bf2477c/granian-2.4.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:0bd37c7f43a784344291b5288680c57ab8a651c67b188d9f735be59f87531dbd", size = 3096595, upload-time = "2025-07-01T21:47:57.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/e0/c0f21edede864276129471c8fef7ec8b28ef41498ae61a5e204eb5fe09da/granian-2.4.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ddd27ed8b98da83c6833b80f41b05b09351872b4eedfe591eb5b21e46506477", size = 3080317, upload-time = "2025-07-01T21:47:58.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/0b/18aeb06d9126405716608b1707d174e00b2fd50ea27c7e36a6a0c97eede4/granian-2.4.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:e42d4e1712de2412449771aae1bbedf302b3fedb256bf9a9798a548a2ceddacf", size = 3420134, upload-time = "2025-07-01T21:47:59.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/7a/c63c8c35215d59306eb42639cfedbe656443247ef0f9212717ad40deee8f/granian-2.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ba5c9f5a5e21c50856480b0d3fa007c846acee44e5b9692f5803ae5ba1f5d7f3", size = 3242402, upload-time = "2025-07-01T21:48:01.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/8a/3417812f0cc6e518dcd06b0c6965d69f5e740d7989a976e6531a420fd884/granian-2.4.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:86b3a50ff2b83eb2ad856ef32b544daa4162b5da88926edc3e18d5111c635713", size = 3044274, upload-time = "2025-07-01T21:48:03.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/df/75f57f08224504260290518501cb25d325a51172adad673843db5f006093/granian-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a8796c39fa0618dd39765fee63776b0ff841986a0caa8aae2d26dce0dae4898c", size = 2698572, upload-time = "2025-07-01T21:48:05.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/27/c2ffaa57710b39d0fb5f03294033411672d700e78cd641eae5e18139a466/granian-2.4.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:95d48c4aff262c5b31438a70f802fa9592c59d3f04fbf07e0f46efefd1e03bb4", size = 3302180, upload-time = "2025-07-01T21:48:07.061Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/c7/a6121c187c762e127367544214041f98963e4e7dfd2c1dfdbfbe1bc46fe3/granian-2.4.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bbe7a9e95335227a741bbfd815594f10d637fc4d6824335bdd09fe8cb7ce9cf5", size = 2994091, upload-time = "2025-07-01T21:48:08.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/9d/74690dd9cb3541c09b98e1fd75deddcc3885af7ecac3eb813e9f2b4df5e4/granian-2.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e95d58dfd6a4bbf89f826863506a789b7fc12e575b4128b3c095450cffa334d4", size = 3216004, upload-time = "2025-07-01T21:48:10.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/83/e09820a814a3071edb0abccf9ddfe7c7d9be337cfb49987a75c759b281a2/granian-2.4.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:266a036f1de45c01b6518a62e4878b6368bc09bff4ff14e4481eb5c556951a8c", size = 3096136, upload-time = "2025-07-01T21:48:11.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/0b/a6adefd57834903af73cafafe02a77a324b9422758cc52923a97eba5085a/granian-2.4.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5aeb00bce5e025fe4b640799c15061aaebc7edf1bd7b8aff6caeed325674fcda", size = 3080194, upload-time = "2025-07-01T21:48:12.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/1b/b4c62359303ade1e6d5a96b019f0db52da0b545a990cc580a6caacfedacb/granian-2.4.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:8982f76b753f5b3b374aff7e6e3b7061e7e42b934a071ae51e8f616ad38089fe", size = 3419814, upload-time = "2025-07-01T21:48:14.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/dd/e240acc4390bbe056592d37dfd89384d706572af196551a5d9f7ddd6ff22/granian-2.4.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3059d4577863bcfc06e1036d6542ec5e6d98af6bbd1703c40806756971fee90a", size = 3241894, upload-time = "2025-07-01T21:48:19.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/8c/af2139e6fae75a587ae616acb4abaaf6b87fc0939c1ed18598e1ab9e3fb5/granian-2.4.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:87b5ca8686dae65cb11c12ef06f8eebae31be8f4385ff1b892ffb8ed604b3ce4", size = 2975244, upload-time = "2025-07-01T21:48:22.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/83/54b31cc7bf578a9fba2112d0fa67b5c87a17198a44fb4ca9588773630bc2/granian-2.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7b0caf3363657913530418e4af115e89f428075bd46c0bf972b1557e417ad9a7", size = 2639421, upload-time = "2025-07-01T21:48:23.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/1f/007dae5d387a19d52eaee04c58e21c0bd261dfb9bc3d5ba60f956b8818f0/granian-2.4.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e324d5ffe8c8c964d2d909ba68b46395b1179cd4aa0e9950f10df0741f689d4d", size = 3067951, upload-time = "2025-07-01T21:48:24.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/f2/c9fd583e1f528361c78077e31e377aad96f38e193e1e175525abc1ff5a2f/granian-2.4.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:33fabdd106df6f4de61b018847bc9aaa39fa8e56ced78f516778b33f7ad26a8f", size = 2964829, upload-time = "2025-07-01T21:48:26.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/95/5e297f7c02f4db5f6681fea8a577921366379d814a3bd2bfd4d184390bac/granian-2.4.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:452ed0de24bcdfc8bc39803650592d38bc728e94819e53c679272a410a1868f8", size = 3070446, upload-time = "2025-07-01T21:48:27.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/24/933e3d7cfd4e2dc97ae7f1e5be1c5a93b3d664118323d58047a320119667/granian-2.4.1-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:b69ff98e5ba85095b88f819525c11118c0f714ff7927ad4157d92a77de873c18", size = 3410970, upload-time = "2025-07-01T21:48:29.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/ff/2bfcb0e8c98ac2abe0c65d6950e35ef2aececb21c1378201591e621c8f96/granian-2.4.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:17517f379b4def0d4ead09cb5febbf07a6f3380065995eb3646f77a67bd0a8d4", size = 3232429, upload-time = "2025-07-01T21:48:31.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/f3/f275a6d59dc373dba73af73c416b9e4140c5aca2988ba76348f256c389b6/granian-2.4.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:36beed559c729ca24d512de4fd7397a5f04fbd01caafa71bd8d2ca7a96d9aeed", size = 3032351, upload-time = "2025-07-01T21:48:34.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/14/892b86220893c5fe303dbe0f09c99643c44bcfc469f2e1ce827abc353a49/granian-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2891d9e214c7369e1c0eb8004d798a1b9a0b5d4f36de5fc73e8bb30b15786f59", size = 2681597, upload-time = "2025-07-01T21:48:35.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/89/02a17e1839e339590e81b13024e4ca31232a7038346c3aaaf7f60a59f936/granian-2.4.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bddd37bf65c007befb0d86dc7968e3fc06ebd114df1e3b270627004bdba049d2", size = 3298967, upload-time = "2025-07-01T21:48:37.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/ca/8f8904ef23d19b436bd64eeaae4fc4c35a78b8f44d905e0ded571ff89b1e/granian-2.4.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:acc82f3e8d85f02e495a01e169dc76ab319875c3a6c512ee09769b27871e8268", size = 2988213, upload-time = "2025-07-01T21:48:38.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/45/6f31a58d12e2d938071a245db19bb2ba09c14b4881d531bd9f86c12313aa/granian-2.4.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d4ea691ac19e808c4deb23cc142708a940a1d03af46f8e0abf9169517343613", size = 3211546, upload-time = "2025-07-01T21:48:40.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/8b/111a1735c055f57e8844e20ab6b05db9305c5e7df87b47b95ba4a4f67924/granian-2.4.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:f446eabd25995d6688459e1ed959b323aa3d7bf4d501d43c249bf8552f642349", size = 3090038, upload-time = "2025-07-01T21:48:42.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/e1/959e7fcfbc6752f30ca491ec786e3051a09dc2f50886e7513d6c54ef8c5e/granian-2.4.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:e40f89956c92f6006bc117001a72c799d8739de5ec08a13e550aa7a116ac6ef0", size = 3074937, upload-time = "2025-07-01T21:48:43.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/5f/9681d9e605f4659b94c13bd12be0324332cbc76a1d9ee369b2fb4f8bb6fb/granian-2.4.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:74554a79d59fcec5dbc44485039eedc7364e56437bec9c4704172a2a8cbdc784", size = 3416187, upload-time = "2025-07-01T21:48:45.325Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/c3/18f49e4c251d624e31ca0bfcb3056c0a162296b904954e8771f122ac42e2/granian-2.4.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:97f79411fe6c9bc82efa2c8875a08adf1dcdf9c0336a1f3858a3835572c40eed", size = 3235677, upload-time = "2025-07-01T21:48:46.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/61/2640db211a9eaf14d95fc94818c9cdddf8e026ec9ee7bad1b39b2d90a6b4/granian-2.4.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e53be3efa80bdd8c8ef4e6bd5e22ddc7bfd17fe8a3e37c43c9b4228c05afd075", size = 2968799, upload-time = "2025-07-01T21:48:49.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b1/cd8138c0f783caef5d2da1bde3f4bc6b71ad8e102acaae173d12e80306d8/granian-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:955e6a861c3de1e510f724d2d07ca5798bfb8fef1de30e166f23caf52d9a4582", size = 2624589, upload-time = "2025-07-01T21:48:50.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/b3/368282d1f830b8008cdad3a413f81d849b5000213d39ecbfab25f32c405a/granian-2.4.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21a0dddf558fe722d8b1b7dc18b4bff05afa90b25f498da8d7c3403fe4e1e9e0", size = 3063109, upload-time = "2025-07-01T21:48:52.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/69/578cecd39ff50e9e29f1e74f243ed30fd743301dd88537462f0fb13b803c/granian-2.4.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a5a6bfd310d7a86b12673b1a1969c44d60a6b9059e8fc86d238aa1d52e5d2268", size = 2959657, upload-time = "2025-07-01T21:48:53.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/0e/1811d70c0701ef7a969d8d9c5cab3415139aa66660925f48676fc48dad22/granian-2.4.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e7ad9d0c1a5f07b5e0085a92f94db1e5a617826801b4dce8bfeae2441a13b55f", size = 3065173, upload-time = "2025-07-01T21:48:55.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/ba/29a554dba7194479b20756075596e387885c91bbfea276375c6fd34797da/granian-2.4.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:e7c099a9a431fc6ee05bb89d106045c43413854a1ed646f960bc06385eaefd7e", size = 3405136, upload-time = "2025-07-01T21:48:56.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/37/d6002091509c4f2a14132be702d0ff910b69fda9d88098e6379347420873/granian-2.4.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:1273bebaf9431aa938708e0c87d0b4eb2ff5a445c17d9a7eb320af96f33fa366", size = 3227816, upload-time = "2025-07-01T21:48:58.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/43/fed39e0611e967934da940435e4ce3bd23835dac8e9811c57eb551e0be05/granian-2.4.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:72f826123561895469b3431db0d96484f52863743181b3f1f41c73b4adbc7807", size = 3049482, upload-time = "2025-07-01T21:49:15.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/13/e7ab0944e82e441d903eafc884b246c25fd2e66e9de01b8c0dde5806ce76/granian-2.4.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:0efdbe606d0b98e2724d90c18e33200870f3eb1b75c33ca384defb7e95bca889", size = 2699245, upload-time = "2025-07-01T21:49:17.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/64/2fb7949494d3d39c1afc26bac9539e129571d5aff54e6ddfad3ebbcaf822/granian-2.4.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64f38d0e0425016b764ef333ed2ddac469eca09d50395ad15059c422d7faa3c0", size = 3212448, upload-time = "2025-07-01T21:49:18.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/09/72d6dbb880f14a5d461a681a9068fce8bd214d4f190cc27d17dff669e5c0/granian-2.4.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:519a9d62fd0a5106b3d316902c315ea65fc8acc5d4c3ba84427dd51367dc251c", size = 3112247, upload-time = "2025-07-01T21:49:20.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/ba/6bd2838e0082fa3b385c94fa4559c847d573d377c3e283c3eadae40a5110/granian-2.4.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d5f336179f010be9bbd2a5999851150e98d31ba3b9baae609eb73c99106dca1e", size = 3092795, upload-time = "2025-07-01T21:49:21.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/55/de4700fbb6d406bd86860f855387e7f3f37e7231429d9e9afb93d04eb2f0/granian-2.4.1-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e82a41444f2cdf70114fdc7b70b2b20e50276c0003f5535f9031f8f605649cb4", size = 3455186, upload-time = "2025-07-01T21:49:23.126Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/45/20d430f2d59e2de3b78577d918a219547930339be6693466d7841b12a7ec/granian-2.4.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:cb728baa8292150c222719d8f1a17eaf4d44d7c1a3e141bc1b9a378373fada5b", size = 3246602, upload-time = "2025-07-01T21:49:24.679Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/33/b5c6d733a9f64049eecc84000eda100e76d699d75299bd61d6f134852eca/granian-2.4.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2e902d611e8b2ff72f9c516284e0c4621c7f93b577ae19aea9eb821c6462adcc", size = 3049355, upload-time = "2025-07-01T21:49:27.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/3e/fb70016f426dc7c6423583d5625391b80e8d479283f7bc0c6452dfb8dfd5/granian-2.4.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e02ac71af55a9514557b61541baea1b657cf2a11aa33335f292a64e73baef160", size = 2699157, upload-time = "2025-07-01T21:49:29.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/9b/d6ea53cbf3f527d38ad30ffa4304ed566de3e481186bfe9396dc19f76c8c/granian-2.4.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf7daddd6c978726af19db1b5a0c49d0f3abf8ef1f93804fc3912fd1e546c71a", size = 3212442, upload-time = "2025-07-01T21:49:30.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/ef/5fff01d6cde612469e0e16198afc9027d1e331304adb025db3461afd4baf/granian-2.4.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:54928278eb4b1a225295c06bbfae5dbc1559d6b8c870052f8a5e245583ed4e28", size = 3112239, upload-time = "2025-07-01T21:49:32.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/64/541b640354e3a12b0125af545fdb138d9c3688b341db2d2cb98540373707/granian-2.4.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:afb0a69869b294db49bbbb5c03bc3d8568b9fc224126b6b5a0a45e37bb980c2c", size = 3092835, upload-time = "2025-07-01T21:49:33.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/b2/c4f6ab5eb28d4cdc611bc10a50c64e959e36a0574ba91ad6eced6fcb8754/granian-2.4.1-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:5f3c94c342fa0239ded5a5d1e855ab3adb9c6ff489458d2648457db047f9a1d8", size = 3455269, upload-time = "2025-07-01T21:49:35.757Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/24/86e07e45695bde6dc8a9d878c2be08d5d0dcc41ec8514ecf77ebc9bb3b59/granian-2.4.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:51613148b46d90374c7050cc9b8cff3e33119b6f8d2db454362371f79fac62f3", size = 3246476, upload-time = "2025-07-01T21:49:37.33Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -2070,7 +2070,7 @@ requires-dist = [
|
||||
{ name = "filelock", specifier = "~=3.18.0" },
|
||||
{ name = "flower", specifier = "~=2.0.1" },
|
||||
{ name = "gotenberg-client", specifier = "~=0.10.0" },
|
||||
{ name = "granian", extras = ["uvloop"], marker = "extra == 'webserver'", specifier = "~=2.5.0" },
|
||||
{ name = "granian", extras = ["uvloop"], marker = "extra == 'webserver'", specifier = "~=2.4.1" },
|
||||
{ name = "httpx-oauth", specifier = "~=0.16" },
|
||||
{ name = "imap-tools", specifier = "~=1.11.0" },
|
||||
{ name = "inotifyrecursive", specifier = "~=0.3" },
|
||||
|
Reference in New Issue
Block a user