mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-12 11:07:13 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bdc407ed9 | ||
|
|
01e1e76f91 | ||
|
|
a41e06f196 |
No files matched your search
@@ -26,7 +26,7 @@ module.exports = {
|
|||||||
'abstract-paperless-service',
|
'abstract-paperless-service',
|
||||||
],
|
],
|
||||||
transformIgnorePatterns: [
|
transformIgnorePatterns: [
|
||||||
'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|normalize-diacritics|marked|@angular/common/locales/.*\\.js$))',
|
'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|normalize-diacritics|@angular/common/locales/.*\\.js$))',
|
||||||
],
|
],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
...esmPreset.moduleNameMapper,
|
...esmPreset.moduleNameMapper,
|
||||||
|
|||||||
@@ -30,7 +30,6 @@
|
|||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"lodash-es": "^4.18.1",
|
"lodash-es": "^4.18.1",
|
||||||
"marked": "~18.0.14",
|
|
||||||
"mime-names": "^1.0.0",
|
"mime-names": "^1.0.0",
|
||||||
"ngx-bootstrap-icons": "^1.9.3",
|
"ngx-bootstrap-icons": "^1.9.3",
|
||||||
"ngx-color": "^10.1.0",
|
"ngx-color": "^10.1.0",
|
||||||
|
|||||||
Generated
-10
@@ -53,9 +53,6 @@ importers:
|
|||||||
lodash-es:
|
lodash-es:
|
||||||
specifier: ^4.18.1
|
specifier: ^4.18.1
|
||||||
version: 4.18.1
|
version: 4.18.1
|
||||||
marked:
|
|
||||||
specifier: ~18.0.14
|
|
||||||
version: 18.0.14
|
|
||||||
mime-names:
|
mime-names:
|
||||||
specifier: ^1.0.0
|
specifier: ^1.0.0
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
@@ -3229,11 +3226,6 @@ packages:
|
|||||||
make-error@1.3.6:
|
make-error@1.3.6:
|
||||||
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
|
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
|
||||||
|
|
||||||
marked@18.0.14:
|
|
||||||
resolution: {integrity: sha512-mBHK6FBHuBAlhgRe88w9F0O1AbwwXJUcQibUbC/QcdTbVGAD7aWza+xt3N6oT/jCZx3/OMeS+8rnuiHZcQ9s7A==}
|
|
||||||
engines: {node: '>= 20'}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
material-colors@1.2.6:
|
material-colors@1.2.6:
|
||||||
resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==}
|
resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==}
|
||||||
|
|
||||||
@@ -7417,8 +7409,6 @@ snapshots:
|
|||||||
|
|
||||||
make-error@1.3.6: {}
|
make-error@1.3.6: {}
|
||||||
|
|
||||||
marked@18.0.14: {}
|
|
||||||
|
|
||||||
material-colors@1.2.6: {}
|
material-colors@1.2.6: {}
|
||||||
|
|
||||||
merge-stream@2.0.0: {}
|
merge-stream@2.0.0: {}
|
||||||
|
|||||||
@@ -1,35 +1,23 @@
|
|||||||
|
|
||||||
<li ngbDropdown class="nav-item" display="dynamic" placement="bottom-end" [popperOptions]="popperOptions" (openChange)="onOpenChange($event)">
|
<li ngbDropdown class="nav-item" (openChange)="onOpenChange($event)">
|
||||||
<button class="btn navbar-action border-0" id="chatDropdown" ngbDropdownToggle aria-label="Chat" i18n-aria-label>
|
<button class="btn navbar-action border-0" id="chatDropdown" ngbDropdownToggle aria-label="Chat" i18n-aria-label>
|
||||||
<i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs>
|
<i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
|
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
|
||||||
<div class="chat-container bg-light p-2">
|
<div class="chat-container bg-light p-2">
|
||||||
<div class="chat-messages small">
|
<div class="chat-messages font-monospace small">
|
||||||
@for (message of messages(); track message) {
|
@for (message of messages(); track message) {
|
||||||
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'">
|
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'">
|
||||||
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'" [class.flex-grow-1]="message.role === 'assistant'">
|
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'">
|
||||||
@if (message.isStreaming && !message.content) {
|
<span class="text-break">
|
||||||
<div class="d-flex align-items-center text-muted">
|
{{ message.content }}
|
||||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
|
||||||
@if (slowResponse()) {
|
|
||||||
<ng-container i18n>Still thinking...</ng-container>
|
|
||||||
} @else {
|
|
||||||
<ng-container i18n>Thinking...</ng-container>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
} @else if (message.role === 'assistant') {
|
|
||||||
<div class="chat-markdown text-break text-wrap" [innerHTML]="message.content | markdown"></div>
|
|
||||||
@if (message.isStreaming) { <span class="blinking-cursor">|</span> }
|
@if (message.isStreaming) { <span class="blinking-cursor">|</span> }
|
||||||
} @else {
|
</span>
|
||||||
<span class="text-break">{{ message.content }}</span>
|
|
||||||
}
|
|
||||||
@if (message.role === 'assistant' && message.references?.length) {
|
@if (message.role === 'assistant' && message.references?.length) {
|
||||||
<div class="chat-references list-group mt-3">
|
<div class="chat-references list-group mt-3">
|
||||||
<div class="list-group-item text-muted small py-1" i18n>Sources</div>
|
|
||||||
@for (reference of message.references; track reference.id) {
|
@for (reference of message.references; track reference.id) {
|
||||||
<a class="list-group-item list-group-item-action d-flex align-items-center text-primary" [routerLink]="['/documents', reference.id]" [title]="reference.title">
|
<a class="list-group-item list-group-item-action d-flex text-primary text-break" [routerLink]="['/documents', reference.id]">
|
||||||
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-2 flex-shrink-0"></i-bs><span class="text-truncate">{{ reference.title }}</span>
|
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{ reference.title }}</span>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -53,11 +41,7 @@
|
|||||||
(ngModelChange)="input.set($event)"
|
(ngModelChange)="input.set($event)"
|
||||||
(keydown)="searchInputKeyDown($event)"
|
(keydown)="searchInputKeyDown($event)"
|
||||||
/>
|
/>
|
||||||
@if (loading()) {
|
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading()">Send</button>
|
||||||
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="stopStreaming()" i18n>Stop</button>
|
|
||||||
} @else {
|
|
||||||
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()">Send</button>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
.dropdown-menu {
|
.dropdown-menu {
|
||||||
width: min(36rem, calc(100vw - 20px));
|
width: var(--pngx-toast-max-width);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-messages {
|
.chat-messages {
|
||||||
max-height: min(60dvh, 36rem);
|
max-height: 350px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-references {
|
||||||
|
font-family: var(--bs-font-sans-serif);
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown-toggle::after {
|
.dropdown-toggle::after {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -16,9 +20,9 @@
|
|||||||
white-space: initial;
|
white-space: initial;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 575.98px) {
|
@media screen and (max-width: 400px) {
|
||||||
.chat-messages {
|
:host ::ng-deep .dropdown-menu-end {
|
||||||
max-height: calc(100dvh - 12rem);
|
right: -3rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,19 +40,3 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-markdown ::ng-deep {
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
|
||||||
font-size: 1em;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
th, td {
|
|
||||||
padding: 0.15rem 0.4rem;
|
|
||||||
border: 1px solid var(--bs-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
> :last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -80,15 +80,8 @@ describe('ChatComponent', () => {
|
|||||||
expect(component.messages()[0].content).toBe('Hello')
|
expect(component.messages()[0].content).toBe('Hello')
|
||||||
expect(component.loading()).toBe(true)
|
expect(component.loading()).toBe(true)
|
||||||
|
|
||||||
fixture.detectChanges()
|
|
||||||
const messagesEl = fixture.nativeElement.querySelector('.chat-messages')
|
|
||||||
expect(messagesEl.querySelector('.spinner-border')).not.toBeNull()
|
|
||||||
expect(messagesEl.textContent).toContain('Thinking...')
|
|
||||||
|
|
||||||
mockStream$.next('Hi')
|
mockStream$.next('Hi')
|
||||||
expect(component.messages()[1].content).toBe('H')
|
expect(component.messages()[1].content).toBe('H')
|
||||||
fixture.detectChanges()
|
|
||||||
expect(messagesEl.querySelector('.spinner-border')).toBeNull()
|
|
||||||
mockStream$.next('Hi there')
|
mockStream$.next('Hi there')
|
||||||
// advance time to process the typewriter effect
|
// advance time to process the typewriter effect
|
||||||
await jest.runAllTimersAsync()
|
await jest.runAllTimersAsync()
|
||||||
@@ -163,75 +156,13 @@ describe('ChatComponent', () => {
|
|||||||
component.input.set('Hello')
|
component.input.set('Hello')
|
||||||
component.sendMessage()
|
component.sendMessage()
|
||||||
|
|
||||||
mockStream$.next('Partial')
|
|
||||||
mockStream$.error('Error')
|
mockStream$.error('Error')
|
||||||
expect(component.messages()[1].content).toBe(
|
expect(component.messages()[1].content).toContain(
|
||||||
'Partial\n\n⚠️ Error receiving response.'
|
'⚠️ Error receiving response.'
|
||||||
)
|
)
|
||||||
expect(component.loading()).toBe(false)
|
expect(component.loading()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should keep streaming until the typewriter catches up', () => {
|
|
||||||
component.input.set('Hello')
|
|
||||||
component.sendMessage()
|
|
||||||
|
|
||||||
mockStream$.next('Hi there')
|
|
||||||
mockStream$.complete()
|
|
||||||
expect(component.messages()[1].isStreaming).toBe(true)
|
|
||||||
expect(component.loading()).toBe(true)
|
|
||||||
|
|
||||||
jest.advanceTimersByTime(1000)
|
|
||||||
expect(component.messages()[1].content).toBe('Hi there')
|
|
||||||
expect(component.messages()[1].isStreaming).toBe(false)
|
|
||||||
expect(component.loading()).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should show a slow response message while waiting', () => {
|
|
||||||
component.input.set('Hello')
|
|
||||||
component.sendMessage()
|
|
||||||
fixture.detectChanges()
|
|
||||||
const messagesEl = fixture.nativeElement.querySelector('.chat-messages')
|
|
||||||
expect(messagesEl.textContent).not.toContain('Still thinking...')
|
|
||||||
|
|
||||||
jest.advanceTimersByTime(8000)
|
|
||||||
fixture.detectChanges()
|
|
||||||
expect(messagesEl.textContent).toContain('Still thinking...')
|
|
||||||
|
|
||||||
mockStream$.next('Hi')
|
|
||||||
expect(component.slowResponse()).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should stop streaming and keep the partial response', () => {
|
|
||||||
component.input.set('Hello')
|
|
||||||
component.sendMessage()
|
|
||||||
fixture.detectChanges()
|
|
||||||
const stopButton = fixture.nativeElement.querySelector('.chat-input button')
|
|
||||||
expect(stopButton.textContent).toContain('Stop')
|
|
||||||
|
|
||||||
mockStream$.next('Hi there')
|
|
||||||
jest.advanceTimersByTime(20)
|
|
||||||
stopButton.click()
|
|
||||||
|
|
||||||
expect(mockStream$.observed).toBe(false)
|
|
||||||
expect(component.messages()[1].content).toBe('Hi there')
|
|
||||||
expect(component.messages()[1].isStreaming).toBe(false)
|
|
||||||
expect(component.loading()).toBe(false)
|
|
||||||
|
|
||||||
jest.advanceTimersByTime(1000)
|
|
||||||
expect(component.messages()[1].content).toBe('Hi there')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should drop the empty response when stopped before any content', () => {
|
|
||||||
component.input.set('Hello')
|
|
||||||
component.sendMessage()
|
|
||||||
component.stopStreaming()
|
|
||||||
|
|
||||||
expect(component.messages()).toHaveLength(1)
|
|
||||||
expect(component.loading()).toBe(false)
|
|
||||||
component.stopStreaming() // no-op once stopped
|
|
||||||
expect(component.messages()).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should enqueue typewriter chunks correctly', () => {
|
it('should enqueue typewriter chunks correctly', () => {
|
||||||
const message = { content: '', role: 'assistant', isStreaming: true }
|
const message = { content: '', role: 'assistant', isStreaming: true }
|
||||||
component.enqueueTypewriter(null, message as any) // coverage for null
|
component.enqueueTypewriter(null, message as any) // coverage for null
|
||||||
@@ -276,18 +207,4 @@ describe('ChatComponent', () => {
|
|||||||
component.searchInputKeyDown(event)
|
component.searchInputKeyDown(event)
|
||||||
expect(component.sendMessage).not.toHaveBeenCalled()
|
expect(component.sendMessage).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render markdown for assistant messages only', () => {
|
|
||||||
component.messages.set([
|
|
||||||
{ role: 'user', content: '**user**' },
|
|
||||||
{ role: 'assistant', content: '**assistant** <script>x</script>' },
|
|
||||||
])
|
|
||||||
fixture.detectChanges()
|
|
||||||
const el: HTMLElement = fixture.nativeElement
|
|
||||||
expect(el.querySelector('.chat-markdown strong')?.textContent).toBe(
|
|
||||||
'assistant'
|
|
||||||
)
|
|
||||||
expect(el.querySelector('.chat-markdown script')).toBeNull()
|
|
||||||
expect(el.textContent).toContain('**user**')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
@@ -10,16 +10,12 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
|||||||
import { NavigationEnd, Router, RouterModule } from '@angular/router'
|
import { NavigationEnd, Router, RouterModule } from '@angular/router'
|
||||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { filter, map, Subscription } from 'rxjs'
|
import { filter, map } from 'rxjs'
|
||||||
import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
|
|
||||||
import {
|
import {
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
ChatService,
|
ChatService,
|
||||||
parseChatResponse,
|
parseChatResponse,
|
||||||
} from 'src/app/services/chat.service'
|
} from 'src/app/services/chat.service'
|
||||||
import { pngxPopperOptions } from 'src/app/utils/popper-options'
|
|
||||||
|
|
||||||
const SLOW_RESPONSE_DELAY_MS = 8000
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-chat',
|
selector: 'pngx-chat',
|
||||||
@@ -29,7 +25,6 @@ const SLOW_RESPONSE_DELAY_MS = 8000
|
|||||||
RouterModule,
|
RouterModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
MarkdownPipe,
|
|
||||||
],
|
],
|
||||||
templateUrl: './chat.component.html',
|
templateUrl: './chat.component.html',
|
||||||
styleUrl: './chat.component.scss',
|
styleUrl: './chat.component.scss',
|
||||||
@@ -39,8 +34,6 @@ export class ChatComponent implements OnInit {
|
|||||||
readonly loading = signal(false)
|
readonly loading = signal(false)
|
||||||
readonly input = signal('')
|
readonly input = signal('')
|
||||||
readonly documentId = signal<number>(undefined)
|
readonly documentId = signal<number>(undefined)
|
||||||
readonly slowResponse = signal(false)
|
|
||||||
public popperOptions = pngxPopperOptions
|
|
||||||
|
|
||||||
private chatService: ChatService = inject(ChatService)
|
private chatService: ChatService = inject(ChatService)
|
||||||
private router: Router = inject(Router)
|
private router: Router = inject(Router)
|
||||||
@@ -50,11 +43,6 @@ export class ChatComponent implements OnInit {
|
|||||||
|
|
||||||
private typewriterBuffer: string[] = []
|
private typewriterBuffer: string[] = []
|
||||||
private typewriterActive = false
|
private typewriterActive = false
|
||||||
private typewriterTimeout: ReturnType<typeof setTimeout>
|
|
||||||
private streamSubscription: Subscription
|
|
||||||
private streamingMessage: ChatMessage
|
|
||||||
private streamFinished = false
|
|
||||||
private slowResponseTimeout: ReturnType<typeof setTimeout>
|
|
||||||
|
|
||||||
public get placeholder(): string {
|
public get placeholder(): string {
|
||||||
return this.documentId()
|
return this.documentId()
|
||||||
@@ -93,20 +81,11 @@ export class ChatComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
this.messages.update((messages) => [...messages, assistantMessage])
|
this.messages.update((messages) => [...messages, assistantMessage])
|
||||||
this.loading.set(true)
|
this.loading.set(true)
|
||||||
this.streamingMessage = assistantMessage
|
|
||||||
this.streamFinished = false
|
|
||||||
this.slowResponseTimeout = setTimeout(
|
|
||||||
() => this.slowResponse.set(true),
|
|
||||||
SLOW_RESPONSE_DELAY_MS
|
|
||||||
)
|
|
||||||
|
|
||||||
let lastVisibleContent = ''
|
let lastVisibleContent = ''
|
||||||
|
|
||||||
this.streamSubscription = this.chatService
|
this.chatService.streamChat(this.documentId(), this.input()).subscribe({
|
||||||
.streamChat(this.documentId(), this.input())
|
|
||||||
.subscribe({
|
|
||||||
next: (chunk) => {
|
next: (chunk) => {
|
||||||
this.clearSlowResponse()
|
|
||||||
const nextResponse = parseChatResponse(chunk)
|
const nextResponse = parseChatResponse(chunk)
|
||||||
|
|
||||||
if (nextResponse.content.length < lastVisibleContent.length) {
|
if (nextResponse.content.length < lastVisibleContent.length) {
|
||||||
@@ -124,59 +103,25 @@ export class ChatComponent implements OnInit {
|
|||||||
this.notifyMessagesChanged()
|
this.notifyMessagesChanged()
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
this.flushTypewriter(assistantMessage)
|
|
||||||
assistantMessage.content += '\n\n⚠️ Error receiving response.'
|
assistantMessage.content += '\n\n⚠️ Error receiving response.'
|
||||||
this.finishStreaming(assistantMessage)
|
assistantMessage.isStreaming = false
|
||||||
|
this.notifyMessagesChanged()
|
||||||
|
this.loading.set(false)
|
||||||
},
|
},
|
||||||
complete: () => {
|
complete: () => {
|
||||||
// let the typewriter catch up before hiding the cursor
|
assistantMessage.isStreaming = false
|
||||||
this.streamFinished = true
|
this.notifyMessagesChanged()
|
||||||
if (!this.typewriterActive) this.finishStreaming(assistantMessage)
|
this.loading.set(false)
|
||||||
|
this.scrollToBottom()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
this.input.set('')
|
this.input.set('')
|
||||||
}
|
}
|
||||||
|
|
||||||
stopStreaming(): void {
|
private resetTypewriter(message: ChatMessage, content: string): void {
|
||||||
const message = this.streamingMessage
|
|
||||||
if (!message?.isStreaming) return
|
|
||||||
|
|
||||||
this.streamSubscription?.unsubscribe()
|
|
||||||
this.flushTypewriter(message)
|
|
||||||
if (!message.content) {
|
|
||||||
this.messages.update((messages) => messages.filter((m) => m !== message))
|
|
||||||
}
|
|
||||||
this.finishStreaming(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
private finishStreaming(message: ChatMessage): void {
|
|
||||||
this.clearSlowResponse()
|
|
||||||
this.streamFinished = false
|
|
||||||
message.isStreaming = false
|
|
||||||
this.notifyMessagesChanged()
|
|
||||||
this.loading.set(false)
|
|
||||||
this.scrollToBottom()
|
|
||||||
}
|
|
||||||
|
|
||||||
private clearSlowResponse(): void {
|
|
||||||
clearTimeout(this.slowResponseTimeout)
|
|
||||||
this.slowResponse.set(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
private flushTypewriter(message: ChatMessage): void {
|
|
||||||
message.content += this.typewriterBuffer.join('')
|
|
||||||
this.cancelTypewriter()
|
|
||||||
}
|
|
||||||
|
|
||||||
private cancelTypewriter(): void {
|
|
||||||
clearTimeout(this.typewriterTimeout)
|
|
||||||
this.typewriterBuffer = []
|
this.typewriterBuffer = []
|
||||||
this.typewriterActive = false
|
this.typewriterActive = false
|
||||||
}
|
|
||||||
|
|
||||||
private resetTypewriter(message: ChatMessage, content: string): void {
|
|
||||||
this.cancelTypewriter()
|
|
||||||
message.content = content
|
message.content = content
|
||||||
this.notifyMessagesChanged()
|
this.notifyMessagesChanged()
|
||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
@@ -196,7 +141,6 @@ export class ChatComponent implements OnInit {
|
|||||||
playTypewriter(message: ChatMessage): void {
|
playTypewriter(message: ChatMessage): void {
|
||||||
if (this.typewriterBuffer.length === 0) {
|
if (this.typewriterBuffer.length === 0) {
|
||||||
this.typewriterActive = false
|
this.typewriterActive = false
|
||||||
if (this.streamFinished) this.finishStreaming(message)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,8 +149,7 @@ export class ChatComponent implements OnInit {
|
|||||||
this.notifyMessagesChanged()
|
this.notifyMessagesChanged()
|
||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
|
|
||||||
// 10ms per character
|
setTimeout(() => this.playTypewriter(message), 10) // 10ms per character
|
||||||
this.typewriterTimeout = setTimeout(() => this.playTypewriter(message), 10)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private notifyMessagesChanged(): void {
|
private notifyMessagesChanged(): void {
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
import { MarkdownPipe } from './markdown.pipe'
|
|
||||||
|
|
||||||
describe('MarkdownPipe', () => {
|
|
||||||
const pipe = new MarkdownPipe()
|
|
||||||
|
|
||||||
it('should return empty string for empty input', () => {
|
|
||||||
expect(pipe.transform(null)).toEqual('')
|
|
||||||
expect(pipe.transform(undefined)).toEqual('')
|
|
||||||
expect(pipe.transform('')).toEqual('')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should render basic markdown', () => {
|
|
||||||
const html = pipe.transform(
|
|
||||||
'**bold** _em_ `code`\n\n- one\n- two\n\n| a | b |\n|---|---|\n| 1 | 2 |'
|
|
||||||
)
|
|
||||||
expect(html).toContain('<strong>bold</strong>')
|
|
||||||
expect(html).toContain('<em>em</em>')
|
|
||||||
expect(html).toContain('<code>code</code>')
|
|
||||||
expect(html).toContain('<li>one</li>')
|
|
||||||
expect(html).toContain('<table>')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should escape raw html', () => {
|
|
||||||
const html = pipe.transform(
|
|
||||||
'hi <img src=x onerror="alert(1)"> <b>x</b>\n\n<script>alert(1)</script>'
|
|
||||||
)
|
|
||||||
expect(html).not.toContain('<img')
|
|
||||||
expect(html).not.toContain('<script')
|
|
||||||
expect(html).not.toContain('<b>')
|
|
||||||
expect(html).toContain('<script>')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should not render images', () => {
|
|
||||||
const html = pipe.transform('')
|
|
||||||
expect(html).not.toContain('<img')
|
|
||||||
expect(html).not.toContain('evil.example')
|
|
||||||
expect(html).toContain('secret')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should render safe links with target and rel', () => {
|
|
||||||
const html = pipe.transform('[docs](https://docs.paperless-ngx.com "Docs")')
|
|
||||||
expect(html).toContain(
|
|
||||||
'<a href="https://docs.paperless-ngx.com/" title="Docs" target="_blank" rel="noopener noreferrer nofollow">docs</a>'
|
|
||||||
)
|
|
||||||
expect(pipe.transform('[mail](mailto:a@b.c)')).toContain(
|
|
||||||
'href="mailto:a@b.c"'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should drop unsafe or relative links but keep their text', () => {
|
|
||||||
for (const href of [
|
|
||||||
'javascript:alert(1)',
|
|
||||||
'JaVaScRiPt:alert(1)',
|
|
||||||
'data:text/html,<script>alert(1)</script>',
|
|
||||||
'vbscript:msgbox',
|
|
||||||
'/api/documents/',
|
|
||||||
]) {
|
|
||||||
const html = pipe.transform(`[click](${href})`)
|
|
||||||
expect(html).not.toContain('<a')
|
|
||||||
expect(html).toContain('click')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should escape link titles', () => {
|
|
||||||
const html = pipe.transform(
|
|
||||||
'[x](https://a.example "a\\" onmouseover=\\"1")'
|
|
||||||
)
|
|
||||||
expect(html).not.toMatch(/"\s*onmouseover=/)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { Pipe, PipeTransform } from '@angular/core'
|
|
||||||
import { Marked, Renderer, Tokens } from 'marked'
|
|
||||||
|
|
||||||
const ALLOWED_LINK_PROTOCOLS = ['http:', 'https:', 'mailto:']
|
|
||||||
|
|
||||||
function escapeHtml(text: string): string {
|
|
||||||
return text
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''')
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeHref(href: string): string | null {
|
|
||||||
try {
|
|
||||||
const url = new URL(href)
|
|
||||||
return ALLOWED_LINK_PROTOCOLS.includes(url.protocol) ? url.href : null
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Treat chat content as untrusted: no raw HTML, no images, and only
|
|
||||||
// absolute links. Angular sanitizer runs on top of this as well.
|
|
||||||
const renderer: Partial<Renderer> = {
|
|
||||||
html({ text }: Tokens.HTML | Tokens.Tag): string {
|
|
||||||
return escapeHtml(text)
|
|
||||||
},
|
|
||||||
image({ text }: Tokens.Image): string {
|
|
||||||
return escapeHtml(text)
|
|
||||||
},
|
|
||||||
link(this: Renderer, { href, title, tokens }: Tokens.Link): string {
|
|
||||||
const text = this.parser.parseInline(tokens)
|
|
||||||
const url = safeHref(href)
|
|
||||||
if (!url) return text
|
|
||||||
const titleAttr = title ? ` title="${escapeHtml(title)}"` : ''
|
|
||||||
return `<a href="${escapeHtml(url)}"${titleAttr} target="_blank" rel="noopener noreferrer nofollow">${text}</a>`
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const markdown = new Marked({
|
|
||||||
async: false,
|
|
||||||
gfm: true,
|
|
||||||
breaks: true,
|
|
||||||
renderer,
|
|
||||||
})
|
|
||||||
|
|
||||||
@Pipe({
|
|
||||||
name: 'markdown',
|
|
||||||
})
|
|
||||||
export class MarkdownPipe implements PipeTransform {
|
|
||||||
transform(value: string | null | undefined): string {
|
|
||||||
if (!value) return ''
|
|
||||||
return markdown.parse(value) as string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -28,6 +28,7 @@ from rest_framework.permissions import BasePermission
|
|||||||
from rest_framework.permissions import DjangoObjectPermissions
|
from rest_framework.permissions import DjangoObjectPermissions
|
||||||
|
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
|
from documents.versioning import get_root_document
|
||||||
|
|
||||||
|
|
||||||
class PaperlessObjectPermissions(DjangoObjectPermissions):
|
class PaperlessObjectPermissions(DjangoObjectPermissions):
|
||||||
@@ -679,7 +680,14 @@ def has_perms_owner_aware(user, perms, obj):
|
|||||||
single-object check still has many production callers. Several callers
|
single-object check still has many production callers. Several callers
|
||||||
remain across ``documents/``, ``paperless_mail/``, and ``paperless_ai/``
|
remain across ``documents/``, ``paperless_mail/``, and ``paperless_ai/``
|
||||||
-- grep for this function name before removing it.
|
-- grep for this function name before removing it.
|
||||||
|
|
||||||
|
A document version is authorized by its root document, like in
|
||||||
|
``permitted_document_ids``, so a version's own owner and grants never
|
||||||
|
matter. Fetch the root with ``select_related("root_document__owner")`` to
|
||||||
|
avoid extra queries.
|
||||||
"""
|
"""
|
||||||
|
if isinstance(obj, Document):
|
||||||
|
obj = get_root_document(obj)
|
||||||
checker = ObjectPermissionChecker(user)
|
checker = ObjectPermissionChecker(user)
|
||||||
return obj.owner is None or obj.owner == user or checker.has_perm(perms, obj)
|
return obj.owner is None or obj.owner == user or checker.has_perm(perms, obj)
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ from documents.templating.utils import convert_format_str_to_template_format
|
|||||||
from documents.templating.workflows import validate_workflow_template
|
from documents.templating.workflows import validate_workflow_template
|
||||||
from documents.validators import uri_validator
|
from documents.validators import uri_validator
|
||||||
from documents.validators import url_validator
|
from documents.validators import url_validator
|
||||||
from documents.versioning import get_root_document
|
|
||||||
from documents.versioning import has_prefetched_effective_content
|
from documents.versioning import has_prefetched_effective_content
|
||||||
from documents.versioning import sort_versions_newest_first
|
from documents.versioning import sort_versions_newest_first
|
||||||
|
|
||||||
@@ -2895,7 +2894,7 @@ class ShareLinkSerializer(OwnedObjectSerializer):
|
|||||||
and has_perms_owner_aware(
|
and has_perms_owner_aware(
|
||||||
self.user,
|
self.user,
|
||||||
"view_document",
|
"view_document",
|
||||||
get_root_document(document),
|
document,
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
return document
|
return document
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from documents.models import Correspondent
|
|||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
|
from documents.permissions import has_perms_owner_aware
|
||||||
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 restrict_queryset_to_visible
|
from documents.permissions import restrict_queryset_to_visible
|
||||||
@@ -470,6 +471,92 @@ class TestPermittedDocumentIdsVersions:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestHasPermsOwnerAwareVersions:
|
||||||
|
"""
|
||||||
|
The single-object check agrees with permitted_document_ids: a version is
|
||||||
|
authorized by its root document.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("root_owner", "version_owner", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
"other",
|
||||||
|
"nobody",
|
||||||
|
False,
|
||||||
|
id="unowned-version-of-private-root",
|
||||||
|
),
|
||||||
|
pytest.param("other", "user", False, id="own-version-of-private-root"),
|
||||||
|
pytest.param("user", "other", True, id="foreign-version-of-own-root"),
|
||||||
|
pytest.param("nobody", "other", True, id="private-version-of-unowned-root"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_version_follows_root_owner(
|
||||||
|
self,
|
||||||
|
root_owner: str,
|
||||||
|
version_owner: str,
|
||||||
|
*,
|
||||||
|
expected: bool,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A root document and a version with differing owners
|
||||||
|
WHEN:
|
||||||
|
- The single-object check runs for the version
|
||||||
|
THEN:
|
||||||
|
- The version is allowed exactly when its root is
|
||||||
|
"""
|
||||||
|
user = UserFactory()
|
||||||
|
owners = {"user": user, "other": UserFactory(), "nobody": None}
|
||||||
|
root = DocumentFactory(owner=owners[root_owner])
|
||||||
|
version = DocumentFactory(root_document=root, owner=owners[version_owner])
|
||||||
|
|
||||||
|
assert has_perms_owner_aware(user, "view_document", version) is expected
|
||||||
|
assert has_perms_owner_aware(user, "view_document", root) is expected
|
||||||
|
|
||||||
|
def test_grant_on_root_applies_and_grant_on_version_does_not(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A private root with a version, and a second private root with a version
|
||||||
|
- The user may change only the first root, and was granted the second
|
||||||
|
root's version directly
|
||||||
|
WHEN:
|
||||||
|
- The single-object check runs for each version
|
||||||
|
THEN:
|
||||||
|
- Only the first root's version is allowed
|
||||||
|
"""
|
||||||
|
user = UserFactory()
|
||||||
|
shared_root = DocumentFactory(owner=UserFactory())
|
||||||
|
shared_version = DocumentFactory(root_document=shared_root, owner=UserFactory())
|
||||||
|
private_root = DocumentFactory(owner=UserFactory())
|
||||||
|
private_version = DocumentFactory(
|
||||||
|
root_document=private_root,
|
||||||
|
owner=UserFactory(),
|
||||||
|
)
|
||||||
|
grant_object(user, shared_root, "change_document")
|
||||||
|
grant_object(user, private_version, "change_document")
|
||||||
|
|
||||||
|
assert has_perms_owner_aware(user, "change_document", shared_version)
|
||||||
|
assert not has_perms_owner_aware(user, "change_document", private_version)
|
||||||
|
|
||||||
|
def test_other_models_use_their_own_owner(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A tag owned by someone else, and one owned by the user
|
||||||
|
WHEN:
|
||||||
|
- The single-object check runs for each
|
||||||
|
THEN:
|
||||||
|
- Only the user's own tag is allowed without a grant
|
||||||
|
"""
|
||||||
|
user = UserFactory()
|
||||||
|
mine = TagFactory(owner=user)
|
||||||
|
theirs = TagFactory(owner=UserFactory())
|
||||||
|
|
||||||
|
assert has_perms_owner_aware(user, "view_tag", mine)
|
||||||
|
assert not has_perms_owner_aware(user, "view_tag", theirs)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestAiChatAllDocumentsPermissionBoundary:
|
class TestAiChatAllDocumentsPermissionBoundary:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1559,7 +1559,7 @@ class DocumentViewSet(
|
|||||||
if request.user is not None and not has_perms_owner_aware(
|
if request.user is not None and not has_perms_owner_aware(
|
||||||
request.user,
|
request.user,
|
||||||
"change_document",
|
"change_document",
|
||||||
get_root_document(doc),
|
doc,
|
||||||
):
|
):
|
||||||
return HttpResponseForbidden("Insufficient permissions")
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
|
|
||||||
@@ -1622,7 +1622,7 @@ class DocumentViewSet(
|
|||||||
if request.user is not None and not has_perms_owner_aware(
|
if request.user is not None and not has_perms_owner_aware(
|
||||||
request.user,
|
request.user,
|
||||||
"change_document",
|
"change_document",
|
||||||
get_root_document(doc),
|
doc,
|
||||||
):
|
):
|
||||||
return HttpResponseForbidden("Insufficient permissions")
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
|
|
||||||
@@ -1875,7 +1875,7 @@ class DocumentViewSet(
|
|||||||
if currentUser is not None and not has_perms_owner_aware(
|
if currentUser is not None and not has_perms_owner_aware(
|
||||||
currentUser,
|
currentUser,
|
||||||
"view_document",
|
"view_document",
|
||||||
get_root_document(doc),
|
doc,
|
||||||
):
|
):
|
||||||
return HttpResponseForbidden("Insufficient permissions to view notes")
|
return HttpResponseForbidden("Insufficient permissions to view notes")
|
||||||
except Document.DoesNotExist:
|
except Document.DoesNotExist:
|
||||||
@@ -1897,7 +1897,7 @@ class DocumentViewSet(
|
|||||||
if currentUser is not None and not has_perms_owner_aware(
|
if currentUser is not None and not has_perms_owner_aware(
|
||||||
currentUser,
|
currentUser,
|
||||||
"change_document",
|
"change_document",
|
||||||
get_root_document(doc),
|
doc,
|
||||||
):
|
):
|
||||||
return HttpResponseForbidden(
|
return HttpResponseForbidden(
|
||||||
"Insufficient permissions to create notes",
|
"Insufficient permissions to create notes",
|
||||||
@@ -1940,7 +1940,7 @@ class DocumentViewSet(
|
|||||||
if currentUser is not None and not has_perms_owner_aware(
|
if currentUser is not None and not has_perms_owner_aware(
|
||||||
currentUser,
|
currentUser,
|
||||||
"change_document",
|
"change_document",
|
||||||
get_root_document(doc),
|
doc,
|
||||||
):
|
):
|
||||||
return HttpResponseForbidden("Insufficient permissions to delete notes")
|
return HttpResponseForbidden("Insufficient permissions to delete notes")
|
||||||
|
|
||||||
@@ -1990,7 +1990,7 @@ class DocumentViewSet(
|
|||||||
if currentUser is not None and not has_perms_owner_aware(
|
if currentUser is not None and not has_perms_owner_aware(
|
||||||
currentUser,
|
currentUser,
|
||||||
"change_document",
|
"change_document",
|
||||||
get_root_document(doc),
|
doc,
|
||||||
):
|
):
|
||||||
return HttpResponseForbidden(
|
return HttpResponseForbidden(
|
||||||
"Insufficient permissions to add share link",
|
"Insufficient permissions to add share link",
|
||||||
@@ -2451,7 +2451,7 @@ class ChatStreamingView(GenericAPIView[Any]):
|
|||||||
if not has_perms_owner_aware(
|
if not has_perms_owner_aware(
|
||||||
request.user,
|
request.user,
|
||||||
"view_document",
|
"view_document",
|
||||||
get_root_document(document),
|
document,
|
||||||
):
|
):
|
||||||
return HttpResponseForbidden("Insufficient permissions")
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-10-10 22:04+0000\n"
|
"POT-Creation-Date: 2026-10-08 18:39+0000\n"
|
||||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: English\n"
|
"Language-Team: English\n"
|
||||||
@@ -1653,7 +1653,7 @@ msgid "workflow runs"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:516 documents/serialisers.py:873
|
#: documents/serialisers.py:516 documents/serialisers.py:873
|
||||||
#: documents/serialisers.py:2903 documents/views.py:343 documents/views.py:2750
|
#: documents/serialisers.py:2903 documents/views.py:344 documents/views.py:2751
|
||||||
#: paperless_mail/serialisers.py:156
|
#: paperless_mail/serialisers.py:156
|
||||||
msgid "Insufficient permissions."
|
msgid "Insufficient permissions."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -1694,7 +1694,7 @@ msgstr ""
|
|||||||
msgid "Duplicate document identifiers are not allowed."
|
msgid "Duplicate document identifiers are not allowed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2989 documents/views.py:4797
|
#: documents/serialisers.py:2989 documents/views.py:4807
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Documents not found: %(ids)s"
|
msgid "Documents not found: %(ids)s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -1945,40 +1945,40 @@ msgstr ""
|
|||||||
msgid ", "
|
msgid ", "
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:336 documents/views.py:2747
|
#: documents/views.py:337 documents/views.py:2748
|
||||||
msgid "Invalid more_like_id"
|
msgid "Invalid more_like_id"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1682
|
#: documents/views.py:1683
|
||||||
msgid "Invalid AI configuration."
|
msgid "Invalid AI configuration."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1693
|
#: documents/views.py:1694
|
||||||
msgid "AI backend request timed out."
|
msgid "AI backend request timed out."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1705
|
#: documents/views.py:1706
|
||||||
msgid "AI backend rejected the request. Check logs for details."
|
msgid "AI backend rejected the request. Check logs for details."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:2572 documents/views.py:2888
|
#: documents/views.py:2573 documents/views.py:2889
|
||||||
msgid "Specify only one of text, title_search, query, or more_like_id."
|
msgid "Specify only one of text, title_search, query, or more_like_id."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4813
|
#: documents/views.py:4823
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Insufficient permissions to share document %(id)s."
|
msgid "Insufficient permissions to share document %(id)s."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4860
|
#: documents/views.py:4870
|
||||||
msgid "Bundle is already being processed."
|
msgid "Bundle is already being processed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4924
|
#: documents/views.py:4934
|
||||||
msgid "The share link bundle is still being prepared. Please try again later."
|
msgid "The share link bundle is still being prepared. Please try again later."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4938
|
#: documents/views.py:4948
|
||||||
msgid "The share link bundle is unavailable."
|
msgid "The share link bundle is unavailable."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
+11
-10
@@ -272,26 +272,27 @@ def check_deprecated_db_settings(
|
|||||||
Detects legacy advanced options that should be migrated to
|
Detects legacy advanced options that should be migrated to
|
||||||
PAPERLESS_DB_OPTIONS. Returns one Warning per deprecated variable found.
|
PAPERLESS_DB_OPTIONS. Returns one Warning per deprecated variable found.
|
||||||
"""
|
"""
|
||||||
deprecated_vars = (
|
deprecated_vars: dict[str, str] = {
|
||||||
"PAPERLESS_DB_TIMEOUT",
|
"PAPERLESS_DB_TIMEOUT": "timeout",
|
||||||
"PAPERLESS_DB_POOLSIZE",
|
"PAPERLESS_DB_POOLSIZE": "pool.min_size / pool.max_size",
|
||||||
"PAPERLESS_DBSSLMODE",
|
"PAPERLESS_DBSSLMODE": "sslmode",
|
||||||
"PAPERLESS_DBSSLROOTCERT",
|
"PAPERLESS_DBSSLROOTCERT": "sslrootcert",
|
||||||
"PAPERLESS_DBSSLCERT",
|
"PAPERLESS_DBSSLCERT": "sslcert",
|
||||||
"PAPERLESS_DBSSLKEY",
|
"PAPERLESS_DBSSLKEY": "sslkey",
|
||||||
)
|
}
|
||||||
|
|
||||||
warnings: list[Warning] = []
|
warnings: list[Warning] = []
|
||||||
|
|
||||||
for var_name in deprecated_vars:
|
for var_name, db_option_key in deprecated_vars.items():
|
||||||
if not os.getenv(var_name):
|
if not os.getenv(var_name):
|
||||||
continue
|
continue
|
||||||
warnings.append(
|
warnings.append(
|
||||||
Warning(
|
Warning(
|
||||||
f"Deprecated environment variable: {var_name}",
|
f"Deprecated environment variable: {var_name}",
|
||||||
hint=(
|
hint=(
|
||||||
f"{var_name} is deprecated. "
|
f"{var_name} is no longer supported and will be removed in v3.2. "
|
||||||
f"Set the equivalent option via PAPERLESS_DB_OPTIONS instead. "
|
f"Set the equivalent option via PAPERLESS_DB_OPTIONS instead. "
|
||||||
|
f'Example: PAPERLESS_DB_OPTIONS=\'{{"{db_option_key}": "<value>"}}\'. '
|
||||||
"See https://docs.paperless-ngx.com/migration-v3/ for the full reference."
|
"See https://docs.paperless-ngx.com/migration-v3/ for the full reference."
|
||||||
),
|
),
|
||||||
id="paperless.W001",
|
id="paperless.W001",
|
||||||
|
|||||||
@@ -211,14 +211,14 @@ class TestAuditLogChecks:
|
|||||||
assert "auditlog table was found but audit log is disabled." in msgs[0].msg
|
assert "auditlog table was found but audit log is disabled." in msgs[0].msg
|
||||||
|
|
||||||
|
|
||||||
DEPRECATED_VARS = (
|
DEPRECATED_VARS: dict[str, str] = {
|
||||||
"PAPERLESS_DB_TIMEOUT",
|
"PAPERLESS_DB_TIMEOUT": "timeout",
|
||||||
"PAPERLESS_DB_POOLSIZE",
|
"PAPERLESS_DB_POOLSIZE": "pool.min_size / pool.max_size",
|
||||||
"PAPERLESS_DBSSLMODE",
|
"PAPERLESS_DBSSLMODE": "sslmode",
|
||||||
"PAPERLESS_DBSSLROOTCERT",
|
"PAPERLESS_DBSSLROOTCERT": "sslrootcert",
|
||||||
"PAPERLESS_DBSSLCERT",
|
"PAPERLESS_DBSSLCERT": "sslcert",
|
||||||
"PAPERLESS_DBSSLKEY",
|
"PAPERLESS_DBSSLKEY": "sslkey",
|
||||||
)
|
}
|
||||||
|
|
||||||
|
|
||||||
class TestDeprecatedDbSettings:
|
class TestDeprecatedDbSettings:
|
||||||
@@ -234,11 +234,26 @@ class TestDeprecatedDbSettings:
|
|||||||
result = check_deprecated_db_settings(None)
|
result = check_deprecated_db_settings(None)
|
||||||
assert result == []
|
assert result == []
|
||||||
|
|
||||||
@pytest.mark.parametrize("env_var", DEPRECATED_VARS)
|
@pytest.mark.parametrize(
|
||||||
|
("env_var", "db_option_key"),
|
||||||
|
[
|
||||||
|
pytest.param("PAPERLESS_DB_TIMEOUT", "timeout", id="db-timeout"),
|
||||||
|
pytest.param(
|
||||||
|
"PAPERLESS_DB_POOLSIZE",
|
||||||
|
"pool.min_size / pool.max_size",
|
||||||
|
id="db-poolsize",
|
||||||
|
),
|
||||||
|
pytest.param("PAPERLESS_DBSSLMODE", "sslmode", id="ssl-mode"),
|
||||||
|
pytest.param("PAPERLESS_DBSSLROOTCERT", "sslrootcert", id="ssl-rootcert"),
|
||||||
|
pytest.param("PAPERLESS_DBSSLCERT", "sslcert", id="ssl-cert"),
|
||||||
|
pytest.param("PAPERLESS_DBSSLKEY", "sslkey", id="ssl-key"),
|
||||||
|
],
|
||||||
|
)
|
||||||
def test_single_deprecated_var_produces_one_warning(
|
def test_single_deprecated_var_produces_one_warning(
|
||||||
self,
|
self,
|
||||||
mocker: MockerFixture,
|
mocker: MockerFixture,
|
||||||
env_var: str,
|
env_var: str,
|
||||||
|
db_option_key: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Each deprecated var in isolation produces exactly one warning."""
|
"""Each deprecated var in isolation produces exactly one warning."""
|
||||||
mocker.patch.dict(os.environ, {env_var: "some_value"}, clear=True)
|
mocker.patch.dict(os.environ, {env_var: "some_value"}, clear=True)
|
||||||
@@ -249,8 +264,7 @@ class TestDeprecatedDbSettings:
|
|||||||
assert isinstance(warning, Warning)
|
assert isinstance(warning, Warning)
|
||||||
assert warning.id == "paperless.W001"
|
assert warning.id == "paperless.W001"
|
||||||
assert env_var in warning.hint
|
assert env_var in warning.hint
|
||||||
assert "PAPERLESS_DB_OPTIONS" in warning.hint
|
assert db_option_key in warning.hint
|
||||||
assert "https://docs.paperless-ngx.com/migration-v3/" in warning.hint
|
|
||||||
|
|
||||||
def test_multiple_deprecated_vars_produce_one_warning_each(
|
def test_multiple_deprecated_vars_produce_one_warning_each(
|
||||||
self,
|
self,
|
||||||
|
|||||||
Reference in new issue
Block a user