Compare commits

..
7 changed files with 37 additions and 92 deletions
@@ -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()
+3
View File
@@ -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)
+1 -1
View File
@@ -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",
},
),
+2 -33
View File
@@ -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,