mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-03 00:17:16 +00:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d3d165b36 | ||
|
|
135f9f6251 | ||
|
|
1c0fbeb6f3 | ||
|
|
1ba1f2b9c2 | ||
|
|
07f1a356f8 | ||
|
|
8d41d31bd7 | ||
|
|
351892bbab | ||
|
|
5d6ea11828 | ||
|
|
c5765a50a1 | ||
|
|
c2a9532b8f | ||
|
|
713c857a08 | ||
|
|
912c6eb52e | ||
|
|
73ef14f37a | ||
|
|
d78754bff1 | ||
|
|
5c5b1ee6b5 | ||
|
|
08f2f4bfe2 | ||
|
|
f993462973 | ||
|
|
ae70b8d60f | ||
|
|
38db6b51db | ||
|
|
31e9f4272c | ||
|
|
b8659c1af3 | ||
|
|
741115b36b | ||
|
|
1211db5cbb |
@@ -1,5 +1,26 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## paperless-ngx 3.1.2
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: fix dark mode select disabled color, ensure disabled cursor on display mode dropdown [@shamoon](https://github.com/shamoon) ([#13881](https://github.com/paperless-ngx/paperless-ngx/pull/13881))
|
||||||
|
- Fix: add disable to the drag-drop list component [@shamoon](https://github.com/shamoon) ([#13880](https://github.com/paperless-ngx/paperless-ngx/pull/13880))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Chore: update screenshots for v3+ [@shamoon](https://github.com/shamoon) ([#13883](https://github.com/paperless-ngx/paperless-ngx/pull/13883))
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>2 changes</summary>
|
||||||
|
|
||||||
|
- Fix: fix dark mode select disabled color, ensure disabled cursor on display mode dropdown [@shamoon](https://github.com/shamoon) ([#13881](https://github.com/paperless-ngx/paperless-ngx/pull/13881))
|
||||||
|
- Fix: add disable to the drag-drop list component [@shamoon](https://github.com/shamoon) ([#13880](https://github.com/paperless-ngx/paperless-ngx/pull/13880))
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## paperless-ngx 3.1.1
|
## paperless-ngx 3.1.1
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -2088,6 +2088,12 @@ password. All of these options come from their similarly-named [Django settings]
|
|||||||
|
|
||||||
Defaults to "always".
|
Defaults to "always".
|
||||||
|
|
||||||
|
#### [`PAPERLESS_REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS=<bool>`](#PAPERLESS_REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS) {#PAPERLESS_REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS}
|
||||||
|
|
||||||
|
: If set to false, Paperless blocks remote OCR endpoint URLs that resolve to non-public addresses (e.g., localhost, etc).
|
||||||
|
|
||||||
|
Defaults to True.
|
||||||
|
|
||||||
## AI {#ai}
|
## AI {#ai}
|
||||||
|
|
||||||
#### [`PAPERLESS_AI_ENABLED=<bool>`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED}
|
#### [`PAPERLESS_AI_ENABLED=<bool>`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED}
|
||||||
|
|||||||
+5
-5
@@ -43,11 +43,11 @@ dependencies = [
|
|||||||
"drf-writable-nested~=0.7.1",
|
"drf-writable-nested~=0.7.1",
|
||||||
"filelock~=3.32.0",
|
"filelock~=3.32.0",
|
||||||
"flower~=2.0.1",
|
"flower~=2.0.1",
|
||||||
"gotenberg-client~=0.14.0",
|
"gotenberg-client>=0.14,<1.1",
|
||||||
"httpx-oauth~=0.17",
|
"httpx-oauth~=0.17",
|
||||||
"ijson>=3.5.1",
|
"ijson>=3.5.1",
|
||||||
"imap-tools~=1.14.0",
|
"imap-tools~=1.14.0",
|
||||||
"jinja2~=3.1.5",
|
"jinja2~=3.1.6",
|
||||||
"langdetect~=1.0.9",
|
"langdetect~=1.0.9",
|
||||||
"llama-index-core>=0.14.23",
|
"llama-index-core>=0.14.23",
|
||||||
"llama-index-embeddings-huggingface>=0.6.1",
|
"llama-index-embeddings-huggingface>=0.6.1",
|
||||||
@@ -56,7 +56,7 @@ dependencies = [
|
|||||||
"llama-index-llms-ollama>=0.9.1",
|
"llama-index-llms-ollama>=0.9.1",
|
||||||
"llama-index-llms-openai-like>=0.7.1",
|
"llama-index-llms-openai-like>=0.7.1",
|
||||||
"nltk~=3.10.0",
|
"nltk~=3.10.0",
|
||||||
"ocrmypdf~=17.7.0",
|
"ocrmypdf>=17.7,<17.11",
|
||||||
"openai>=2.48",
|
"openai>=2.48",
|
||||||
"pathvalidate~=3.3.1",
|
"pathvalidate~=3.3.1",
|
||||||
"pdf2image~=1.17.0",
|
"pdf2image~=1.17.0",
|
||||||
@@ -73,7 +73,7 @@ dependencies = [
|
|||||||
"setproctitle~=1.3.4",
|
"setproctitle~=1.3.4",
|
||||||
"sqlite-vec==0.1.9",
|
"sqlite-vec==0.1.9",
|
||||||
"tantivy~=0.26.0",
|
"tantivy~=0.26.0",
|
||||||
"tika-client~=0.11.0",
|
"tika-client>=0.11,<1.1",
|
||||||
"torch~=2.13.0",
|
"torch~=2.13.0",
|
||||||
"watchfiles>=1.2",
|
"watchfiles>=1.2",
|
||||||
"whitenoise~=6.11",
|
"whitenoise~=6.11",
|
||||||
@@ -247,7 +247,7 @@ per-file-ignores."src/documents/models.py" = [
|
|||||||
isort.force-single-line = true
|
isort.force-single-line = true
|
||||||
|
|
||||||
[tool.codespell]
|
[tool.codespell]
|
||||||
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish"
|
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish,NIN,nin"
|
||||||
skip = """\
|
skip = """\
|
||||||
src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/paperless/tests/samples\
|
src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/paperless/tests/samples\
|
||||||
/mail/*,src/documents/tests/samples/*,*.po,*.json\
|
/mail/*,src/documents/tests/samples/*,*.po,*.json\
|
||||||
|
|||||||
+140
-140
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,8 @@ export class TrashComponent
|
|||||||
private modalService = inject(NgbModal)
|
private modalService = inject(NgbModal)
|
||||||
private settingsService = inject(SettingsService)
|
private settingsService = inject(SettingsService)
|
||||||
private router = inject(Router)
|
private router = inject(Router)
|
||||||
|
private readonly emptyTrashDelaySetting =
|
||||||
|
this.settingsService.getSignal<number>(SETTINGS_KEYS.EMPTY_TRASH_DELAY)
|
||||||
|
|
||||||
readonly documentsInTrash = signal<Document[]>([])
|
readonly documentsInTrash = signal<Document[]>([])
|
||||||
readonly selectedDocuments = signal<Set<number>>(new Set())
|
readonly selectedDocuments = signal<Set<number>>(new Set())
|
||||||
@@ -200,8 +202,7 @@ export class TrashComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
getDaysRemaining(document: Document): number {
|
getDaysRemaining(document: Document): number {
|
||||||
this.settingsService.trackChanges()
|
const delay = this.emptyTrashDelaySetting()
|
||||||
const delay = this.settingsService.get(SETTINGS_KEYS.EMPTY_TRASH_DELAY)
|
|
||||||
const diff = new Date().getTime() - new Date(document.deleted_at).getTime()
|
const diff = new Date().getTime() - new Date(document.deleted_at).getTime()
|
||||||
const days = Math.ceil(diff / (1000 * 3600 * 24))
|
const days = Math.ceil(diff / (1000 * 3600 * 24))
|
||||||
return delay - days
|
return delay - days
|
||||||
|
|||||||
@@ -111,7 +111,7 @@
|
|||||||
</h6>
|
</h6>
|
||||||
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
||||||
@for (view of savedViewService.sidebarViews; track view.id) {
|
@for (view of savedViewService.sidebarViews; track view.id) {
|
||||||
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews() || !canSaveSettings"
|
<li class="nav-item app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews() || !canSaveSettings"
|
||||||
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
|
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
|
||||||
(cdkDragEnded)="onDragEnd($event)">
|
(cdkDragEnded)="onDragEnd($event)">
|
||||||
<a class="nav-link" routerLink="view/{{view.id}}"
|
<a class="nav-link" routerLink="view/{{view.id}}"
|
||||||
@@ -128,7 +128,7 @@
|
|||||||
}
|
}
|
||||||
</a>
|
</a>
|
||||||
@if (settingsService.organizingSidebarSavedViews() && canSaveSettings) {
|
@if (settingsService.organizingSidebarSavedViews() && canSaveSettings) {
|
||||||
<div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
|
<div class="position-absolute end-0 top-0 px-1 py-2" [class.me-n2]="slimSidebarEnabled" cdkDragHandle>
|
||||||
<i-bs name="grip-vertical"></i-bs>
|
<i-bs name="grip-vertical"></i-bs>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -332,7 +332,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
||||||
<div class="text-muted small d-flex align-items-center flex-wrap nav-label">
|
<div class="text-muted small d-flex align-items-center flex-wrap nav-label">
|
||||||
<div class="me-3">
|
<div class="me-2">
|
||||||
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer"
|
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer"
|
||||||
href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover
|
href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover
|
||||||
[disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
[disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||||
@@ -341,7 +341,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@if (!settingsService.updateCheckingIsSet || appRemoteVersion()) {
|
@if (!settingsService.updateCheckingIsSet || appRemoteVersion()) {
|
||||||
<div class="version-check">
|
<div class="version-check d-flex align-items-center">
|
||||||
<ng-template #updateAvailablePopContent>
|
<ng-template #updateAvailablePopContent>
|
||||||
<span class="small">Paperless-ngx {{ appRemoteVersion().version }} <ng-container i18n>is
|
<span class="small">Paperless-ngx {{ appRemoteVersion().version }} <ng-container i18n>is
|
||||||
available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span>
|
available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span>
|
||||||
|
|||||||
@@ -34,9 +34,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
&.expanded {
|
--pngx-sidebar-width: var(--pngx-sidebar-expanded-width);
|
||||||
--pngx-sidebar-width: var(--pngx-sidebar-expanded-width);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 767.98px) {
|
@media (max-width: 767.98px) {
|
||||||
@@ -113,6 +111,13 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// only animate when the user toggles slim mode
|
||||||
|
.sidebar:not(.animating),
|
||||||
|
.sidebar:not(.animating) ~ main,
|
||||||
|
.sidebar:not(.animating) .sidebar-slim-toggler {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar.slim {
|
.sidebar.slim {
|
||||||
max-width: 55px;
|
max-width: 55px;
|
||||||
|
|
||||||
@@ -123,8 +128,6 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.slim:not(.animating) {
|
.sidebar.slim:not(.animating) {
|
||||||
transition: none;
|
|
||||||
|
|
||||||
li.nav-item span,
|
li.nav-item span,
|
||||||
.sidebar-heading span {
|
.sidebar-heading span {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -144,10 +147,6 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.slim:not(.animating) ~ main.col-slim {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar.animating {
|
.sidebar.animating {
|
||||||
li.nav-item span,
|
li.nav-item span,
|
||||||
.sidebar-heading span {
|
.sidebar-heading span {
|
||||||
@@ -196,6 +195,26 @@ main {
|
|||||||
--bs-popover-body-padding-y: .5rem;
|
--bs-popover-body-padding-y: .5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.sidebar-sticky > ul,
|
||||||
|
.sidebar-sticky > .nav-group {
|
||||||
|
animation: sidebar-nav-in .3s cubic-bezier(.22, .61, .36, 1) backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@for $i from 2 through 5 {
|
||||||
|
.sidebar-sticky > :nth-child(#{$i}) {
|
||||||
|
animation-delay: #{($i - 1) * 0.04}s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sidebar-nav-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-sticky {
|
.sidebar-sticky {
|
||||||
position: relative;
|
position: relative;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|||||||
@@ -193,6 +193,23 @@ describe('AppFrameComponent', () => {
|
|||||||
expect(savedViewSpy).toHaveBeenCalled()
|
expect(savedViewSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should update reinitialized signal-backed settings without manual change detection', async () => {
|
||||||
|
settingsService.initializeSettings().subscribe()
|
||||||
|
httpTestingController
|
||||||
|
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
|
||||||
|
.flush({
|
||||||
|
settings: { app_title: 'Reactive title' },
|
||||||
|
user: {},
|
||||||
|
permissions: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
await fixture.whenStable()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('.brand-title').textContent
|
||||||
|
).toBe('Reactive title')
|
||||||
|
})
|
||||||
|
|
||||||
it('should check for update if enabled', () => {
|
it('should check for update if enabled', () => {
|
||||||
const updateCheckSpy = jest.spyOn(remoteVersionService, 'checkForUpdates')
|
const updateCheckSpy = jest.spyOn(remoteVersionService, 'checkForUpdates')
|
||||||
updateCheckSpy.mockImplementation(() => {
|
updateCheckSpy.mockImplementation(() => {
|
||||||
|
|||||||
@@ -98,6 +98,29 @@ export class AppFrameComponent
|
|||||||
readonly isMenuCollapsed = signal(true)
|
readonly isMenuCollapsed = signal(true)
|
||||||
readonly slimSidebarAnimating = signal(false)
|
readonly slimSidebarAnimating = signal(false)
|
||||||
readonly mobileSearchHidden = signal(false)
|
readonly mobileSearchHidden = signal(false)
|
||||||
|
private readonly versionSetting = this.settingsService.getSignal<string>(
|
||||||
|
SETTINGS_KEYS.VERSION
|
||||||
|
)
|
||||||
|
private readonly appTitleSetting = this.settingsService.getSignal<string>(
|
||||||
|
SETTINGS_KEYS.APP_TITLE
|
||||||
|
)
|
||||||
|
private readonly appLogoSetting = this.settingsService.getSignal<string>(
|
||||||
|
SETTINGS_KEYS.APP_LOGO
|
||||||
|
)
|
||||||
|
private readonly slimSidebarSetting = this.settingsService.getSignal<boolean>(
|
||||||
|
SETTINGS_KEYS.SLIM_SIDEBAR
|
||||||
|
)
|
||||||
|
private readonly attributesSectionsCollapsedSetting =
|
||||||
|
this.settingsService.getSignal<CollapsibleSection[]>(
|
||||||
|
SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED
|
||||||
|
)
|
||||||
|
private readonly aiEnabledSetting = this.settingsService.getSignal<boolean>(
|
||||||
|
SETTINGS_KEYS.AI_ENABLED
|
||||||
|
)
|
||||||
|
private readonly sidebarViewsShowCountSetting =
|
||||||
|
this.settingsService.getSignal<boolean>(
|
||||||
|
SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT
|
||||||
|
)
|
||||||
private lastScrollY: number = 0
|
private lastScrollY: number = 0
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -191,33 +214,23 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get versionString(): string {
|
get versionString(): string {
|
||||||
this.settingsService.trackChanges()
|
return `${environment.appTitle} v${this.versionSetting()}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
|
||||||
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get appTitle(): string {
|
get appTitle(): string {
|
||||||
this.settingsService.trackChanges()
|
return this.appTitleSetting() || environment.appTitle
|
||||||
return (
|
|
||||||
this.settingsService.get(SETTINGS_KEYS.APP_TITLE) || environment.appTitle
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get customAppTitle(): string {
|
get customAppTitle(): string {
|
||||||
this.settingsService.trackChanges()
|
return this.appTitleSetting()
|
||||||
return this.settingsService.get(SETTINGS_KEYS.APP_TITLE)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get hasCustomBranding(): boolean {
|
get hasCustomBranding(): boolean {
|
||||||
this.settingsService.trackChanges()
|
return !!(this.appTitleSetting()?.length || this.appLogoSetting()?.length)
|
||||||
return !!(
|
|
||||||
this.settingsService.get(SETTINGS_KEYS.APP_TITLE)?.length ||
|
|
||||||
this.settingsService.get(SETTINGS_KEYS.APP_LOGO)?.length
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get customAppLogo(): string {
|
get customAppLogo(): string {
|
||||||
this.settingsService.trackChanges()
|
const logo = this.appLogoSetting()
|
||||||
const logo = this.settingsService.get(SETTINGS_KEYS.APP_LOGO)
|
|
||||||
return logo?.length
|
return logo?.length
|
||||||
? environment.apiBaseUrl.replace(/\/api\/$/, logo)
|
? environment.apiBaseUrl.replace(/\/api\/$/, logo)
|
||||||
: null
|
: null
|
||||||
@@ -262,8 +275,7 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get slimSidebarEnabled(): boolean {
|
get slimSidebarEnabled(): boolean {
|
||||||
this.settingsService.trackChanges()
|
return this.slimSidebarSetting()
|
||||||
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
set slimSidebarEnabled(enabled: boolean) {
|
set slimSidebarEnabled(enabled: boolean) {
|
||||||
@@ -286,10 +298,9 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get attributesSectionsCollapsed(): boolean {
|
get attributesSectionsCollapsed(): boolean {
|
||||||
this.settingsService.trackChanges()
|
return this.attributesSectionsCollapsedSetting()?.includes(
|
||||||
return this.settingsService
|
CollapsibleSection.ATTRIBUTES
|
||||||
.get(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED)
|
)
|
||||||
?.includes(CollapsibleSection.ATTRIBUTES)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
set attributesSectionsCollapsed(collapsed: boolean) {
|
set attributesSectionsCollapsed(collapsed: boolean) {
|
||||||
@@ -312,8 +323,7 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get aiEnabled(): boolean {
|
get aiEnabled(): boolean {
|
||||||
this.settingsService.trackChanges()
|
return this.aiEnabledSetting()
|
||||||
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('window:resize')
|
@HostListener('window:resize')
|
||||||
@@ -480,9 +490,8 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get showSidebarCounts(): boolean {
|
get showSidebarCounts(): boolean {
|
||||||
this.settingsService.trackChanges()
|
|
||||||
return (
|
return (
|
||||||
this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT) &&
|
this.sidebarViewsShowCountSetting() &&
|
||||||
!this.settingsService.organizingSidebarSavedViews()
|
!this.settingsService.organizingSidebarSavedViews()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
private hotkeyService = inject(HotKeyService)
|
private hotkeyService = inject(HotKeyService)
|
||||||
private settingsService = inject(SettingsService)
|
private settingsService = inject(SettingsService)
|
||||||
private locationStrategy = inject(LocationStrategy)
|
private locationStrategy = inject(LocationStrategy)
|
||||||
|
private readonly searchFullTypeSetting =
|
||||||
|
this.settingsService.getSignal<GlobalSearchType>(
|
||||||
|
SETTINGS_KEYS.SEARCH_FULL_TYPE
|
||||||
|
)
|
||||||
|
|
||||||
public DataType = DataType
|
public DataType = DataType
|
||||||
readonly query = signal<string>(null)
|
readonly query = signal<string>(null)
|
||||||
@@ -97,11 +101,7 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
@ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef>
|
@ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef>
|
||||||
|
|
||||||
get useAdvancedForFullSearch(): boolean {
|
get useAdvancedForFullSearch(): boolean {
|
||||||
this.settingsService.trackChanges()
|
return this.searchFullTypeSetting() === GlobalSearchType.ADVANCED
|
||||||
return (
|
|
||||||
this.settingsService.get(SETTINGS_KEYS.SEARCH_FULL_TYPE) ===
|
|
||||||
GlobalSearchType.ADVANCED
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
@if (useDropdown) {
|
@if (useDropdown) {
|
||||||
<div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions">
|
<div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions">
|
||||||
<button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled" [aria-label]="title">
|
<button class="btn btn-sm" [ngClass]="!editing && isActive ? 'btn-primary' : 'btn-outline-primary'" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled" [aria-label]="title">
|
||||||
<i-bs name="{{icon}}"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
|
<i-bs name="{{icon}}"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
|
||||||
@if (isActive) {
|
@if (isActive) {
|
||||||
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
|
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
|
||||||
|
|||||||
+7
-4
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
getLocaleNumberSymbol,
|
getLocaleNumberSymbol,
|
||||||
|
NgClass,
|
||||||
NgTemplateOutlet,
|
NgTemplateOutlet,
|
||||||
NumberSymbol,
|
NumberSymbol,
|
||||||
} from '@angular/common'
|
} from '@angular/common'
|
||||||
@@ -48,25 +49,26 @@ import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.comp
|
|||||||
import { DocumentLinkComponent } from '../input/document-link/document-link.component'
|
import { DocumentLinkComponent } from '../input/document-link/document-link.component'
|
||||||
|
|
||||||
export class CustomFieldQueriesModel {
|
export class CustomFieldQueriesModel {
|
||||||
private _queries: CustomFieldQueryElement[] = []
|
private readonly _queries = signal<CustomFieldQueryElement[]>([])
|
||||||
private rootSubscriptions: Subscription[] = []
|
private rootSubscriptions: Subscription[] = []
|
||||||
|
|
||||||
public readonly changed = new Subject<CustomFieldQueriesModel>()
|
public readonly changed = new Subject<CustomFieldQueriesModel>()
|
||||||
|
|
||||||
public get queries(): CustomFieldQueryElement[] {
|
public get queries(): CustomFieldQueryElement[] {
|
||||||
return this._queries
|
return this._queries()
|
||||||
}
|
}
|
||||||
|
|
||||||
public set queries(value: CustomFieldQueryElement[]) {
|
public set queries(value: CustomFieldQueryElement[]) {
|
||||||
this.teardownRootSubscriptions()
|
this.teardownRootSubscriptions()
|
||||||
this._queries = value ?? []
|
const queries = value ?? []
|
||||||
for (const element of this._queries) {
|
for (const element of queries) {
|
||||||
this.rootSubscriptions.push(
|
this.rootSubscriptions.push(
|
||||||
element.changed.subscribe(() => {
|
element.changed.subscribe(() => {
|
||||||
this.changed.next(this)
|
this.changed.next(this)
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
this._queries.set(queries)
|
||||||
}
|
}
|
||||||
|
|
||||||
public clear(fireEvent = true) {
|
public clear(fireEvent = true) {
|
||||||
@@ -209,6 +211,7 @@ export class CustomFieldQueriesModel {
|
|||||||
DocumentLinkComponent,
|
DocumentLinkComponent,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgbDatepickerModule,
|
NgbDatepickerModule,
|
||||||
|
NgClass,
|
||||||
NgTemplateOutlet,
|
NgTemplateOutlet,
|
||||||
NgSelectModule,
|
NgSelectModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
|
|||||||
+19
-13
@@ -196,6 +196,16 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function setActionSettings({
|
||||||
|
email = true,
|
||||||
|
remoteOcr = true,
|
||||||
|
ai = true,
|
||||||
|
} = {}) {
|
||||||
|
settingsService.set(SETTINGS_KEYS.EMAIL_ENABLED, email)
|
||||||
|
settingsService.set(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED, remoteOcr)
|
||||||
|
settingsService.set(SETTINGS_KEYS.AI_ENABLED, ai)
|
||||||
|
}
|
||||||
|
|
||||||
it('should support create and edit modes, support adding triggers and actions on new workflow', () => {
|
it('should support create and edit modes, support adding triggers and actions on new workflow', () => {
|
||||||
component.dialogMode.set(EditDialogMode.CREATE)
|
component.dialogMode.set(EditDialogMode.CREATE)
|
||||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||||
@@ -218,7 +228,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should return source options, type options, type name, schedule date field options', () => {
|
it('should return source options, type options, type name, schedule date field options', () => {
|
||||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
setActionSettings()
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
|
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
|
||||||
expect(component.triggerTypeOptions).toEqual(WORKFLOW_TYPE_OPTIONS)
|
expect(component.triggerTypeOptions).toEqual(WORKFLOW_TYPE_OPTIONS)
|
||||||
@@ -242,7 +252,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Email, remote OCR and AI all disabled
|
// Email, remote OCR and AI all disabled
|
||||||
jest.spyOn(settingsService, 'get').mockReturnValue(false)
|
setActionSettings({ email: false, remoteOcr: false, ai: false })
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
expect(component.actionTypeOptions).toEqual(
|
expect(component.actionTypeOptions).toEqual(
|
||||||
WORKFLOW_ACTION_OPTIONS.filter(
|
WORKFLOW_ACTION_OPTIONS.filter(
|
||||||
@@ -255,7 +265,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should offer remote OCR only for consumption workflows', () => {
|
it('should offer remote OCR only for consumption workflows', () => {
|
||||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
setActionSettings()
|
||||||
|
|
||||||
// A consumption trigger makes the action reachable
|
// A consumption trigger makes the action reachable
|
||||||
component.object = {
|
component.object = {
|
||||||
@@ -285,7 +295,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should offer remote OCR on a trigger added to a new workflow', () => {
|
it('should offer remote OCR on a trigger added to a new workflow', () => {
|
||||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
setActionSettings()
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
|
|
||||||
// Nothing for the action to apply to yet
|
// Nothing for the action to apply to yet
|
||||||
@@ -311,7 +321,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should keep remote OCR listed when an action already uses it', () => {
|
it('should keep remote OCR listed when an action already uses it', () => {
|
||||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
setActionSettings()
|
||||||
|
|
||||||
// Otherwise changing the trigger would silently blank the selection
|
// Otherwise changing the trigger would silently blank the selection
|
||||||
component.object = {
|
component.object = {
|
||||||
@@ -329,9 +339,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should not offer remote OCR when no engine is configured', () => {
|
it('should not offer remote OCR when no engine is configured', () => {
|
||||||
jest
|
setActionSettings({ remoteOcr: false })
|
||||||
.spyOn(settingsService, 'get')
|
|
||||||
.mockImplementation((key) => key !== SETTINGS_KEYS.REMOTE_OCR_CONFIGURED)
|
|
||||||
|
|
||||||
component.object = {
|
component.object = {
|
||||||
name: 'Workflow 1',
|
name: 'Workflow 1',
|
||||||
@@ -348,7 +356,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should offer apply AI suggestions unless every trigger is consumption', () => {
|
it('should offer apply AI suggestions unless every trigger is consumption', () => {
|
||||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
setActionSettings()
|
||||||
|
|
||||||
// Consumption runs before the document has been parsed, so there would be
|
// Consumption runs before the document has been parsed, so there would be
|
||||||
// no content to make suggestions from
|
// no content to make suggestions from
|
||||||
@@ -382,7 +390,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should keep apply AI suggestions listed when an action already uses it', () => {
|
it('should keep apply AI suggestions listed when an action already uses it', () => {
|
||||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
setActionSettings()
|
||||||
|
|
||||||
// Otherwise changing the trigger would silently blank the selection
|
// Otherwise changing the trigger would silently blank the selection
|
||||||
component.object = {
|
component.object = {
|
||||||
@@ -400,9 +408,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should not offer apply AI suggestions when AI is disabled', () => {
|
it('should not offer apply AI suggestions when AI is disabled', () => {
|
||||||
jest
|
setActionSettings({ ai: false })
|
||||||
.spyOn(settingsService, 'get')
|
|
||||||
.mockImplementation((key) => key !== SETTINGS_KEYS.AI_ENABLED)
|
|
||||||
|
|
||||||
component.object = {
|
component.object = {
|
||||||
name: 'Workflow 1',
|
name: 'Workflow 1',
|
||||||
|
|||||||
+10
-4
@@ -537,6 +537,13 @@ export class WorkflowEditDialogComponent
|
|||||||
readonly dateCustomFields = computed(() =>
|
readonly dateCustomFields = computed(() =>
|
||||||
this.customFields()?.filter((f) => f.data_type === CustomFieldDataType.Date)
|
this.customFields()?.filter((f) => f.data_type === CustomFieldDataType.Date)
|
||||||
)
|
)
|
||||||
|
private readonly emailEnabledSetting =
|
||||||
|
this.settingsService.getSignal<boolean>(SETTINGS_KEYS.EMAIL_ENABLED)
|
||||||
|
private readonly remoteOcrConfiguredSetting =
|
||||||
|
this.settingsService.getSignal<boolean>(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED)
|
||||||
|
private readonly aiEnabledSetting = this.settingsService.getSignal<boolean>(
|
||||||
|
SETTINGS_KEYS.AI_ENABLED
|
||||||
|
)
|
||||||
|
|
||||||
expandedItem: number = null
|
expandedItem: number = null
|
||||||
|
|
||||||
@@ -589,7 +596,7 @@ export class WorkflowEditDialogComponent
|
|||||||
private getAllowedActionTypes() {
|
private getAllowedActionTypes() {
|
||||||
let allowed = WORKFLOW_ACTION_OPTIONS
|
let allowed = WORKFLOW_ACTION_OPTIONS
|
||||||
|
|
||||||
if (!this.settingsService.get(SETTINGS_KEYS.EMAIL_ENABLED)) {
|
if (!this.emailEnabledSetting()) {
|
||||||
allowed = allowed.filter((a) => a.id !== WorkflowActionType.Email)
|
allowed = allowed.filter((a) => a.id !== WorkflowActionType.Email)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,7 +604,7 @@ export class WorkflowEditDialogComponent
|
|||||||
// offered for workflows that run at consumption.
|
// offered for workflows that run at consumption.
|
||||||
const formWorkflow: Workflow = this.objectForm?.value
|
const formWorkflow: Workflow = this.objectForm?.value
|
||||||
const remoteOcrUsable =
|
const remoteOcrUsable =
|
||||||
this.settingsService.get(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED) &&
|
this.remoteOcrConfiguredSetting() &&
|
||||||
(formWorkflow?.triggers?.some(
|
(formWorkflow?.triggers?.some(
|
||||||
(trigger) => trigger.type === WorkflowTriggerType.Consumption
|
(trigger) => trigger.type === WorkflowTriggerType.Consumption
|
||||||
) ||
|
) ||
|
||||||
@@ -612,7 +619,7 @@ export class WorkflowEditDialogComponent
|
|||||||
// once every trigger is consumption, so it stays offered on a workflow
|
// once every trigger is consumption, so it stays offered on a workflow
|
||||||
// that has no triggers yet.
|
// that has no triggers yet.
|
||||||
const aiSuggestionsUsable =
|
const aiSuggestionsUsable =
|
||||||
this.settingsService.get(SETTINGS_KEYS.AI_ENABLED) &&
|
this.aiEnabledSetting() &&
|
||||||
(!formWorkflow?.triggers?.length ||
|
(!formWorkflow?.triggers?.length ||
|
||||||
formWorkflow.triggers.some(
|
formWorkflow.triggers.some(
|
||||||
(trigger) => trigger.type !== WorkflowTriggerType.Consumption
|
(trigger) => trigger.type !== WorkflowTriggerType.Consumption
|
||||||
@@ -1362,7 +1369,6 @@ export class WorkflowEditDialogComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get actionTypeOptions() {
|
get actionTypeOptions() {
|
||||||
this.settingsService.trackChanges()
|
|
||||||
// Computed on read rather than cached
|
// Computed on read rather than cached
|
||||||
return this.getAllowedActionTypes()
|
return this.getAllowedActionTypes()
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-4
@@ -839,7 +839,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
selectionModel.items = [memoRoot]
|
selectionModel.items = [memoRoot]
|
||||||
selectionModel.documentCounts = [{ id: memoRoot.id, document_count: 9 }]
|
selectionModel.documentCounts = [{ id: memoRoot.id, document_count: 9 }]
|
||||||
|
|
||||||
const getRootDocCount = (selectionModel as any).createRootDocCounter()
|
const getRootDocCount = (selectionModel as any).createRootDocCounter(
|
||||||
|
selectionModel.items
|
||||||
|
)
|
||||||
|
|
||||||
expect(getRootDocCount(memoRoot.id)).toEqual(9)
|
expect(getRootDocCount(memoRoot.id)).toEqual(9)
|
||||||
selectionModel.documentCounts = []
|
selectionModel.documentCounts = []
|
||||||
@@ -855,7 +857,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
selectionModel.items = [rootWithoutSelection]
|
selectionModel.items = [rootWithoutSelection]
|
||||||
selectionModel.documentCounts = []
|
selectionModel.documentCounts = []
|
||||||
|
|
||||||
const getRootDocCount = (selectionModel as any).createRootDocCounter()
|
const getRootDocCount = (selectionModel as any).createRootDocCounter(
|
||||||
|
selectionModel.items
|
||||||
|
)
|
||||||
|
|
||||||
expect(getRootDocCount(rootWithoutSelection.id)).toEqual(4)
|
expect(getRootDocCount(rootWithoutSelection.id)).toEqual(4)
|
||||||
})
|
})
|
||||||
@@ -865,7 +869,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
selectionModel.items = [rootWithoutCounts]
|
selectionModel.items = [rootWithoutCounts]
|
||||||
selectionModel.documentCounts = []
|
selectionModel.documentCounts = []
|
||||||
|
|
||||||
const getRootDocCount = (selectionModel as any).createRootDocCounter()
|
const getRootDocCount = (selectionModel as any).createRootDocCounter(
|
||||||
|
selectionModel.items
|
||||||
|
)
|
||||||
|
|
||||||
expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0)
|
expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0)
|
||||||
})
|
})
|
||||||
@@ -966,7 +972,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
component.selectionModel['temporarySelectionStates'].set(id, state)
|
component.selectionModel['temporarySelectionStates'].set(id, state)
|
||||||
const changedSpy = jest.spyOn(component.selectionModel.changed, 'next')
|
const changedSpy = jest.spyOn(component.selectionModel.changed, 'next')
|
||||||
component.selectionModel.exclude(id)
|
component.selectionModel.exclude(id)
|
||||||
expect(component.selectionModel.temporaryLogicalOperator).toBe(
|
expect(component.selectionModel.temporaryLogicalOperator()).toBe(
|
||||||
LogicalOperator.And
|
LogicalOperator.And
|
||||||
)
|
)
|
||||||
expect(component.selectionModel['temporarySelectionStates'].get(id)).toBe(
|
expect(component.selectionModel['temporarySelectionStates'].get(id)).toBe(
|
||||||
|
|||||||
+125
-104
@@ -64,43 +64,56 @@ export class FilterableDropdownSelectionModel {
|
|||||||
|
|
||||||
manyToOne = false
|
manyToOne = false
|
||||||
singleSelect = false
|
singleSelect = false
|
||||||
private _logicalOperator: LogicalOperator = LogicalOperator.And
|
|
||||||
temporaryLogicalOperator: LogicalOperator = this._logicalOperator
|
|
||||||
private _intersection: Intersection = Intersection.Include
|
|
||||||
temporaryIntersection: Intersection = this._intersection
|
|
||||||
|
|
||||||
private _documentCounts: SelectionDataItem[] = []
|
private readonly _logicalOperator = signal(LogicalOperator.And)
|
||||||
|
readonly temporaryLogicalOperator = signal(LogicalOperator.And)
|
||||||
|
private readonly _intersection = signal(Intersection.Include)
|
||||||
|
readonly temporaryIntersection = signal(Intersection.Include)
|
||||||
|
private readonly _documentCounts = signal<SelectionDataItem[]>([])
|
||||||
|
private readonly _items = signal<MatchingModel[]>([])
|
||||||
|
private readonly _selectionStates = signal(
|
||||||
|
new Map<number, ToggleableItemState>()
|
||||||
|
)
|
||||||
|
private readonly _temporarySelectionStates = signal(
|
||||||
|
new Map<number, ToggleableItemState>()
|
||||||
|
)
|
||||||
|
|
||||||
public documentCountSortingEnabled = false
|
public documentCountSortingEnabled = false
|
||||||
|
|
||||||
|
private get selectionStates(): ReadonlyMap<number, ToggleableItemState> {
|
||||||
|
return this._selectionStates()
|
||||||
|
}
|
||||||
|
|
||||||
|
private get temporarySelectionStates(): ReadonlyMap<
|
||||||
|
number,
|
||||||
|
ToggleableItemState
|
||||||
|
> {
|
||||||
|
return this._temporarySelectionStates()
|
||||||
|
}
|
||||||
|
|
||||||
public set documentCounts(counts: SelectionDataItem[]) {
|
public set documentCounts(counts: SelectionDataItem[]) {
|
||||||
this._documentCounts = counts
|
this._documentCounts.set(counts)
|
||||||
if (this.documentCountSortingEnabled) {
|
if (this.documentCountSortingEnabled) {
|
||||||
this.sortItems()
|
this._items.set(this.sortItems(this.items))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _items: MatchingModel[] = []
|
|
||||||
get items(): MatchingModel[] {
|
get items(): MatchingModel[] {
|
||||||
return this._items
|
return this._items()
|
||||||
}
|
}
|
||||||
|
|
||||||
set items(items: MatchingModel[]) {
|
set items(items: MatchingModel[]) {
|
||||||
if (items) {
|
if (items) {
|
||||||
this._items = Array.from(items)
|
this._items.set(this.withNullItem(this.sortItems(Array.from(items))))
|
||||||
this.sortItems()
|
|
||||||
this.setNullItem()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private setNullItem() {
|
private withNullItem(items: MatchingModel[]): MatchingModel[] {
|
||||||
if (this.manyToOne && this.logicalOperator === LogicalOperator.Or) {
|
if (this.manyToOne && this.logicalOperator === LogicalOperator.Or) {
|
||||||
if (this._items[0]?.id === null) {
|
return items[0]?.id === null ? items.slice(1) : items
|
||||||
this._items.shift()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const item = {
|
const nullItem = {
|
||||||
name: $localize`:Filter drop down element to filter for documents with no correspondent/type/tag assigned:Not assigned`,
|
name: $localize`:Filter drop down element to filter for documents with no correspondent/type/tag assigned:Not assigned`,
|
||||||
id:
|
id:
|
||||||
this.manyToOne || this.intersection === Intersection.Include
|
this.manyToOne || this.intersection === Intersection.Include
|
||||||
@@ -108,22 +121,17 @@ export class FilterableDropdownSelectionModel {
|
|||||||
: NEGATIVE_NULL_FILTER_VALUE,
|
: NEGATIVE_NULL_FILTER_VALUE,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
return items[0]?.id === null || items[0]?.id === NEGATIVE_NULL_FILTER_VALUE
|
||||||
this._items[0]?.id === null ||
|
? [nullItem, ...items.slice(1)]
|
||||||
this._items[0]?.id === NEGATIVE_NULL_FILTER_VALUE
|
: [nullItem, ...items]
|
||||||
) {
|
|
||||||
this._items[0] = item
|
|
||||||
} else if (this._items) {
|
|
||||||
this._items.unshift(item)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(manyToOne: boolean = false) {
|
constructor(manyToOne: boolean = false) {
|
||||||
this.manyToOne = manyToOne
|
this.manyToOne = manyToOne
|
||||||
}
|
}
|
||||||
|
|
||||||
private sortItems() {
|
private sortItems(items: MatchingModel[]): MatchingModel[] {
|
||||||
this._items.sort((a, b) => {
|
const sorted = [...items].sort((a, b) => {
|
||||||
if (
|
if (
|
||||||
(a.id == null && b.id != null) ||
|
(a.id == null && b.id != null) ||
|
||||||
(a.id == NEGATIVE_NULL_FILTER_VALUE &&
|
(a.id == NEGATIVE_NULL_FILTER_VALUE &&
|
||||||
@@ -154,13 +162,13 @@ export class FilterableDropdownSelectionModel {
|
|||||||
) {
|
) {
|
||||||
return -1
|
return -1
|
||||||
} else if (
|
} else if (
|
||||||
this._documentCounts.length &&
|
this._documentCounts().length &&
|
||||||
this.getDocumentCount(b.id) === 0 &&
|
this.getDocumentCount(b.id) === 0 &&
|
||||||
this.getDocumentCount(a.id) > this.getDocumentCount(b.id)
|
this.getDocumentCount(a.id) > this.getDocumentCount(b.id)
|
||||||
) {
|
) {
|
||||||
return -1
|
return -1
|
||||||
} else if (
|
} else if (
|
||||||
this._documentCounts.length &&
|
this._documentCounts().length &&
|
||||||
this.getDocumentCount(a.id) === 0 &&
|
this.getDocumentCount(a.id) === 0 &&
|
||||||
this.getDocumentCount(a.id) < this.getDocumentCount(b.id)
|
this.getDocumentCount(a.id) < this.getDocumentCount(b.id)
|
||||||
) {
|
) {
|
||||||
@@ -170,15 +178,11 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (this._documentCounts.length) {
|
return this._documentCounts().length
|
||||||
this.promoteBranchesWithDocumentCounts()
|
? this.promoteBranchesWithDocumentCounts(sorted)
|
||||||
}
|
: sorted
|
||||||
}
|
}
|
||||||
|
|
||||||
private selectionStates = new Map<number, ToggleableItemState>()
|
|
||||||
|
|
||||||
private temporarySelectionStates = new Map<number, ToggleableItemState>()
|
|
||||||
|
|
||||||
getSelectedItems() {
|
getSelectedItems() {
|
||||||
return this.items.filter(
|
return this.items.filter(
|
||||||
(i) =>
|
(i) =>
|
||||||
@@ -194,30 +198,33 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
set(id: number, state: ToggleableItemState, fireEvent = true) {
|
set(id: number, state: ToggleableItemState, fireEvent = true) {
|
||||||
|
const states = new Map(this.temporarySelectionStates)
|
||||||
if (state == ToggleableItemState.NotSelected) {
|
if (state == ToggleableItemState.NotSelected) {
|
||||||
this.temporarySelectionStates.delete(id)
|
states.delete(id)
|
||||||
} else {
|
} else {
|
||||||
this.temporarySelectionStates.set(id, state)
|
states.set(id, state)
|
||||||
}
|
}
|
||||||
|
this._temporarySelectionStates.set(states)
|
||||||
if (fireEvent) {
|
if (fireEvent) {
|
||||||
this.changed.next(this)
|
this.changed.next(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggle(id: number, fireEvent = true) {
|
toggle(id: number, fireEvent = true) {
|
||||||
let state = this.temporarySelectionStates.get(id)
|
const states = new Map(this.temporarySelectionStates)
|
||||||
|
let state = states.get(id)
|
||||||
if (
|
if (
|
||||||
state == undefined ||
|
state == undefined ||
|
||||||
(state != ToggleableItemState.Selected &&
|
(state != ToggleableItemState.Selected &&
|
||||||
state != ToggleableItemState.Excluded)
|
state != ToggleableItemState.Excluded)
|
||||||
) {
|
) {
|
||||||
if (this.manyToOne || this.singleSelect) {
|
if (this.manyToOne || this.singleSelect) {
|
||||||
this.temporarySelectionStates.set(id, ToggleableItemState.Selected)
|
states.set(id, ToggleableItemState.Selected)
|
||||||
|
|
||||||
if (this.singleSelect) {
|
if (this.singleSelect) {
|
||||||
for (let key of this.temporarySelectionStates.keys()) {
|
for (let key of states.keys()) {
|
||||||
if (key != id) {
|
if (key != id) {
|
||||||
this.temporarySelectionStates.delete(key)
|
states.delete(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,25 +240,26 @@ export class FilterableDropdownSelectionModel {
|
|||||||
) {
|
) {
|
||||||
newState = ToggleableItemState.NotSelected
|
newState = ToggleableItemState.NotSelected
|
||||||
}
|
}
|
||||||
this.temporarySelectionStates.set(id, newState)
|
states.set(id, newState)
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
state == ToggleableItemState.Selected ||
|
state == ToggleableItemState.Selected ||
|
||||||
state == ToggleableItemState.Excluded
|
state == ToggleableItemState.Excluded
|
||||||
) {
|
) {
|
||||||
this.temporarySelectionStates.delete(id)
|
states.delete(id)
|
||||||
this.clearDescendantSelections(id)
|
this.clearDescendantSelections(states, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
for (let key of this.temporarySelectionStates.keys()) {
|
for (let key of states.keys()) {
|
||||||
if (key) {
|
if (key) {
|
||||||
this.temporarySelectionStates.delete(key)
|
states.delete(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.temporarySelectionStates.delete(null)
|
states.delete(null)
|
||||||
}
|
}
|
||||||
|
this._temporarySelectionStates.set(states)
|
||||||
|
|
||||||
if (fireEvent) {
|
if (fireEvent) {
|
||||||
this.changed.next(this)
|
this.changed.next(this)
|
||||||
@@ -259,20 +267,21 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
exclude(id: number, fireEvent: boolean = true) {
|
exclude(id: number, fireEvent: boolean = true) {
|
||||||
let state = this.temporarySelectionStates.get(id)
|
const states = new Map(this.temporarySelectionStates)
|
||||||
|
let state = states.get(id)
|
||||||
if (id && (state == null || state != ToggleableItemState.Excluded)) {
|
if (id && (state == null || state != ToggleableItemState.Excluded)) {
|
||||||
this.temporaryLogicalOperator = this._logicalOperator = this.manyToOne
|
const operator = this.manyToOne ? LogicalOperator.And : LogicalOperator.Or
|
||||||
? LogicalOperator.And
|
this.temporaryLogicalOperator.set(operator)
|
||||||
: LogicalOperator.Or
|
this._logicalOperator.set(operator)
|
||||||
|
|
||||||
if (this.manyToOne || this.singleSelect) {
|
if (this.manyToOne || this.singleSelect) {
|
||||||
this.temporarySelectionStates.set(id, ToggleableItemState.Excluded)
|
states.set(id, ToggleableItemState.Excluded)
|
||||||
this.clearDescendantSelections(id)
|
this.clearDescendantSelections(states, id)
|
||||||
|
|
||||||
if (this.singleSelect) {
|
if (this.singleSelect) {
|
||||||
for (let key of this.temporarySelectionStates.keys()) {
|
for (let key of states.keys()) {
|
||||||
if (key != id) {
|
if (key != id) {
|
||||||
this.temporarySelectionStates.delete(key)
|
states.delete(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -287,17 +296,18 @@ export class FilterableDropdownSelectionModel {
|
|||||||
) {
|
) {
|
||||||
newState = ToggleableItemState.NotSelected
|
newState = ToggleableItemState.NotSelected
|
||||||
}
|
}
|
||||||
this.temporarySelectionStates.set(id, newState)
|
states.set(id, newState)
|
||||||
if (newState == ToggleableItemState.Excluded) {
|
if (newState == ToggleableItemState.Excluded) {
|
||||||
this.clearDescendantSelections(id)
|
this.clearDescendantSelections(states, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (!id || state == ToggleableItemState.Excluded) {
|
} else if (!id || state == ToggleableItemState.Excluded) {
|
||||||
this.temporarySelectionStates.delete(id)
|
states.delete(id)
|
||||||
if (id) {
|
if (id) {
|
||||||
this.clearDescendantSelections(id)
|
this.clearDescendantSelections(states, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this._temporarySelectionStates.set(states)
|
||||||
|
|
||||||
if (fireEvent) {
|
if (fireEvent) {
|
||||||
this.changed.next(this)
|
this.changed.next(this)
|
||||||
@@ -308,9 +318,12 @@ export class FilterableDropdownSelectionModel {
|
|||||||
return this.selectionStates.get(id) || ToggleableItemState.NotSelected
|
return this.selectionStates.get(id) || ToggleableItemState.NotSelected
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearDescendantSelections(id: number) {
|
private clearDescendantSelections(
|
||||||
|
states: Map<number, ToggleableItemState>,
|
||||||
|
id: number
|
||||||
|
) {
|
||||||
for (const descendantID of this.getDescendantIDs(id)) {
|
for (const descendantID of this.getDescendantIDs(id)) {
|
||||||
this.temporarySelectionStates.delete(descendantID)
|
states.delete(descendantID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,7 +333,7 @@ export class FilterableDropdownSelectionModel {
|
|||||||
|
|
||||||
while (queue.length) {
|
while (queue.length) {
|
||||||
const parentID = queue.shift()
|
const parentID = queue.shift()
|
||||||
for (const item of this._items) {
|
for (const item of this.items) {
|
||||||
if (
|
if (
|
||||||
typeof item?.id === 'number' &&
|
typeof item?.id === 'number' &&
|
||||||
typeof (item as any)['parent'] === 'number' &&
|
typeof (item as any)['parent'] === 'number' &&
|
||||||
@@ -336,12 +349,12 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get logicalOperator(): LogicalOperator {
|
get logicalOperator(): LogicalOperator {
|
||||||
return this.temporaryLogicalOperator
|
return this.temporaryLogicalOperator()
|
||||||
}
|
}
|
||||||
|
|
||||||
set logicalOperator(operator: LogicalOperator) {
|
set logicalOperator(operator: LogicalOperator) {
|
||||||
this.temporaryLogicalOperator = operator
|
this.temporaryLogicalOperator.set(operator)
|
||||||
this.setNullItem()
|
this._items.set(this.withNullItem(this.items))
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleOperator() {
|
toggleOperator() {
|
||||||
@@ -349,12 +362,12 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get intersection(): Intersection {
|
get intersection(): Intersection {
|
||||||
return this.temporaryIntersection
|
return this.temporaryIntersection()
|
||||||
}
|
}
|
||||||
|
|
||||||
set intersection(intersection: Intersection) {
|
set intersection(intersection: Intersection) {
|
||||||
this.temporaryIntersection = intersection
|
this.temporaryIntersection.set(intersection)
|
||||||
this.setNullItem()
|
this._items.set(this.withNullItem(this.items))
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleIntersection() {
|
toggleIntersection() {
|
||||||
@@ -364,18 +377,20 @@ export class FilterableDropdownSelectionModel {
|
|||||||
? ToggleableItemState.Selected
|
? ToggleableItemState.Selected
|
||||||
: ToggleableItemState.Excluded
|
: ToggleableItemState.Excluded
|
||||||
|
|
||||||
this.temporarySelectionStates.forEach((state, key) => {
|
const states = new Map(this.temporarySelectionStates)
|
||||||
|
states.forEach((state, key) => {
|
||||||
if (key === null && this.intersection === Intersection.Exclude) {
|
if (key === null && this.intersection === Intersection.Exclude) {
|
||||||
this.temporarySelectionStates.set(NEGATIVE_NULL_FILTER_VALUE, newState)
|
states.set(NEGATIVE_NULL_FILTER_VALUE, newState)
|
||||||
} else if (
|
} else if (
|
||||||
key === NEGATIVE_NULL_FILTER_VALUE &&
|
key === NEGATIVE_NULL_FILTER_VALUE &&
|
||||||
this.intersection === Intersection.Include
|
this.intersection === Intersection.Include
|
||||||
) {
|
) {
|
||||||
this.temporarySelectionStates.set(null, newState)
|
states.set(null, newState)
|
||||||
} else {
|
} else {
|
||||||
this.temporarySelectionStates.set(key, newState)
|
states.set(key, newState)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
this._temporarySelectionStates.set(states)
|
||||||
|
|
||||||
this.changed.next(this)
|
this.changed.next(this)
|
||||||
}
|
}
|
||||||
@@ -395,10 +410,12 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clear(fireEvent = true) {
|
clear(fireEvent = true) {
|
||||||
this.temporarySelectionStates.clear()
|
this._temporarySelectionStates.set(new Map())
|
||||||
this.temporaryLogicalOperator = this._logicalOperator = LogicalOperator.And
|
this.temporaryLogicalOperator.set(LogicalOperator.And)
|
||||||
this.temporaryIntersection = this._intersection = Intersection.Include
|
this._logicalOperator.set(LogicalOperator.And)
|
||||||
this.setNullItem()
|
this.temporaryIntersection.set(Intersection.Include)
|
||||||
|
this._intersection.set(Intersection.Include)
|
||||||
|
this._items.set(this.withNullItem(this.items))
|
||||||
if (fireEvent) {
|
if (fireEvent) {
|
||||||
this.changed.next(this)
|
this.changed.next(this)
|
||||||
}
|
}
|
||||||
@@ -419,9 +436,9 @@ export class FilterableDropdownSelectionModel {
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return true
|
return true
|
||||||
} else if (this.temporaryLogicalOperator !== this._logicalOperator) {
|
} else if (this.temporaryLogicalOperator() !== this._logicalOperator()) {
|
||||||
return true
|
return true
|
||||||
} else if (this.temporaryIntersection !== this._intersection) {
|
} else if (this.temporaryIntersection() !== this._intersection()) {
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
return false
|
return false
|
||||||
@@ -438,23 +455,29 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getDocumentCount(id: number) {
|
getDocumentCount(id: number) {
|
||||||
return this._documentCounts.find((c) => c.id === id)?.document_count
|
return this._documentCounts().find((c) => c.id === id)?.document_count
|
||||||
}
|
}
|
||||||
|
|
||||||
private promoteBranchesWithDocumentCounts() {
|
private promoteBranchesWithDocumentCounts(
|
||||||
const parentById = this.buildParentById()
|
items: MatchingModel[]
|
||||||
|
): MatchingModel[] {
|
||||||
|
const parentById = this.buildParentById(items)
|
||||||
const findRootId = this.createRootFinder(parentById)
|
const findRootId = this.createRootFinder(parentById)
|
||||||
const getRootDocCount = this.createRootDocCounter()
|
const getRootDocCount = this.createRootDocCounter(items)
|
||||||
const summaries = this.buildBranchSummaries(findRootId, getRootDocCount)
|
const summaries = this.buildBranchSummaries(
|
||||||
|
items,
|
||||||
|
findRootId,
|
||||||
|
getRootDocCount
|
||||||
|
)
|
||||||
const orderedBranches = this.orderBranchesByPriority(summaries)
|
const orderedBranches = this.orderBranchesByPriority(summaries)
|
||||||
|
|
||||||
this._items = orderedBranches.flatMap((summary) => summary.items)
|
return orderedBranches.flatMap((summary) => summary.items)
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildParentById(): Map<number, number | null> {
|
private buildParentById(items: MatchingModel[]): Map<number, number | null> {
|
||||||
const parentById = new Map<number, number | null>()
|
const parentById = new Map<number, number | null>()
|
||||||
|
|
||||||
for (const item of this._items) {
|
for (const item of items) {
|
||||||
if (typeof item?.id === 'number') {
|
if (typeof item?.id === 'number') {
|
||||||
const parentValue = (item as any)['parent']
|
const parentValue = (item as any)['parent']
|
||||||
parentById.set(
|
parentById.set(
|
||||||
@@ -492,7 +515,9 @@ export class FilterableDropdownSelectionModel {
|
|||||||
return findRootId
|
return findRootId
|
||||||
}
|
}
|
||||||
|
|
||||||
private createRootDocCounter(): (rootId: number) => number {
|
private createRootDocCounter(
|
||||||
|
items: MatchingModel[]
|
||||||
|
): (rootId: number) => number {
|
||||||
const docCountMemo = new Map<number, number>()
|
const docCountMemo = new Map<number, number>()
|
||||||
|
|
||||||
return (rootId: number): number => {
|
return (rootId: number): number => {
|
||||||
@@ -507,7 +532,7 @@ export class FilterableDropdownSelectionModel {
|
|||||||
return explicit
|
return explicit
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootItem = this._items.find((i) => i.id === rootId)
|
const rootItem = items.find((i) => i.id === rootId)
|
||||||
const fallback =
|
const fallback =
|
||||||
typeof (rootItem as any)?.['document_count'] === 'number'
|
typeof (rootItem as any)?.['document_count'] === 'number'
|
||||||
? (rootItem as any)['document_count']
|
? (rootItem as any)['document_count']
|
||||||
@@ -519,12 +544,13 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildBranchSummaries(
|
private buildBranchSummaries(
|
||||||
|
items: MatchingModel[],
|
||||||
findRootId: (id: number) => number,
|
findRootId: (id: number) => number,
|
||||||
getRootDocCount: (rootId: number) => number
|
getRootDocCount: (rootId: number) => number
|
||||||
): Map<string, BranchSummary> {
|
): Map<string, BranchSummary> {
|
||||||
const summaries = new Map<string, BranchSummary>()
|
const summaries = new Map<string, BranchSummary>()
|
||||||
|
|
||||||
for (const [index, item] of this._items.entries()) {
|
for (const [index, item] of items.entries()) {
|
||||||
const { key, special, rootId } = this.describeBranchItem(
|
const { key, special, rootId } = this.describeBranchItem(
|
||||||
item,
|
item,
|
||||||
index,
|
index,
|
||||||
@@ -616,28 +642,23 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init(map: Map<number, ToggleableItemState>) {
|
init(map: Map<number, ToggleableItemState>) {
|
||||||
this.temporarySelectionStates = map
|
this._temporarySelectionStates.set(new Map(map))
|
||||||
this.apply()
|
this.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
apply() {
|
apply() {
|
||||||
this.selectionStates.clear()
|
this._selectionStates.set(new Map(this.temporarySelectionStates))
|
||||||
this.temporarySelectionStates.forEach((value, key) => {
|
this._logicalOperator.set(this.temporaryLogicalOperator())
|
||||||
this.selectionStates.set(key, value)
|
this._intersection.set(this.temporaryIntersection())
|
||||||
})
|
this._items.set(this.sortItems(this.items))
|
||||||
this._logicalOperator = this.temporaryLogicalOperator
|
|
||||||
this._intersection = this.temporaryIntersection
|
|
||||||
this.sortItems()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
reset(complete: boolean = false) {
|
reset(complete: boolean = false) {
|
||||||
this.temporarySelectionStates.clear()
|
|
||||||
if (complete) {
|
if (complete) {
|
||||||
this.selectionStates.clear()
|
this._selectionStates.set(new Map())
|
||||||
|
this._temporarySelectionStates.set(new Map())
|
||||||
} else {
|
} else {
|
||||||
this.selectionStates.forEach((value, key) => {
|
this._temporarySelectionStates.set(new Map(this.selectionStates))
|
||||||
this.temporarySelectionStates.set(key, value)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
@@ -7,6 +7,8 @@
|
|||||||
padding-left: calc(calc(var(--depth) - 2) * 1rem);
|
padding-left: calc(calc(var(--depth) - 2) * 1rem);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
|
||||||
.indicator {
|
.indicator {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -18,3 +20,7 @@
|
|||||||
margin-left: .5rem;
|
margin-left: .5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|||||||
+10
-9
@@ -7,7 +7,7 @@
|
|||||||
<div class="list-group list-group-flush">
|
<div class="list-group list-group-flush">
|
||||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NONE)" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NONE)" [disabled]="disabled">
|
||||||
<div class="selected-icon me-1">
|
<div class="selected-icon me-1">
|
||||||
@if (selectionModel.ownerFilter === OwnerFilterType.NONE) {
|
@if (selectionModel.ownerFilter() === OwnerFilterType.NONE) {
|
||||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SELF)" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SELF)" [disabled]="disabled">
|
||||||
<div class="selected-icon me-1">
|
<div class="selected-icon me-1">
|
||||||
@if (selectionModel.ownerFilter === OwnerFilterType.SELF) {
|
@if (selectionModel.ownerFilter() === OwnerFilterType.SELF) {
|
||||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NOT_SELF)" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NOT_SELF)" [disabled]="disabled">
|
||||||
<div class="selected-icon me-1">
|
<div class="selected-icon me-1">
|
||||||
@if (selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) {
|
@if (selectionModel.ownerFilter() === OwnerFilterType.NOT_SELF) {
|
||||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SHARED_BY_ME)" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SHARED_BY_ME)" [disabled]="disabled">
|
||||||
<div class="selected-icon me-1">
|
<div class="selected-icon me-1">
|
||||||
@if (selectionModel.ownerFilter === OwnerFilterType.SHARED_BY_ME) {
|
@if (selectionModel.ownerFilter() === OwnerFilterType.SHARED_BY_ME) {
|
||||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.UNOWNED)" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.UNOWNED)" [disabled]="disabled">
|
||||||
<div class="selected-icon me-1">
|
<div class="selected-icon me-1">
|
||||||
@if (selectionModel.ownerFilter === OwnerFilterType.UNOWNED) {
|
@if (selectionModel.ownerFilter() === OwnerFilterType.UNOWNED) {
|
||||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }" class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" [disabled]="disabled">
|
<button *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }" class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" [disabled]="disabled">
|
||||||
<div class="selected-icon me-1">
|
<div class="selected-icon me-1">
|
||||||
@if (selectionModel.ownerFilter === OwnerFilterType.OTHERS) {
|
@if (selectionModel.ownerFilter() === OwnerFilterType.OTHERS) {
|
||||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -65,7 +65,8 @@
|
|||||||
<ng-select
|
<ng-select
|
||||||
name="user"
|
name="user"
|
||||||
class="user-select small"
|
class="user-select small"
|
||||||
[(ngModel)]="selectionModel.includeUsers"
|
[ngModel]="selectionModel.includeUsers()"
|
||||||
|
(ngModelChange)="selectionModel.includeUsers.set($event)"
|
||||||
[disabled]="disabled"
|
[disabled]="disabled"
|
||||||
[clearable]="false"
|
[clearable]="false"
|
||||||
[items]="users()"
|
[items]="users()"
|
||||||
@@ -78,10 +79,10 @@
|
|||||||
</ng-select>
|
</ng-select>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@if (selectionModel.ownerFilter === OwnerFilterType.NONE || selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) {
|
@if (selectionModel.ownerFilter() === OwnerFilterType.NONE || selectionModel.ownerFilter() === OwnerFilterType.NOT_SELF) {
|
||||||
<div class="list-group-item list-group-item-action d-flex align-items-center p-2 ps-3 border-bottom-0 border-start-0 border-end-0">
|
<div class="list-group-item list-group-item-action d-flex align-items-center p-2 ps-3 border-bottom-0 border-start-0 border-end-0">
|
||||||
<div class="form-check form-switch w-100">
|
<div class="form-check form-switch w-100">
|
||||||
<input type="checkbox" class="form-check-input" id="hideUnowned" [(ngModel)]="this.selectionModel.hideUnowned" (change)="onChange()" [disabled]="disabled">
|
<input type="checkbox" class="form-check-input" id="hideUnowned" [ngModel]="selectionModel.hideUnowned()" (ngModelChange)="selectionModel.hideUnowned.set($event)" (change)="onChange()" [disabled]="disabled">
|
||||||
<label class="form-check-label w-100" for="hideUnowned"><small i18n>Hide unowned</small></label>
|
<label class="form-check-label w-100" for="hideUnowned"><small i18n>Hide unowned</small></label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+39
-30
@@ -90,56 +90,56 @@ describe('PermissionsFilterDropdownComponent', () => {
|
|||||||
component.setFilter(OwnerFilterType.OTHERS)
|
component.setFilter(OwnerFilterType.OTHERS)
|
||||||
expect(component.isActive).toBeTruthy()
|
expect(component.isActive).toBeTruthy()
|
||||||
component.setFilter(OwnerFilterType.NONE)
|
component.setFilter(OwnerFilterType.NONE)
|
||||||
component.selectionModel.hideUnowned = true
|
component.selectionModel.hideUnowned.set(true)
|
||||||
expect(component.isActive).toBeTruthy()
|
expect(component.isActive).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should describe concrete user filters honestly', () => {
|
it('should describe concrete user filters honestly', () => {
|
||||||
component.selectionModel.ownerFilter = OwnerFilterType.SELF
|
component.selectionModel.ownerFilter.set(OwnerFilterType.SELF)
|
||||||
component.selectionModel.userID = 1
|
component.selectionModel.userID.set(1)
|
||||||
expect(component.ownerFilterLabel).toEqual('Owned by user1')
|
expect(component.ownerFilterLabel).toEqual('Owned by user1')
|
||||||
|
|
||||||
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
|
component.selectionModel.ownerFilter.set(OwnerFilterType.NOT_SELF)
|
||||||
component.selectionModel.excludeUsers = [1]
|
component.selectionModel.excludeUsers.set([1])
|
||||||
expect(component.ownerExclusionFilterLabel).toEqual('Not owned by user1')
|
expect(component.ownerExclusionFilterLabel).toEqual('Not owned by user1')
|
||||||
|
|
||||||
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME
|
component.selectionModel.ownerFilter.set(OwnerFilterType.SHARED_BY_ME)
|
||||||
component.selectionModel.userID = 1
|
component.selectionModel.userID.set(1)
|
||||||
expect(component.sharedByFilterLabel).toEqual('Shared by user1')
|
expect(component.sharedByFilterLabel).toEqual('Shared by user1')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should describe concrete filters when usernames are unavailable', () => {
|
it('should describe concrete filters when usernames are unavailable', () => {
|
||||||
component.selectionModel.ownerFilter = OwnerFilterType.SELF
|
component.selectionModel.ownerFilter.set(OwnerFilterType.SELF)
|
||||||
component.selectionModel.userID = 99
|
component.selectionModel.userID.set(99)
|
||||||
expect(component.ownerFilterLabel).toEqual('Owned by another user')
|
expect(component.ownerFilterLabel).toEqual('Owned by another user')
|
||||||
|
|
||||||
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
|
component.selectionModel.ownerFilter.set(OwnerFilterType.NOT_SELF)
|
||||||
component.selectionModel.excludeUsers = [99]
|
component.selectionModel.excludeUsers.set([99])
|
||||||
expect(component.ownerExclusionFilterLabel).toEqual(
|
expect(component.ownerExclusionFilterLabel).toEqual(
|
||||||
'Not owned by another user'
|
'Not owned by another user'
|
||||||
)
|
)
|
||||||
|
|
||||||
component.selectionModel.excludeUsers = [98, 99]
|
component.selectionModel.excludeUsers.set([98, 99])
|
||||||
expect(component.ownerExclusionFilterLabel).toEqual(
|
expect(component.ownerExclusionFilterLabel).toEqual(
|
||||||
'Not owned by selected users'
|
'Not owned by selected users'
|
||||||
)
|
)
|
||||||
|
|
||||||
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME
|
component.selectionModel.ownerFilter.set(OwnerFilterType.SHARED_BY_ME)
|
||||||
component.selectionModel.userID = 99
|
component.selectionModel.userID.set(99)
|
||||||
expect(component.sharedByFilterLabel).toEqual('Shared by another user')
|
expect(component.sharedByFilterLabel).toEqual('Shared by another user')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should retain relative labels for filters bound to the current user', () => {
|
it('should retain relative labels for filters bound to the current user', () => {
|
||||||
component.selectionModel.userID = currentUserID
|
component.selectionModel.userID.set(currentUserID)
|
||||||
expect(component.ownerFilterLabel).toEqual('My documents')
|
expect(component.ownerFilterLabel).toEqual('My documents')
|
||||||
expect(component.sharedByFilterLabel).toEqual('Shared by me')
|
expect(component.sharedByFilterLabel).toEqual('Shared by me')
|
||||||
|
|
||||||
component.selectionModel.excludeUsers = [currentUserID]
|
component.selectionModel.excludeUsers.set([currentUserID])
|
||||||
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
|
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should retain relative labels for inactive filter choices', () => {
|
it('should retain relative labels for inactive filter choices', () => {
|
||||||
component.selectionModel.ownerFilter = OwnerFilterType.NONE
|
component.selectionModel.ownerFilter.set(OwnerFilterType.NONE)
|
||||||
|
|
||||||
expect(component.ownerFilterLabel).toEqual('My documents')
|
expect(component.ownerFilterLabel).toEqual('My documents')
|
||||||
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
|
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
|
||||||
@@ -148,32 +148,41 @@ describe('PermissionsFilterDropdownComponent', () => {
|
|||||||
|
|
||||||
it('should support reset', () => {
|
it('should support reset', () => {
|
||||||
component.setFilter(OwnerFilterType.OTHERS)
|
component.setFilter(OwnerFilterType.OTHERS)
|
||||||
expect(component.selectionModel.ownerFilter).not.toEqual(
|
expect(component.selectionModel.ownerFilter()).not.toEqual(
|
||||||
OwnerFilterType.NONE
|
OwnerFilterType.NONE
|
||||||
)
|
)
|
||||||
component.reset()
|
component.reset()
|
||||||
expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.NONE)
|
expect(component.selectionModel.ownerFilter()).toEqual(OwnerFilterType.NONE)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should toggle owner filter type when users selected', () => {
|
it('should toggle owner filter type when users selected', () => {
|
||||||
component.selectionModel.ownerFilter = OwnerFilterType.NONE
|
component.selectionModel.ownerFilter.set(OwnerFilterType.NONE)
|
||||||
|
|
||||||
// this would normally be done by select component
|
// this would normally be done by select component
|
||||||
component.selectionModel.includeUsers = [12]
|
component.selectionModel.includeUsers.set([12])
|
||||||
component.onUserSelect()
|
component.onUserSelect()
|
||||||
expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.OTHERS)
|
expect(component.selectionModel.ownerFilter()).toEqual(
|
||||||
|
OwnerFilterType.OTHERS
|
||||||
|
)
|
||||||
|
|
||||||
// this would normally be done by select component
|
// this would normally be done by select component
|
||||||
component.selectionModel.includeUsers = null
|
component.selectionModel.includeUsers.set(null)
|
||||||
component.onUserSelect()
|
component.onUserSelect()
|
||||||
|
|
||||||
expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.NONE)
|
expect(component.selectionModel.ownerFilter()).toEqual(OwnerFilterType.NONE)
|
||||||
})
|
})
|
||||||
it('should emit a selection model depending on the type of owner filter set', () => {
|
it('should emit a selection model depending on the type of owner filter set', () => {
|
||||||
component.selectionModel.ownerFilter = OwnerFilterType.NONE
|
const emitted = () => ({
|
||||||
|
excludeUsers: ownerFilterSetResult.excludeUsers(),
|
||||||
|
hideUnowned: ownerFilterSetResult.hideUnowned(),
|
||||||
|
includeUsers: ownerFilterSetResult.includeUsers(),
|
||||||
|
ownerFilter: ownerFilterSetResult.ownerFilter(),
|
||||||
|
userID: ownerFilterSetResult.userID(),
|
||||||
|
})
|
||||||
|
component.selectionModel.ownerFilter.set(OwnerFilterType.NONE)
|
||||||
|
|
||||||
component.setFilter(OwnerFilterType.SELF)
|
component.setFilter(OwnerFilterType.SELF)
|
||||||
expect(ownerFilterSetResult).toEqual({
|
expect(emitted()).toEqual({
|
||||||
excludeUsers: [],
|
excludeUsers: [],
|
||||||
hideUnowned: false,
|
hideUnowned: false,
|
||||||
includeUsers: [],
|
includeUsers: [],
|
||||||
@@ -182,7 +191,7 @@ describe('PermissionsFilterDropdownComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
component.setFilter(OwnerFilterType.NOT_SELF)
|
component.setFilter(OwnerFilterType.NOT_SELF)
|
||||||
expect(ownerFilterSetResult).toEqual({
|
expect(emitted()).toEqual({
|
||||||
excludeUsers: [currentUserID],
|
excludeUsers: [currentUserID],
|
||||||
hideUnowned: false,
|
hideUnowned: false,
|
||||||
includeUsers: [],
|
includeUsers: [],
|
||||||
@@ -191,7 +200,7 @@ describe('PermissionsFilterDropdownComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
component.setFilter(OwnerFilterType.NONE)
|
component.setFilter(OwnerFilterType.NONE)
|
||||||
expect(ownerFilterSetResult).toEqual({
|
expect(emitted()).toEqual({
|
||||||
excludeUsers: [],
|
excludeUsers: [],
|
||||||
hideUnowned: false,
|
hideUnowned: false,
|
||||||
includeUsers: [],
|
includeUsers: [],
|
||||||
@@ -200,7 +209,7 @@ describe('PermissionsFilterDropdownComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
component.setFilter(OwnerFilterType.SHARED_BY_ME)
|
component.setFilter(OwnerFilterType.SHARED_BY_ME)
|
||||||
expect(ownerFilterSetResult).toEqual({
|
expect(emitted()).toEqual({
|
||||||
excludeUsers: [],
|
excludeUsers: [],
|
||||||
hideUnowned: false,
|
hideUnowned: false,
|
||||||
includeUsers: [],
|
includeUsers: [],
|
||||||
@@ -209,7 +218,7 @@ describe('PermissionsFilterDropdownComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
component.setFilter(OwnerFilterType.UNOWNED)
|
component.setFilter(OwnerFilterType.UNOWNED)
|
||||||
expect(ownerFilterSetResult).toEqual({
|
expect(emitted()).toEqual({
|
||||||
excludeUsers: [],
|
excludeUsers: [],
|
||||||
hideUnowned: false,
|
hideUnowned: false,
|
||||||
includeUsers: [],
|
includeUsers: [],
|
||||||
|
|||||||
+53
-53
@@ -25,18 +25,18 @@ import { ComponentWithPermissions } from '../../with-permissions/with-permission
|
|||||||
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
|
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
|
||||||
|
|
||||||
export class PermissionsSelectionModel {
|
export class PermissionsSelectionModel {
|
||||||
ownerFilter: OwnerFilterType
|
readonly ownerFilter = signal(OwnerFilterType.NONE)
|
||||||
hideUnowned: boolean
|
readonly hideUnowned = signal(false)
|
||||||
userID: number
|
readonly userID = signal<number>(null)
|
||||||
includeUsers: number[]
|
readonly includeUsers = signal<number[]>([])
|
||||||
excludeUsers: number[]
|
readonly excludeUsers = signal<number[]>([])
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
this.ownerFilter = OwnerFilterType.NONE
|
this.ownerFilter.set(OwnerFilterType.NONE)
|
||||||
this.userID = null
|
this.userID.set(null)
|
||||||
this.hideUnowned = false
|
this.hideUnowned.set(false)
|
||||||
this.includeUsers = []
|
this.includeUsers.set([])
|
||||||
this.excludeUsers = []
|
this.excludeUsers.set([])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,33 +84,31 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
|||||||
|
|
||||||
readonly users = signal<User[]>([])
|
readonly users = signal<User[]>([])
|
||||||
|
|
||||||
hideUnowned: boolean
|
|
||||||
|
|
||||||
get isActive(): boolean {
|
get isActive(): boolean {
|
||||||
return (
|
return (
|
||||||
this.selectionModel.ownerFilter !== OwnerFilterType.NONE ||
|
this.selectionModel.ownerFilter() !== OwnerFilterType.NONE ||
|
||||||
this.selectionModel.hideUnowned
|
this.selectionModel.hideUnowned()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
get ownerFilterLabel(): string {
|
get ownerFilterLabel(): string {
|
||||||
if (
|
if (
|
||||||
this.selectionModel?.ownerFilter !== OwnerFilterType.SELF ||
|
this.selectionModel?.ownerFilter() !== OwnerFilterType.SELF ||
|
||||||
this.selectionModel?.userID === this.settingsService.currentUser()?.id
|
this.selectionModel?.userID() === this.settingsService.currentUser()?.id
|
||||||
) {
|
) {
|
||||||
return $localize`My documents`
|
return $localize`My documents`
|
||||||
}
|
}
|
||||||
|
|
||||||
const username = this.getUsername(this.selectionModel?.userID)
|
const username = this.getUsername(this.selectionModel?.userID())
|
||||||
return username
|
return username
|
||||||
? $localize`Owned by ${username}`
|
? $localize`Owned by ${username}`
|
||||||
: $localize`Owned by another user`
|
: $localize`Owned by another user`
|
||||||
}
|
}
|
||||||
|
|
||||||
get ownerExclusionFilterLabel(): string {
|
get ownerExclusionFilterLabel(): string {
|
||||||
const excludedUsers = this.selectionModel?.excludeUsers ?? []
|
const excludedUsers = this.selectionModel?.excludeUsers() ?? []
|
||||||
if (
|
if (
|
||||||
this.selectionModel?.ownerFilter !== OwnerFilterType.NOT_SELF ||
|
this.selectionModel?.ownerFilter() !== OwnerFilterType.NOT_SELF ||
|
||||||
(excludedUsers.length === 1 &&
|
(excludedUsers.length === 1 &&
|
||||||
excludedUsers[0] === this.settingsService.currentUser()?.id)
|
excludedUsers[0] === this.settingsService.currentUser()?.id)
|
||||||
) {
|
) {
|
||||||
@@ -130,13 +128,13 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
|||||||
|
|
||||||
get sharedByFilterLabel(): string {
|
get sharedByFilterLabel(): string {
|
||||||
if (
|
if (
|
||||||
this.selectionModel?.ownerFilter !== OwnerFilterType.SHARED_BY_ME ||
|
this.selectionModel?.ownerFilter() !== OwnerFilterType.SHARED_BY_ME ||
|
||||||
this.selectionModel?.userID === this.settingsService.currentUser()?.id
|
this.selectionModel?.userID() === this.settingsService.currentUser()?.id
|
||||||
) {
|
) {
|
||||||
return $localize`Shared by me`
|
return $localize`Shared by me`
|
||||||
}
|
}
|
||||||
|
|
||||||
const username = this.getUsername(this.selectionModel?.userID)
|
const username = this.getUsername(this.selectionModel?.userID())
|
||||||
return username
|
return username
|
||||||
? $localize`Shared by ${username}`
|
? $localize`Shared by ${username}`
|
||||||
: $localize`Shared by another user`
|
: $localize`Shared by another user`
|
||||||
@@ -169,34 +167,36 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
|||||||
}
|
}
|
||||||
|
|
||||||
setFilter(type: OwnerFilterType) {
|
setFilter(type: OwnerFilterType) {
|
||||||
this.selectionModel.ownerFilter = type
|
this.selectionModel.ownerFilter.set(type)
|
||||||
if (this.selectionModel.ownerFilter === OwnerFilterType.SELF) {
|
if (this.selectionModel.ownerFilter() === OwnerFilterType.SELF) {
|
||||||
this.selectionModel.includeUsers = []
|
this.selectionModel.includeUsers.set([])
|
||||||
this.selectionModel.excludeUsers = []
|
this.selectionModel.excludeUsers.set([])
|
||||||
this.selectionModel.userID = this.settingsService.currentUser().id
|
this.selectionModel.userID.set(this.settingsService.currentUser().id)
|
||||||
this.selectionModel.hideUnowned = false
|
this.selectionModel.hideUnowned.set(false)
|
||||||
} else if (this.selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) {
|
} else if (this.selectionModel.ownerFilter() === OwnerFilterType.NOT_SELF) {
|
||||||
this.selectionModel.userID = null
|
this.selectionModel.userID.set(null)
|
||||||
this.selectionModel.includeUsers = []
|
this.selectionModel.includeUsers.set([])
|
||||||
this.selectionModel.excludeUsers = [this.settingsService.currentUser().id]
|
this.selectionModel.excludeUsers.set([
|
||||||
this.selectionModel.hideUnowned = false
|
this.settingsService.currentUser().id,
|
||||||
} else if (this.selectionModel.ownerFilter === OwnerFilterType.NONE) {
|
])
|
||||||
this.selectionModel.userID = null
|
this.selectionModel.hideUnowned.set(false)
|
||||||
this.selectionModel.includeUsers = []
|
} else if (this.selectionModel.ownerFilter() === OwnerFilterType.NONE) {
|
||||||
this.selectionModel.excludeUsers = []
|
this.selectionModel.userID.set(null)
|
||||||
this.selectionModel.hideUnowned = false
|
this.selectionModel.includeUsers.set([])
|
||||||
|
this.selectionModel.excludeUsers.set([])
|
||||||
|
this.selectionModel.hideUnowned.set(false)
|
||||||
} else if (
|
} else if (
|
||||||
this.selectionModel.ownerFilter === OwnerFilterType.SHARED_BY_ME
|
this.selectionModel.ownerFilter() === OwnerFilterType.SHARED_BY_ME
|
||||||
) {
|
) {
|
||||||
this.selectionModel.userID = this.settingsService.currentUser()?.id
|
this.selectionModel.userID.set(this.settingsService.currentUser()?.id)
|
||||||
this.selectionModel.includeUsers = []
|
this.selectionModel.includeUsers.set([])
|
||||||
this.selectionModel.excludeUsers = []
|
this.selectionModel.excludeUsers.set([])
|
||||||
this.selectionModel.hideUnowned = false
|
this.selectionModel.hideUnowned.set(false)
|
||||||
} else if (this.selectionModel.ownerFilter === OwnerFilterType.UNOWNED) {
|
} else if (this.selectionModel.ownerFilter() === OwnerFilterType.UNOWNED) {
|
||||||
this.selectionModel.userID = null
|
this.selectionModel.userID.set(null)
|
||||||
this.selectionModel.includeUsers = []
|
this.selectionModel.includeUsers.set([])
|
||||||
this.selectionModel.excludeUsers = []
|
this.selectionModel.excludeUsers.set([])
|
||||||
this.selectionModel.hideUnowned = false
|
this.selectionModel.hideUnowned.set(false)
|
||||||
}
|
}
|
||||||
this.onChange()
|
this.onChange()
|
||||||
}
|
}
|
||||||
@@ -206,11 +206,11 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
|||||||
}
|
}
|
||||||
|
|
||||||
onUserSelect() {
|
onUserSelect() {
|
||||||
if (this.selectionModel.includeUsers?.length) {
|
this.selectionModel.ownerFilter.set(
|
||||||
this.selectionModel.ownerFilter = OwnerFilterType.OTHERS
|
this.selectionModel.includeUsers()?.length
|
||||||
} else {
|
? OwnerFilterType.OTHERS
|
||||||
this.selectionModel.ownerFilter = OwnerFilterType.NONE
|
: OwnerFilterType.NONE
|
||||||
}
|
)
|
||||||
this.onChange()
|
this.onChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1209,24 +1209,53 @@ describe('DocumentDetailComponent', () => {
|
|||||||
expect(fixture.debugElement.queryAll(By.css('textarea.rtl'))).not.toBeNull()
|
expect(fixture.debugElement.queryAll(By.css('textarea.rtl'))).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should display built-in pdf viewer if not disabled', () => {
|
it('should display built-in pdf viewer if not disabled', async () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
component.document().archived_file_name = 'file.pdf'
|
component.document.update((document) => ({
|
||||||
|
...document,
|
||||||
|
archived_file_name: 'file.pdf',
|
||||||
|
}))
|
||||||
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
|
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
|
||||||
expect(component.useNativePdfViewer).toBeFalsy()
|
expect(component.useNativePdfViewer).toBeFalsy()
|
||||||
fixture.detectChanges()
|
await fixture.whenStable()
|
||||||
expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull()
|
expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should display native pdf viewer if enabled', () => {
|
it('should display native pdf viewer if enabled', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
component.document().archived_file_name = 'file.pdf'
|
component.document.update((document) => ({
|
||||||
|
...document,
|
||||||
|
archived_file_name: 'file.pdf',
|
||||||
|
}))
|
||||||
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, true)
|
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, true)
|
||||||
expect(component.useNativePdfViewer).toBeTruthy()
|
expect(component.useNativePdfViewer).toBeTruthy()
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(fixture.debugElement.query(By.css('object'))).not.toBeNull()
|
expect(fixture.debugElement.query(By.css('object'))).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should reflect signal-backed document detail display settings', () => {
|
||||||
|
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL, false)
|
||||||
|
settingsService.set(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS, [
|
||||||
|
component.DocumentDetailFieldID.Correspondent,
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(component.showThumbnailOverlay).toBeFalsy()
|
||||||
|
expect(
|
||||||
|
component.isFieldHidden(component.DocumentDetailFieldID.Correspondent)
|
||||||
|
).toBeTruthy()
|
||||||
|
expect(
|
||||||
|
component.isFieldHidden(component.DocumentDetailFieldID.DocumentType)
|
||||||
|
).toBeFalsy()
|
||||||
|
|
||||||
|
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL, true)
|
||||||
|
settingsService.set(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS, [])
|
||||||
|
|
||||||
|
expect(component.showThumbnailOverlay).toBeTruthy()
|
||||||
|
expect(
|
||||||
|
component.isFieldHidden(component.DocumentDetailFieldID.Correspondent)
|
||||||
|
).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
it('should attempt to retrieve metadata', () => {
|
it('should attempt to retrieve metadata', () => {
|
||||||
const metadataSpy = jest.spyOn(documentService, 'getMetadata')
|
const metadataSpy = jest.spyOn(documentService, 'getMetadata')
|
||||||
metadataSpy.mockReturnValue(of({ has_archive_version: true }))
|
metadataSpy.mockReturnValue(of({ has_archive_version: true }))
|
||||||
@@ -1685,7 +1714,10 @@ describe('DocumentDetailComponent', () => {
|
|||||||
|
|
||||||
it('should change preview element by render type', () => {
|
it('should change preview element by render type', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
component.document().archived_file_name = 'file.pdf'
|
component.document.update((document) => ({
|
||||||
|
...document,
|
||||||
|
archived_file_name: 'file.pdf',
|
||||||
|
}))
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(component.archiveContentRenderType).toEqual(
|
expect(component.archiveContentRenderType).toEqual(
|
||||||
component.ContentRenderType.PDF
|
component.ContentRenderType.PDF
|
||||||
@@ -1694,8 +1726,11 @@ describe('DocumentDetailComponent', () => {
|
|||||||
fixture.debugElement.query(By.css('pdf-viewer-container'))
|
fixture.debugElement.query(By.css('pdf-viewer-container'))
|
||||||
).not.toBeUndefined()
|
).not.toBeUndefined()
|
||||||
|
|
||||||
component.document().archived_file_name = undefined
|
component.document.update((document) => ({
|
||||||
component.document().mime_type = 'text/plain'
|
...document,
|
||||||
|
archived_file_name: undefined,
|
||||||
|
mime_type: 'text/plain',
|
||||||
|
}))
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(component.archiveContentRenderType).toEqual(
|
expect(component.archiveContentRenderType).toEqual(
|
||||||
component.ContentRenderType.Text
|
component.ContentRenderType.Text
|
||||||
@@ -1704,7 +1739,10 @@ describe('DocumentDetailComponent', () => {
|
|||||||
fixture.debugElement.query(By.css('div.preview-sticky'))
|
fixture.debugElement.query(By.css('div.preview-sticky'))
|
||||||
).not.toBeUndefined()
|
).not.toBeUndefined()
|
||||||
|
|
||||||
component.document().mime_type = 'image/jpeg'
|
component.document.update((document) => ({
|
||||||
|
...document,
|
||||||
|
mime_type: 'image/jpeg',
|
||||||
|
}))
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(component.archiveContentRenderType).toEqual(
|
expect(component.archiveContentRenderType).toEqual(
|
||||||
component.ContentRenderType.Image
|
component.ContentRenderType.Image
|
||||||
@@ -1712,9 +1750,12 @@ describe('DocumentDetailComponent', () => {
|
|||||||
expect(
|
expect(
|
||||||
fixture.debugElement.query(By.css('.preview-sticky img'))
|
fixture.debugElement.query(By.css('.preview-sticky img'))
|
||||||
).not.toBeUndefined()
|
).not.toBeUndefined()
|
||||||
;((component.document().mime_type =
|
component.document.update((document) => ({
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'),
|
...document,
|
||||||
fixture.detectChanges())
|
mime_type:
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
}))
|
||||||
|
fixture.detectChanges()
|
||||||
expect(component.archiveContentRenderType).toEqual(
|
expect(component.archiveContentRenderType).toEqual(
|
||||||
component.ContentRenderType.Other
|
component.ContentRenderType.Other
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -227,6 +227,19 @@ export class DocumentDetailComponent
|
|||||||
private deviceDetectorService = inject(DeviceDetectorService)
|
private deviceDetectorService = inject(DeviceDetectorService)
|
||||||
private savedViewService = inject(SavedViewService)
|
private savedViewService = inject(SavedViewService)
|
||||||
private readonly websocketStatusService = inject(WebsocketStatusService)
|
private readonly websocketStatusService = inject(WebsocketStatusService)
|
||||||
|
private readonly useNativePdfViewerSetting = this.settings.getSignal<boolean>(
|
||||||
|
SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER
|
||||||
|
)
|
||||||
|
private readonly aiEnabledSetting = this.settings.getSignal<boolean>(
|
||||||
|
SETTINGS_KEYS.AI_ENABLED
|
||||||
|
)
|
||||||
|
private readonly showThumbnailOverlaySetting =
|
||||||
|
this.settings.getSignal<boolean>(
|
||||||
|
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL
|
||||||
|
)
|
||||||
|
private readonly hiddenFieldsSetting = this.settings.getSignal<
|
||||||
|
DocumentDetailFieldID[]
|
||||||
|
>(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
|
||||||
|
|
||||||
@ViewChild('inputTitle')
|
@ViewChild('inputTitle')
|
||||||
titleInput: TextComponent
|
titleInput: TextComponent
|
||||||
@@ -333,8 +346,7 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get useNativePdfViewer(): boolean {
|
get useNativePdfViewer(): boolean {
|
||||||
this.settings.trackChanges()
|
return this.useNativePdfViewerSetting()
|
||||||
return this.settings.get(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get isMobile(): boolean {
|
get isMobile(): boolean {
|
||||||
@@ -342,12 +354,10 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get aiEnabled(): boolean {
|
get aiEnabled(): boolean {
|
||||||
this.settings.trackChanges()
|
return this.aiEnabledSetting()
|
||||||
return this.settings.get(SETTINGS_KEYS.AI_ENABLED)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get archiveContentRenderType(): ContentRenderType {
|
get archiveContentRenderType(): ContentRenderType {
|
||||||
this.settings.trackChanges()
|
|
||||||
const hasArchiveVersion =
|
const hasArchiveVersion =
|
||||||
this.metadata()?.has_archive_version ??
|
this.metadata()?.has_archive_version ??
|
||||||
!!this.document()?.archived_file_name
|
!!this.document()?.archived_file_name
|
||||||
@@ -359,22 +369,17 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get originalContentRenderType(): ContentRenderType {
|
get originalContentRenderType(): ContentRenderType {
|
||||||
this.settings.trackChanges()
|
|
||||||
return this.getRenderType(
|
return this.getRenderType(
|
||||||
this.metadata()?.original_mime_type || this.document()?.mime_type
|
this.metadata()?.original_mime_type || this.document()?.mime_type
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
get showThumbnailOverlay(): boolean {
|
get showThumbnailOverlay(): boolean {
|
||||||
this.settings.trackChanges()
|
return this.showThumbnailOverlaySetting()
|
||||||
return this.settings.get(SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isFieldHidden(fieldId: DocumentDetailFieldID): boolean {
|
isFieldHidden(fieldId: DocumentDetailFieldID): boolean {
|
||||||
this.settings.trackChanges()
|
return this.hiddenFieldsSetting().includes(fieldId)
|
||||||
return this.settings
|
|
||||||
.get(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
|
|
||||||
.includes(fieldId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getRenderType(mimeType: string): ContentRenderType {
|
private getRenderType(mimeType: string): ContentRenderType {
|
||||||
|
|||||||
@@ -116,7 +116,7 @@
|
|||||||
|
|
||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
|
|
||||||
<div class="row sticky-top py-3 mt-n2 mt-md-n3 bg-body">
|
<div class="row sticky-top py-3 mt-n2 mt-md-n3 bg-body rounded shadow-sm">
|
||||||
<pngx-filter-editor [hidden]="isBulkEditing" [disabled]="isBulkEditing" [filterRules]="list.filterRules" (filterRulesChange)="onFilterRulesChange($event)" (resetFilterRules)="onFilterRulesReset($event)" [unmodifiedFilterRules]="unmodifiedFilterRules()" [selectionData]="list.selectionData" #filterEditor></pngx-filter-editor>
|
<pngx-filter-editor [hidden]="isBulkEditing" [disabled]="isBulkEditing" [filterRules]="list.filterRules" (filterRulesChange)="onFilterRulesChange($event)" (resetFilterRules)="onFilterRulesReset($event)" [unmodifiedFilterRules]="unmodifiedFilterRules()" [selectionData]="list.selectionData" #filterEditor></pngx-filter-editor>
|
||||||
<pngx-bulk-editor [hidden]="!isBulkEditing" [disabled]="!isBulkEditing"></pngx-bulk-editor>
|
<pngx-bulk-editor [hidden]="!isBulkEditing" [disabled]="!isBulkEditing"></pngx-bulk-editor>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ export class DocumentListComponent
|
|||||||
settingsService = inject(SettingsService)
|
settingsService = inject(SettingsService)
|
||||||
private hotKeyService = inject(HotKeyService)
|
private hotKeyService = inject(HotKeyService)
|
||||||
permissionService = inject(PermissionsService)
|
permissionService = inject(PermissionsService)
|
||||||
|
private readonly notesEnabledSetting =
|
||||||
|
this.settingsService.getSignal<boolean>(SETTINGS_KEYS.NOTES_ENABLED)
|
||||||
|
|
||||||
DisplayField = DisplayField
|
DisplayField = DisplayField
|
||||||
DisplayMode = DisplayMode
|
DisplayMode = DisplayMode
|
||||||
@@ -574,8 +576,7 @@ export class DocumentListComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get notesEnabled(): boolean {
|
get notesEnabled(): boolean {
|
||||||
this.settingsService.trackChanges()
|
return this.notesEnabledSetting()
|
||||||
return this.settingsService.get(SETTINGS_KEYS.NOTES_ENABLED)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resetFilters() {
|
resetFilters() {
|
||||||
|
|||||||
+129
-20
@@ -621,6 +621,43 @@ describe('FilterEditorComponent', () => {
|
|||||||
component.toggleTag(2) // coverage
|
component.toggleTag(2) // coverage
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should reflect ingested tag filter rules in the dropdown toggle', () => {
|
||||||
|
const dropdown = fixture.debugElement.query(
|
||||||
|
By.css('pngx-filterable-dropdown')
|
||||||
|
)
|
||||||
|
const toggle = dropdown.nativeElement.querySelector('#dropdown_tags')
|
||||||
|
expect(toggle.classList.contains('btn-primary')).toBeFalsy()
|
||||||
|
expect(
|
||||||
|
dropdown.nativeElement.querySelector('pngx-clearable-badge')
|
||||||
|
).toBeNull()
|
||||||
|
|
||||||
|
// switching to a view with a tag filter
|
||||||
|
component.filterRules = [
|
||||||
|
{
|
||||||
|
rule_type: FILTER_HAS_TAGS_ALL,
|
||||||
|
value: '2',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(toggle.classList.contains('btn-primary')).toBeTruthy()
|
||||||
|
expect(
|
||||||
|
dropdown.nativeElement.querySelector('pngx-clearable-badge')
|
||||||
|
).not.toBeNull()
|
||||||
|
|
||||||
|
// and back to a view without one
|
||||||
|
component.filterRules = [
|
||||||
|
{
|
||||||
|
rule_type: FILTER_HAS_CORRESPONDENT_ANY,
|
||||||
|
value: '12',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(toggle.classList.contains('btn-primary')).toBeFalsy()
|
||||||
|
expect(
|
||||||
|
dropdown.nativeElement.querySelector('pngx-clearable-badge')
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it('should ingest filter rules for has any tags', () => {
|
it('should ingest filter rules for has any tags', () => {
|
||||||
expect(component.tagSelectionModel.getSelectedItems()).toHaveLength(0)
|
expect(component.tagSelectionModel.getSelectedItems()).toHaveLength(0)
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
@@ -1034,8 +1071,51 @@ describe('FilterEditorComponent', () => {
|
|||||||
).toEqual([42, CustomFieldQueryOperator.Exists, 'true'])
|
).toEqual([42, CustomFieldQueryOperator.Exists, 'true'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should reflect ingested custom field query rules in the dropdown toggle', () => {
|
||||||
|
const dropdown = fixture.debugElement.query(
|
||||||
|
By.css('pngx-custom-fields-query-dropdown')
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
dropdown.nativeElement.querySelector('pngx-clearable-badge')
|
||||||
|
).toBeNull()
|
||||||
|
|
||||||
|
// switching to a view with a custom field query
|
||||||
|
component.filterRules = [
|
||||||
|
{
|
||||||
|
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
|
||||||
|
value: '["OR",[[42,"exists","true"]]]',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(
|
||||||
|
dropdown.nativeElement.querySelector('pngx-clearable-badge')
|
||||||
|
).not.toBeNull()
|
||||||
|
expect(
|
||||||
|
dropdown.nativeElement
|
||||||
|
.querySelector('#dropdown_toggle')
|
||||||
|
.classList.contains('btn-primary')
|
||||||
|
).toBeTruthy()
|
||||||
|
|
||||||
|
// and back to a view without one
|
||||||
|
component.filterRules = [
|
||||||
|
{
|
||||||
|
rule_type: FILTER_HAS_TAGS_ALL,
|
||||||
|
value: '19',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(
|
||||||
|
dropdown.nativeElement.querySelector('pngx-clearable-badge')
|
||||||
|
).toBeNull()
|
||||||
|
expect(
|
||||||
|
dropdown.nativeElement
|
||||||
|
.querySelector('#dropdown_toggle')
|
||||||
|
.classList.contains('btn-primary')
|
||||||
|
).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
it('should ingest filter rules for owner', () => {
|
it('should ingest filter rules for owner', () => {
|
||||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||||
OwnerFilterType.NONE
|
OwnerFilterType.NONE
|
||||||
)
|
)
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
@@ -1044,15 +1124,38 @@ describe('FilterEditorComponent', () => {
|
|||||||
value: '100',
|
value: '100',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||||
OwnerFilterType.SELF
|
OwnerFilterType.SELF
|
||||||
)
|
)
|
||||||
expect(component.permissionsSelectionModel.hideUnowned).toBeFalsy()
|
expect(component.permissionsSelectionModel.hideUnowned()).toBeFalsy()
|
||||||
expect(component.permissionsSelectionModel.userID).toEqual(100)
|
expect(component.permissionsSelectionModel.userID()).toEqual(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reflect ingested owner filter rules in the dropdown toggle', () => {
|
||||||
|
const dropdown = fixture.debugElement.query(
|
||||||
|
By.css('pngx-permissions-filter-dropdown')
|
||||||
|
)
|
||||||
|
const toggle = dropdown.nativeElement.querySelector('button')
|
||||||
|
expect(toggle.classList.contains('btn-primary')).toBeFalsy()
|
||||||
|
|
||||||
|
// switching to a view with an owner filter
|
||||||
|
component.filterRules = [
|
||||||
|
{
|
||||||
|
rule_type: FILTER_OWNER,
|
||||||
|
value: '100',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(toggle.classList.contains('btn-primary')).toBeTruthy()
|
||||||
|
|
||||||
|
// and back to a view without one
|
||||||
|
component.filterRules = []
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(toggle.classList.contains('btn-primary')).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should ingest filter rules for owner is others', () => {
|
it('should ingest filter rules for owner is others', () => {
|
||||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||||
OwnerFilterType.NONE
|
OwnerFilterType.NONE
|
||||||
)
|
)
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
@@ -1061,14 +1164,14 @@ describe('FilterEditorComponent', () => {
|
|||||||
value: '50',
|
value: '50',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||||
OwnerFilterType.OTHERS
|
OwnerFilterType.OTHERS
|
||||||
)
|
)
|
||||||
expect(component.permissionsSelectionModel.includeUsers).toContain(50)
|
expect(component.permissionsSelectionModel.includeUsers()).toContain(50)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should ingest filter rules for owner does not include others', () => {
|
it('should ingest filter rules for owner does not include others', () => {
|
||||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||||
OwnerFilterType.NONE
|
OwnerFilterType.NONE
|
||||||
)
|
)
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
@@ -1077,14 +1180,14 @@ describe('FilterEditorComponent', () => {
|
|||||||
value: '50',
|
value: '50',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||||
OwnerFilterType.NOT_SELF
|
OwnerFilterType.NOT_SELF
|
||||||
)
|
)
|
||||||
expect(component.permissionsSelectionModel.excludeUsers).toContain(50)
|
expect(component.permissionsSelectionModel.excludeUsers()).toContain(50)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should ingest filter rules for owner is null', () => {
|
it('should ingest filter rules for owner is null', () => {
|
||||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||||
OwnerFilterType.NONE
|
OwnerFilterType.NONE
|
||||||
)
|
)
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
@@ -1093,10 +1196,10 @@ describe('FilterEditorComponent', () => {
|
|||||||
value: 'true',
|
value: 'true',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||||
OwnerFilterType.UNOWNED
|
OwnerFilterType.UNOWNED
|
||||||
)
|
)
|
||||||
expect(component.permissionsSelectionModel.hideUnowned).toBeFalsy()
|
expect(component.permissionsSelectionModel.hideUnowned()).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should ingest filter rules for owner is not null', () => {
|
it('should ingest filter rules for owner is not null', () => {
|
||||||
@@ -1106,14 +1209,14 @@ describe('FilterEditorComponent', () => {
|
|||||||
value: 'false',
|
value: 'false',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
expect(component.permissionsSelectionModel.hideUnowned).toBeTruthy()
|
expect(component.permissionsSelectionModel.hideUnowned()).toBeTruthy()
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
{
|
{
|
||||||
rule_type: FILTER_OWNER_ISNULL,
|
rule_type: FILTER_OWNER_ISNULL,
|
||||||
value: '0',
|
value: '0',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
expect(component.permissionsSelectionModel.hideUnowned).toBeTruthy()
|
expect(component.permissionsSelectionModel.hideUnowned()).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should ingest filter rules for shared by me', () => {
|
it('should ingest filter rules for shared by me', () => {
|
||||||
@@ -1123,7 +1226,7 @@ describe('FilterEditorComponent', () => {
|
|||||||
value: '2',
|
value: '2',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
expect(component.permissionsSelectionModel.userID).toEqual(2)
|
expect(component.permissionsSelectionModel.userID()).toEqual(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
// GET filterRules
|
// GET filterRules
|
||||||
@@ -1889,7 +1992,10 @@ describe('FilterEditorComponent', () => {
|
|||||||
value: '1',
|
value: '1',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
component.permissionsSelectionModel.excludeUsers.push(2)
|
component.permissionsSelectionModel.excludeUsers.update((users) => [
|
||||||
|
...users,
|
||||||
|
2,
|
||||||
|
])
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(component.filterRules).toEqual([
|
expect(component.filterRules).toEqual([
|
||||||
{
|
{
|
||||||
@@ -1939,8 +2045,11 @@ describe('FilterEditorComponent', () => {
|
|||||||
// TODO: mock input in code
|
// TODO: mock input in code
|
||||||
// userSelect.query(By.css('input')).nativeElement.value = '3'
|
// userSelect.query(By.css('input')).nativeElement.value = '3'
|
||||||
// userSelect.triggerEventHandler('change')
|
// userSelect.triggerEventHandler('change')
|
||||||
component.permissionsSelectionModel.ownerFilter = OwnerFilterType.OTHERS
|
component.permissionsSelectionModel.ownerFilter.set(OwnerFilterType.OTHERS)
|
||||||
component.permissionsSelectionModel.includeUsers.push(3)
|
component.permissionsSelectionModel.includeUsers.update((users) => [
|
||||||
|
...users,
|
||||||
|
3,
|
||||||
|
])
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(component.filterRules).toEqual([
|
expect(component.filterRules).toEqual([
|
||||||
{
|
{
|
||||||
@@ -1960,7 +2069,7 @@ describe('FilterEditorComponent', () => {
|
|||||||
ownerToggle.nativeElement.checked = true
|
ownerToggle.nativeElement.checked = true
|
||||||
// ownerToggle.triggerEventHandler('change')
|
// ownerToggle.triggerEventHandler('change')
|
||||||
// TODO: ngModel isn't doing this here
|
// TODO: ngModel isn't doing this here
|
||||||
component.permissionsSelectionModel.hideUnowned = true
|
component.permissionsSelectionModel.hideUnowned.set(true)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(component.filterRules).toEqual([
|
expect(component.filterRules).toEqual([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -735,38 +735,50 @@ export class FilterEditorComponent
|
|||||||
this._textFilter = rule.value
|
this._textFilter = rule.value
|
||||||
break
|
break
|
||||||
case FILTER_OWNER:
|
case FILTER_OWNER:
|
||||||
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.SELF
|
this.permissionsSelectionModel.ownerFilter.set(OwnerFilterType.SELF)
|
||||||
this.permissionsSelectionModel.hideUnowned = false
|
this.permissionsSelectionModel.hideUnowned.set(false)
|
||||||
if (rule.value)
|
if (rule.value)
|
||||||
this.permissionsSelectionModel.userID = parseInt(rule.value, 10)
|
this.permissionsSelectionModel.userID.set(
|
||||||
|
Number.parseInt(rule.value, 10)
|
||||||
|
)
|
||||||
break
|
break
|
||||||
case FILTER_OWNER_ANY:
|
case FILTER_OWNER_ANY:
|
||||||
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.OTHERS
|
this.permissionsSelectionModel.ownerFilter.set(OwnerFilterType.OTHERS)
|
||||||
if (rule.value)
|
if (rule.value)
|
||||||
this.permissionsSelectionModel.includeUsers.push(
|
this.permissionsSelectionModel.includeUsers.update((users) => [
|
||||||
parseInt(rule.value, 10)
|
...users,
|
||||||
)
|
Number.parseInt(rule.value, 10),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
case FILTER_OWNER_DOES_NOT_INCLUDE:
|
case FILTER_OWNER_DOES_NOT_INCLUDE:
|
||||||
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.NOT_SELF
|
this.permissionsSelectionModel.ownerFilter.set(
|
||||||
|
OwnerFilterType.NOT_SELF
|
||||||
|
)
|
||||||
if (rule.value)
|
if (rule.value)
|
||||||
this.permissionsSelectionModel.excludeUsers.push(
|
this.permissionsSelectionModel.excludeUsers.update((users) => [
|
||||||
parseInt(rule.value, 10)
|
...users,
|
||||||
)
|
Number.parseInt(rule.value, 10),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
case FILTER_SHARED_BY_USER:
|
case FILTER_SHARED_BY_USER:
|
||||||
this.permissionsSelectionModel.ownerFilter =
|
this.permissionsSelectionModel.ownerFilter.set(
|
||||||
OwnerFilterType.SHARED_BY_ME
|
OwnerFilterType.SHARED_BY_ME
|
||||||
|
)
|
||||||
if (rule.value)
|
if (rule.value)
|
||||||
this.permissionsSelectionModel.userID = parseInt(rule.value, 10)
|
this.permissionsSelectionModel.userID.set(
|
||||||
|
Number.parseInt(rule.value, 10)
|
||||||
|
)
|
||||||
break
|
break
|
||||||
case FILTER_OWNER_ISNULL:
|
case FILTER_OWNER_ISNULL:
|
||||||
if (rule.value === 'true' || rule.value === '1') {
|
if (rule.value === 'true' || rule.value === '1') {
|
||||||
this.permissionsSelectionModel.hideUnowned = false
|
this.permissionsSelectionModel.hideUnowned.set(false)
|
||||||
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.UNOWNED
|
this.permissionsSelectionModel.ownerFilter.set(
|
||||||
|
OwnerFilterType.UNOWNED
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
this.permissionsSelectionModel.hideUnowned =
|
this.permissionsSelectionModel.hideUnowned.set(
|
||||||
rule.value === 'false' || rule.value === '0'
|
rule.value === 'false' || rule.value === '0'
|
||||||
|
)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1074,34 +1086,35 @@ export class FilterEditorComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this.permissionsSelectionModel.ownerFilter == OwnerFilterType.SELF) {
|
if (this.permissionsSelectionModel.ownerFilter() == OwnerFilterType.SELF) {
|
||||||
filterRules.push({
|
filterRules.push({
|
||||||
rule_type: FILTER_OWNER,
|
rule_type: FILTER_OWNER,
|
||||||
value: this.permissionsSelectionModel.userID.toString(),
|
value: this.permissionsSelectionModel.userID().toString(),
|
||||||
})
|
})
|
||||||
} else if (
|
} else if (
|
||||||
this.permissionsSelectionModel.ownerFilter == OwnerFilterType.NOT_SELF
|
this.permissionsSelectionModel.ownerFilter() == OwnerFilterType.NOT_SELF
|
||||||
) {
|
) {
|
||||||
filterRules.push({
|
filterRules.push({
|
||||||
rule_type: FILTER_OWNER_DOES_NOT_INCLUDE,
|
rule_type: FILTER_OWNER_DOES_NOT_INCLUDE,
|
||||||
value: this.permissionsSelectionModel.excludeUsers?.join(','),
|
value: this.permissionsSelectionModel.excludeUsers()?.join(','),
|
||||||
})
|
})
|
||||||
} else if (
|
} else if (
|
||||||
this.permissionsSelectionModel.ownerFilter == OwnerFilterType.OTHERS
|
this.permissionsSelectionModel.ownerFilter() == OwnerFilterType.OTHERS
|
||||||
) {
|
) {
|
||||||
filterRules.push({
|
filterRules.push({
|
||||||
rule_type: FILTER_OWNER_ANY,
|
rule_type: FILTER_OWNER_ANY,
|
||||||
value: this.permissionsSelectionModel.includeUsers?.join(','),
|
value: this.permissionsSelectionModel.includeUsers()?.join(','),
|
||||||
})
|
})
|
||||||
} else if (
|
} else if (
|
||||||
this.permissionsSelectionModel.ownerFilter == OwnerFilterType.SHARED_BY_ME
|
this.permissionsSelectionModel.ownerFilter() ==
|
||||||
|
OwnerFilterType.SHARED_BY_ME
|
||||||
) {
|
) {
|
||||||
filterRules.push({
|
filterRules.push({
|
||||||
rule_type: FILTER_SHARED_BY_USER,
|
rule_type: FILTER_SHARED_BY_USER,
|
||||||
value: this.permissionsSelectionModel.userID.toString(),
|
value: this.permissionsSelectionModel.userID().toString(),
|
||||||
})
|
})
|
||||||
} else if (
|
} else if (
|
||||||
this.permissionsSelectionModel.ownerFilter == OwnerFilterType.UNOWNED
|
this.permissionsSelectionModel.ownerFilter() == OwnerFilterType.UNOWNED
|
||||||
) {
|
) {
|
||||||
filterRules.push({
|
filterRules.push({
|
||||||
rule_type: FILTER_OWNER_ISNULL,
|
rule_type: FILTER_OWNER_ISNULL,
|
||||||
@@ -1109,7 +1122,7 @@ export class FilterEditorComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.permissionsSelectionModel.hideUnowned) {
|
if (this.permissionsSelectionModel.hideUnowned()) {
|
||||||
filterRules.push({
|
filterRules.push({
|
||||||
rule_type: FILTER_OWNER_ISNULL,
|
rule_type: FILTER_OWNER_ISNULL,
|
||||||
value: 'false',
|
value: 'false',
|
||||||
|
|||||||
@@ -210,6 +210,48 @@ describe('SettingsService', () => {
|
|||||||
expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('#000000')
|
expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('#000000')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('provides stable signals that update when settings change', () => {
|
||||||
|
const req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}ui_settings/`
|
||||||
|
)
|
||||||
|
req.flush(ui_settings)
|
||||||
|
|
||||||
|
const notesEnabled = settingsService.getSignal<boolean>(
|
||||||
|
SETTINGS_KEYS.NOTES_ENABLED
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(notesEnabled()).toBeTruthy()
|
||||||
|
expect(
|
||||||
|
settingsService.getSignal<boolean>(SETTINGS_KEYS.NOTES_ENABLED)
|
||||||
|
).toBe(notesEnabled)
|
||||||
|
|
||||||
|
settingsService.set(SETTINGS_KEYS.NOTES_ENABLED, false)
|
||||||
|
|
||||||
|
expect(notesEnabled()).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates setting signals when settings are reinitialized', () => {
|
||||||
|
let req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}ui_settings/`
|
||||||
|
)
|
||||||
|
req.flush(ui_settings)
|
||||||
|
const appTitle = settingsService.getSignal<string>(SETTINGS_KEYS.APP_TITLE)
|
||||||
|
|
||||||
|
settingsService.initializeSettings().subscribe()
|
||||||
|
req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}ui_settings/`
|
||||||
|
)
|
||||||
|
req.flush({
|
||||||
|
...ui_settings,
|
||||||
|
settings: {
|
||||||
|
...ui_settings.settings,
|
||||||
|
app_title: 'Updated title',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(appTitle()).toBe('Updated title')
|
||||||
|
})
|
||||||
|
|
||||||
it('sets django cookie for languages', () => {
|
it('sets django cookie for languages', () => {
|
||||||
httpTestingController
|
httpTestingController
|
||||||
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
|
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { HttpClient } from '@angular/common/http'
|
|||||||
import {
|
import {
|
||||||
DOCUMENT,
|
DOCUMENT,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
|
Signal,
|
||||||
|
computed,
|
||||||
inject,
|
inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
LOCALE_ID,
|
LOCALE_ID,
|
||||||
@@ -297,6 +299,7 @@ export class SettingsService {
|
|||||||
|
|
||||||
private settings: Record<string, any> = {}
|
private settings: Record<string, any> = {}
|
||||||
private readonly settingsVersion = signal(0)
|
private readonly settingsVersion = signal(0)
|
||||||
|
private readonly settingSignals = new Map<string, Signal<unknown>>()
|
||||||
readonly currentUser = signal<User>(undefined)
|
readonly currentUser = signal<User>(undefined)
|
||||||
|
|
||||||
public settingsSaved: EventEmitter<any> = new EventEmitter()
|
public settingsSaved: EventEmitter<any> = new EventEmitter()
|
||||||
@@ -326,10 +329,6 @@ export class SettingsService {
|
|||||||
return !UNSAFE_OBJECT_KEYS.has(key)
|
return !UNSAFE_OBJECT_KEYS.has(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
public trackChanges(): void {
|
|
||||||
this.settingsVersion()
|
|
||||||
}
|
|
||||||
|
|
||||||
private assignSafeSettings(source: Record<string, any>) {
|
private assignSafeSettings(source: Record<string, any>) {
|
||||||
if (!source || typeof source !== 'object' || Array.isArray(source)) {
|
if (!source || typeof source !== 'object' || Array.isArray(source)) {
|
||||||
return
|
return
|
||||||
@@ -339,6 +338,7 @@ export class SettingsService {
|
|||||||
if (!this.isSafeObjectKey(key)) continue
|
if (!this.isSafeObjectKey(key)) continue
|
||||||
this.settings[key] = source[key]
|
this.settings[key] = source[key]
|
||||||
}
|
}
|
||||||
|
this.settingsVersion.update((version) => version + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// this is called by the app initializer in app.module
|
// this is called by the app initializer in app.module
|
||||||
@@ -594,6 +594,18 @@ export class SettingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSignal<T = any>(key: string): Signal<T> {
|
||||||
|
let settingSignal = this.settingSignals.get(key)
|
||||||
|
if (!settingSignal) {
|
||||||
|
settingSignal = computed(() => {
|
||||||
|
this.settingsVersion()
|
||||||
|
return this.get(key)
|
||||||
|
})
|
||||||
|
this.settingSignals.set(key, settingSignal)
|
||||||
|
}
|
||||||
|
return settingSignal as Signal<T>
|
||||||
|
}
|
||||||
|
|
||||||
set(key: string, value: any) {
|
set(key: string, value: any) {
|
||||||
// parse key:key:key into nested object
|
// parse key:key:key into nested object
|
||||||
let settingObj = this.settings
|
let settingObj = this.settings
|
||||||
|
|||||||
+5
-16
@@ -47,6 +47,8 @@ $grid-breakpoints: (
|
|||||||
);
|
);
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
--bs-border-radius: #{$border-radius};
|
||||||
|
|
||||||
@each $name, $value in $grid-breakpoints {
|
@each $name, $value in $grid-breakpoints {
|
||||||
--bs-breakpoint-#{$name}: #{$value};
|
--bs-breakpoint-#{$name}: #{$value};
|
||||||
}
|
}
|
||||||
@@ -78,19 +80,12 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
--bs-btn-border-radius: .425rem;
|
--bs-border-radius-sm: #{$border-radius};
|
||||||
--bs-border-radius-sm: .425rem;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-control,
|
|
||||||
.form-select,
|
|
||||||
.input-group-text {
|
|
||||||
border-radius: .425rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination, .input-group {
|
.pagination, .input-group {
|
||||||
--bs-border-radius-sm: .425rem;
|
--bs-border-radius-sm: #{$border-radius};
|
||||||
}
|
}
|
||||||
|
|
||||||
@media(min-width: 768px) {
|
@media(min-width: 768px) {
|
||||||
@@ -689,10 +684,6 @@ table.table {
|
|||||||
--bs-toast-max-width: var(--pngx-toast-max-width);
|
--bs-toast-max-width: var(--pngx-toast-max-width);
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert {
|
|
||||||
--bs-border-radius: .425rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-primary {
|
.alert-primary {
|
||||||
--bs-alert-color: var(--bs-primary);
|
--bs-alert-color: var(--bs-primary);
|
||||||
--bs-alert-bg: var(--pngx-primary-faded);
|
--bs-alert-bg: var(--pngx-primary-faded);
|
||||||
@@ -824,8 +815,6 @@ code {
|
|||||||
--bs-accordion-bg: var(--bs-light);
|
--bs-accordion-bg: var(--bs-light);
|
||||||
--bs-accordion-active-color: var(--bs-primary);
|
--bs-accordion-active-color: var(--bs-primary);
|
||||||
--bs-accordion-active-bg: var(--pngx-bg-alt);
|
--bs-accordion-active-bg: var(--pngx-bg-alt);
|
||||||
--bs-border-radius: .425rem;
|
|
||||||
--bs-accordion-inner-border-radius: calc(.425rem - 1px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.accordion-button::after {
|
.accordion-button::after {
|
||||||
@@ -849,7 +838,7 @@ code {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Animate items as they're being sorted. */
|
/* Animate items as they're being sorted. */
|
||||||
.cdk-drop-list-dragging .cdk-drag {
|
.cdk-drop-list-dragging .cdk-drag:not(.cdk-drag-preview) {
|
||||||
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,%3csvg xmlns='h
|
|||||||
--bs-tertiary-bg: var(--pngx-bg-darker);
|
--bs-tertiary-bg: var(--pngx-bg-darker);
|
||||||
--bs-dark-border-subtle: var(--pngx-bg-darker);
|
--bs-dark-border-subtle: var(--pngx-bg-darker);
|
||||||
--bs-border-color-translucent: rgba(0, 0, 0, .175); // override bs
|
--bs-border-color-translucent: rgba(0, 0, 0, .175); // override bs
|
||||||
|
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.15); // slightly darker than bs default
|
||||||
|
|
||||||
.text-dark, .text-light {
|
.text-dark, .text-light {
|
||||||
color: var(--bs-body-color) !important;
|
color: var(--bs-body-color) !important;
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ from django.core.cache import cache
|
|||||||
from django.core.cache import caches
|
from django.core.cache import caches
|
||||||
|
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
|
from paperless.signed_pickle import SignedPickleError
|
||||||
|
from paperless.signed_pickle import signed_pickle_dumps
|
||||||
|
from paperless.signed_pickle import signed_pickle_loads
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django.core.cache.backends.base import BaseCache
|
from django.core.cache.backends.base import BaseCache
|
||||||
@@ -118,9 +121,11 @@ class StoredLRUCache(LRUCache):
|
|||||||
serialized_data = self._backend.get(self._backend_key)
|
serialized_data = self._backend.get(self._backend_key)
|
||||||
try:
|
try:
|
||||||
self._data = (
|
self._data = (
|
||||||
pickle.loads(serialized_data) if serialized_data else OrderedDict()
|
signed_pickle_loads(serialized_data)
|
||||||
|
if serialized_data
|
||||||
|
else OrderedDict()
|
||||||
)
|
)
|
||||||
except pickle.PickleError:
|
except (SignedPickleError, pickle.PickleError):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Cache exists in backend but could not be read (possibly invalid format)",
|
"Cache exists in backend but could not be read (possibly invalid format)",
|
||||||
)
|
)
|
||||||
@@ -132,7 +137,7 @@ class StoredLRUCache(LRUCache):
|
|||||||
"""
|
"""
|
||||||
self._backend.set(
|
self._backend.set(
|
||||||
self._backend_key,
|
self._backend_key,
|
||||||
pickle.dumps(self._data),
|
signed_pickle_dumps(self._data),
|
||||||
self.backend_ttl,
|
self.backend_ttl,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ from documents.caching import CLASSIFIER_VERSION_KEY
|
|||||||
from documents.caching import StoredLRUCache
|
from documents.caching import StoredLRUCache
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.models import MatchingModel
|
from documents.models import MatchingModel
|
||||||
|
from paperless.signed_pickle import SignedPickleError
|
||||||
|
from paperless.signed_pickle import signed_pickle_dumps
|
||||||
|
from paperless.signed_pickle import signed_pickle_loads
|
||||||
|
|
||||||
logger = logging.getLogger("paperless.classifier")
|
logger = logging.getLogger("paperless.classifier")
|
||||||
|
|
||||||
@@ -527,10 +530,17 @@ class DocumentClassifier:
|
|||||||
serialized_result = read_cache.get(key)
|
serialized_result = read_cache.get(key)
|
||||||
if serialized_result is None:
|
if serialized_result is None:
|
||||||
result = self.data_vectorizer.transform([self.preprocess_content(content)])
|
result = self.data_vectorizer.transform([self.preprocess_content(content)])
|
||||||
read_cache.set(key, pickle.dumps(result), CACHE_5_MINUTES)
|
read_cache.set(key, signed_pickle_dumps(result), CACHE_5_MINUTES)
|
||||||
else:
|
else:
|
||||||
read_cache.touch(key, CACHE_5_MINUTES)
|
try:
|
||||||
result = pickle.loads(serialized_result)
|
result = signed_pickle_loads(serialized_result)
|
||||||
|
except SignedPickleError:
|
||||||
|
result = self.data_vectorizer.transform(
|
||||||
|
[self.preprocess_content(content)],
|
||||||
|
)
|
||||||
|
read_cache.set(key, signed_pickle_dumps(result), CACHE_5_MINUTES)
|
||||||
|
else:
|
||||||
|
read_cache.touch(key, CACHE_5_MINUTES)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def predict_correspondent(self, content: str) -> int | None:
|
def predict_correspondent(self, content: str) -> int | None:
|
||||||
|
|||||||
@@ -314,7 +314,7 @@ def _consume_file(
|
|||||||
consumption_dir: Path,
|
consumption_dir: Path,
|
||||||
*,
|
*,
|
||||||
subdirs_as_tags: bool,
|
subdirs_as_tags: bool,
|
||||||
) -> None:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Queue a file for consumption.
|
Queue a file for consumption.
|
||||||
|
|
||||||
@@ -322,15 +322,20 @@ def _consume_file(
|
|||||||
filepath: Path to the file to consume.
|
filepath: Path to the file to consume.
|
||||||
consumption_dir: Base consumption directory.
|
consumption_dir: Base consumption directory.
|
||||||
subdirs_as_tags: Whether to create tags from subdirectory names.
|
subdirs_as_tags: Whether to create tags from subdirectory names.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the file was successfully handed to Celery, False otherwise.
|
||||||
|
Callers must not record the file as queued on failure, or the rescan
|
||||||
|
will never retry it.
|
||||||
"""
|
"""
|
||||||
# Verify file still exists and is accessible
|
# Verify file still exists and is accessible
|
||||||
try:
|
try:
|
||||||
if not filepath.is_file():
|
if not filepath.is_file():
|
||||||
logger.debug(f"Not consuming {filepath}: not a file or doesn't exist")
|
logger.debug(f"Not consuming {filepath}: not a file or doesn't exist")
|
||||||
return
|
return False
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.warning(f"Not consuming {filepath}: {e}")
|
logger.warning(f"Not consuming {filepath}: {e}")
|
||||||
return
|
return False
|
||||||
|
|
||||||
# Get tags from path if configured
|
# Get tags from path if configured
|
||||||
tag_ids: list[int] | None = None
|
tag_ids: list[int] | None = None
|
||||||
@@ -355,6 +360,9 @@ def _consume_file(
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(f"Error while queuing document {filepath}")
|
logger.exception(f"Error while queuing document {filepath}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@@ -492,12 +500,12 @@ class Command(BaseCommand):
|
|||||||
if not consumer_filter(Change.added, str(filepath)):
|
if not consumer_filter(Change.added, str(filepath)):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
_consume_file(
|
if _consume_file(
|
||||||
filepath=filepath,
|
filepath=filepath,
|
||||||
consumption_dir=directory,
|
consumption_dir=directory,
|
||||||
subdirs_as_tags=subdirs_as_tags,
|
subdirs_as_tags=subdirs_as_tags,
|
||||||
)
|
):
|
||||||
queued.add(filepath.resolve())
|
queued.add(filepath.resolve())
|
||||||
|
|
||||||
return queued
|
return queued
|
||||||
|
|
||||||
@@ -651,14 +659,16 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
# Check for stable files
|
# Check for stable files
|
||||||
for stable_path in tracker.get_stable_files():
|
for stable_path in tracker.get_stable_files():
|
||||||
_consume_file(
|
# Only remember files that were actually queued, so the
|
||||||
|
# rescan does not re-queue them while the consume task
|
||||||
|
# has yet to remove them from disk, but does retry a
|
||||||
|
# failed publish instead of stranding it
|
||||||
|
if _consume_file(
|
||||||
filepath=stable_path,
|
filepath=stable_path,
|
||||||
consumption_dir=directory,
|
consumption_dir=directory,
|
||||||
subdirs_as_tags=subdirs_as_tags,
|
subdirs_as_tags=subdirs_as_tags,
|
||||||
)
|
):
|
||||||
# Remember it so the rescan does not re-queue it while
|
queued.add(stable_path)
|
||||||
# the consume task has yet to remove it from disk
|
|
||||||
queued.add(stable_path)
|
|
||||||
|
|
||||||
# Exit watch loop to reconfigure timeout
|
# Exit watch loop to reconfigure timeout
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -462,7 +462,11 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
|||||||
"""
|
"""
|
||||||
Returns a sanitized filename for the document, not including any paths.
|
Returns a sanitized filename for the document, not including any paths.
|
||||||
"""
|
"""
|
||||||
result = str(self)
|
# Root owns metadata for all versions
|
||||||
|
context_document = (
|
||||||
|
self.root_document if self.root_document_id is not None else self
|
||||||
|
)
|
||||||
|
result = str(context_document)
|
||||||
|
|
||||||
if counter:
|
if counter:
|
||||||
result += f"_{counter:02}"
|
result += f"_{counter:02}"
|
||||||
|
|||||||
@@ -1003,7 +1003,7 @@ def run_workflows(
|
|||||||
|
|
||||||
# kwargs so the PaperlessTask record can note the
|
# kwargs so the PaperlessTask record can note the
|
||||||
# document, see _extract_input_data
|
# document, see _extract_input_data
|
||||||
apply_ai_suggestions.delay(
|
apply_ai_suggestions.delay_on_commit(
|
||||||
action_id=action.pk,
|
action_id=action.pk,
|
||||||
document_id=document.pk,
|
document_id=document.pk,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1063,3 +1063,79 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn("non-public address", str(response.data).lower())
|
self.assertIn("non-public address", str(response.data).lower())
|
||||||
|
|
||||||
|
@override_settings(REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS=False)
|
||||||
|
def test_update_remote_ocr_endpoint_blocks_internal_endpoint_when_disallowed(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Internal remote OCR endpoints are disallowed
|
||||||
|
WHEN:
|
||||||
|
- The config is updated with a remote OCR endpoint resolving internally
|
||||||
|
THEN:
|
||||||
|
- The request is rejected
|
||||||
|
"""
|
||||||
|
response = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}1/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"remote_ocr_endpoint": "http://127.0.0.1:5000",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn("non-public address", str(response.data).lower())
|
||||||
|
|
||||||
|
@override_settings(REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS=True)
|
||||||
|
def test_update_remote_ocr_endpoint_allows_internal_endpoint_by_default(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Internal remote OCR endpoints are allowed (the default)
|
||||||
|
WHEN:
|
||||||
|
- The config is updated with a remote OCR endpoint resolving internally
|
||||||
|
THEN:
|
||||||
|
- The request is accepted, preserving existing self-hosted deployments
|
||||||
|
"""
|
||||||
|
response = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}1/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"remote_ocr_endpoint": "http://127.0.0.1:5000",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(
|
||||||
|
response.data["remote_ocr_endpoint"],
|
||||||
|
"http://127.0.0.1:5000",
|
||||||
|
)
|
||||||
|
|
||||||
|
@override_settings(REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS=False)
|
||||||
|
def test_update_remote_ocr_endpoint_empty_value_skips_validation(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Internal remote OCR endpoints are disallowed
|
||||||
|
WHEN:
|
||||||
|
- The config is updated with an empty remote OCR endpoint
|
||||||
|
THEN:
|
||||||
|
- The request is accepted; clearing the field never needs
|
||||||
|
outbound URL validation
|
||||||
|
"""
|
||||||
|
response = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}1/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"remote_ocr_endpoint": "",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(response.data["remote_ocr_endpoint"], "")
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APIClient
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
|
|
||||||
class TestChatStreamingViewInputValidation(APITestCase):
|
class TestChatStreamingViewInputValidation(APITestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
@@ -42,3 +49,73 @@ class TestChatStreamingViewInputValidation(APITestCase):
|
|||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestChatStreamingViewUnrestrictedFlag:
|
||||||
|
"""The document id filter may only be skipped (``unrestricted=True``) for
|
||||||
|
an active superuser, never for a regular user -- regardless of what
|
||||||
|
permissions that user holds.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mocked_stream_chat(self, mocker: MockerFixture) -> mock.MagicMock:
|
||||||
|
"""AI enabled, with stream_chat_with_documents patched so the view
|
||||||
|
never touches the real vector store; returns the patched callable so
|
||||||
|
tests can inspect how it was called.
|
||||||
|
"""
|
||||||
|
mocker.patch("documents.views.AIConfig").return_value.ai_enabled = True
|
||||||
|
return mocker.patch(
|
||||||
|
"documents.views.stream_chat_with_documents",
|
||||||
|
return_value=iter(()),
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def viewer_client(self, user_client: APIClient, regular_user: User) -> APIClient:
|
||||||
|
"""The conftest regular-user client, granted the global
|
||||||
|
view_document permission -- the minimum ViewDocumentsPermissions
|
||||||
|
needs to reach the view at all. Model-level only: says nothing
|
||||||
|
about which documents (if any) this user can actually see.
|
||||||
|
"""
|
||||||
|
regular_user.user_permissions.add(
|
||||||
|
*Permission.objects.filter(codename="view_document"),
|
||||||
|
)
|
||||||
|
return user_client
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("client_fixture", "expected_unrestricted"),
|
||||||
|
[
|
||||||
|
pytest.param("admin_client", True, id="superuser_is_unrestricted"),
|
||||||
|
pytest.param("viewer_client", False, id="regular_user_is_restricted"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_unrestricted_only_for_superuser(
|
||||||
|
self,
|
||||||
|
request: pytest.FixtureRequest,
|
||||||
|
mocked_stream_chat: mock.MagicMock,
|
||||||
|
client_fixture: str,
|
||||||
|
*,
|
||||||
|
expected_unrestricted: bool,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A superuser, or a regular user holding the global
|
||||||
|
view_document permission (but no object-level document access)
|
||||||
|
WHEN:
|
||||||
|
- They post a chat question with no document_id
|
||||||
|
THEN:
|
||||||
|
- stream_chat_with_documents is called with unrestricted=True
|
||||||
|
only for the superuser; the regular user is always
|
||||||
|
unrestricted=False, regardless of their permissions
|
||||||
|
"""
|
||||||
|
client: APIClient = request.getfixturevalue(client_fixture)
|
||||||
|
|
||||||
|
client.post(
|
||||||
|
"/api/documents/chat/",
|
||||||
|
data={"q": "What's in these documents?"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
mocked_stream_chat.call_args.kwargs["unrestricted"] is expected_unrestricted
|
||||||
|
)
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ class TestApiObjects(DirectoriesMixin, APITestCase):
|
|||||||
- API is called
|
- API is called
|
||||||
THEN:
|
THEN:
|
||||||
- Last correspondence date is returned only if requested for list, and for detail
|
- Last correspondence date is returned only if requested for list, and for detail
|
||||||
|
- The date is scoped to documents the requesting user may view
|
||||||
"""
|
"""
|
||||||
|
|
||||||
Document.objects.create(
|
Document.objects.create(
|
||||||
@@ -145,6 +146,32 @@ class TestApiObjects(DirectoriesMixin, APITestCase):
|
|||||||
response.data["last_correspondence"],
|
response.data["last_correspondence"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# A newer document owned by another user must not leak through the
|
||||||
|
# aggregate for a non-superuser who cannot view it
|
||||||
|
other = User.objects.create_user(username="other")
|
||||||
|
Document.objects.create(
|
||||||
|
mime_type="application/pdf",
|
||||||
|
correspondent=self.c1,
|
||||||
|
created=datetime.date(2023, 6, 1),
|
||||||
|
checksum="hidden",
|
||||||
|
owner=other,
|
||||||
|
)
|
||||||
|
|
||||||
|
user = User.objects.create_user(username="regular")
|
||||||
|
user.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_correspondent"),
|
||||||
|
)
|
||||||
|
self.client.force_authenticate(user=user)
|
||||||
|
|
||||||
|
response = self.client.get("/api/correspondents/?last_correspondence=true")
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
result = next(r for r in response.data["results"] if r["id"] == self.c1.id)
|
||||||
|
self.assertIn("2022-01-02", result["last_correspondence"])
|
||||||
|
|
||||||
|
response = self.client.get(f"/api/correspondents/{self.c1.id}/")
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertIn("2022-01-02", response.data["last_correspondence"])
|
||||||
|
|
||||||
def test_paginated_objects_include_all_only_for_legacy_version(self) -> None:
|
def test_paginated_objects_include_all_only_for_legacy_version(self) -> None:
|
||||||
response_v10 = self.client.get("/api/correspondents/")
|
response_v10 = self.client.get("/api/correspondents/")
|
||||||
self.assertEqual(response_v10.status_code, status.HTTP_200_OK)
|
self.assertEqual(response_v10.status_code, status.HTTP_200_OK)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import pickle
|
|
||||||
|
|
||||||
from documents.caching import StoredLRUCache
|
from documents.caching import StoredLRUCache
|
||||||
|
from paperless.signed_pickle import HMAC_SIZE
|
||||||
|
from paperless.signed_pickle import signed_pickle_dumps
|
||||||
|
from paperless.signed_pickle import signed_pickle_loads
|
||||||
|
|
||||||
|
|
||||||
def test_lru_cache_entries() -> None:
|
def test_lru_cache_entries() -> None:
|
||||||
@@ -42,4 +43,16 @@ def test_stored_lru_cache_key_ttl(mocker) -> None:
|
|||||||
key, data, timeout = mock_backend.set.call_args[0]
|
key, data, timeout = mock_backend.set.call_args[0]
|
||||||
assert key == "test_key"
|
assert key == "test_key"
|
||||||
assert timeout == 321
|
assert timeout == 321
|
||||||
assert pickle.loads(data) == {"x": "X", "y": "Y"}
|
assert signed_pickle_loads(data) == {"x": "X", "y": "Y"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_stored_lru_cache_rejects_tampered_data(mocker) -> None:
|
||||||
|
serialized_data = bytearray(signed_pickle_dumps({"x": "X"}))
|
||||||
|
serialized_data[HMAC_SIZE] ^= 0xFF
|
||||||
|
mock_backend = mocker.Mock()
|
||||||
|
mock_backend.get.return_value = bytes(serialized_data)
|
||||||
|
cache = StoredLRUCache("test_key", backend=mock_backend)
|
||||||
|
|
||||||
|
cache.load()
|
||||||
|
|
||||||
|
assert cache.get("x") is None
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ from documents.models import MatchingModel
|
|||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from documents.tests.utils import DirectoriesMixin
|
||||||
|
from paperless.signed_pickle import HMAC_SIZE
|
||||||
|
from paperless.signed_pickle import signed_pickle_dumps
|
||||||
|
|
||||||
|
|
||||||
def dummy_preprocess(content: str, **kwargs):
|
def dummy_preprocess(content: str, **kwargs):
|
||||||
@@ -265,6 +267,27 @@ class TestClassifier(DirectoriesMixin, TestCase):
|
|||||||
self.assertEqual(mock_preprocess_content.call_count, 2)
|
self.assertEqual(mock_preprocess_content.call_count, 2)
|
||||||
self.assertEqual(mock_transform.call_count, 2)
|
self.assertEqual(mock_transform.call_count, 2)
|
||||||
|
|
||||||
|
def test_vectorize_recomputes_tampered_cache_entry(self) -> None:
|
||||||
|
cached = bytearray(signed_pickle_dumps(["cached vector"]))
|
||||||
|
cached[HMAC_SIZE] ^= 0xFF
|
||||||
|
self.classifier.data_vectorizer = mock.Mock()
|
||||||
|
self.classifier.data_vectorizer.transform.return_value = ["fresh vector"]
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch(
|
||||||
|
"documents.classifier.read_cache.get",
|
||||||
|
return_value=bytes(cached),
|
||||||
|
),
|
||||||
|
mock.patch("documents.classifier.read_cache.set") as cache_set,
|
||||||
|
mock.patch("documents.classifier.read_cache.touch") as cache_touch,
|
||||||
|
):
|
||||||
|
result = self.classifier._vectorize("content")
|
||||||
|
|
||||||
|
self.assertEqual(result, ["fresh vector"])
|
||||||
|
self.classifier.data_vectorizer.transform.assert_called_once()
|
||||||
|
cache_set.assert_called_once()
|
||||||
|
cache_touch.assert_not_called()
|
||||||
|
|
||||||
def test_no_retrain_if_no_change(self) -> None:
|
def test_no_retrain_if_no_change(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -156,6 +156,40 @@ class TestDocument(TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(doc.get_public_filename(), "2020-12-25 test")
|
self.assertEqual(doc.get_public_filename(), "2020-12-25 test")
|
||||||
|
|
||||||
|
def test_version_file_name_uses_root_document_metadata(self) -> None:
|
||||||
|
root_correspondent = Correspondent.objects.create(name="Root correspondent")
|
||||||
|
version_correspondent = Correspondent.objects.create(
|
||||||
|
name="Version correspondent",
|
||||||
|
)
|
||||||
|
root = Document.objects.create(
|
||||||
|
mime_type="application/pdf",
|
||||||
|
title="Root title",
|
||||||
|
created=date(2020, 12, 25),
|
||||||
|
correspondent=root_correspondent,
|
||||||
|
)
|
||||||
|
version = Document.objects.create(
|
||||||
|
mime_type="application/pdf",
|
||||||
|
title="Version title",
|
||||||
|
created=date(1990, 1, 1),
|
||||||
|
correspondent=version_correspondent,
|
||||||
|
root_document=root,
|
||||||
|
version_index=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
version.get_public_filename(),
|
||||||
|
"2020-12-25 Root correspondent Root title.pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
root.title = "Updated root title"
|
||||||
|
root.save(update_fields=("title",))
|
||||||
|
version.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
version.get_public_filename(),
|
||||||
|
"2020-12-25 Root correspondent Updated root title.pdf",
|
||||||
|
)
|
||||||
|
|
||||||
def test_suggestion_content_uses_latest_version_content_for_root_documents(
|
def test_suggestion_content_uses_latest_version_content_for_root_documents(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -445,12 +445,13 @@ class TestConsumeFile:
|
|||||||
target = consumption_dir / "document.pdf"
|
target = consumption_dir / "document.pdf"
|
||||||
shutil.copy(sample_pdf, target)
|
shutil.copy(sample_pdf, target)
|
||||||
|
|
||||||
_consume_file(
|
result = _consume_file(
|
||||||
filepath=target,
|
filepath=target,
|
||||||
consumption_dir=consumption_dir,
|
consumption_dir=consumption_dir,
|
||||||
subdirs_as_tags=False,
|
subdirs_as_tags=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
mock_consume_file_delay.apply_async.assert_called_once()
|
mock_consume_file_delay.apply_async.assert_called_once()
|
||||||
call_args = mock_consume_file_delay.apply_async.call_args
|
call_args = mock_consume_file_delay.apply_async.call_args
|
||||||
consumable_doc = call_args.kwargs["kwargs"]["input_doc"]
|
consumable_doc = call_args.kwargs["kwargs"]["input_doc"]
|
||||||
@@ -464,11 +465,12 @@ class TestConsumeFile:
|
|||||||
mock_consume_file_delay: MagicMock,
|
mock_consume_file_delay: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test _consume_file handles nonexistent files gracefully."""
|
"""Test _consume_file handles nonexistent files gracefully."""
|
||||||
_consume_file(
|
result = _consume_file(
|
||||||
filepath=consumption_dir / "nonexistent.pdf",
|
filepath=consumption_dir / "nonexistent.pdf",
|
||||||
consumption_dir=consumption_dir,
|
consumption_dir=consumption_dir,
|
||||||
subdirs_as_tags=False,
|
subdirs_as_tags=False,
|
||||||
)
|
)
|
||||||
|
assert result is False
|
||||||
mock_consume_file_delay.apply_async.assert_not_called()
|
mock_consume_file_delay.apply_async.assert_not_called()
|
||||||
|
|
||||||
def test_consume_directory(
|
def test_consume_directory(
|
||||||
@@ -480,11 +482,12 @@ class TestConsumeFile:
|
|||||||
subdir = consumption_dir / "subdir"
|
subdir = consumption_dir / "subdir"
|
||||||
subdir.mkdir()
|
subdir.mkdir()
|
||||||
|
|
||||||
_consume_file(
|
result = _consume_file(
|
||||||
filepath=subdir,
|
filepath=subdir,
|
||||||
consumption_dir=consumption_dir,
|
consumption_dir=consumption_dir,
|
||||||
subdirs_as_tags=False,
|
subdirs_as_tags=False,
|
||||||
)
|
)
|
||||||
|
assert result is False
|
||||||
mock_consume_file_delay.apply_async.assert_not_called()
|
mock_consume_file_delay.apply_async.assert_not_called()
|
||||||
|
|
||||||
def test_consume_with_permission_error(
|
def test_consume_with_permission_error(
|
||||||
@@ -499,13 +502,33 @@ class TestConsumeFile:
|
|||||||
shutil.copy(sample_pdf, target)
|
shutil.copy(sample_pdf, target)
|
||||||
|
|
||||||
mocker.patch.object(Path, "is_file", side_effect=PermissionError("denied"))
|
mocker.patch.object(Path, "is_file", side_effect=PermissionError("denied"))
|
||||||
_consume_file(
|
result = _consume_file(
|
||||||
filepath=target,
|
filepath=target,
|
||||||
consumption_dir=consumption_dir,
|
consumption_dir=consumption_dir,
|
||||||
subdirs_as_tags=False,
|
subdirs_as_tags=False,
|
||||||
)
|
)
|
||||||
|
assert result is False
|
||||||
mock_consume_file_delay.apply_async.assert_not_called()
|
mock_consume_file_delay.apply_async.assert_not_called()
|
||||||
|
|
||||||
|
def test_consume_with_apply_async_failure(
|
||||||
|
self,
|
||||||
|
consumption_dir: Path,
|
||||||
|
sample_pdf: Path,
|
||||||
|
mock_consume_file_delay: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test _consume_file reports failure when apply_async raises."""
|
||||||
|
target = consumption_dir / "document.pdf"
|
||||||
|
shutil.copy(sample_pdf, target)
|
||||||
|
|
||||||
|
mock_consume_file_delay.apply_async.side_effect = Exception("broker down")
|
||||||
|
|
||||||
|
result = _consume_file(
|
||||||
|
filepath=target,
|
||||||
|
consumption_dir=consumption_dir,
|
||||||
|
subdirs_as_tags=False,
|
||||||
|
)
|
||||||
|
assert result is False
|
||||||
|
|
||||||
def test_consume_with_tags_error(
|
def test_consume_with_tags_error(
|
||||||
self,
|
self,
|
||||||
consumption_dir: Path,
|
consumption_dir: Path,
|
||||||
@@ -522,11 +545,12 @@ class TestConsumeFile:
|
|||||||
side_effect=DatabaseError("Something happened"),
|
side_effect=DatabaseError("Something happened"),
|
||||||
)
|
)
|
||||||
|
|
||||||
_consume_file(
|
result = _consume_file(
|
||||||
filepath=target,
|
filepath=target,
|
||||||
consumption_dir=consumption_dir,
|
consumption_dir=consumption_dir,
|
||||||
subdirs_as_tags=True,
|
subdirs_as_tags=True,
|
||||||
)
|
)
|
||||||
|
assert result is True
|
||||||
mock_consume_file_delay.apply_async.assert_called_once()
|
mock_consume_file_delay.apply_async.assert_called_once()
|
||||||
call_args = mock_consume_file_delay.apply_async.call_args
|
call_args = mock_consume_file_delay.apply_async.call_args
|
||||||
overrides = call_args.kwargs["kwargs"]["overrides"]
|
overrides = call_args.kwargs["kwargs"]["overrides"]
|
||||||
@@ -1249,6 +1273,52 @@ class TestProcessExistingFilesQueued:
|
|||||||
assert target.resolve() in queued
|
assert target.resolve() in queued
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.management
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestCommandRetryAfterQueueFailure:
|
||||||
|
"""
|
||||||
|
Regression test for GH #13923.
|
||||||
|
|
||||||
|
A file whose ``apply_async`` publish fails (e.g. broker briefly down)
|
||||||
|
must not be marked as queued, so the periodic rescan retries it once
|
||||||
|
the broker recovers, instead of stranding it until the consumer
|
||||||
|
process is restarted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_watch_loop_retries_failed_publish_on_rescan(
|
||||||
|
self,
|
||||||
|
consumption_dir: Path,
|
||||||
|
sample_pdf: Path,
|
||||||
|
mock_consume_file_delay: MagicMock,
|
||||||
|
start_consumer: Callable[..., ConsumerThread],
|
||||||
|
) -> None:
|
||||||
|
"""A publish failure from the watch loop is retried by the rescan."""
|
||||||
|
apply_async = mock_consume_file_delay.apply_async
|
||||||
|
|
||||||
|
def fail_first_call(*args: object, **kwargs: object) -> None:
|
||||||
|
if apply_async.call_count == 1:
|
||||||
|
raise Exception("broker down")
|
||||||
|
|
||||||
|
apply_async.side_effect = fail_first_call
|
||||||
|
|
||||||
|
thread = start_consumer(stability_delay=0.1, rescan_interval=0.3)
|
||||||
|
|
||||||
|
target = consumption_dir / "document.pdf"
|
||||||
|
shutil.copy(sample_pdf, target)
|
||||||
|
|
||||||
|
deadline = monotonic() + 5.0
|
||||||
|
while apply_async.call_count < 2 and monotonic() < deadline:
|
||||||
|
sleep(0.1)
|
||||||
|
|
||||||
|
if thread.exception:
|
||||||
|
raise thread.exception
|
||||||
|
|
||||||
|
assert apply_async.call_count >= 2, (
|
||||||
|
"Expected the failed publish to be retried by the rescan, "
|
||||||
|
f"but apply_async was only called {apply_async.call_count} time(s)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.management
|
@pytest.mark.management
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestCommandRescanRecovery:
|
class TestCommandRescanRecovery:
|
||||||
|
|||||||
@@ -192,6 +192,50 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_302_FOUND)
|
self.assertEqual(response.status_code, status.HTTP_302_FOUND)
|
||||||
self.assertIn("sharelink_notfound=1", response["Location"])
|
self.assertIn("sharelink_notfound=1", response["Location"])
|
||||||
|
|
||||||
|
def test_share_link_missing_file_redirects(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A share link whose document file is missing from disk
|
||||||
|
WHEN:
|
||||||
|
- The public share link is requested anonymously
|
||||||
|
THEN:
|
||||||
|
- The user is redirected to login instead of a 500 error
|
||||||
|
"""
|
||||||
|
doc = DocumentFactory.create(filename="missing-original.pdf")
|
||||||
|
share_link = ShareLink.objects.create(
|
||||||
|
slug="missingfilelink",
|
||||||
|
document=doc,
|
||||||
|
file_version=ShareLink.FileVersion.ORIGINAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.get(f"/share/{share_link.slug}/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_302_FOUND)
|
||||||
|
self.assertIn("sharelink_notfound=1", response["Location"])
|
||||||
|
|
||||||
|
def test_download_ready_bundle_missing_file_returns_503(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A READY bundle whose zip file is missing from disk
|
||||||
|
WHEN:
|
||||||
|
- The public share link is requested anonymously
|
||||||
|
THEN:
|
||||||
|
- A 503 is returned instead of a 500 error
|
||||||
|
"""
|
||||||
|
bundle = ShareLinkBundle.objects.create(
|
||||||
|
slug="missingbundlefile",
|
||||||
|
file_version=ShareLink.FileVersion.ARCHIVE,
|
||||||
|
status=ShareLinkBundle.Status.READY,
|
||||||
|
file_path="bundles/gone.zip",
|
||||||
|
)
|
||||||
|
bundle.documents.set([self.document])
|
||||||
|
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.get(f"/share/{bundle.slug}/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE)
|
||||||
|
|
||||||
|
|
||||||
class ShareLinkBundleTaskTests(DirectoriesMixin, APITestCase):
|
class ShareLinkBundleTaskTests(DirectoriesMixin, APITestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
|
|||||||
@@ -5621,11 +5621,15 @@ class TestApplyAISuggestionsWorkflowAction(
|
|||||||
action = self.make_action()
|
action = self.make_action()
|
||||||
self.make_workflow(action, WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED)
|
self.make_workflow(action, WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED)
|
||||||
|
|
||||||
with mock.patch("documents.tasks.apply_ai_suggestions.delay") as delay:
|
with (
|
||||||
|
mock.patch("documents.tasks.apply_ai_suggestions.delay") as delay,
|
||||||
|
self.captureOnCommitCallbacks(execute=True),
|
||||||
|
):
|
||||||
run_workflows(
|
run_workflows(
|
||||||
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
self.doc,
|
self.doc,
|
||||||
)
|
)
|
||||||
|
delay.assert_not_called()
|
||||||
|
|
||||||
delay.assert_called_once_with(action_id=action.pk, document_id=self.doc.pk)
|
delay.assert_called_once_with(action_id=action.pk, document_id=self.doc.pk)
|
||||||
|
|
||||||
|
|||||||
+30
-9
@@ -180,6 +180,7 @@ from documents.permissions import has_system_status_permission
|
|||||||
from documents.permissions import permitted_document_ids
|
from documents.permissions import permitted_document_ids
|
||||||
from documents.permissions import permitted_object_ids
|
from documents.permissions import permitted_object_ids
|
||||||
from documents.permissions import set_permissions_for_object
|
from documents.permissions import set_permissions_for_object
|
||||||
|
from documents.permissions import user_is_unrestricted
|
||||||
from documents.plugins.date_parsing import get_date_parser
|
from documents.plugins.date_parsing import get_date_parser
|
||||||
from documents.schema import generate_object_with_permissions_schema
|
from documents.schema import generate_object_with_permissions_schema
|
||||||
from documents.search import SearchHit
|
from documents.search import SearchHit
|
||||||
@@ -577,13 +578,19 @@ class CorrespondentViewSet(
|
|||||||
def list(self, request, *args, **kwargs):
|
def list(self, request, *args, **kwargs):
|
||||||
if request.query_params.get("last_correspondence", None):
|
if request.query_params.get("last_correspondence", None):
|
||||||
self.queryset = self.queryset.annotate(
|
self.queryset = self.queryset.annotate(
|
||||||
last_correspondence=Max("documents__created"),
|
last_correspondence=Max(
|
||||||
|
"documents__created",
|
||||||
|
filter=self.get_document_count_filter(),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return super().list(request, *args, **kwargs)
|
return super().list(request, *args, **kwargs)
|
||||||
|
|
||||||
def retrieve(self, request, *args, **kwargs):
|
def retrieve(self, request, *args, **kwargs):
|
||||||
self.queryset = self.queryset.annotate(
|
self.queryset = self.queryset.annotate(
|
||||||
last_correspondence=Max("documents__created"),
|
last_correspondence=Max(
|
||||||
|
"documents__created",
|
||||||
|
filter=self.get_document_count_filter(),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return super().retrieve(request, *args, **kwargs)
|
return super().retrieve(request, *args, **kwargs)
|
||||||
|
|
||||||
@@ -2323,10 +2330,12 @@ class ChatStreamingView(GenericAPIView[Any]):
|
|||||||
return HttpResponseForbidden("Insufficient permissions")
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
|
|
||||||
documents = Document.objects.filter(pk=document.pk)
|
documents = Document.objects.filter(pk=document.pk)
|
||||||
|
unrestricted = False
|
||||||
else:
|
else:
|
||||||
documents = Document.objects.filter(
|
documents = Document.objects.filter(
|
||||||
id__in=permitted_document_ids(request.user),
|
id__in=permitted_document_ids(request.user),
|
||||||
)
|
)
|
||||||
|
unrestricted = user_is_unrestricted(request.user)
|
||||||
|
|
||||||
output_language = get_llm_output_language(
|
output_language = get_llm_output_language(
|
||||||
ai_config=ai_config,
|
ai_config=ai_config,
|
||||||
@@ -2337,6 +2346,7 @@ class ChatStreamingView(GenericAPIView[Any]):
|
|||||||
stream_chat_with_documents(
|
stream_chat_with_documents(
|
||||||
query_str=question,
|
query_str=question,
|
||||||
documents=documents,
|
documents=documents,
|
||||||
|
unrestricted=unrestricted,
|
||||||
output_language=output_language,
|
output_language=output_language,
|
||||||
),
|
),
|
||||||
content_type="text/event-stream",
|
content_type="text/event-stream",
|
||||||
@@ -4573,6 +4583,10 @@ class ShareLinkViewSet(
|
|||||||
class ShareLinkBundleViewSet(PassUserMixin, ModelViewSet[ShareLinkBundle]):
|
class ShareLinkBundleViewSet(PassUserMixin, ModelViewSet[ShareLinkBundle]):
|
||||||
model = ShareLinkBundle
|
model = ShareLinkBundle
|
||||||
|
|
||||||
|
# Bundles are immutable once created; rebuild via the dedicated action
|
||||||
|
# rather than PUT/PATCH.
|
||||||
|
http_method_names = ["get", "post", "delete", "head", "options"]
|
||||||
|
|
||||||
queryset = ShareLinkBundle.objects.all()
|
queryset = ShareLinkBundle.objects.all()
|
||||||
|
|
||||||
serializer_class = ShareLinkBundleSerializer
|
serializer_class = ShareLinkBundleSerializer
|
||||||
@@ -4707,12 +4721,15 @@ class SharedLinkView(View):
|
|||||||
and share_link.expiration < timezone.now()
|
and share_link.expiration < timezone.now()
|
||||||
):
|
):
|
||||||
return HttpResponseRedirect("/accounts/login/?sharelink_expired=1")
|
return HttpResponseRedirect("/accounts/login/?sharelink_expired=1")
|
||||||
return serve_file(
|
try:
|
||||||
doc=share_link.document,
|
return serve_file(
|
||||||
use_archive=share_link.file_version == ShareLink.FileVersion.ARCHIVE
|
doc=share_link.document,
|
||||||
and share_link.document.has_archive_version,
|
use_archive=share_link.file_version == ShareLink.FileVersion.ARCHIVE
|
||||||
disposition="inline",
|
and share_link.document.has_archive_version,
|
||||||
)
|
disposition="inline",
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return HttpResponseRedirect("/accounts/login/?sharelink_notfound=1")
|
||||||
|
|
||||||
bundle = ShareLinkBundle.objects.filter(slug=slug).first()
|
bundle = ShareLinkBundle.objects.filter(slug=slug).first()
|
||||||
if bundle is None:
|
if bundle is None:
|
||||||
@@ -4734,7 +4751,11 @@ class SharedLinkView(View):
|
|||||||
|
|
||||||
file_path = bundle.absolute_file_path
|
file_path = bundle.absolute_file_path
|
||||||
|
|
||||||
if bundle.status == ShareLinkBundle.Status.FAILED or file_path is None:
|
if (
|
||||||
|
bundle.status == ShareLinkBundle.Status.FAILED
|
||||||
|
or file_path is None
|
||||||
|
or not file_path.exists()
|
||||||
|
):
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
_(
|
_(
|
||||||
"The share link bundle is unavailable.",
|
"The share link bundle is unavailable.",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+3
-31
@@ -1,12 +1,12 @@
|
|||||||
import hmac
|
|
||||||
import os
|
import os
|
||||||
import pickle
|
|
||||||
from hashlib import sha256
|
|
||||||
|
|
||||||
from celery import Celery
|
from celery import Celery
|
||||||
from celery.signals import worker_process_init
|
from celery.signals import worker_process_init
|
||||||
from kombu.serialization import register
|
from kombu.serialization import register
|
||||||
|
|
||||||
|
from paperless.signed_pickle import signed_pickle_dumps
|
||||||
|
from paperless.signed_pickle import signed_pickle_loads
|
||||||
|
|
||||||
# Set the default Django settings module for the 'celery' program.
|
# Set the default Django settings module for the 'celery' program.
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "paperless.settings")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "paperless.settings")
|
||||||
|
|
||||||
@@ -18,34 +18,6 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "paperless.settings")
|
|||||||
# on the worker side using Django's SECRET_KEY.
|
# on the worker side using Django's SECRET_KEY.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
HMAC_SIZE = 32 # SHA-256 digest length
|
|
||||||
|
|
||||||
|
|
||||||
def _get_signing_key() -> bytes:
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
return settings.SECRET_KEY.encode()
|
|
||||||
|
|
||||||
|
|
||||||
def signed_pickle_dumps(obj: object) -> bytes:
|
|
||||||
data = pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL)
|
|
||||||
signature = hmac.new(_get_signing_key(), data, sha256).digest()
|
|
||||||
return signature + data
|
|
||||||
|
|
||||||
|
|
||||||
def signed_pickle_loads(payload: bytes) -> object:
|
|
||||||
if len(payload) < HMAC_SIZE:
|
|
||||||
msg = "Signed-pickle payload too short"
|
|
||||||
raise ValueError(msg)
|
|
||||||
signature = payload[:HMAC_SIZE]
|
|
||||||
data = payload[HMAC_SIZE:]
|
|
||||||
expected = hmac.new(_get_signing_key(), data, sha256).digest()
|
|
||||||
if not hmac.compare_digest(signature, expected):
|
|
||||||
msg = "Signed-pickle HMAC verification failed — message may have been tampered with"
|
|
||||||
raise ValueError(msg)
|
|
||||||
return pickle.loads(data)
|
|
||||||
|
|
||||||
|
|
||||||
register(
|
register(
|
||||||
"signed-pickle",
|
"signed-pickle",
|
||||||
signed_pickle_dumps,
|
signed_pickle_dumps,
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ def _rewrite_request_to_pinned_ip(
|
|||||||
method=request.method,
|
method=request.method,
|
||||||
url=new_url,
|
url=new_url,
|
||||||
headers=new_headers,
|
headers=new_headers,
|
||||||
content=request.stream,
|
stream=request.stream,
|
||||||
extensions=request.extensions,
|
extensions=request.extensions,
|
||||||
)
|
)
|
||||||
rewritten_request.extensions["sni_hostname"] = hostname
|
rewritten_request.extensions["sni_hostname"] = hostname
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ if TYPE_CHECKING:
|
|||||||
import datetime
|
import datetime
|
||||||
from types import TracebackType
|
from types import TracebackType
|
||||||
|
|
||||||
|
from azure.core.pipeline import PipelineRequest
|
||||||
|
|
||||||
from paperless.parsers import MetadataEntry
|
from paperless.parsers import MetadataEntry
|
||||||
from paperless.parsers import ParserContext
|
from paperless.parsers import ParserContext
|
||||||
|
|
||||||
@@ -436,9 +438,45 @@ class RemoteDocumentParser:
|
|||||||
from azure.ai.documentintelligence.models import DocumentContentFormat
|
from azure.ai.documentintelligence.models import DocumentContentFormat
|
||||||
from azure.core.credentials import AzureKeyCredential
|
from azure.core.credentials import AzureKeyCredential
|
||||||
|
|
||||||
|
from paperless.network import validate_outbound_http_url
|
||||||
|
|
||||||
|
allow_internal = settings.REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS
|
||||||
|
|
||||||
|
try:
|
||||||
|
validate_outbound_http_url(config.endpoint, allow_internal=allow_internal)
|
||||||
|
except ValueError as e:
|
||||||
|
raise ParseError(f"Invalid remote OCR endpoint: {e}") from e
|
||||||
|
|
||||||
|
def _revalidate_request_host(request: PipelineRequest) -> None:
|
||||||
|
"""Re-validates the destination host of every request sent.
|
||||||
|
|
||||||
|
The check above only covers the moment the client is built. A
|
||||||
|
single analysis involves several requests spread over the
|
||||||
|
polling loop below, and any one of them can be redirected.
|
||||||
|
Wiring this through ``raw_request_hook`` (Azure's built-in
|
||||||
|
CustomHookPolicy) rather than a custom policy means it runs
|
||||||
|
*after* RedirectPolicy in the pipeline, so it sees - and
|
||||||
|
re-checks - every actual outbound URL, including redirect
|
||||||
|
targets, not just the original request.
|
||||||
|
"""
|
||||||
|
validate_outbound_http_url(
|
||||||
|
request.http_request.url,
|
||||||
|
allow_internal=allow_internal,
|
||||||
|
)
|
||||||
|
|
||||||
client = DocumentIntelligenceClient(
|
client = DocumentIntelligenceClient(
|
||||||
endpoint=config.endpoint,
|
endpoint=config.endpoint,
|
||||||
credential=AzureKeyCredential(config.api_key),
|
credential=AzureKeyCredential(config.api_key),
|
||||||
|
raw_request_hook=_revalidate_request_host,
|
||||||
|
# AzureKeyCredential is sent as Ocp-Apim-Subscription-Key, which
|
||||||
|
# Azure's default SensitiveHeaderCleanupPolicy does not strip on
|
||||||
|
# a cross-domain redirect (only Authorization and
|
||||||
|
# x-ms-authorization-auxiliary are, by default).
|
||||||
|
blocked_redirect_headers=[
|
||||||
|
"Authorization",
|
||||||
|
"x-ms-authorization-auxiliary",
|
||||||
|
"Ocp-Apim-Subscription-Key",
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -305,6 +305,22 @@ class ApplicationConfigurationSerializer(
|
|||||||
|
|
||||||
validate_llm_embedding_endpoint = validate_llm_endpoint
|
validate_llm_embedding_endpoint = validate_llm_endpoint
|
||||||
|
|
||||||
|
def validate_remote_ocr_endpoint(self, value: str | None) -> str | None:
|
||||||
|
if not value:
|
||||||
|
return value
|
||||||
|
|
||||||
|
try:
|
||||||
|
validate_outbound_http_url(
|
||||||
|
value,
|
||||||
|
allow_internal=settings.REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
f"Invalid remote OCR endpoint: {e.args[0]}, see logs for details",
|
||||||
|
) from e
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ApplicationConfiguration
|
model = ApplicationConfiguration
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
|
|||||||
@@ -705,6 +705,12 @@ CELERY_BROKER_TRANSPORT_OPTIONS = {
|
|||||||
CELERY_TASK_TRACK_STARTED = True
|
CELERY_TASK_TRACK_STARTED = True
|
||||||
CELERY_TASK_TIME_LIMIT: Final[int] = get_int_from_env("PAPERLESS_WORKER_TIMEOUT", 1800)
|
CELERY_TASK_TIME_LIMIT: Final[int] = get_int_from_env("PAPERLESS_WORKER_TIMEOUT", 1800)
|
||||||
|
|
||||||
|
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#std-setting-task_allow_error_cb_on_chord_header
|
||||||
|
# Without this, a failing chord header never triggers the errback, so a mail
|
||||||
|
# whose attachments all fail is never recorded and is re-fetched forever.
|
||||||
|
# The errback runs once per failed header task, so it must be idempotent.
|
||||||
|
CELERY_TASK_ALLOW_ERROR_CB_ON_CHORD_HEADER = True
|
||||||
|
|
||||||
CELERY_CACHE_BACKEND = "default"
|
CELERY_CACHE_BACKEND = "default"
|
||||||
|
|
||||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#task-serializer
|
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#task-serializer
|
||||||
@@ -1208,6 +1214,10 @@ REMOTE_OCR_MODE = get_choice_from_env(
|
|||||||
{"always", "workflow_only"},
|
{"always", "workflow_only"},
|
||||||
default="always",
|
default="always",
|
||||||
)
|
)
|
||||||
|
REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS = get_bool_from_env(
|
||||||
|
"PAPERLESS_REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS",
|
||||||
|
"true",
|
||||||
|
)
|
||||||
|
|
||||||
################################################################################
|
################################################################################
|
||||||
# AI Settings #
|
# AI Settings #
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hmac
|
||||||
|
import pickle
|
||||||
|
from hashlib import sha256
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
HMAC_SIZE = sha256().digest_size
|
||||||
|
|
||||||
|
|
||||||
|
class SignedPickleError(ValueError):
|
||||||
|
"""Raised when a signed pickle payload cannot be authenticated."""
|
||||||
|
|
||||||
|
|
||||||
|
def _get_signing_key() -> bytes:
|
||||||
|
return settings.SECRET_KEY.encode()
|
||||||
|
|
||||||
|
|
||||||
|
def signed_pickle_dumps(obj: object) -> bytes:
|
||||||
|
data = pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL)
|
||||||
|
signature = hmac.new(_get_signing_key(), data, sha256).digest()
|
||||||
|
return signature + data
|
||||||
|
|
||||||
|
|
||||||
|
def signed_pickle_loads(payload: bytes) -> Any:
|
||||||
|
if len(payload) <= HMAC_SIZE:
|
||||||
|
msg = "Signed-pickle payload too short"
|
||||||
|
raise SignedPickleError(msg)
|
||||||
|
|
||||||
|
signature = payload[:HMAC_SIZE]
|
||||||
|
data = payload[HMAC_SIZE:]
|
||||||
|
expected = hmac.new(_get_signing_key(), data, sha256).digest()
|
||||||
|
if not hmac.compare_digest(signature, expected):
|
||||||
|
msg = "Signed-pickle HMAC verification failed; payload may have been tampered with"
|
||||||
|
raise SignedPickleError(msg)
|
||||||
|
|
||||||
|
return pickle.loads(data)
|
||||||
@@ -6,9 +6,9 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
|
|
||||||
from paperless.celery import HMAC_SIZE
|
|
||||||
from paperless.celery import signed_pickle_dumps
|
from paperless.celery import signed_pickle_dumps
|
||||||
from paperless.celery import signed_pickle_loads
|
from paperless.celery import signed_pickle_loads
|
||||||
|
from paperless.signed_pickle import HMAC_SIZE
|
||||||
|
|
||||||
|
|
||||||
class TestSignedPickleSerializer:
|
class TestSignedPickleSerializer:
|
||||||
|
|||||||
@@ -295,7 +295,7 @@ urlpatterns = [
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
re_path(r"share/(?P<slug>\w+)/?$", SharedLinkView.as_view()),
|
re_path(r"^share/(?P<slug>\w+)/?$", SharedLinkView.as_view()),
|
||||||
re_path(r"^favicon.ico$", FaviconView.as_view(), name="favicon"),
|
re_path(r"^favicon.ico$", FaviconView.as_view(), name="favicon"),
|
||||||
re_path(r"admin/", admin.site.urls),
|
re_path(r"admin/", admin.site.urls),
|
||||||
re_path(
|
re_path(
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ from documents.models import Document
|
|||||||
from paperless.config import AIConfig
|
from paperless.config import AIConfig
|
||||||
from paperless_ai.client import AIClient
|
from paperless_ai.client import AIClient
|
||||||
from paperless_ai.db import db_connection_released
|
from paperless_ai.db import db_connection_released
|
||||||
from paperless_ai.indexing import _document_id_filters
|
from paperless_ai.indexing import document_id_filters
|
||||||
|
from paperless_ai.indexing import exclude_document_ids_filter
|
||||||
from paperless_ai.indexing import get_rag_prompt_helper
|
from paperless_ai.indexing import get_rag_prompt_helper
|
||||||
from paperless_ai.indexing import load_or_build_index
|
from paperless_ai.indexing import load_or_build_index
|
||||||
from paperless_ai.indexing import read_store
|
from paperless_ai.indexing import read_store
|
||||||
@@ -95,12 +96,15 @@ def _format_chat_metadata_trailer(references: list[dict[str, int | str]]) -> str
|
|||||||
def stream_chat_with_documents(
|
def stream_chat_with_documents(
|
||||||
query_str: str,
|
query_str: str,
|
||||||
documents: QuerySet[Document],
|
documents: QuerySet[Document],
|
||||||
|
*,
|
||||||
|
unrestricted: bool = False,
|
||||||
output_language: str | None = None,
|
output_language: str | None = None,
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
yield from _stream_chat_with_documents(
|
yield from _stream_chat_with_documents(
|
||||||
query_str,
|
query_str,
|
||||||
documents,
|
documents,
|
||||||
|
unrestricted=unrestricted,
|
||||||
output_language=output_language,
|
output_language=output_language,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -111,6 +115,8 @@ def stream_chat_with_documents(
|
|||||||
def _stream_chat_with_documents(
|
def _stream_chat_with_documents(
|
||||||
query_str: str,
|
query_str: str,
|
||||||
documents: QuerySet[Document],
|
documents: QuerySet[Document],
|
||||||
|
*,
|
||||||
|
unrestricted: bool = False,
|
||||||
output_language: str | None = None,
|
output_language: str | None = None,
|
||||||
):
|
):
|
||||||
if not documents.exists():
|
if not documents.exists():
|
||||||
@@ -123,9 +129,18 @@ def _stream_chat_with_documents(
|
|||||||
from llama_index.core.retrievers import VectorIndexRetriever
|
from llama_index.core.retrievers import VectorIndexRetriever
|
||||||
|
|
||||||
config = AIConfig()
|
config = AIConfig()
|
||||||
filters = _document_id_filters(
|
if unrestricted:
|
||||||
str(pk) for pk in documents.values_list("pk", flat=True)
|
# Exclude trashed ids (usually few) instead of an IN filter over the
|
||||||
)
|
# full permitted set, which risks the vector store's bound parameter
|
||||||
|
# limit (_MAX_IN_VALUES) on large installs. Trashed documents stay
|
||||||
|
# indexed until permanent deletion (delete_document_from_llm_index
|
||||||
|
# hangs off post_delete, not trash), so must be excluded explicitly.
|
||||||
|
trashed_ids = Document.deleted_objects.values_list("pk", flat=True)
|
||||||
|
filters = exclude_document_ids_filter(str(pk) for pk in trashed_ids)
|
||||||
|
else:
|
||||||
|
filters = document_id_filters(
|
||||||
|
str(pk) for pk in documents.values_list("pk", flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
# Hold the shared read lock for the whole operation: the query engine
|
# Hold the shared read lock for the whole operation: the query engine
|
||||||
# retrieves from the vector store again during synthesis, so the connection
|
# retrieves from the vector store again during synthesis, so the connection
|
||||||
|
|||||||
@@ -131,11 +131,10 @@ class AIClient:
|
|||||||
|
|
||||||
from llama_index.core.llms import ChatMessage
|
from llama_index.core.llms import ChatMessage
|
||||||
|
|
||||||
user_msg = ChatMessage(role="user", content=prompt)
|
|
||||||
if self.settings.llm_backend == LLMBackend.OLLAMA:
|
if self.settings.llm_backend == LLMBackend.OLLAMA:
|
||||||
with self._normalize_timeouts():
|
with self._normalize_timeouts():
|
||||||
result = self.llm.chat(
|
result = self.llm.chat(
|
||||||
[user_msg],
|
[ChatMessage(role="user", content=prompt)],
|
||||||
format=DocumentClassifierSchema.model_json_schema(),
|
format=DocumentClassifierSchema.model_json_schema(),
|
||||||
think=False,
|
think=False,
|
||||||
)
|
)
|
||||||
@@ -149,6 +148,11 @@ class AIClient:
|
|||||||
from llama_index.core.program.function_program import get_function_tool
|
from llama_index.core.program.function_program import get_function_tool
|
||||||
|
|
||||||
tool = get_function_tool(DocumentClassifierSchema)
|
tool = get_function_tool(DocumentClassifierSchema)
|
||||||
|
user_msg = ChatMessage(
|
||||||
|
role="user",
|
||||||
|
content=f"{prompt}\n\n"
|
||||||
|
f"Answer by calling the {tool.metadata.name} tool. Do not write the answer as text.",
|
||||||
|
)
|
||||||
with self._normalize_timeouts():
|
with self._normalize_timeouts():
|
||||||
result = self.llm.chat_with_tools(
|
result = self.llm.chat_with_tools(
|
||||||
tools=[tool],
|
tools=[tool],
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ def _embed_nodes(nodes: list["BaseNode"], embed_model) -> None:
|
|||||||
node.embedding = emb
|
node.embedding = emb
|
||||||
|
|
||||||
|
|
||||||
def _document_id_filters(doc_ids):
|
def document_id_filters(doc_ids):
|
||||||
"""Return a MetadataFilters IN filter scoped to ``doc_ids``."""
|
"""Return a MetadataFilters IN filter scoped to ``doc_ids``."""
|
||||||
from llama_index.core.vector_stores.types import FilterOperator
|
from llama_index.core.vector_stores.types import FilterOperator
|
||||||
from llama_index.core.vector_stores.types import MetadataFilter
|
from llama_index.core.vector_stores.types import MetadataFilter
|
||||||
@@ -396,6 +396,23 @@ def _exclude_document_id_filter(document_id: int | str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def exclude_document_ids_filter(doc_ids):
|
||||||
|
"""Return a MetadataFilters NIN filter excluding every id in ``doc_ids``."""
|
||||||
|
from llama_index.core.vector_stores.types import FilterOperator
|
||||||
|
from llama_index.core.vector_stores.types import MetadataFilter
|
||||||
|
from llama_index.core.vector_stores.types import MetadataFilters
|
||||||
|
|
||||||
|
return MetadataFilters(
|
||||||
|
filters=[
|
||||||
|
MetadataFilter(
|
||||||
|
key="document_id",
|
||||||
|
operator=FilterOperator.NIN,
|
||||||
|
value=list(doc_ids),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def update_llm_index(
|
def update_llm_index(
|
||||||
*,
|
*,
|
||||||
iter_wrapper: IterWrapper[Document] = identity,
|
iter_wrapper: IterWrapper[Document] = identity,
|
||||||
@@ -660,7 +677,7 @@ def retrieve_similar_nodes(
|
|||||||
|
|
||||||
filter_parts = []
|
filter_parts = []
|
||||||
if allowed_document_ids is not None:
|
if allowed_document_ids is not None:
|
||||||
filter_parts.extend(_document_id_filters(allowed_document_ids).filters)
|
filter_parts.extend(document_id_filters(allowed_document_ids).filters)
|
||||||
if document.pk is not None:
|
if document.pk is not None:
|
||||||
filter_parts.extend(_exclude_document_id_filter(document.pk).filters)
|
filter_parts.extend(_exclude_document_id_filter(document.pk).filters)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Rewrite only the "title", "tags", "document_types", and "storage_paths" fields i
|
|||||||
|
|
||||||
Do not translate correspondents or dates.
|
Do not translate correspondents or dates.
|
||||||
Preserve proper nouns, organization names, product names, and exact official document names. Translate generic category words when a {{ language_name }} equivalent exists.
|
Preserve proper nouns, organization names, product names, and exact official document names. Translate generic category words when a {{ language_name }} equivalent exists.
|
||||||
Return the same JSON schema with all fields present.
|
Keep every entry you were given in those four fields, in the same order, using the original wording where no translation applies.
|
||||||
|
|
||||||
Suggestions:
|
Suggestions:
|
||||||
{{ suggestions_json }}
|
{{ suggestions_json }}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from typing import Any
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.db.models.signals import post_init
|
from django.db.models.signals import post_init
|
||||||
|
from django.utils import timezone
|
||||||
from llama_index.core import settings as llama_settings
|
from llama_index.core import settings as llama_settings
|
||||||
from llama_index.core.embeddings.mock_embed_model import MockEmbedding
|
from llama_index.core.embeddings.mock_embed_model import MockEmbedding
|
||||||
from llama_index.core.schema import TextNode
|
from llama_index.core.schema import TextNode
|
||||||
@@ -18,6 +23,11 @@ from paperless_ai.chat import _build_chat_prompt
|
|||||||
from paperless_ai.chat import _build_refine_prompt
|
from paperless_ai.chat import _build_refine_prompt
|
||||||
from paperless_ai.chat import stream_chat_with_documents
|
from paperless_ai.chat import stream_chat_with_documents
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest_mock
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def patch_embed_model():
|
def patch_embed_model():
|
||||||
@@ -310,8 +320,40 @@ def test_stream_chat_unexpected_failure_returns_generic_error(caplog) -> None:
|
|||||||
assert "private provider detail" in caplog.text
|
assert "private provider detail" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def _retriever_filter_values(captured_filters: list[Any]) -> list[str]:
|
||||||
|
"""The value list of the single MetadataFilter the retriever received."""
|
||||||
|
assert captured_filters, "VectorIndexRetriever was never constructed"
|
||||||
|
filt = captured_filters[0]
|
||||||
|
assert filt is not None, "Retriever must receive a MetadataFilters"
|
||||||
|
return filt.filters[0].value
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestStreamChatRetrieval:
|
class TestStreamChatRetrieval:
|
||||||
|
@pytest.fixture
|
||||||
|
def captured_filters(self, mocker: pytest_mock.MockerFixture) -> list[Any]:
|
||||||
|
"""Stub out the AI client and the retriever, capturing the ``filters``
|
||||||
|
kwarg of every VectorIndexRetriever construction.
|
||||||
|
|
||||||
|
VectorIndexRetriever is imported inside _stream_chat_with_documents,
|
||||||
|
so it is patched at the llama_index source for the lazy import to
|
||||||
|
pick it up.
|
||||||
|
"""
|
||||||
|
captured: list[Any] = []
|
||||||
|
retriever = mocker.MagicMock()
|
||||||
|
retriever.retrieve.return_value = []
|
||||||
|
|
||||||
|
def capture_retriever(*args, **kwargs) -> pytest_mock.MockType:
|
||||||
|
captured.append(kwargs.get("filters"))
|
||||||
|
return retriever
|
||||||
|
|
||||||
|
mocker.patch("paperless_ai.chat.AIClient")
|
||||||
|
mocker.patch(
|
||||||
|
"llama_index.core.retrievers.VectorIndexRetriever",
|
||||||
|
side_effect=capture_retriever,
|
||||||
|
)
|
||||||
|
return captured
|
||||||
|
|
||||||
def test_no_nodes_yields_no_content_message(
|
def test_no_nodes_yields_no_content_message(
|
||||||
self,
|
self,
|
||||||
temp_llm_index_dir,
|
temp_llm_index_dir,
|
||||||
@@ -329,9 +371,9 @@ class TestStreamChatRetrieval:
|
|||||||
|
|
||||||
def test_chat_filter_contains_only_requested_document_ids(
|
def test_chat_filter_contains_only_requested_document_ids(
|
||||||
self,
|
self,
|
||||||
temp_llm_index_dir,
|
temp_llm_index_dir: Path,
|
||||||
mock_embed_model,
|
mock_embed_model: pytest_mock.MockType,
|
||||||
mocker,
|
captured_filters: list[Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""The MetadataFilter passed to the retriever must be scoped to the
|
"""The MetadataFilter passed to the retriever must be scoped to the
|
||||||
requested documents only — content from other indexed documents must
|
requested documents only — content from other indexed documents must
|
||||||
@@ -342,22 +384,6 @@ class TestStreamChatRetrieval:
|
|||||||
indexing.llm_index_add_or_update_document(included)
|
indexing.llm_index_add_or_update_document(included)
|
||||||
indexing.llm_index_add_or_update_document(excluded)
|
indexing.llm_index_add_or_update_document(excluded)
|
||||||
|
|
||||||
# VectorIndexRetriever is imported inside _stream_chat_with_documents;
|
|
||||||
# patch it at the llama_index source so the lazy import picks it up.
|
|
||||||
captured_filters = []
|
|
||||||
mock_retriever = mocker.MagicMock()
|
|
||||||
mock_retriever.retrieve.return_value = []
|
|
||||||
|
|
||||||
def capture_retriever(*args, **kwargs):
|
|
||||||
captured_filters.append(kwargs.get("filters"))
|
|
||||||
return mock_retriever
|
|
||||||
|
|
||||||
mocker.patch("paperless_ai.chat.AIClient")
|
|
||||||
mocker.patch(
|
|
||||||
"llama_index.core.retrievers.VectorIndexRetriever",
|
|
||||||
side_effect=capture_retriever,
|
|
||||||
)
|
|
||||||
|
|
||||||
list(
|
list(
|
||||||
chat.stream_chat_with_documents(
|
chat.stream_chat_with_documents(
|
||||||
"question?",
|
"question?",
|
||||||
@@ -365,13 +391,78 @@ class TestStreamChatRetrieval:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert captured_filters, "VectorIndexRetriever was never constructed"
|
filter_values = _retriever_filter_values(captured_filters)
|
||||||
filt = captured_filters[0]
|
|
||||||
assert filt is not None, "Retriever must receive a MetadataFilters"
|
|
||||||
filter_values = filt.filters[0].value
|
|
||||||
assert str(included.pk) in filter_values
|
assert str(included.pk) in filter_values
|
||||||
assert str(excluded.pk) not in filter_values
|
assert str(excluded.pk) not in filter_values
|
||||||
|
|
||||||
|
def test_unrestricted_chat_excludes_nothing_when_no_documents_are_trashed(
|
||||||
|
self,
|
||||||
|
temp_llm_index_dir: Path,
|
||||||
|
mock_embed_model: pytest_mock.MockType,
|
||||||
|
captured_filters: list[Any],
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A document indexed in the vector store, nothing trashed
|
||||||
|
WHEN:
|
||||||
|
- stream_chat_with_documents is called with unrestricted=True
|
||||||
|
THEN:
|
||||||
|
- The retriever receives a NOT IN filter excluding zero ids, so
|
||||||
|
the whole index is effectively searched -- and no IN-list is
|
||||||
|
built from the full permitted set, which is what risks the
|
||||||
|
vector store's safety limit on large installs
|
||||||
|
"""
|
||||||
|
document = DocumentFactory.create(content="indexed document content")
|
||||||
|
indexing.llm_index_add_or_update_document(document)
|
||||||
|
|
||||||
|
list(
|
||||||
|
chat.stream_chat_with_documents(
|
||||||
|
"question?",
|
||||||
|
Document.objects.filter(pk=document.pk),
|
||||||
|
unrestricted=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _retriever_filter_values(captured_filters) == []
|
||||||
|
|
||||||
|
def test_unrestricted_chat_excludes_trashed_documents(
|
||||||
|
self,
|
||||||
|
temp_llm_index_dir: Path,
|
||||||
|
mock_embed_model: pytest_mock.MockType,
|
||||||
|
captured_filters: list[Any],
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Two indexed documents, one of them trashed -- trashed documents
|
||||||
|
stay in the vector index until permanently deleted, since
|
||||||
|
delete_document_from_llm_index is wired to post_delete
|
||||||
|
WHEN:
|
||||||
|
- stream_chat_with_documents is called with unrestricted=True
|
||||||
|
THEN:
|
||||||
|
- The retriever receives a NOT IN filter excluding the trashed
|
||||||
|
document's id, so an unrestricted caller (e.g. a superuser)
|
||||||
|
never has trashed content surfaced in a chat answer
|
||||||
|
"""
|
||||||
|
kept = DocumentFactory.create(content="kept document content")
|
||||||
|
trashed = DocumentFactory.create(content="trashed document content")
|
||||||
|
indexing.llm_index_add_or_update_document(kept)
|
||||||
|
indexing.llm_index_add_or_update_document(trashed)
|
||||||
|
Document.global_objects.filter(pk=trashed.pk).update(
|
||||||
|
deleted_at=timezone.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
list(
|
||||||
|
chat.stream_chat_with_documents(
|
||||||
|
"question?",
|
||||||
|
Document.objects.filter(pk=kept.pk),
|
||||||
|
unrestricted=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
filter_values = _retriever_filter_values(captured_filters)
|
||||||
|
assert str(trashed.pk) in filter_values
|
||||||
|
assert str(kept.pk) not in filter_values
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_get_document_references_only_queries_referenced_documents(
|
def test_get_document_references_only_queries_referenced_documents(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -146,6 +146,8 @@ def test_run_llm_query_ollama_uses_structured_json(mock_ai_config, mock_ollama_l
|
|||||||
format=ANY,
|
format=ANY,
|
||||||
think=False,
|
think=False,
|
||||||
)
|
)
|
||||||
|
messages = mock_llm_instance.chat.call_args.args[0]
|
||||||
|
assert messages[0].content == "test_prompt"
|
||||||
|
|
||||||
|
|
||||||
def test_run_llm_query_openai_uses_tools(mock_ai_config, mock_openai_llm):
|
def test_run_llm_query_openai_uses_tools(mock_ai_config, mock_openai_llm):
|
||||||
@@ -183,6 +185,13 @@ def test_run_llm_query_openai_uses_tools(mock_ai_config, mock_openai_llm):
|
|||||||
assert result["title"] == "Test Title"
|
assert result["title"] == "Test Title"
|
||||||
assert result["tags"] == {"existing_ids": [1], "new_names": []}
|
assert result["tags"] == {"existing_ids": [1], "new_names": []}
|
||||||
mock_llm_instance.chat_with_tools.assert_called_once()
|
mock_llm_instance.chat_with_tools.assert_called_once()
|
||||||
|
kwargs = mock_llm_instance.chat_with_tools.call_args.kwargs
|
||||||
|
offered_tool_name = kwargs["tools"][0].metadata.name
|
||||||
|
assert kwargs["user_msg"].content == (
|
||||||
|
"test_prompt\n\n"
|
||||||
|
f"Answer by calling the {offered_tool_name} tool. "
|
||||||
|
"Do not write the answer as text."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_run_llm_query_openai_timeout_raises_local_error(
|
def test_run_llm_query_openai_timeout_raises_local_error(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import inspect
|
import inspect
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
from collections.abc import Callable
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -97,6 +98,18 @@ def _ne_filter(document_id: int):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _nin_filter(document_ids: list[int]):
|
||||||
|
return MetadataFilters(
|
||||||
|
filters=[
|
||||||
|
MetadataFilter(
|
||||||
|
key="document_id",
|
||||||
|
operator=FilterOperator.NIN,
|
||||||
|
value=document_ids,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestCrud:
|
class TestCrud:
|
||||||
def test_add_then_query_returns_node(self, store) -> None:
|
def test_add_then_query_returns_node(self, store) -> None:
|
||||||
node = make_node("n1", 1)
|
node = make_node("n1", 1)
|
||||||
@@ -280,6 +293,47 @@ class TestBuildWhere:
|
|||||||
"b1",
|
"b1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def test_nin_filter_translates_to_not_in_clause(self) -> None:
|
||||||
|
where, params = _build_where(_nin_filter([1, 2]))
|
||||||
|
assert where == "(document_id NOT IN (?,?))"
|
||||||
|
assert params == [1, 2]
|
||||||
|
|
||||||
|
def test_query_with_nin_filter_excludes_matching_documents(self, store) -> None:
|
||||||
|
store.add([make_node("a1", 1), make_node("b1", 2), make_node("c1", 3)])
|
||||||
|
assert sorted(
|
||||||
|
_query(store, [0.0] * DIM, top_k=5, filters=_nin_filter([1, 2])).ids,
|
||||||
|
) == ["c1"]
|
||||||
|
|
||||||
|
def test_empty_in_filter_excludes_everything(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An IN filter with an empty value list
|
||||||
|
WHEN:
|
||||||
|
- _build_where() translates it to SQL
|
||||||
|
THEN:
|
||||||
|
- It excludes everything (the opposite of an empty NOT IN
|
||||||
|
filter) -- an empty inclusion list must never widen results
|
||||||
|
"""
|
||||||
|
where, params = _build_where(_in_filter([]))
|
||||||
|
assert where == "(1 = 0)"
|
||||||
|
assert params == []
|
||||||
|
|
||||||
|
def test_empty_nin_filter_excludes_nothing(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A NOT IN filter with an empty value list -- e.g. an
|
||||||
|
unrestricted chat caller when nothing is currently trashed
|
||||||
|
WHEN:
|
||||||
|
- _build_where() translates it to SQL
|
||||||
|
THEN:
|
||||||
|
- It excludes nothing (unlike an empty IN filter, which
|
||||||
|
excludes everything) -- an empty exclusion list must never
|
||||||
|
narrow results
|
||||||
|
"""
|
||||||
|
where, params = _build_where(_nin_filter([]))
|
||||||
|
assert where == "(1 = 1)"
|
||||||
|
assert params == []
|
||||||
|
|
||||||
def test_fails_closed_when_no_filter_is_translatable(self) -> None:
|
def test_fails_closed_when_no_filter_is_translatable(self) -> None:
|
||||||
# A nested MetadataFilters is not a MetadataFilter, so it is skipped.
|
# A nested MetadataFilters is not a MetadataFilter, so it is skipped.
|
||||||
# With no translatable clauses, the function must fail closed rather
|
# With no translatable clauses, the function must fail closed rather
|
||||||
@@ -297,24 +351,31 @@ class TestBuildWhere:
|
|||||||
assert where == "1 = 0"
|
assert where == "1 = 0"
|
||||||
assert params == []
|
assert params == []
|
||||||
|
|
||||||
def test_fails_closed_when_in_filter_exceeds_max_values(
|
@pytest.mark.parametrize(
|
||||||
|
"build_filter",
|
||||||
|
[_in_filter, _nin_filter],
|
||||||
|
ids=["in", "nin"],
|
||||||
|
)
|
||||||
|
def test_fails_closed_when_filter_exceeds_max_values(
|
||||||
self,
|
self,
|
||||||
|
build_filter: Callable[[list[str]], MetadataFilters],
|
||||||
caplog: pytest.LogCaptureFixture,
|
caplog: pytest.LogCaptureFixture,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- An IN filter with more values than _MAX_IN_VALUES (SQLite's
|
- An IN or NOT IN filter with more values than _MAX_IN_VALUES
|
||||||
own bound-parameter limit is 32766; this guard sits below
|
(SQLite's own bound-parameter limit is 32766; this guard sits
|
||||||
that with headroom for the query's other bound parameters)
|
below that with headroom for the query's other bound parameters)
|
||||||
WHEN:
|
WHEN:
|
||||||
- _build_where() translates it to SQL
|
- _build_where() translates it to SQL
|
||||||
THEN:
|
THEN:
|
||||||
- It fails closed ("1 = 0", no params) instead of building an
|
- It fails closed ("1 = 0", no params) instead of building a
|
||||||
IN clause SQLite would reject, and logs a warning -- this
|
clause SQLite would reject, and logs a warning -- this filter
|
||||||
filter scopes document access, so refusing to build it must
|
scopes document access, so refusing to build it must never
|
||||||
never widen the scope to "everything" by accident
|
widen the scope to "everything" by accident. Failing open on
|
||||||
|
a NOT IN would surface exactly the excluded rows
|
||||||
"""
|
"""
|
||||||
oversized = _in_filter([str(i) for i in range(_MAX_IN_VALUES + 1)])
|
oversized = build_filter([str(i) for i in range(_MAX_IN_VALUES + 1)])
|
||||||
|
|
||||||
with caplog.at_level("WARNING"):
|
with caplog.at_level("WARNING"):
|
||||||
where, params = _build_where(oversized)
|
where, params = _build_where(oversized)
|
||||||
|
|||||||
@@ -107,12 +107,13 @@ def _vec0_params(rows: list[_Row]) -> list[tuple[str, int, str, bytes]]:
|
|||||||
|
|
||||||
|
|
||||||
def _build_where(filters: MetadataFilters | None) -> tuple[str, list[int]]:
|
def _build_where(filters: MetadataFilters | None) -> tuple[str, list[int]]:
|
||||||
"""Translate the EQ / IN / NE filters we use into a parameterized SQL
|
"""Translate the EQ / IN / NIN / NE filters we use into a parameterized
|
||||||
clause on vec0 metadata columns. Returns ("", []) when there is nothing
|
SQL clause on vec0 metadata columns. Returns ("", []) when there is
|
||||||
to filter. document_id is vec0's only filterable column and is INTEGER;
|
nothing to filter. document_id is vec0's only filterable column and is
|
||||||
every value is coerced via int() here so callers (which today still pass
|
INTEGER; every value is coerced via int() here so callers (which today
|
||||||
strings in places, e.g. indexing.py's MetadataFilter construction) don't
|
still pass strings in places, e.g. indexing.py's MetadataFilter
|
||||||
have to be individually correct -- vec0 doesn't coerce types itself.
|
construction) don't have to be individually correct -- vec0 doesn't
|
||||||
|
coerce types itself.
|
||||||
"""
|
"""
|
||||||
if filters is None or not filters.filters:
|
if filters is None or not filters.filters:
|
||||||
return "", []
|
return "", []
|
||||||
@@ -125,20 +126,25 @@ def _build_where(filters: MetadataFilters | None) -> tuple[str, list[int]]:
|
|||||||
continue
|
continue
|
||||||
if f.key not in _FILTER_COLUMNS: # pragma: no cover - we build the keys
|
if f.key not in _FILTER_COLUMNS: # pragma: no cover - we build the keys
|
||||||
raise NotImplementedError(f"Unsupported filter column: {f.key}")
|
raise NotImplementedError(f"Unsupported filter column: {f.key}")
|
||||||
if f.operator == FilterOperator.IN:
|
if f.operator in (FilterOperator.IN, FilterOperator.NIN):
|
||||||
|
is_in = f.operator == FilterOperator.IN
|
||||||
|
sql_op = "IN" if is_in else "NOT IN"
|
||||||
values = [int(v) for v in f.value] # type: ignore[union-attr]
|
values = [int(v) for v in f.value] # type: ignore[union-attr]
|
||||||
if not values: # pragma: no cover
|
if not values:
|
||||||
clauses.append("1 = 0")
|
# An empty IN list matches nothing; an empty NOT IN list
|
||||||
|
# excludes nothing, so it matches everything.
|
||||||
|
clauses.append("1 = 0" if is_in else "1 = 1")
|
||||||
continue
|
continue
|
||||||
if len(values) > _MAX_IN_VALUES:
|
if len(values) > _MAX_IN_VALUES:
|
||||||
# Fail closed (see the empty-clauses case below) rather than
|
# Refuse rather than risk SQLite's own bound-parameter limit
|
||||||
# let SQLite raise "too many SQL variables" past its own
|
# ("too many SQL variables"): a list this large must match no
|
||||||
# limit: this filter scopes document access, so an IN list
|
# rows, never widen the scope to "everything" -- true for
|
||||||
# too large to safely bind must match no rows, never widen
|
# NOT IN too, where failing open would surface every
|
||||||
# the scope to "everything" by accident.
|
# excluded row.
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Refusing to build an IN filter on %r with %d values "
|
"Refusing to build a %s filter on %r with %d values "
|
||||||
"(over the %d-value safety limit); returning no rows.",
|
"(over the %d-value safety limit); returning no rows.",
|
||||||
|
sql_op,
|
||||||
f.key,
|
f.key,
|
||||||
len(values),
|
len(values),
|
||||||
_MAX_IN_VALUES,
|
_MAX_IN_VALUES,
|
||||||
@@ -146,7 +152,7 @@ def _build_where(filters: MetadataFilters | None) -> tuple[str, list[int]]:
|
|||||||
clauses.append("1 = 0")
|
clauses.append("1 = 0")
|
||||||
continue
|
continue
|
||||||
placeholders = ",".join("?" for _ in values)
|
placeholders = ",".join("?" for _ in values)
|
||||||
clauses.append(f"{f.key} IN ({placeholders})")
|
clauses.append(f"{f.key} {sql_op} ({placeholders})")
|
||||||
params.extend(values)
|
params.extend(values)
|
||||||
elif f.operator == FilterOperator.EQ:
|
elif f.operator == FilterOperator.EQ:
|
||||||
clauses.append(f"{f.key} = ?")
|
clauses.append(f"{f.key} = ?")
|
||||||
@@ -154,7 +160,7 @@ def _build_where(filters: MetadataFilters | None) -> tuple[str, list[int]]:
|
|||||||
elif f.operator == FilterOperator.NE:
|
elif f.operator == FilterOperator.NE:
|
||||||
clauses.append(f"{f.key} != ?")
|
clauses.append(f"{f.key} != ?")
|
||||||
params.append(int(f.value))
|
params.append(int(f.value))
|
||||||
else: # pragma: no cover - we only ever build EQ/IN/NE filters
|
else: # pragma: no cover - we only ever build EQ/IN/NIN/NE filters
|
||||||
raise NotImplementedError(f"Unsupported filter operator: {f.operator}")
|
raise NotImplementedError(f"Unsupported filter operator: {f.operator}")
|
||||||
if not clauses:
|
if not clauses:
|
||||||
# Filters were requested but none could be translated. Fail closed
|
# Filters were requested but none could be translated. Fail closed
|
||||||
|
|||||||
@@ -334,18 +334,24 @@ def error_callback(
|
|||||||
"""
|
"""
|
||||||
A shared task that is called whenever something goes wrong during
|
A shared task that is called whenever something goes wrong during
|
||||||
consumption of a file. See queue_consumption_tasks.
|
consumption of a file. See queue_consumption_tasks.
|
||||||
|
|
||||||
|
With CELERY_TASK_ALLOW_ERROR_CB_ON_CHORD_HEADER enabled this runs once per
|
||||||
|
failed header task, not once per chord, so it must be idempotent.
|
||||||
"""
|
"""
|
||||||
rule = MailRule.objects.get(pk=rule_id)
|
rule = MailRule.objects.get(pk=rule_id)
|
||||||
|
received = make_aware(message_date) if is_naive(message_date) else message_date
|
||||||
|
|
||||||
ProcessedMail.objects.create(
|
ProcessedMail.objects.get_or_create(
|
||||||
rule=rule,
|
rule=rule,
|
||||||
folder=rule.folder,
|
folder=rule.folder,
|
||||||
uid=message_uid,
|
uid=message_uid,
|
||||||
uid_validity=uid_validity,
|
uid_validity=uid_validity,
|
||||||
subject=message_subject,
|
defaults={
|
||||||
received=make_aware(message_date) if is_naive(message_date) else message_date,
|
"subject": message_subject,
|
||||||
status="FAILED",
|
"received": received,
|
||||||
error=traceback.format_exc(),
|
"status": "FAILED",
|
||||||
|
"error": traceback.format_exc(),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from paperless_mail.mail import MailAccountHandler
|
|||||||
from paperless_mail.mail import MailError
|
from paperless_mail.mail import MailError
|
||||||
from paperless_mail.mail import TagMailAction
|
from paperless_mail.mail import TagMailAction
|
||||||
from paperless_mail.mail import apply_mail_action
|
from paperless_mail.mail import apply_mail_action
|
||||||
|
from paperless_mail.mail import error_callback
|
||||||
from paperless_mail.mail import get_mailbox
|
from paperless_mail.mail import get_mailbox
|
||||||
from paperless_mail.models import MailAccount
|
from paperless_mail.models import MailAccount
|
||||||
from paperless_mail.models import MailRule
|
from paperless_mail.models import MailRule
|
||||||
@@ -2045,6 +2046,44 @@ class TestPostConsumeAction(TestCase):
|
|||||||
self.assertIn("Test Exception", processed_mail.error)
|
self.assertIn("Test Exception", processed_mail.error)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestErrorCallback:
|
||||||
|
def test_error_callback_is_idempotent_for_same_mail(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A mail rule and a mail that failed to be consumed
|
||||||
|
WHEN:
|
||||||
|
- error_callback is invoked more than once for the same mail, as
|
||||||
|
happens when task_allow_error_cb_on_chord_header fires the
|
||||||
|
errback once per failed header task in a chord
|
||||||
|
THEN:
|
||||||
|
- Only one ProcessedMail row is created for that mail
|
||||||
|
"""
|
||||||
|
rule = MailRuleFactory()
|
||||||
|
message_uid = "12345"
|
||||||
|
|
||||||
|
for _ in range(2):
|
||||||
|
error_callback(
|
||||||
|
None,
|
||||||
|
Exception("Test Exception"),
|
||||||
|
None,
|
||||||
|
rule_id=rule.pk,
|
||||||
|
message_uid=message_uid,
|
||||||
|
message_subject="Test Subject",
|
||||||
|
message_date=timezone.make_aware(
|
||||||
|
timezone.datetime(2023, 1, 1, 12, 0, 0),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
processed_mails = ProcessedMail.objects.filter(
|
||||||
|
rule=rule,
|
||||||
|
uid=message_uid,
|
||||||
|
folder=rule.folder,
|
||||||
|
)
|
||||||
|
assert processed_mails.count() == 1
|
||||||
|
assert processed_mails.get().status == "FAILED"
|
||||||
|
|
||||||
|
|
||||||
class TestManagementCommand(TestCase):
|
class TestManagementCommand(TestCase):
|
||||||
@mock.patch(
|
@mock.patch(
|
||||||
"paperless_mail.management.commands.mail_fetcher.tasks.process_mail_accounts",
|
"paperless_mail.management.commands.mail_fetcher.tasks.process_mail_accounts",
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ requires-python = ">=3.11"
|
|||||||
resolution-markers = [
|
resolution-markers = [
|
||||||
"python_full_version >= '3.15' and sys_platform == 'darwin'",
|
"python_full_version >= '3.15' and sys_platform == 'darwin'",
|
||||||
"python_full_version >= '3.15' and sys_platform == 'linux'",
|
"python_full_version >= '3.15' and sys_platform == 'linux'",
|
||||||
|
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||||
|
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||||
"python_full_version >= '3.12' and python_full_version < '3.15' and sys_platform == 'darwin'",
|
"python_full_version >= '3.12' and python_full_version < '3.15' and sys_platform == 'darwin'",
|
||||||
"python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
"python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||||
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||||
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
|
||||||
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
|
||||||
"(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')",
|
"(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')",
|
||||||
"python_full_version < '3.12' and sys_platform == 'darwin'",
|
"python_full_version < '3.12' and sys_platform == 'darwin'",
|
||||||
"python_full_version < '3.12' and sys_platform == 'linux'",
|
"python_full_version < '3.12' and sys_platform == 'linux'",
|
||||||
@@ -864,18 +864,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" },
|
{ url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "deprecation"
|
|
||||||
version = "2.1.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "packaging" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", size = 173788, upload-time = "2020-04-20T14:23:38.738Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dirtyjson"
|
name = "dirtyjson"
|
||||||
version = "1.0.8"
|
version = "1.0.8"
|
||||||
@@ -1116,14 +1104,14 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "djangorestframework"
|
name = "djangorestframework"
|
||||||
version = "3.17.1"
|
version = "3.17.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "django" },
|
{ name = "django" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/ca/d7/c016e69fac19ff8afdc89db9d31d9ae43ae031e4d1993b20aca179b8301a/djangorestframework-3.17.1.tar.gz", hash = "sha256:a6def5f447fe78ff853bff1d47a3c59bf38f5434b031780b351b0c73a62db1a5", size = 905742, upload-time = "2026-03-24T16:58:33.705Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/3b/35/c96055e700fdff25da3a7b7756cfd1d4dc54f38b9bc6d6c5e19e3a0fdc20/djangorestframework-3.17.2.tar.gz", hash = "sha256:89ed713b6dc83e1539f214b7d10808ae19bb8511004beba886225da6d5c9dafa", size = 906683, upload-time = "2026-08-05T07:47:22.5Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/e1/2c516bdc83652b1a60c6119366ac2c0607b479ed05cd6093f916ca8928f8/djangorestframework-3.17.1-py3-none-any.whl", hash = "sha256:c3c74dd3e83a5a3efc37b3c18d92bd6f86a6791c7b7d4dff62bb068500e76457", size = 898844, upload-time = "2026-03-24T16:58:31.845Z" },
|
{ url = "https://files.pythonhosted.org/packages/a2/46/c14108e400b208c394325eb63fbae06c81341b6447fa1a6f9da718b17fe7/djangorestframework-3.17.2-py3-none-any.whl", hash = "sha256:cb0546a7415d5b46c04e0f4fe0a54b2109f4fdd5e83ca773c8c6183a6493d042", size = 899109, upload-time = "2026-08-05T07:47:20.853Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1410,14 +1398,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gotenberg-client"
|
name = "gotenberg-client"
|
||||||
version = "0.14.0"
|
version = "1.0.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
sdist = { url = "https://files.pythonhosted.org/packages/68/a3/48b438bded1a514289b8b92fa5f29077712702cda8c01f923b930f80cff8/gotenberg_client-1.0.0.tar.gz", hash = "sha256:871b339ed98911279f94f3aaa6403ca7c59aaa695d8663249be6314ccd46719c", size = 1274193, upload-time = "2026-08-07T04:22:11.962Z" }
|
||||||
{ name = "httpx", extra = ["http2"] },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/34/8e3be3a6a1b654d2a3bfa3e5d201183aeff6d50c42199ac0b8ed912c01c5/gotenberg_client-0.14.0.tar.gz", hash = "sha256:a853700c6b01c3372871264c4eb9ae3375addafbcbbfd3341e411f4217a8088c", size = 1214438, upload-time = "2026-03-11T17:23:11.122Z" }
|
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/55/1a/67ff4cca162ae4195bd6f1a107779898b6f2977cc33ae7e05a5178a395fa/gotenberg_client-0.14.0-py3-none-any.whl", hash = "sha256:868f1be46d1ed0f327ca3efeb1888b4fe35641c35bfa39684d23a59365703156", size = 50977, upload-time = "2026-03-11T17:23:09.397Z" },
|
{ url = "https://files.pythonhosted.org/packages/7e/5f/8a2d984e3c45162124d57541a7ad6bf67cd9707a4e49ef9562abc58c3255/gotenberg_client-1.0.0-py3-none-any.whl", hash = "sha256:458669231d972f7328fa84fb3085fed8a8052d7d26263aa5bf8a0edd1d06cd0a", size = 67433, upload-time = "2026-08-07T04:22:10.349Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1561,19 +1546,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "h2"
|
|
||||||
version = "4.4.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "hpack" },
|
|
||||||
{ name = "hyperframe" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-03T11:45:09.509Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/22/e85faf23bd72a92d1921e37d674ca56eb298a3c8be31fdecef0ff2b3aaac/h2-4.4.1-py3-none-any.whl", hash = "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", size = 62636, upload-time = "2026-08-03T11:44:59.164Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hf-xet"
|
name = "hf-xet"
|
||||||
version = "1.5.1"
|
version = "1.5.1"
|
||||||
@@ -1663,15 +1635,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/bc/1f/fb7375467e9adaa371cd617c2984fefe44bdce73add4c70b8dd8cab1b33a/hiredis-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e8a4b8540581dcd1b2b25827a54cfd538e0afeaa1a0e3ca87ad7126965981cc", size = 176127, upload-time = "2025-10-14T16:33:02.793Z" },
|
{ url = "https://files.pythonhosted.org/packages/bc/1f/fb7375467e9adaa371cd617c2984fefe44bdce73add4c70b8dd8cab1b33a/hiredis-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e8a4b8540581dcd1b2b25827a54cfd538e0afeaa1a0e3ca87ad7126965981cc", size = 176127, upload-time = "2025-10-14T16:33:02.793Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hpack"
|
|
||||||
version = "4.2.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httpcore"
|
name = "httpcore"
|
||||||
version = "1.0.9"
|
version = "1.0.9"
|
||||||
@@ -1700,11 +1663,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
|
||||||
http2 = [
|
|
||||||
{ name = "h2" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httpx-oauth"
|
name = "httpx-oauth"
|
||||||
version = "0.17.0"
|
version = "0.17.0"
|
||||||
@@ -1746,15 +1704,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/c5/7b/bca5613a0c3b542420cf92bd5e5fb8ebd5435ce1011a091f66bb7693285e/humanize-4.15.0-py3-none-any.whl", hash = "sha256:b1186eb9f5a9749cd9cb8565aee77919dd7c8d076161cf44d70e59e3301e1769", size = 132203, upload-time = "2025-12-20T20:16:11.67Z" },
|
{ url = "https://files.pythonhosted.org/packages/c5/7b/bca5613a0c3b542420cf92bd5e5fb8ebd5435ce1011a091f66bb7693285e/humanize-4.15.0-py3-none-any.whl", hash = "sha256:b1186eb9f5a9749cd9cb8565aee77919dd7c8d076161cf44d70e59e3301e1769", size = 132203, upload-time = "2025-12-20T20:16:11.67Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hyperframe"
|
|
||||||
version = "6.1.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyperlink"
|
name = "hyperlink"
|
||||||
version = "21.0.0"
|
version = "21.0.0"
|
||||||
@@ -2803,10 +2752,9 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ocrmypdf"
|
name = "ocrmypdf"
|
||||||
version = "17.7.1"
|
version = "17.10.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "deprecation" },
|
|
||||||
{ name = "fpdf2" },
|
{ name = "fpdf2" },
|
||||||
{ name = "img2pdf" },
|
{ name = "img2pdf" },
|
||||||
{ name = "packaging" },
|
{ name = "packaging" },
|
||||||
@@ -2818,11 +2766,12 @@ dependencies = [
|
|||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pypdfium2" },
|
{ name = "pypdfium2" },
|
||||||
{ name = "rich" },
|
{ name = "rich" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
{ name = "uharfbuzz" },
|
{ name = "uharfbuzz" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/15/ac/30171791db306c7b1c705957a0b9bed9df443b9465533dfc1945a654805b/ocrmypdf-17.7.1.tar.gz", hash = "sha256:d61184b84e3001ebe7c5acb265041bd8591f924b8616bbefc746a5bdafab3eca", size = 7438611, upload-time = "2026-06-27T08:50:06.824Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/20/2e/96c9912ad50fe3e186f0d3580d06d27ac2300fcc178d7457794f4bcaf3b8/ocrmypdf-17.10.0.tar.gz", hash = "sha256:3e80a22e7ca9a746034e990414c9f18791f168800f3ede92101504f45be6129c", size = 7499394, upload-time = "2026-08-05T00:25:50.595Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/03/9e/88bed373c0449dc3bd8772744788f89e6c9c4e6034f03703304b9d9c2d4c/ocrmypdf-17.7.1-py3-none-any.whl", hash = "sha256:3e69d11cc98f5019af61bc457b106365d67c791ec4f358e4f8e938f99d654492", size = 506631, upload-time = "2026-06-27T08:50:05.031Z" },
|
{ url = "https://files.pythonhosted.org/packages/39/6b/29a4c5f4e67d16bff32f706bf338d443761fe4933b9be4067e958db04aaa/ocrmypdf-17.10.0-py3-none-any.whl", hash = "sha256:34ba1b595ecacc94b6dc3c9d4fa51953de63082cd16cf8595251bd72120b930a", size = 523170, upload-time = "2026-08-05T00:25:48.734Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3048,12 +2997,12 @@ requires-dist = [
|
|||||||
{ name = "drf-writable-nested", specifier = "~=0.7.1" },
|
{ name = "drf-writable-nested", specifier = "~=0.7.1" },
|
||||||
{ name = "filelock", specifier = "~=3.32.0" },
|
{ name = "filelock", specifier = "~=3.32.0" },
|
||||||
{ name = "flower", specifier = "~=2.0.1" },
|
{ name = "flower", specifier = "~=2.0.1" },
|
||||||
{ name = "gotenberg-client", specifier = "~=0.14.0" },
|
{ name = "gotenberg-client", specifier = ">=0.14,<1.1" },
|
||||||
{ name = "granian", extras = ["uvloop"], marker = "extra == 'webserver'", specifier = "~=2.7.0" },
|
{ name = "granian", extras = ["uvloop"], marker = "extra == 'webserver'", specifier = "~=2.7.0" },
|
||||||
{ name = "httpx-oauth", specifier = "~=0.17" },
|
{ name = "httpx-oauth", specifier = "~=0.17" },
|
||||||
{ name = "ijson", specifier = ">=3.5.1" },
|
{ name = "ijson", specifier = ">=3.5.1" },
|
||||||
{ name = "imap-tools", specifier = "~=1.14.0" },
|
{ name = "imap-tools", specifier = "~=1.14.0" },
|
||||||
{ name = "jinja2", specifier = "~=3.1.5" },
|
{ name = "jinja2", specifier = "~=3.1.6" },
|
||||||
{ name = "langdetect", specifier = "~=1.0.9" },
|
{ name = "langdetect", specifier = "~=1.0.9" },
|
||||||
{ name = "llama-index-core", specifier = ">=0.14.23" },
|
{ name = "llama-index-core", specifier = ">=0.14.23" },
|
||||||
{ name = "llama-index-embeddings-huggingface", specifier = ">=0.6.1" },
|
{ name = "llama-index-embeddings-huggingface", specifier = ">=0.6.1" },
|
||||||
@@ -3063,7 +3012,7 @@ requires-dist = [
|
|||||||
{ name = "llama-index-llms-openai-like", specifier = ">=0.7.1" },
|
{ name = "llama-index-llms-openai-like", specifier = ">=0.7.1" },
|
||||||
{ name = "mysqlclient", marker = "extra == 'mariadb'", specifier = "~=2.2.7" },
|
{ name = "mysqlclient", marker = "extra == 'mariadb'", specifier = "~=2.2.7" },
|
||||||
{ name = "nltk", specifier = "~=3.10.0" },
|
{ name = "nltk", specifier = "~=3.10.0" },
|
||||||
{ name = "ocrmypdf", specifier = "~=17.7.0" },
|
{ name = "ocrmypdf", specifier = ">=17.7,<17.11" },
|
||||||
{ name = "openai", specifier = ">=2.48" },
|
{ name = "openai", specifier = ">=2.48" },
|
||||||
{ name = "pathvalidate", specifier = "~=3.3.1" },
|
{ name = "pathvalidate", specifier = "~=3.3.1" },
|
||||||
{ name = "pdf2image", specifier = "~=1.17.0" },
|
{ name = "pdf2image", specifier = "~=1.17.0" },
|
||||||
@@ -3087,7 +3036,7 @@ requires-dist = [
|
|||||||
{ name = "setproctitle", specifier = "~=1.3.4" },
|
{ name = "setproctitle", specifier = "~=1.3.4" },
|
||||||
{ name = "sqlite-vec", specifier = "==0.1.9" },
|
{ name = "sqlite-vec", specifier = "==0.1.9" },
|
||||||
{ name = "tantivy", specifier = "~=0.26.0" },
|
{ name = "tantivy", specifier = "~=0.26.0" },
|
||||||
{ name = "tika-client", specifier = "~=0.11.0" },
|
{ name = "tika-client", specifier = ">=0.11,<1.1" },
|
||||||
{ name = "torch", specifier = "~=2.13.0", index = "https://download.pytorch.org/whl/cpu" },
|
{ name = "torch", specifier = "~=2.13.0", index = "https://download.pytorch.org/whl/cpu" },
|
||||||
{ name = "watchfiles", specifier = ">=1.2" },
|
{ name = "watchfiles", specifier = ">=1.2" },
|
||||||
{ name = "whitenoise", specifier = "~=6.11" },
|
{ name = "whitenoise", specifier = "~=6.11" },
|
||||||
@@ -4796,15 +4745,14 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tika-client"
|
name = "tika-client"
|
||||||
version = "0.11.0"
|
version = "1.0.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anyio" },
|
{ name = "anyio" },
|
||||||
{ name = "httpx" },
|
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/4d/d9/01f2049240dacf67c9be61d9c59e72b6827a862e8fd87e77e458e0a3b797/tika_client-0.11.0.tar.gz", hash = "sha256:c741caaca08bbd715a8db3fe6f0430a54d075fef3d59a441e8b8d810f58de4f0", size = 2178828, upload-time = "2026-03-11T16:50:25.865Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/d2/54/7525db2491a1bdfbaf869a713d1492572161b24a145d3c8dec9688635ec3/tika_client-1.0.0.tar.gz", hash = "sha256:899c2fd08c717d8d590d46d76942103ef972fc37d43eadbfd6772a9961351ced", size = 2212108, upload-time = "2026-08-07T04:22:26.265Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/53/04/5a433d621ec559d1d216d200eea43b0ac63435beb5dd52bbc75f4aaef465/tika_client-0.11.0-py3-none-any.whl", hash = "sha256:461903ccbe705d84dd3e4a1ca83e04174776d4b06dc57b902f9281633a3836e6", size = 18470, upload-time = "2026-03-11T16:50:24.672Z" },
|
{ url = "https://files.pythonhosted.org/packages/f5/9d/5b0815192600f338ee18f6c37bc61be28cd9618e3f46cc3311c4c1677abb/tika_client-1.0.0-py3-none-any.whl", hash = "sha256:f9d4c86b2cf037a71d8b6322aaace78c16c7f1fcca47e3726a442f87cee9a0b8", size = 25584, upload-time = "2026-08-07T04:22:25.106Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5014,10 +4962,10 @@ version = "2.13.0+cpu"
|
|||||||
source = { registry = "https://download.pytorch.org/whl/cpu" }
|
source = { registry = "https://download.pytorch.org/whl/cpu" }
|
||||||
resolution-markers = [
|
resolution-markers = [
|
||||||
"python_full_version >= '3.15' and sys_platform == 'linux'",
|
"python_full_version >= '3.15' and sys_platform == 'linux'",
|
||||||
"python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
|
||||||
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
|
||||||
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||||
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||||
|
"python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||||
|
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||||
"(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')",
|
"(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')",
|
||||||
"python_full_version < '3.12' and sys_platform == 'linux'",
|
"python_full_version < '3.12' and sys_platform == 'linux'",
|
||||||
]
|
]
|
||||||
@@ -5927,24 +5875,24 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zxing-cpp"
|
name = "zxing-cpp"
|
||||||
version = "3.1.0"
|
version = "3.1.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/23/39/6621d964dbf7f31dbaade0981a5df4e9db247457962e6bbb88d1b8d55763/zxing_cpp-3.1.0.tar.gz", hash = "sha256:ecd2f0641ca2298f5decfd1746d7b08a7639523d515c5ed0c3df4e67327a6e45", size = 1435439, upload-time = "2026-07-07T17:07:46.251Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/b9/30/ad0e0352c593712ebb47143571ff11b130812e2852d7540e7c80cdf23340/zxing_cpp-3.1.1.tar.gz", hash = "sha256:1051a521b21a9fe206702ad4186aeb195154e3e1badcd99576d030723f36382b", size = 1437030, upload-time = "2026-07-29T08:50:59.019Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/78/10/de951b133449e9aa2696fc5a52784560c277fa091b5290480e037077825a/zxing_cpp-3.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:6a6caa4953e65b8c348846adf2f89836dcaf7def8dff29eca4efc431a2669876", size = 903673, upload-time = "2026-07-07T17:07:11.592Z" },
|
{ url = "https://files.pythonhosted.org/packages/d1/c4/d64c1b751561eee75706def600041e4c72642403864ac6c52588fdb54bb3/zxing_cpp-3.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:9e558cf4d6d0dd0ae1199541bc8fd01e8fb67e18673faa7ca96e50440fdd6f93", size = 912350, upload-time = "2026-07-29T08:50:23.952Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/dc/cd/b44ce663678025376b9e6eb8718820c5ef7c89ba3b9e48159d7aef6e33c9/zxing_cpp-3.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b2b7d6193a1131cfd83f6b03b8d95448e9eaf104deccc833a59cb2d168b19d5d", size = 855357, upload-time = "2026-07-07T17:07:13.221Z" },
|
{ url = "https://files.pythonhosted.org/packages/01/1b/94067d5a5d324a30cd9862296171ec50cda58c9e31317eca53286aeab832/zxing_cpp-3.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ec41a833dc1697e5360b5d9e2620fab1f3e92892b890c31fe85b50a10ca05217", size = 865032, upload-time = "2026-07-29T08:50:25.304Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3c/f9/2fcdf24c7d3568c1b303057ae7bdf52d6d3189bc2ca2244354f72d848d3f/zxing_cpp-3.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:360420e4ca2104d35e8bb9ed6b5aa509f8c02ebf0761bf10a9bc09a6c1065e39", size = 1016879, upload-time = "2026-07-07T17:07:14.513Z" },
|
{ url = "https://files.pythonhosted.org/packages/12/ee/4ab8cf9594959e1dc8f3c0e234d225fd1080cecc349c99cac4850005055a/zxing_cpp-3.1.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:07ac611267b7220b769c182ae33473ee95aca1cc6c57e597755288b557848935", size = 1028402, upload-time = "2026-07-29T08:50:26.935Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/23/3b/880e77fa59e4dc4d1db23310aa4a67e156fa33ff5341904167b71968f510/zxing_cpp-3.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d69e61ab1a104dccc4bd58e1a04268e326801f05f28d7d4528c63b8ff05443aa", size = 1093104, upload-time = "2026-07-07T17:07:16.044Z" },
|
{ url = "https://files.pythonhosted.org/packages/12/83/5af471c7ad3fbb11d3efba64b41aba9f209d5dcc2945ca6b0afb29a9fed0/zxing_cpp-3.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a5b32d719a5448f1b2f474e04d2db6ce41cc6973fb5c705d47dbe899361e5f9", size = 1102966, upload-time = "2026-07-29T08:50:28.428Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/74/d57cf8815ec3990289aa043d09addf9df0bd3a9b52ee6c21eefe48ae427d/zxing_cpp-3.1.0-cp312-abi3-macosx_10_13_x86_64.whl", hash = "sha256:765a28c28d0f92ceba0085d4ef4044e804327dcbb1fbb80e35b969e17d65500e", size = 902329, upload-time = "2026-07-07T17:07:20.373Z" },
|
{ url = "https://files.pythonhosted.org/packages/56/57/ac717270db6888973eba83e9832fe800808b555df0ebe34e37b6a6e07545/zxing_cpp-3.1.1-cp312-abi3-macosx_10_13_x86_64.whl", hash = "sha256:09dea611a7c9dc7c713a82303b15b733dc71abb1a77454b26b779e33671cef05", size = 911430, upload-time = "2026-07-29T08:50:32.625Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/39/92/1b0d86b65d3c2bd361d289e49b310bbd4c893807c734311c7a55ad40cd71/zxing_cpp-3.1.0-cp312-abi3-macosx_11_0_arm64.whl", hash = "sha256:f4d3823f3915a5e66b1273f6ceddfc43a7712e0904c4be67ecdd62aea55c2e78", size = 853686, upload-time = "2026-07-07T17:07:22.067Z" },
|
{ url = "https://files.pythonhosted.org/packages/12/70/f14831dd92d5c844a39c03ebe9ba185e073d4467d50b48dcf2a816cae0c5/zxing_cpp-3.1.1-cp312-abi3-macosx_11_0_arm64.whl", hash = "sha256:037cbcaeb0cb12497fc15ced23f6b778fce8a6a1d1bbffddbffd004c6225744d", size = 863740, upload-time = "2026-07-29T08:50:34.23Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/66/1c/efc817e2597268dff67af217f7ae35fc5f457ac5d8dce6f9f3dd706c6bcc/zxing_cpp-3.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9fc27251e8c17be28c5c6603c487d2ef62a058fb0e2b39f4ecfcebb017d55f3", size = 1012792, upload-time = "2026-07-07T17:07:23.496Z" },
|
{ url = "https://files.pythonhosted.org/packages/0d/f3/3fb2c6c48e6f58382fbbd31965c7caafd81f75b7e6707b011bdb940adb5f/zxing_cpp-3.1.1-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4dae01111f323f46736fc21f05c14dcaaac06cea5fdc8fd994ba19f6f918c6e", size = 1024253, upload-time = "2026-07-29T08:50:35.599Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/b2/6c120d8641d1d6de3deb409962746d5ab92261e7f7ff69909708561a7483/zxing_cpp-3.1.0-cp312-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c41f006d078421906bea04fea5e2cdcbbdee54819783926064dd7089a83d1a78", size = 1090631, upload-time = "2026-07-07T17:07:24.95Z" },
|
{ url = "https://files.pythonhosted.org/packages/0c/30/79683cf7139ee5325fbc68169eb8dc1cb2033ec43339b5f39de990f909a7/zxing_cpp-3.1.1-cp312-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cf67341949946307d086b302cefd453fb47bc6d6ddc7d088839e9481982757b", size = 1096795, upload-time = "2026-07-29T08:50:36.896Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b2/8e/7d26d38691461112f839d350706549fb7fd3bcb7407661e8e615aa5aed00/zxing_cpp-3.1.0-cp313-cp313t-macosx_10_15_x86_64.whl", hash = "sha256:069126336b1bd0bb48ed54ce3f24e7a77231723bba1507e1bf480a7ef4c1e349", size = 907114, upload-time = "2026-07-07T17:07:28.833Z" },
|
{ url = "https://files.pythonhosted.org/packages/b0/30/e98ce9c56bd1f1fe0a1fd0e5c39202da49baa3620031cb80ac7a04759ffb/zxing_cpp-3.1.1-cp313-cp313t-macosx_10_15_x86_64.whl", hash = "sha256:9d291fd958c26066aca97c4a416a9f15475a99c97b253cd4d2c6754a485b01e6", size = 915582, upload-time = "2026-07-29T08:50:41.286Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7c/3a/167245970cb22534c2acc2b1b938c3fbb82c07d82f5997f0c9d2e0f10aa2/zxing_cpp-3.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:32af79b2e80f57672d8be5d21a4199970ad13368f67c498a5ee8160b01d7072d", size = 858770, upload-time = "2026-07-07T17:07:30.139Z" },
|
{ url = "https://files.pythonhosted.org/packages/3d/d8/ab1db4571348e8756c2019425c72b3cb936f72c4a7c2af35687396381c36/zxing_cpp-3.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:670e2946232128b1ebba5b1f623e016ac8f8ad743ae3a0fb2e50b33180f216a2", size = 867699, upload-time = "2026-07-29T08:50:42.815Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0e/9b/43dbd81544df336506aba01c9356a418764c065c59cc6dc5fdc7d734f054/zxing_cpp-3.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65ad9383ec43e5b5172f98f913e79dcad064badd1dcb5e43c3ad64cc58281824", size = 1019568, upload-time = "2026-07-07T17:07:31.916Z" },
|
{ url = "https://files.pythonhosted.org/packages/6a/09/78a038367fd3d4fc00fa1f696672bfff002b4771814c3b20b1c392872043/zxing_cpp-3.1.1-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9efc7ed301846a8c060720f09bed8a29fefccef54b5106c291e4136ffe87d089", size = 1030204, upload-time = "2026-07-29T08:50:44.356Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2e/4d/42448dfc8b4677579de3d1a20bad39a275fdb1adf10984aac380f551776c/zxing_cpp-3.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cbdfc4520fd568d13f361e36be704def5e659607636b1509051746cf9d33c5b", size = 1094619, upload-time = "2026-07-07T17:07:33.38Z" },
|
{ url = "https://files.pythonhosted.org/packages/90/7b/0fc91d2d0463164268d06dd3e9b97520f9fe5c79dc6a954c92cd9ac92fbf/zxing_cpp-3.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f37e714ad4fd0ae4dd759b19fef25bd524a2865bc3ca8730b4e318c0cc7800e", size = 1104920, upload-time = "2026-07-29T08:50:45.639Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/a2/fccf3c7a3e9bd9340ef2566a1b86d6041359fd7e1c8a44099d63a9bb2485/zxing_cpp-3.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:698daecab96a7cc6b5496fa040dbbd58edd183a7e0aef4aca8e86ae63a132ebb", size = 907094, upload-time = "2026-07-07T17:07:37.733Z" },
|
{ url = "https://files.pythonhosted.org/packages/d2/a8/8c005a5251734f57a30f1e85fa2a8965d53cd0df99d1abf642956153410e/zxing_cpp-3.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b4bd34f71868af0e34b000da4fc885c85a7f0ef37eecc0ec433ff27b263a5b7", size = 915637, upload-time = "2026-07-29T08:50:50.129Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/54/44/d4f3ddc19b9d468ea6778bc114426a5b355f1f3ba634035258106f5b2aff/zxing_cpp-3.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f53cc0aceb9e1db8894113a5f4e1a011c38b9ba9218e4c6f13506015807fdde7", size = 858777, upload-time = "2026-07-07T17:07:39.101Z" },
|
{ url = "https://files.pythonhosted.org/packages/5d/31/a2e693c9771b88e45dd7e52b56c85c169649123cf0eebfb32151efdfb356/zxing_cpp-3.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:94e342d390933b9678f71bf6005cf2125cdb27c2355c21fa194e3a672502aac6", size = 867750, upload-time = "2026-07-29T08:50:51.788Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/37/18/7ab3d4f1818bb850235761f45b7f9bbce3a75c5508a8e779bff2fc7d9d68/zxing_cpp-3.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8535ad4e24fbd58ca8d30ecd0c89dabc671482ca14db2fc9ac763a22379f3bd", size = 1019558, upload-time = "2026-07-07T17:07:40.448Z" },
|
{ url = "https://files.pythonhosted.org/packages/f0/30/d2f7e626b4216bbb47783d7431cd27b151cfe5abeb22aa06f0b130095841/zxing_cpp-3.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71df8523deb2fb40b834238e6fa739e210e3a6e27c5b94a99b4106c08e339b9b", size = 1030274, upload-time = "2026-07-29T08:50:53.535Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1c/53/50ce13676db8343de6121d7ecc888b0ac19716dff15d76fef0140534cbf2/zxing_cpp-3.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ec38090a9265753fb4a1f481cc0fa0e8d442f85618b3a450c9529fd6c4d848b", size = 1094624, upload-time = "2026-07-07T17:07:42.285Z" },
|
{ url = "https://files.pythonhosted.org/packages/4e/b9/c4b6db45a3a9f7e34a3faadcce78c2084f0bc2ce0ee8344d61f1149d2318/zxing_cpp-3.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:388626ac8df24f63c2bb17dcd42fd21daeeea6fd6759bd9b1c064b71142da07e", size = 1104873, upload-time = "2026-07-29T08:50:54.941Z" },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user