mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-07-30 07:44:54 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
668fa77428 | ||
|
|
5bd72014a6 | ||
|
|
bbb9c86ba4 |
@@ -129,13 +129,25 @@ describe('PngxPdfViewerComponent', () => {
|
||||
;(component as any).applyScale()
|
||||
expect(viewer.currentScaleValue).toBe(PdfZoomScale.PageFit)
|
||||
expect(viewer.currentScale).toBe(2)
|
||||
})
|
||||
|
||||
it('does not reapply scale for page-only changes', async () => {
|
||||
await initComponent()
|
||||
|
||||
const pdf = (component as any).pdf as { numPages: number }
|
||||
pdf.numPages = 3
|
||||
const viewer = (component as any).pdfViewer as PDFViewer
|
||||
viewer.setDocument(pdf)
|
||||
const applyScaleSpy = jest.spyOn(component as any, 'applyScale')
|
||||
component.page = 2
|
||||
;(component as any).lastViewerPage = 2
|
||||
;(component as any).applyViewerState()
|
||||
|
||||
component.ngOnChanges({
|
||||
page: new SimpleChange(1, 2, false),
|
||||
})
|
||||
|
||||
expect(viewer.currentPageNumber).toBe(2)
|
||||
expect((component as any).lastViewerPage).toBeUndefined()
|
||||
expect(applyScaleSpy).toHaveBeenCalled()
|
||||
expect(applyScaleSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not reset the viewer when it is already on the requested page', async () => {
|
||||
|
||||
@@ -116,7 +116,10 @@ export class PngxPdfViewerComponent
|
||||
changes['zoomScale'] ||
|
||||
changes['rotation']
|
||||
) {
|
||||
this.applyViewerState()
|
||||
// Prevent loop with page / scale application see https://github.com/paperless-ngx/paperless-ngx/issues/13404
|
||||
this.applyViewerState(
|
||||
!!(changes['zoom'] || changes['zoomScale'] || changes['rotation'])
|
||||
)
|
||||
}
|
||||
|
||||
if (changes['searchQuery']) {
|
||||
@@ -240,7 +243,7 @@ export class PngxPdfViewerComponent
|
||||
}
|
||||
}
|
||||
|
||||
private applyViewerState(): void {
|
||||
private applyViewerState(applyScale = true): void {
|
||||
if (!this.pdfViewer) {
|
||||
return
|
||||
}
|
||||
@@ -264,7 +267,7 @@ export class PngxPdfViewerComponent
|
||||
if (this.page === this.lastViewerPage) {
|
||||
this.lastViewerPage = undefined
|
||||
}
|
||||
if (hasPages) {
|
||||
if (hasPages && applyScale) {
|
||||
this.applyScale()
|
||||
}
|
||||
this.dispatchFindIfReady()
|
||||
|
||||
@@ -36,6 +36,9 @@ def send_email(
|
||||
|
||||
TODO: re-evaluate this pending https://code.djangoproject.com/ticket/35581 / https://github.com/django/django/pull/18966
|
||||
"""
|
||||
if "\r" in subject or "\n" in subject:
|
||||
subject = " ".join(line.strip(" \t") for line in subject.splitlines())
|
||||
|
||||
email = EmailMessage(
|
||||
subject=subject,
|
||||
body=body,
|
||||
|
||||
@@ -386,10 +386,19 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
raise DeserializationError(
|
||||
f"{model.__name__} has no updatable fields; PK-only models are not supported by the importer",
|
||||
)
|
||||
# MySQL/MariaDB support upserts via ON DUPLICATE KEY UPDATE but,
|
||||
# unlike PostgreSQL/SQLite, cannot target a specific unique field
|
||||
# for the conflict -- passing unique_fields there raises
|
||||
# NotSupportedError.
|
||||
unique_fields = (
|
||||
[model._meta.pk.attname]
|
||||
if connection.features.supports_update_conflicts_with_target
|
||||
else None
|
||||
)
|
||||
model.objects.bulk_create( # type: ignore[attr-defined]
|
||||
instances,
|
||||
update_conflicts=True,
|
||||
unique_fields=[model._meta.pk.attname],
|
||||
unique_fields=unique_fields,
|
||||
update_fields=update_fields,
|
||||
)
|
||||
loaded_models.add(model)
|
||||
|
||||
@@ -75,7 +75,7 @@ class TestEmail(DirectoriesMixin, SampleDirMixin, APITestCase):
|
||||
{
|
||||
"documents": [self.doc1.pk, self.doc2.pk],
|
||||
"addresses": "hello@paperless-ngx.com,test@example.com",
|
||||
"subject": "Bulk email test",
|
||||
"subject": "Bulk email\n test",
|
||||
"message": "Here are your documents",
|
||||
},
|
||||
),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from datetime import timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -23,7 +22,6 @@ from paperless_ai.embedding import get_configured_model_name
|
||||
from paperless_ai.embedding import get_embedding_model
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.db.models import QuerySet
|
||||
from llama_index.core.schema import BaseNode
|
||||
|
||||
from paperless_ai.vector_store import PaperlessSqliteVecVectorStore
|
||||
@@ -34,35 +32,6 @@ logger = logging.getLogger("paperless_ai.indexing")
|
||||
RAG_NUM_OUTPUT = 512
|
||||
RAG_CHUNK_OVERLAP = 200
|
||||
|
||||
# update_llm_index(): row count per .iterator() batch when streaming
|
||||
# documents for a rebuild/update, matching _DocumentViewerStream's chunk
|
||||
# size in documents/search/_backend.py.
|
||||
_INDEX_STREAM_CHUNK_SIZE = 1000
|
||||
|
||||
|
||||
class _StreamedDocuments:
|
||||
"""A thin QuerySet wrapper that streams via ``.iterator()`` instead of
|
||||
materializing every row (plus its ``content`` and prefetch caches) into
|
||||
memory at once, while still supporting ``len()`` so ``iter_wrapper``'s
|
||||
progress bar shows a real total instead of falling back to indeterminate.
|
||||
Same shape as ``documents/search/_backend.py``'s ``_DocumentViewerStream``,
|
||||
just without that class's extra per-batch permission lookup -- nothing
|
||||
here needs one.
|
||||
"""
|
||||
|
||||
def __init__(self, documents: "QuerySet[Document]") -> None:
|
||||
self._documents = documents
|
||||
|
||||
def __len__(self) -> int:
|
||||
return self._documents.count()
|
||||
|
||||
def __iter__(self) -> Iterator[Document]:
|
||||
# iterator(chunk_size=...) streams from a server-side cursor instead
|
||||
# of materializing the whole queryset in memory; since Django 4.1 it
|
||||
# still honours prefetch_related, running the prefetches one batch
|
||||
# at a time.
|
||||
return iter(self._documents.iterator(chunk_size=_INDEX_STREAM_CHUNK_SIZE))
|
||||
|
||||
|
||||
def queue_llm_index_update_if_needed(*, rebuild: bool, reason: str) -> bool:
|
||||
# NOTE: The check-then-enqueue sequence below is non-atomic (TOCTOU): two
|
||||
@@ -416,7 +385,7 @@ def update_llm_index(
|
||||
if rebuild or not store.table_exists():
|
||||
logger.info("Rebuilding LLM index.")
|
||||
store.drop_table()
|
||||
for document in iter_wrapper(_StreamedDocuments(documents)):
|
||||
for document in iter_wrapper(documents):
|
||||
nodes = build_document_node(document, chunk_size=chunk_size)
|
||||
_embed_nodes(nodes, embed_model)
|
||||
store.add(nodes)
|
||||
@@ -429,7 +398,7 @@ def update_llm_index(
|
||||
)
|
||||
existing = store.get_modified_times()
|
||||
changed = 0
|
||||
for document in iter_wrapper(_StreamedDocuments(scoped_documents)):
|
||||
for document in iter_wrapper(scoped_documents):
|
||||
doc_id = str(document.id)
|
||||
if existing.get(doc_id) == document.modified.isoformat():
|
||||
continue
|
||||
|
||||
@@ -186,57 +186,6 @@ def test_truncate_embedding_query_returns_single_chunk() -> None:
|
||||
assert "word199" not in result
|
||||
|
||||
|
||||
class TestStreamedDocuments:
|
||||
"""_StreamedDocuments streams via .iterator() instead of materializing
|
||||
the whole queryset (plus its content and prefetch caches) in memory at
|
||||
once, while still supporting len() so a progress bar wrapped around it
|
||||
shows a real total.
|
||||
"""
|
||||
|
||||
def test_len_uses_queryset_count(
|
||||
self,
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A mock queryset
|
||||
WHEN:
|
||||
- len() is called on a _StreamedDocuments wrapping it
|
||||
THEN:
|
||||
- The queryset's count() is used, not a materializing len()
|
||||
"""
|
||||
mock_queryset = mocker.MagicMock()
|
||||
mock_queryset.count.return_value = 42
|
||||
|
||||
assert len(indexing._StreamedDocuments(mock_queryset)) == 42
|
||||
mock_queryset.count.assert_called_once_with()
|
||||
|
||||
def test_iter_streams_via_queryset_iterator(
|
||||
self,
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A mock queryset whose .iterator() yields documents
|
||||
WHEN:
|
||||
- A _StreamedDocuments wrapping it is iterated
|
||||
THEN:
|
||||
- .iterator() is called with the streaming chunk size (not
|
||||
plain iteration, which would materialize prefetches for the
|
||||
whole queryset instead of one batch at a time), and its
|
||||
results are yielded through unchanged
|
||||
"""
|
||||
mock_queryset = mocker.MagicMock()
|
||||
mock_queryset.iterator.return_value = iter(["doc-1", "doc-2"])
|
||||
|
||||
result = list(indexing._StreamedDocuments(mock_queryset))
|
||||
|
||||
assert result == ["doc-1", "doc-2"]
|
||||
mock_queryset.iterator.assert_called_once_with(
|
||||
chunk_size=indexing._INDEX_STREAM_CHUNK_SIZE,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_llm_index(
|
||||
temp_llm_index_dir: Path,
|
||||
|
||||
Reference in New Issue
Block a user