Version #{{ version.id }}
+ Version {{ versions.length - $index }} (#{{ version.id }})
}
}
diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts
index 2b4782e96..8e97b5f9e 100644
--- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts
+++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts
@@ -830,7 +830,7 @@ export class BulkEditorComponent
})
const rotateDialog = modal.componentInstance as RotateConfirmDialogComponent
rotateDialog.title = $localize`Rotate confirm`
- rotateDialog.messageBold = $localize`This operation will permanently rotate the original version of ${this.list.selected.size} document(s).`
+ rotateDialog.messageBold = $localize`This operation will add rotated versions of the ${this.list.selected.size} document(s).`
rotateDialog.btnClass = 'btn-danger'
rotateDialog.btnCaption = $localize`Proceed`
rotateDialog.documentID = Array.from(this.list.selected)[0]
diff --git a/src-ui/src/environments/environment.prod.ts b/src-ui/src/environments/environment.prod.ts
index 0ad536f2b..afd702263 100644
--- a/src-ui/src/environments/environment.prod.ts
+++ b/src-ui/src/environments/environment.prod.ts
@@ -6,7 +6,7 @@ export const environment = {
apiVersion: '9', // match src/paperless/settings.py
appTitle: 'Paperless-ngx',
tag: 'prod',
- version: '2.20.8',
+ version: '2.20.9',
webSocketHost: window.location.host,
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
webSocketBaseUrl: base_url.pathname + 'ws/',
diff --git a/src/documents/consumer.py b/src/documents/consumer.py
index d8682a6d7..023ff1f55 100644
--- a/src/documents/consumer.py
+++ b/src/documents/consumer.py
@@ -43,6 +43,7 @@ from documents.plugins.helpers import ProgressManager
from documents.plugins.helpers import ProgressStatusOptions
from documents.signals import document_consumption_finished
from documents.signals import document_consumption_started
+from documents.signals import document_updated
from documents.signals.handlers import run_workflows
from documents.templating.workflows import parse_w_workflow_placeholders
from documents.utils import copy_basic_file_stats
@@ -646,6 +647,12 @@ class ConsumerPlugin(
# This triggers things like file renaming
document.save()
+ if document.root_document_id:
+ document_updated.send(
+ sender=self.__class__,
+ document=document.root_document,
+ )
+
# Delete the file only if it was successfully consumed
self.log.debug(f"Deleting original file {self.input_doc.original_file}")
self.input_doc.original_file.unlink()
diff --git a/src/documents/management/commands/base.py b/src/documents/management/commands/base.py
index 39510e419..a2b0880f5 100644
--- a/src/documents/management/commands/base.py
+++ b/src/documents/management/commands/base.py
@@ -6,11 +6,14 @@ Provides automatic progress bar and multiprocessing support with minimal boilerp
from __future__ import annotations
+import logging
import os
+from collections.abc import Callable
from collections.abc import Iterable
from collections.abc import Sized
from concurrent.futures import ProcessPoolExecutor
from concurrent.futures import as_completed
+from contextlib import contextmanager
from dataclasses import dataclass
from typing import TYPE_CHECKING
from typing import Any
@@ -22,7 +25,11 @@ from django import db
from django.core.management import CommandError
from django.db.models import QuerySet
from django_rich.management import RichCommand
+from rich import box
from rich.console import Console
+from rich.console import Group
+from rich.console import RenderableType
+from rich.live import Live
from rich.progress import BarColumn
from rich.progress import MofNCompleteColumn
from rich.progress import Progress
@@ -30,11 +37,11 @@ from rich.progress import SpinnerColumn
from rich.progress import TextColumn
from rich.progress import TimeElapsedColumn
from rich.progress import TimeRemainingColumn
+from rich.table import Table
+from rich.text import Text
if TYPE_CHECKING:
- from collections.abc import Callable
from collections.abc import Generator
- from collections.abc import Iterable
from collections.abc import Sequence
from django.core.management import CommandParser
@@ -43,6 +50,78 @@ T = TypeVar("T")
R = TypeVar("R")
+@dataclass(slots=True, frozen=True)
+class _BufferedRecord:
+ level: int
+ name: str
+ message: str
+
+
+class BufferingLogHandler(logging.Handler):
+ """Captures log records during a command run for deferred rendering.
+
+ Attach to a logger before a long operation and call ``render()``
+ afterwards to emit the buffered records via Rich, optionally filtered
+ by minimum level.
+ """
+
+ def __init__(self) -> None:
+ super().__init__()
+ self._records: list[_BufferedRecord] = []
+
+ def emit(self, record: logging.LogRecord) -> None:
+ self._records.append(
+ _BufferedRecord(
+ level=record.levelno,
+ name=record.name,
+ message=self.format(record),
+ ),
+ )
+
+ def render(
+ self,
+ console: Console,
+ *,
+ min_level: int = logging.DEBUG,
+ title: str = "Log Output",
+ ) -> None:
+ records = [r for r in self._records if r.level >= min_level]
+ if not records:
+ return
+
+ table = Table(
+ title=title,
+ show_header=True,
+ header_style="bold",
+ show_lines=False,
+ box=box.SIMPLE,
+ )
+ table.add_column("Level", style="bold", width=8)
+ table.add_column("Logger", style="dim")
+ table.add_column("Message", no_wrap=False)
+
+ _level_styles: dict[int, str] = {
+ logging.DEBUG: "dim",
+ logging.INFO: "cyan",
+ logging.WARNING: "yellow",
+ logging.ERROR: "red",
+ logging.CRITICAL: "bold red",
+ }
+
+ for record in records:
+ style = _level_styles.get(record.level, "")
+ table.add_row(
+ Text(logging.getLevelName(record.level), style=style),
+ record.name,
+ record.message,
+ )
+
+ console.print(table)
+
+ def clear(self) -> None:
+ self._records.clear()
+
+
@dataclass(frozen=True, slots=True)
class ProcessResult(Generic[T, R]):
"""
@@ -91,6 +170,23 @@ class PaperlessCommand(RichCommand):
for result in self.process_parallel(process_doc, ids):
if result.error:
self.console.print(f"[red]Failed: {result.error}[/red]")
+
+ class Command(PaperlessCommand):
+ help = "Import documents with live stats"
+
+ def handle(self, *args, **options):
+ stats = ImportStats()
+
+ def render_stats() -> Table:
+ ... # build Rich Table from stats
+
+ for item in self.track_with_stats(
+ items,
+ description="Importing...",
+ stats_renderer=render_stats,
+ ):
+ result = import_item(item)
+ stats.imported += 1
"""
supports_progress_bar: ClassVar[bool] = True
@@ -128,13 +224,11 @@ class PaperlessCommand(RichCommand):
This is called by Django's command infrastructure after argument parsing
but before handle(). We use it to set instance attributes from options.
"""
- # Set progress bar state
if self.supports_progress_bar:
self.no_progress_bar = options.get("no_progress_bar", False)
else:
self.no_progress_bar = True
- # Set multiprocessing state
if self.supports_multiprocessing:
self.process_count = options.get("processes", 1)
if self.process_count < 1:
@@ -144,9 +238,69 @@ class PaperlessCommand(RichCommand):
return super().execute(*args, **options)
+ @contextmanager
+ def buffered_logging(
+ self,
+ *logger_names: str,
+ level: int = logging.DEBUG,
+ ) -> Generator[BufferingLogHandler, None, None]:
+ """Context manager that captures log output from named loggers.
+
+ Installs a ``BufferingLogHandler`` on each named logger for the
+ duration of the block, suppressing propagation to avoid interleaving
+ with the Rich live display. The handler is removed on exit regardless
+ of whether an exception occurred.
+
+ Usage::
+
+ with self.buffered_logging("paperless", "documents") as log_buf:
+ # ... run progress loop ...
+ if options["verbose"]:
+ log_buf.render(self.console)
+ """
+ handler = BufferingLogHandler()
+ handler.setFormatter(logging.Formatter("%(message)s"))
+
+ loggers: list[logging.Logger] = []
+ original_propagate: dict[str, bool] = {}
+
+ for name in logger_names:
+ log = logging.getLogger(name)
+ log.addHandler(handler)
+ original_propagate[name] = log.propagate
+ log.propagate = False
+ loggers.append(log)
+
+ try:
+ yield handler
+ finally:
+ for log in loggers:
+ log.removeHandler(handler)
+ log.propagate = original_propagate[log.name]
+
+ @staticmethod
+ def _progress_columns() -> tuple[Any, ...]:
+ """
+ Return the standard set of progress bar columns.
+
+ Extracted so both _create_progress (standalone) and track_with_stats
+ (inside Live) use identical column configuration without duplication.
+ """
+ return (
+ SpinnerColumn(),
+ TextColumn("[progress.description]{task.description}"),
+ BarColumn(),
+ MofNCompleteColumn(),
+ TimeElapsedColumn(),
+ TimeRemainingColumn(),
+ )
+
def _create_progress(self, description: str) -> Progress:
"""
- Create a configured Progress instance.
+ Create a standalone Progress instance with its own stderr Console.
+
+ Use this for track(). For track_with_stats(), Progress is created
+ directly inside a Live context instead.
Progress output is directed to stderr to match the convention that
progress bars are transient UI feedback, not command output. This
@@ -161,12 +315,7 @@ class PaperlessCommand(RichCommand):
A Progress instance configured with appropriate columns.
"""
return Progress(
- SpinnerColumn(),
- TextColumn("[progress.description]{task.description}"),
- BarColumn(),
- MofNCompleteColumn(),
- TimeElapsedColumn(),
- TimeRemainingColumn(),
+ *self._progress_columns(),
console=Console(stderr=True),
transient=False,
)
@@ -222,7 +371,6 @@ class PaperlessCommand(RichCommand):
yield from iterable
return
- # Attempt to determine total if not provided
if total is None:
total = self._get_iterable_length(iterable)
@@ -232,6 +380,87 @@ class PaperlessCommand(RichCommand):
yield item
progress.advance(task_id)
+ def track_with_stats(
+ self,
+ iterable: Iterable[T],
+ *,
+ description: str = "Processing...",
+ stats_renderer: Callable[[], RenderableType],
+ total: int | None = None,
+ ) -> Generator[T, None, None]:
+ """
+ Iterate over items with a progress bar and a live-updating stats display.
+
+ The progress bar and stats renderable are combined in a single Live
+ context, so the stats panel re-renders in place below the progress bar
+ after each item is processed.
+
+ Respects --no-progress-bar flag. When disabled, yields items without
+ any display (stats are still updated by the caller's loop body, so
+ they will be accurate for any post-loop summary the caller prints).
+
+ Args:
+ iterable: The items to iterate over.
+ description: Text to display alongside the progress bar.
+ stats_renderer: Zero-argument callable that returns a Rich
+ renderable. Called after each item to refresh the display.
+ The caller typically closes over a mutable dataclass and
+ rebuilds a Table from it on each call.
+ total: Total number of items. If None, attempts to determine
+ automatically via .count() (for querysets) or len().
+
+ Yields:
+ Items from the iterable.
+
+ Example:
+ @dataclass
+ class Stats:
+ processed: int = 0
+ failed: int = 0
+
+ stats = Stats()
+
+ def render_stats() -> Table:
+ table = Table(box=None)
+ table.add_column("Processed")
+ table.add_column("Failed")
+ table.add_row(str(stats.processed), str(stats.failed))
+ return table
+
+ for item in self.track_with_stats(
+ items,
+ description="Importing...",
+ stats_renderer=render_stats,
+ ):
+ try:
+ import_item(item)
+ stats.processed += 1
+ except Exception:
+ stats.failed += 1
+ """
+ if self.no_progress_bar:
+ yield from iterable
+ return
+
+ if total is None:
+ total = self._get_iterable_length(iterable)
+
+ stderr_console = Console(stderr=True)
+
+ # Progress is created without its own console so Live controls rendering.
+ progress = Progress(*self._progress_columns())
+ task_id = progress.add_task(description, total=total)
+
+ with Live(
+ Group(progress, stats_renderer()),
+ console=stderr_console,
+ refresh_per_second=4,
+ ) as live:
+ for item in iterable:
+ yield item
+ progress.advance(task_id)
+ live.update(Group(progress, stats_renderer()))
+
def process_parallel(
self,
fn: Callable[[T], R],
@@ -269,7 +498,7 @@ class PaperlessCommand(RichCommand):
total = len(items)
if self.process_count == 1:
- # Sequential execution in main process - critical for testing
+ # Sequential execution in main process - critical for testing, so we don't fork in fork, etc
yield from self._process_sequential(fn, items, description, total)
else:
# Parallel execution with ProcessPoolExecutor
@@ -298,6 +527,7 @@ class PaperlessCommand(RichCommand):
total: int,
) -> Generator[ProcessResult[T, R], None, None]:
"""Process items in parallel using ProcessPoolExecutor."""
+
# Close database connections before forking - required for PostgreSQL
db.connections.close_all()
diff --git a/src/documents/management/commands/document_index.py b/src/documents/management/commands/document_index.py
index 1fa4f5a70..05efe870c 100644
--- a/src/documents/management/commands/document_index.py
+++ b/src/documents/management/commands/document_index.py
@@ -1,22 +1,25 @@
-from django.core.management import BaseCommand
from django.db import transaction
-from documents.management.commands.mixins import ProgressBarMixin
+from documents.management.commands.base import PaperlessCommand
from documents.tasks import index_optimize
from documents.tasks import index_reindex
-class Command(ProgressBarMixin, BaseCommand):
+class Command(PaperlessCommand):
help = "Manages the document index."
def add_arguments(self, parser):
+ super().add_arguments(parser)
parser.add_argument("command", choices=["reindex", "optimize"])
- self.add_argument_progress_bar_mixin(parser)
def handle(self, *args, **options):
- self.handle_progress_bar_mixin(**options)
with transaction.atomic():
if options["command"] == "reindex":
- index_reindex(progress_bar_disable=self.no_progress_bar)
+ index_reindex(
+ iter_wrapper=lambda docs: self.track(
+ docs,
+ description="Indexing documents...",
+ ),
+ )
elif options["command"] == "optimize":
index_optimize()
diff --git a/src/documents/management/commands/document_llmindex.py b/src/documents/management/commands/document_llmindex.py
index d2df02ed9..6af1c7c9f 100644
--- a/src/documents/management/commands/document_llmindex.py
+++ b/src/documents/management/commands/document_llmindex.py
@@ -1,22 +1,22 @@
-from django.core.management import BaseCommand
-from django.db import transaction
+from typing import Any
-from documents.management.commands.mixins import ProgressBarMixin
+from documents.management.commands.base import PaperlessCommand
from documents.tasks import llmindex_index
-class Command(ProgressBarMixin, BaseCommand):
+class Command(PaperlessCommand):
help = "Manages the LLM-based vector index for Paperless."
- def add_arguments(self, parser):
+ def add_arguments(self, parser: Any) -> None:
+ super().add_arguments(parser)
parser.add_argument("command", choices=["rebuild", "update"])
- self.add_argument_progress_bar_mixin(parser)
- def handle(self, *args, **options):
- self.handle_progress_bar_mixin(**options)
- with transaction.atomic():
- llmindex_index(
- progress_bar_disable=self.no_progress_bar,
- rebuild=options["command"] == "rebuild",
- scheduled=False,
- )
+ def handle(self, *args: Any, **options: Any) -> None:
+ llmindex_index(
+ rebuild=options["command"] == "rebuild",
+ scheduled=False,
+ iter_wrapper=lambda docs: self.track(
+ docs,
+ description="Indexing documents...",
+ ),
+ )
diff --git a/src/documents/management/commands/document_retagger.py b/src/documents/management/commands/document_retagger.py
index 32f895d4e..9dadc803f 100644
--- a/src/documents/management/commands/document_retagger.py
+++ b/src/documents/management/commands/document_retagger.py
@@ -1,4 +1,12 @@
+from __future__ import annotations
+
import logging
+from dataclasses import dataclass
+from dataclasses import field
+from typing import TYPE_CHECKING
+
+from rich.table import Table
+from rich.text import Text
from documents.classifier import load_classifier
from documents.management.commands.base import PaperlessCommand
@@ -8,9 +16,162 @@ from documents.signals.handlers import set_document_type
from documents.signals.handlers import set_storage_path
from documents.signals.handlers import set_tags
+if TYPE_CHECKING:
+ from rich.console import RenderableType
+
+ from documents.models import Correspondent
+ from documents.models import DocumentType
+ from documents.models import StoragePath
+ from documents.models import Tag
+
logger = logging.getLogger("paperless.management.retagger")
+@dataclass(slots=True)
+class RetaggerStats:
+ """Cumulative counters updated as the retagger processes documents.
+
+ Mutable by design -- fields are incremented in the processing loop.
+ slots=True reduces per-instance memory overhead and speeds attribute access.
+ """
+
+ correspondents: int = 0
+ document_types: int = 0
+ tags_added: int = 0
+ tags_removed: int = 0
+ storage_paths: int = 0
+ documents_processed: int = 0
+
+
+@dataclass(slots=True)
+class DocumentSuggestion:
+ """Buffered classifier suggestions for a single document (suggest mode only).
+
+ Mutable by design -- fields are assigned incrementally as each setter runs.
+ """
+
+ document: Document
+ correspondent: Correspondent | None = None
+ document_type: DocumentType | None = None
+ tags_to_add: frozenset[Tag] = field(default_factory=frozenset)
+ tags_to_remove: frozenset[Tag] = field(default_factory=frozenset)
+ storage_path: StoragePath | None = None
+
+ @property
+ def has_suggestions(self) -> bool:
+ return bool(
+ self.correspondent is not None
+ or self.document_type is not None
+ or self.tags_to_add
+ or self.tags_to_remove
+ or self.storage_path is not None,
+ )
+
+
+def _build_stats_table(stats: RetaggerStats, *, suggest: bool) -> Table:
+ """
+ Build the live-updating stats table shown below the progress bar.
+
+ In suggest mode the labels read "would set / would add" to make clear
+ that nothing has been written to the database.
+ """
+ table = Table(box=None, padding=(0, 2), show_header=True, header_style="bold")
+
+ table.add_column("Documents")
+ table.add_column("Correspondents")
+ table.add_column("Doc Types")
+ table.add_column("Tags (+)")
+ table.add_column("Tags (-)")
+ table.add_column("Storage Paths")
+
+ verb = "would set" if suggest else "set"
+
+ table.add_row(
+ str(stats.documents_processed),
+ f"{stats.correspondents} {verb}",
+ f"{stats.document_types} {verb}",
+ f"+{stats.tags_added}",
+ f"-{stats.tags_removed}",
+ f"{stats.storage_paths} {verb}",
+ )
+
+ return table
+
+
+def _build_suggestion_table(
+ suggestions: list[DocumentSuggestion],
+ base_url: str | None,
+) -> Table:
+ """
+ Build the final suggestion table printed after the progress bar completes.
+
+ Only documents with at least one suggestion are included.
+ """
+ table = Table(
+ title="Suggested Changes",
+ show_header=True,
+ header_style="bold cyan",
+ show_lines=True,
+ )
+
+ table.add_column("Document", style="bold", no_wrap=False, min_width=20)
+ table.add_column("Correspondent")
+ table.add_column("Doc Type")
+ table.add_column("Tags")
+ table.add_column("Storage Path")
+
+ for suggestion in suggestions:
+ if not suggestion.has_suggestions:
+ continue
+
+ doc = suggestion.document
+
+ if base_url:
+ doc_cell = Text()
+ doc_cell.append(str(doc))
+ doc_cell.append(f"\n{base_url}/documents/{doc.pk}", style="dim")
+ else:
+ doc_cell = Text(f"{doc} [{doc.pk}]")
+
+ tag_parts: list[str] = []
+ for tag in sorted(suggestion.tags_to_add, key=lambda t: t.name):
+ tag_parts.append(f"[green]+{tag.name}[/green]")
+ for tag in sorted(suggestion.tags_to_remove, key=lambda t: t.name):
+ tag_parts.append(f"[red]-{tag.name}[/red]")
+ tag_cell = Text.from_markup(", ".join(tag_parts)) if tag_parts else Text("-")
+
+ table.add_row(
+ doc_cell,
+ str(suggestion.correspondent) if suggestion.correspondent else "-",
+ str(suggestion.document_type) if suggestion.document_type else "-",
+ tag_cell,
+ str(suggestion.storage_path) if suggestion.storage_path else "-",
+ )
+
+ return table
+
+
+def _build_summary_table(stats: RetaggerStats) -> Table:
+ """Build the final applied-changes summary table."""
+ table = Table(
+ title="Retagger Summary",
+ show_header=True,
+ header_style="bold cyan",
+ )
+
+ table.add_column("Metric", style="bold")
+ table.add_column("Count", justify="right")
+
+ table.add_row("Documents processed", str(stats.documents_processed))
+ table.add_row("Correspondents set", str(stats.correspondents))
+ table.add_row("Document types set", str(stats.document_types))
+ table.add_row("Tags added", str(stats.tags_added))
+ table.add_row("Tags removed", str(stats.tags_removed))
+ table.add_row("Storage paths set", str(stats.storage_paths))
+
+ return table
+
+
class Command(PaperlessCommand):
help = (
"Using the current classification model, assigns correspondents, tags "
@@ -19,7 +180,7 @@ class Command(PaperlessCommand):
"modified) after their initial import."
)
- def add_arguments(self, parser):
+ def add_arguments(self, parser) -> None:
super().add_arguments(parser)
parser.add_argument("-c", "--correspondent", default=False, action="store_true")
parser.add_argument("-T", "--tags", default=False, action="store_true")
@@ -31,9 +192,9 @@ class Command(PaperlessCommand):
default=False,
action="store_true",
help=(
- "By default this command won't try to assign a correspondent "
- "if more than one matches the document. Use this flag if "
- "you'd rather it just pick the first one it finds."
+ "By default this command will not try to assign a correspondent "
+ "if more than one matches the document. Use this flag to pick "
+ "the first match instead."
),
)
parser.add_argument(
@@ -42,91 +203,140 @@ class Command(PaperlessCommand):
default=False,
action="store_true",
help=(
- "If set, the document retagger will overwrite any previously "
- "set correspondent, document and remove correspondents, types "
- "and tags that do not match anymore due to changed rules."
+ "Overwrite any previously set correspondent, document type, and "
+ "remove tags that no longer match due to changed rules."
),
)
parser.add_argument(
"--suggest",
default=False,
action="store_true",
- help="Return the suggestion, don't change anything.",
+ help="Show what would be changed without applying anything.",
)
parser.add_argument(
"--base-url",
- help="The base URL to use to build the link to the documents.",
+ help="Base URL used to build document links in suggest output.",
)
parser.add_argument(
"--id-range",
- help="A range of document ids on which the retagging should be applied.",
+ help="Restrict retagging to documents within this ID range (inclusive).",
nargs=2,
type=int,
)
- def handle(self, *args, **options):
+ def handle(self, *args, **options) -> None:
+ suggest: bool = options["suggest"]
+ overwrite: bool = options["overwrite"]
+ use_first: bool = options["use_first"]
+ base_url: str | None = options["base_url"]
+
+ do_correspondent: bool = options["correspondent"]
+ do_document_type: bool = options["document_type"]
+ do_tags: bool = options["tags"]
+ do_storage_path: bool = options["storage_path"]
+
+ if not any([do_correspondent, do_document_type, do_tags, do_storage_path]):
+ self.console.print(
+ "[yellow]No classifier targets specified. "
+ "Use -c, -T, -t, or -s to select what to retag.[/yellow]",
+ )
+ return
+
if options["inbox_only"]:
queryset = Document.objects.filter(tags__is_inbox_tag=True)
else:
queryset = Document.objects.all()
if options["id_range"]:
- queryset = queryset.filter(
- id__range=(options["id_range"][0], options["id_range"][1]),
- )
+ lo, hi = options["id_range"]
+ queryset = queryset.filter(id__range=(lo, hi))
documents = queryset.distinct()
-
classifier = load_classifier()
- for document in self.track(documents, description="Retagging..."):
- if options["correspondent"]:
- set_correspondent(
- sender=None,
- document=document,
- classifier=classifier,
- replace=options["overwrite"],
- use_first=options["use_first"],
- suggest=options["suggest"],
- base_url=options["base_url"],
- stdout=self.stdout,
- style_func=self.style,
- )
+ stats = RetaggerStats()
+ suggestions: list[DocumentSuggestion] = []
- if options["document_type"]:
- set_document_type(
- sender=None,
- document=document,
- classifier=classifier,
- replace=options["overwrite"],
- use_first=options["use_first"],
- suggest=options["suggest"],
- base_url=options["base_url"],
- stdout=self.stdout,
- style_func=self.style,
- )
+ def render_stats() -> RenderableType:
+ return _build_stats_table(stats, suggest=suggest)
- if options["tags"]:
- set_tags(
- sender=None,
- document=document,
- classifier=classifier,
- replace=options["overwrite"],
- suggest=options["suggest"],
- base_url=options["base_url"],
- stdout=self.stdout,
- style_func=self.style,
- )
+ with self.buffered_logging(
+ "paperless",
+ "paperless.handlers",
+ "documents",
+ ) as log_buf:
+ for document in self.track_with_stats(
+ documents,
+ description="Retagging...",
+ stats_renderer=render_stats,
+ ):
+ suggestion = DocumentSuggestion(document=document)
- if options["storage_path"]:
- set_storage_path(
- sender=None,
- document=document,
- classifier=classifier,
- replace=options["overwrite"],
- use_first=options["use_first"],
- suggest=options["suggest"],
- base_url=options["base_url"],
- stdout=self.stdout,
- style_func=self.style,
- )
+ if do_correspondent:
+ correspondent = set_correspondent(
+ None,
+ document,
+ classifier=classifier,
+ replace=overwrite,
+ use_first=use_first,
+ dry_run=suggest,
+ )
+ if correspondent is not None:
+ stats.correspondents += 1
+ suggestion.correspondent = correspondent
+
+ if do_document_type:
+ document_type = set_document_type(
+ None,
+ document,
+ classifier=classifier,
+ replace=overwrite,
+ use_first=use_first,
+ dry_run=suggest,
+ )
+ if document_type is not None:
+ stats.document_types += 1
+ suggestion.document_type = document_type
+
+ if do_tags:
+ tags_to_add, tags_to_remove = set_tags(
+ None,
+ document,
+ classifier=classifier,
+ replace=overwrite,
+ dry_run=suggest,
+ )
+ stats.tags_added += len(tags_to_add)
+ stats.tags_removed += len(tags_to_remove)
+ suggestion.tags_to_add = frozenset(tags_to_add)
+ suggestion.tags_to_remove = frozenset(tags_to_remove)
+
+ if do_storage_path:
+ storage_path = set_storage_path(
+ None,
+ document,
+ classifier=classifier,
+ replace=overwrite,
+ use_first=use_first,
+ dry_run=suggest,
+ )
+ if storage_path is not None:
+ stats.storage_paths += 1
+ suggestion.storage_path = storage_path
+
+ stats.documents_processed += 1
+
+ if suggest:
+ suggestions.append(suggestion)
+
+ # Post-loop output
+ if suggest:
+ visible = [s for s in suggestions if s.has_suggestions]
+ if visible:
+ self.console.print(_build_suggestion_table(visible, base_url))
+ else:
+ self.console.print("[green]No changes suggested.[/green]")
+ else:
+ self.console.print(_build_summary_table(stats))
+
+ log_buf.render(self.console, min_level=logging.INFO, title="Retagger Log")
diff --git a/src/documents/management/commands/document_sanity_checker.py b/src/documents/management/commands/document_sanity_checker.py
index b634d4dc9..df5a3e4bf 100644
--- a/src/documents/management/commands/document_sanity_checker.py
+++ b/src/documents/management/commands/document_sanity_checker.py
@@ -1,17 +1,117 @@
-from django.core.management.base import BaseCommand
+"""Management command to check the document archive for issues."""
-from documents.management.commands.mixins import ProgressBarMixin
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from rich.panel import Panel
+from rich.table import Table
+from rich.text import Text
+
+from documents.management.commands.base import PaperlessCommand
+from documents.models import Document
+from documents.sanity_checker import SanityCheckMessages
from documents.sanity_checker import check_sanity
+_LEVEL_STYLE: dict[int, tuple[str, str]] = {
+ logging.ERROR: ("bold red", "ERROR"),
+ logging.WARNING: ("yellow", "WARN"),
+ logging.INFO: ("dim", "INFO"),
+}
-class Command(ProgressBarMixin, BaseCommand):
+
+class Command(PaperlessCommand):
help = "This command checks your document archive for issues."
- def add_arguments(self, parser):
- self.add_argument_progress_bar_mixin(parser)
+ def _render_results(self, messages: SanityCheckMessages) -> None:
+ """Render sanity check results as a Rich table."""
- def handle(self, *args, **options):
- self.handle_progress_bar_mixin(**options)
- messages = check_sanity(progress=self.use_progress_bar, scheduled=False)
+ if (
+ not messages.has_error
+ and not messages.has_warning
+ and not messages.has_info
+ ):
+ self.console.print(
+ Panel(
+ "[green]No issues detected.[/green]",
+ title="Sanity Check",
+ border_style="green",
+ ),
+ )
+ return
- messages.log_messages()
+ # Build a lookup for document titles
+ doc_pks = [pk for pk in messages.document_pks() if pk is not None]
+ titles: dict[int, str] = {}
+ if doc_pks:
+ titles = dict(
+ Document.global_objects.filter(pk__in=doc_pks)
+ .only("pk", "title")
+ .values_list("pk", "title"),
+ )
+
+ table = Table(
+ title="Sanity Check Results",
+ show_lines=True,
+ title_style="bold",
+ )
+ table.add_column("Level", width=7, no_wrap=True)
+ table.add_column("Document", min_width=20)
+ table.add_column("Issue", ratio=1)
+
+ for doc_pk, doc_messages in messages.iter_messages():
+ if doc_pk is not None:
+ title = titles.get(doc_pk, "Unknown")
+ doc_label = f"#{doc_pk} {title}"
+ else:
+ doc_label = "(global)"
+
+ for msg in doc_messages:
+ style, label = _LEVEL_STYLE.get(
+ msg["level"],
+ ("dim", "INFO"),
+ )
+ table.add_row(
+ Text(label, style=style),
+ Text(doc_label),
+ Text(str(msg["message"])),
+ )
+
+ self.console.print(table)
+
+ parts: list[str] = []
+
+ if messages.document_error_count:
+ parts.append(
+ f"{messages.document_error_count} document(s) with [bold red]errors[/bold red]",
+ )
+ if messages.document_warning_count:
+ parts.append(
+ f"{messages.document_warning_count} document(s) with [yellow]warnings[/yellow]",
+ )
+ if messages.document_info_count:
+ parts.append(f"{messages.document_info_count} document(s) with infos")
+ if messages.global_warning_count:
+ parts.append(
+ f"{messages.global_warning_count} global [yellow]warning(s)[/yellow]",
+ )
+
+ if parts:
+ if len(parts) > 1:
+ summary = ", ".join(parts[:-1]) + " and " + parts[-1]
+ else:
+ summary = parts[0]
+ self.console.print(f"\nFound {summary}.")
+ else:
+ self.console.print("\nNo issues found.")
+
+ def handle(self, *args: Any, **options: Any) -> None:
+ messages = check_sanity(
+ scheduled=False,
+ iter_wrapper=lambda docs: self.track(
+ docs,
+ description="Checking documents...",
+ ),
+ )
+ self._render_results(messages)
diff --git a/src/documents/models.py b/src/documents/models.py
index d8209a55e..31c92e396 100644
--- a/src/documents/models.py
+++ b/src/documents/models.py
@@ -75,7 +75,7 @@ class MatchingModel(ModelWithOwner):
is_insensitive = models.BooleanField(_("is insensitive"), default=True)
- class Meta:
+ class Meta(ModelWithOwner.Meta):
abstract = True
ordering = ("name",)
constraints = [
diff --git a/src/documents/sanity_checker.py b/src/documents/sanity_checker.py
index 08763d937..ef0d37e7d 100644
--- a/src/documents/sanity_checker.py
+++ b/src/documents/sanity_checker.py
@@ -1,80 +1,174 @@
+"""
+Sanity checker for the Paperless-ngx document archive.
+
+Verifies that all documents have valid files, correct checksums,
+and consistent metadata. Reports orphaned files in the media directory.
+
+Progress display is the caller's responsibility -- pass an ``iter_wrapper``
+to wrap the document queryset (e.g., with a progress bar). The default
+is an identity function that adds no overhead.
+"""
+
+from __future__ import annotations
+
import hashlib
import logging
import uuid
from collections import defaultdict
+from collections.abc import Callable
+from collections.abc import Iterable
+from collections.abc import Iterator
from pathlib import Path
+from typing import TYPE_CHECKING
from typing import Final
+from typing import TypedDict
+from typing import TypeVar
from celery import states
from django.conf import settings
from django.utils import timezone
-from tqdm import tqdm
from documents.models import Document
from documents.models import PaperlessTask
from paperless.config import GeneralConfig
+logger = logging.getLogger("paperless.sanity_checker")
+
+_T = TypeVar("_T")
+IterWrapper = Callable[[Iterable[_T]], Iterable[_T]]
+
+
+class MessageEntry(TypedDict):
+ """A single sanity check message with its severity level."""
+
+ level: int
+ message: str
+
+
+def _identity(iterable: Iterable[_T]) -> Iterable[_T]:
+ """Pass through an iterable unchanged (default iter_wrapper)."""
+ return iterable
+
class SanityCheckMessages:
- def __init__(self) -> None:
- self._messages: dict[int, list[dict]] = defaultdict(list)
- self.has_error = False
- self.has_warning = False
+ """Collects sanity check messages grouped by document primary key.
- def error(self, doc_pk, message) -> None:
+ Messages are categorized as error, warning, or info. ``None`` is used
+ as the key for messages not associated with a specific document
+ (e.g., orphaned files).
+ """
+
+ def __init__(self) -> None:
+ self._messages: dict[int | None, list[MessageEntry]] = defaultdict(list)
+ self.has_error: bool = False
+ self.has_warning: bool = False
+ self.has_info: bool = False
+ self.document_count: int = 0
+ self.document_error_count: int = 0
+ self.document_warning_count: int = 0
+ self.document_info_count: int = 0
+ self.global_warning_count: int = 0
+
+ # -- Recording ----------------------------------------------------------
+
+ def error(self, doc_pk: int | None, message: str) -> None:
self._messages[doc_pk].append({"level": logging.ERROR, "message": message})
self.has_error = True
+ if doc_pk is not None:
+ self.document_count += 1
+ self.document_error_count += 1
- def warning(self, doc_pk, message) -> None:
+ def warning(self, doc_pk: int | None, message: str) -> None:
self._messages[doc_pk].append({"level": logging.WARNING, "message": message})
self.has_warning = True
- def info(self, doc_pk, message) -> None:
+ if doc_pk is not None:
+ self.document_count += 1
+ self.document_warning_count += 1
+ else:
+ # This is the only type of global message we do right now
+ self.global_warning_count += 1
+
+ def info(self, doc_pk: int | None, message: str) -> None:
self._messages[doc_pk].append({"level": logging.INFO, "message": message})
+ self.has_info = True
+
+ if doc_pk is not None:
+ self.document_count += 1
+ self.document_info_count += 1
+
+ # -- Iteration / query --------------------------------------------------
+
+ def document_pks(self) -> list[int | None]:
+ """Return all document PKs (including None for global messages)."""
+ return list(self._messages.keys())
+
+ def iter_messages(self) -> Iterator[tuple[int | None, list[MessageEntry]]]:
+ """Iterate over (doc_pk, messages) pairs."""
+ yield from self._messages.items()
+
+ def __getitem__(self, item: int | None) -> list[MessageEntry]:
+ return self._messages[item]
+
+ # -- Summarize Helpers --------------------------------------------------
+
+ @property
+ def has_global_issues(self) -> bool:
+ return None in self._messages
+
+ @property
+ def total_issue_count(self) -> int:
+ """Total number of error and warning messages across all documents and global."""
+ return (
+ self.document_error_count
+ + self.document_warning_count
+ + self.global_warning_count
+ )
+
+ # -- Logging output (used by Celery task path) --------------------------
def log_messages(self) -> None:
- logger = logging.getLogger("paperless.sanity_checker")
+ """Write all messages to the ``paperless.sanity_checker`` logger.
+ This is the output path for headless / Celery execution.
+ Management commands use Rich rendering instead.
+ """
if len(self._messages) == 0:
logger.info("Sanity checker detected no issues.")
- else:
- # Query once
- all_docs = Document.global_objects.all()
+ return
- for doc_pk in self._messages:
- if doc_pk is not None:
- doc = all_docs.get(pk=doc_pk)
- logger.info(
- f"Detected following issue(s) with document #{doc.pk},"
- f" titled {doc.title}",
- )
- for msg in self._messages[doc_pk]:
- logger.log(msg["level"], msg["message"])
+ doc_pks = [pk for pk in self._messages if pk is not None]
+ titles: dict[int, str] = {}
+ if doc_pks:
+ titles = dict(
+ Document.global_objects.filter(pk__in=doc_pks)
+ .only("pk", "title")
+ .values_list("pk", "title"),
+ )
- def __len__(self):
- return len(self._messages)
-
- def __getitem__(self, item):
- return self._messages[item]
+ for doc_pk, entries in self._messages.items():
+ if doc_pk is not None:
+ title = titles.get(doc_pk, "Unknown")
+ logger.info(
+ "Detected following issue(s) with document #%s, titled %s",
+ doc_pk,
+ title,
+ )
+ for msg in entries:
+ logger.log(msg["level"], msg["message"])
class SanityCheckFailedException(Exception):
pass
-def check_sanity(*, progress=False, scheduled=True) -> SanityCheckMessages:
- paperless_task = PaperlessTask.objects.create(
- task_id=uuid.uuid4(),
- type=PaperlessTask.TaskType.SCHEDULED_TASK
- if scheduled
- else PaperlessTask.TaskType.MANUAL_TASK,
- task_name=PaperlessTask.TaskName.CHECK_SANITY,
- status=states.STARTED,
- date_created=timezone.now(),
- date_started=timezone.now(),
- )
- messages = SanityCheckMessages()
+# ---------------------------------------------------------------------------
+# Internal helpers
+# ---------------------------------------------------------------------------
+
+def _build_present_files() -> set[Path]:
+ """Collect all files in MEDIA_ROOT, excluding directories and ignorable files."""
present_files = {
x.resolve()
for x in Path(settings.MEDIA_ROOT).glob("**/*")
@@ -82,95 +176,178 @@ def check_sanity(*, progress=False, scheduled=True) -> SanityCheckMessages:
}
lockfile = Path(settings.MEDIA_LOCK).resolve()
- if lockfile in present_files:
- present_files.remove(lockfile)
+ present_files.discard(lockfile)
general_config = GeneralConfig()
app_logo = general_config.app_logo or settings.APP_LOGO
if app_logo:
logo_file = Path(settings.MEDIA_ROOT / Path(app_logo.lstrip("/"))).resolve()
- if logo_file in present_files:
- present_files.remove(logo_file)
+ present_files.discard(logo_file)
- for doc in tqdm(Document.global_objects.all(), disable=not progress):
- # Check sanity of the thumbnail
- thumbnail_path: Final[Path] = Path(doc.thumbnail_path).resolve()
- if not thumbnail_path.exists() or not thumbnail_path.is_file():
- messages.error(doc.pk, "Thumbnail of document does not exist.")
- else:
- if thumbnail_path in present_files:
- present_files.remove(thumbnail_path)
- try:
- _ = thumbnail_path.read_bytes()
- except OSError as e:
- messages.error(doc.pk, f"Cannot read thumbnail file of document: {e}")
+ return present_files
- # Check sanity of the original file
- # TODO: extract method
- source_path: Final[Path] = Path(doc.source_path).resolve()
- if not source_path.exists() or not source_path.is_file():
- messages.error(doc.pk, "Original of document does not exist.")
- else:
- if source_path in present_files:
- present_files.remove(source_path)
- try:
- checksum = hashlib.md5(source_path.read_bytes()).hexdigest()
- except OSError as e:
- messages.error(doc.pk, f"Cannot read original file of document: {e}")
- else:
- if checksum != doc.checksum:
- messages.error(
- doc.pk,
- "Checksum mismatch. "
- f"Stored: {doc.checksum}, actual: {checksum}.",
- )
- # Check sanity of the archive file.
- if doc.archive_checksum is not None and doc.archive_filename is None:
+def _check_thumbnail(
+ doc: Document,
+ messages: SanityCheckMessages,
+ present_files: set[Path],
+) -> None:
+ """Verify the thumbnail exists and is readable."""
+ thumbnail_path: Final[Path] = Path(doc.thumbnail_path).resolve()
+ if not thumbnail_path.exists() or not thumbnail_path.is_file():
+ messages.error(doc.pk, "Thumbnail of document does not exist.")
+ return
+
+ present_files.discard(thumbnail_path)
+ try:
+ _ = thumbnail_path.read_bytes()
+ except OSError as e:
+ messages.error(doc.pk, f"Cannot read thumbnail file of document: {e}")
+
+
+def _check_original(
+ doc: Document,
+ messages: SanityCheckMessages,
+ present_files: set[Path],
+) -> None:
+ """Verify the original file exists, is readable, and has matching checksum."""
+ source_path: Final[Path] = Path(doc.source_path).resolve()
+ if not source_path.exists() or not source_path.is_file():
+ messages.error(doc.pk, "Original of document does not exist.")
+ return
+
+ present_files.discard(source_path)
+ try:
+ checksum = hashlib.md5(source_path.read_bytes()).hexdigest()
+ except OSError as e:
+ messages.error(doc.pk, f"Cannot read original file of document: {e}")
+ else:
+ if checksum != doc.checksum:
messages.error(
doc.pk,
- "Document has an archive file checksum, but no archive filename.",
+ f"Checksum mismatch. Stored: {doc.checksum}, actual: {checksum}.",
)
- elif doc.archive_checksum is None and doc.archive_filename is not None:
+
+
+def _check_archive(
+ doc: Document,
+ messages: SanityCheckMessages,
+ present_files: set[Path],
+) -> None:
+ """Verify archive file consistency: checksum/filename pairing and file integrity."""
+ if doc.archive_checksum is not None and doc.archive_filename is None:
+ messages.error(
+ doc.pk,
+ "Document has an archive file checksum, but no archive filename.",
+ )
+ elif doc.archive_checksum is None and doc.archive_filename is not None:
+ messages.error(
+ doc.pk,
+ "Document has an archive file, but its checksum is missing.",
+ )
+ elif doc.has_archive_version:
+ if TYPE_CHECKING:
+ assert isinstance(doc.archive_path, Path)
+ archive_path: Final[Path] = Path(doc.archive_path).resolve()
+ if not archive_path.exists() or not archive_path.is_file():
+ messages.error(doc.pk, "Archived version of document does not exist.")
+ return
+
+ present_files.discard(archive_path)
+ try:
+ checksum = hashlib.md5(archive_path.read_bytes()).hexdigest()
+ except OSError as e:
messages.error(
doc.pk,
- "Document has an archive file, but its checksum is missing.",
+ f"Cannot read archive file of document: {e}",
)
- elif doc.has_archive_version:
- archive_path: Final[Path] = Path(doc.archive_path).resolve()
- if not archive_path.exists() or not archive_path.is_file():
- messages.error(doc.pk, "Archived version of document does not exist.")
- else:
- if archive_path in present_files:
- present_files.remove(archive_path)
- try:
- checksum = hashlib.md5(archive_path.read_bytes()).hexdigest()
- except OSError as e:
- messages.error(
- doc.pk,
- f"Cannot read archive file of document : {e}",
- )
- else:
- if checksum != doc.archive_checksum:
- messages.error(
- doc.pk,
- "Checksum mismatch of archived document. "
- f"Stored: {doc.archive_checksum}, "
- f"actual: {checksum}.",
- )
+ else:
+ if checksum != doc.archive_checksum:
+ messages.error(
+ doc.pk,
+ "Checksum mismatch of archived document. "
+ f"Stored: {doc.archive_checksum}, actual: {checksum}.",
+ )
- # other document checks
- if not doc.content:
- messages.info(doc.pk, "Document contains no OCR data")
+
+def _check_content(doc: Document, messages: SanityCheckMessages) -> None:
+ """Flag documents with no OCR content."""
+ if not doc.content:
+ messages.info(doc.pk, "Document contains no OCR data")
+
+
+def _check_document(
+ doc: Document,
+ messages: SanityCheckMessages,
+ present_files: set[Path],
+) -> None:
+ """Run all checks for a single document."""
+ _check_thumbnail(doc, messages, present_files)
+ _check_original(doc, messages, present_files)
+ _check_archive(doc, messages, present_files)
+ _check_content(doc, messages)
+
+
+# ---------------------------------------------------------------------------
+# Public entry point
+# ---------------------------------------------------------------------------
+
+
+def check_sanity(
+ *,
+ scheduled: bool = True,
+ iter_wrapper: IterWrapper[Document] = _identity,
+) -> SanityCheckMessages:
+ """Run a full sanity check on the document archive.
+
+ Args:
+ scheduled: Whether this is a scheduled (automatic) or manual check.
+ Controls the task type recorded in the database.
+ iter_wrapper: A callable that wraps the document iterable, e.g.,
+ for progress bar display. Defaults to identity (no wrapping).
+
+ Returns:
+ A SanityCheckMessages instance containing all detected issues.
+ """
+ paperless_task = PaperlessTask.objects.create(
+ task_id=uuid.uuid4(),
+ type=(
+ PaperlessTask.TaskType.SCHEDULED_TASK
+ if scheduled
+ else PaperlessTask.TaskType.MANUAL_TASK
+ ),
+ task_name=PaperlessTask.TaskName.CHECK_SANITY,
+ status=states.STARTED,
+ date_created=timezone.now(),
+ date_started=timezone.now(),
+ )
+
+ messages = SanityCheckMessages()
+ present_files = _build_present_files()
+
+ documents = Document.global_objects.all()
+ for doc in iter_wrapper(documents):
+ _check_document(doc, messages, present_files)
for extra_file in present_files:
messages.warning(None, f"Orphaned file in media dir: {extra_file}")
paperless_task.status = states.SUCCESS if not messages.has_error else states.FAILURE
- # result is concatenated messages
- paperless_task.result = f"{len(messages)} issues found."
- if messages.has_error:
- paperless_task.result += " Check logs for details."
+ if messages.total_issue_count == 0:
+ paperless_task.result = "No issues found."
+ else:
+ parts: list[str] = []
+ if messages.document_error_count:
+ parts.append(f"{messages.document_error_count} document(s) with errors")
+ if messages.document_warning_count:
+ parts.append(f"{messages.document_warning_count} document(s) with warnings")
+ if messages.global_warning_count:
+ parts.append(f"{messages.global_warning_count} global warning(s)")
+ paperless_task.result = ", ".join(parts) + " found."
+ if messages.has_error:
+ paperless_task.result += " Check logs for details."
+
paperless_task.date_done = timezone.now()
paperless_task.save(update_fields=["status", "result", "date_done"])
+
return messages
diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py
index 8a1693019..7df7e2e5e 100644
--- a/src/documents/serialisers.py
+++ b/src/documents/serialisers.py
@@ -80,6 +80,7 @@ from documents.parsers import is_mime_type_supported
from documents.permissions import get_document_count_filter_for_user
from documents.permissions import get_groups_with_only_permission
from documents.permissions import get_objects_for_user_owner_aware
+from documents.permissions import has_perms_owner_aware
from documents.permissions import set_permissions_for_object
from documents.regex import validate_regex_pattern
from documents.templating.filepath import validate_filepath_template_and_render
@@ -2321,6 +2322,17 @@ class ShareLinkSerializer(OwnedObjectSerializer):
validated_data["slug"] = get_random_string(50)
return super().create(validated_data)
+ def validate_document(self, document):
+ if self.user is not None and has_perms_owner_aware(
+ self.user,
+ "view_document",
+ document,
+ ):
+ return document
+ raise PermissionDenied(
+ _("Insufficient permissions."),
+ )
+
class ShareLinkBundleSerializer(OwnedObjectSerializer):
document_ids = serializers.ListField(
diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py
index 4fe78a570..8aa969e5b 100644
--- a/src/documents/signals/handlers.py
+++ b/src/documents/signals/handlers.py
@@ -34,12 +34,14 @@ from documents.file_handling import create_source_path_directory
from documents.file_handling import delete_empty_directories
from documents.file_handling import generate_filename
from documents.file_handling import generate_unique_filename
+from documents.models import Correspondent
from documents.models import CustomField
from documents.models import CustomFieldInstance
from documents.models import Document
-from documents.models import MatchingModel
+from documents.models import DocumentType
from documents.models import PaperlessTask
from documents.models import SavedView
+from documents.models import StoragePath
from documents.models import Tag
from documents.models import UiSettings
from documents.models import Workflow
@@ -85,47 +87,41 @@ def add_inbox_tags(sender, document: Document, logging_group=None, **kwargs) ->
document.add_nested_tags(inbox_tags)
-def _suggestion_printer(
- stdout,
- style_func,
- suggestion_type: str,
- document: Document,
- selected: MatchingModel,
- base_url: str | None = None,
-) -> None:
- """
- Smaller helper to reduce duplication when just outputting suggestions to the console
- """
- doc_str = str(document)
- if base_url is not None:
- stdout.write(style_func.SUCCESS(doc_str))
- stdout.write(style_func.SUCCESS(f"{base_url}/documents/{document.pk}"))
- else:
- stdout.write(style_func.SUCCESS(f"{doc_str} [{document.pk}]"))
- stdout.write(f"Suggest {suggestion_type}: {selected}")
-
-
def set_correspondent(
- sender,
+ sender: object,
document: Document,
*,
- logging_group=None,
+ logging_group: object = None,
classifier: DocumentClassifier | None = None,
- replace=False,
- use_first=True,
- suggest=False,
- base_url=None,
- stdout=None,
- style_func=None,
- **kwargs,
-) -> None:
+ replace: bool = False,
+ use_first: bool = True,
+ dry_run: bool = False,
+ **kwargs: Any,
+) -> Correspondent | None:
+ """
+ Assign a correspondent to a document based on classifier results.
+
+ Args:
+ document: The document to classify.
+ logging_group: Optional logging group for structured log output.
+ classifier: The trained classifier. If None, only rule-based matching runs.
+ replace: If True, overwrite an existing correspondent assignment.
+ use_first: If True, pick the first match when multiple correspondents
+ match. If False, skip assignment when multiple match.
+ dry_run: If True, compute and return the selection without saving.
+ **kwargs: Absorbed for Django signal compatibility (e.g. sender, signal).
+
+ Returns:
+ The correspondent that was (or would be) assigned, or None if no match
+ was found or assignment was skipped.
+ """
if document.correspondent and not replace:
- return
+ return None
potential_correspondents = matching.match_correspondents(document, classifier)
-
potential_count = len(potential_correspondents)
selected = potential_correspondents[0] if potential_correspondents else None
+
if potential_count > 1:
if use_first:
logger.debug(
@@ -139,49 +135,53 @@ def set_correspondent(
f"not assigning any correspondent",
extra={"group": logging_group},
)
- return
+ return None
- if selected or replace:
- if suggest:
- _suggestion_printer(
- stdout,
- style_func,
- "correspondent",
- document,
- selected,
- base_url,
- )
- else:
- logger.info(
- f"Assigning correspondent {selected} to {document}",
- extra={"group": logging_group},
- )
+ if (selected or replace) and not dry_run:
+ logger.info(
+ f"Assigning correspondent {selected} to {document}",
+ extra={"group": logging_group},
+ )
+ document.correspondent = selected
+ document.save(update_fields=("correspondent",))
- document.correspondent = selected
- document.save(update_fields=("correspondent",))
+ return selected
def set_document_type(
- sender,
+ sender: object,
document: Document,
*,
- logging_group=None,
+ logging_group: object = None,
classifier: DocumentClassifier | None = None,
- replace=False,
- use_first=True,
- suggest=False,
- base_url=None,
- stdout=None,
- style_func=None,
- **kwargs,
-) -> None:
+ replace: bool = False,
+ use_first: bool = True,
+ dry_run: bool = False,
+ **kwargs: Any,
+) -> DocumentType | None:
+ """
+ Assign a document type to a document based on classifier results.
+
+ Args:
+ document: The document to classify.
+ logging_group: Optional logging group for structured log output.
+ classifier: The trained classifier. If None, only rule-based matching runs.
+ replace: If True, overwrite an existing document type assignment.
+ use_first: If True, pick the first match when multiple types match.
+ If False, skip assignment when multiple match.
+ dry_run: If True, compute and return the selection without saving.
+ **kwargs: Absorbed for Django signal compatibility (e.g. sender, signal).
+
+ Returns:
+ The document type that was (or would be) assigned, or None if no match
+ was found or assignment was skipped.
+ """
if document.document_type and not replace:
- return
+ return None
- potential_document_type = matching.match_document_types(document, classifier)
-
- potential_count = len(potential_document_type)
- selected = potential_document_type[0] if potential_document_type else None
+ potential_document_types = matching.match_document_types(document, classifier)
+ potential_count = len(potential_document_types)
+ selected = potential_document_types[0] if potential_document_types else None
if potential_count > 1:
if use_first:
@@ -196,42 +196,64 @@ def set_document_type(
f"not assigning any document type",
extra={"group": logging_group},
)
- return
+ return None
- if selected or replace:
- if suggest:
- _suggestion_printer(
- stdout,
- style_func,
- "document type",
- document,
- selected,
- base_url,
- )
- else:
- logger.info(
- f"Assigning document type {selected} to {document}",
- extra={"group": logging_group},
- )
+ if (selected or replace) and not dry_run:
+ logger.info(
+ f"Assigning document type {selected} to {document}",
+ extra={"group": logging_group},
+ )
+ document.document_type = selected
+ document.save(update_fields=("document_type",))
- document.document_type = selected
- document.save(update_fields=("document_type",))
+ return selected
def set_tags(
- sender,
+ sender: object,
document: Document,
*,
- logging_group=None,
+ logging_group: object = None,
classifier: DocumentClassifier | None = None,
- replace=False,
- suggest=False,
- base_url=None,
- stdout=None,
- style_func=None,
- **kwargs,
-) -> None:
+ replace: bool = False,
+ dry_run: bool = False,
+ **kwargs: Any,
+) -> tuple[set[Tag], set[Tag]]:
+ """
+ Assign tags to a document based on classifier results.
+
+ When replace=True, existing auto-matched and rule-matched tags are removed
+ before applying the new set (inbox tags and manually-added tags are preserved).
+
+ Args:
+ document: The document to classify.
+ logging_group: Optional logging group for structured log output.
+ classifier: The trained classifier. If None, only rule-based matching runs.
+ replace: If True, remove existing classifier-managed tags before applying
+ new ones. Inbox tags and manually-added tags are always preserved.
+ dry_run: If True, compute what would change without saving anything.
+ **kwargs: Absorbed for Django signal compatibility (e.g. sender, signal).
+
+ Returns:
+ A two-tuple of (tags_added, tags_removed). In non-replace mode,
+ tags_removed is always an empty set. In dry_run mode, neither set
+ is applied to the database.
+ """
+ # Compute which tags would be removed under replace mode.
+ # The filter mirrors the .delete() call below: keep inbox tags and
+ # manually-added tags (match="" and not auto-matched).
if replace:
+ tags_to_remove: set[Tag] = set(
+ document.tags.exclude(
+ is_inbox_tag=True,
+ ).exclude(
+ Q(match="") & ~Q(matching_algorithm=Tag.MATCH_AUTO),
+ ),
+ )
+ else:
+ tags_to_remove = set()
+
+ if replace and not dry_run:
Document.tags.through.objects.filter(document=document).exclude(
Q(tag__is_inbox_tag=True),
).exclude(
@@ -239,65 +261,53 @@ def set_tags(
).delete()
current_tags = set(document.tags.all())
-
matched_tags = matching.match_tags(document, classifier)
+ tags_to_add = set(matched_tags) - current_tags
- relevant_tags = set(matched_tags) - current_tags
-
- if suggest:
- extra_tags = current_tags - set(matched_tags)
- extra_tags = [
- t for t in extra_tags if t.matching_algorithm == MatchingModel.MATCH_AUTO
- ]
- if not relevant_tags and not extra_tags:
- return
- doc_str = style_func.SUCCESS(str(document))
- if base_url:
- stdout.write(doc_str)
- stdout.write(f"{base_url}/documents/{document.pk}")
- else:
- stdout.write(doc_str + style_func.SUCCESS(f" [{document.pk}]"))
- if relevant_tags:
- stdout.write("Suggest tags: " + ", ".join([t.name for t in relevant_tags]))
- if extra_tags:
- stdout.write("Extra tags: " + ", ".join([t.name for t in extra_tags]))
- else:
- if not relevant_tags:
- return
-
- message = 'Tagging "{}" with "{}"'
+ if tags_to_add and not dry_run:
logger.info(
- message.format(document, ", ".join([t.name for t in relevant_tags])),
+ f'Tagging "{document}" with "{", ".join(t.name for t in tags_to_add)}"',
extra={"group": logging_group},
)
+ document.add_nested_tags(tags_to_add)
- document.add_nested_tags(relevant_tags)
+ return tags_to_add, tags_to_remove
def set_storage_path(
- sender,
+ sender: object,
document: Document,
*,
- logging_group=None,
+ logging_group: object = None,
classifier: DocumentClassifier | None = None,
- replace=False,
- use_first=True,
- suggest=False,
- base_url=None,
- stdout=None,
- style_func=None,
- **kwargs,
-) -> None:
+ replace: bool = False,
+ use_first: bool = True,
+ dry_run: bool = False,
+ **kwargs: Any,
+) -> StoragePath | None:
+ """
+ Assign a storage path to a document based on classifier results.
+
+ Args:
+ document: The document to classify.
+ logging_group: Optional logging group for structured log output.
+ classifier: The trained classifier. If None, only rule-based matching runs.
+ replace: If True, overwrite an existing storage path assignment.
+ use_first: If True, pick the first match when multiple paths match.
+ If False, skip assignment when multiple match.
+ dry_run: If True, compute and return the selection without saving.
+ **kwargs: Absorbed for Django signal compatibility (e.g. sender, signal).
+
+ Returns:
+ The storage path that was (or would be) assigned, or None if no match
+ was found or assignment was skipped.
+ """
if document.storage_path and not replace:
- return
+ return None
- potential_storage_path = matching.match_storage_paths(
- document,
- classifier,
- )
-
- potential_count = len(potential_storage_path)
- selected = potential_storage_path[0] if potential_storage_path else None
+ potential_storage_paths = matching.match_storage_paths(document, classifier)
+ potential_count = len(potential_storage_paths)
+ selected = potential_storage_paths[0] if potential_storage_paths else None
if potential_count > 1:
if use_first:
@@ -312,26 +322,17 @@ def set_storage_path(
f"not assigning any storage directory",
extra={"group": logging_group},
)
- return
+ return None
- if selected or replace:
- if suggest:
- _suggestion_printer(
- stdout,
- style_func,
- "storage directory",
- document,
- selected,
- base_url,
- )
- else:
- logger.info(
- f"Assigning storage path {selected} to {document}",
- extra={"group": logging_group},
- )
+ if (selected or replace) and not dry_run:
+ logger.info(
+ f"Assigning storage path {selected} to {document}",
+ extra={"group": logging_group},
+ )
+ document.storage_path = selected
+ document.save(update_fields=("storage_path",))
- document.storage_path = selected
- document.save(update_fields=("storage_path",))
+ return selected
# see empty_trash in documents/tasks.py for signal handling
@@ -808,6 +809,14 @@ def run_workflows(
"""
use_overrides = overrides is not None
+
+ if isinstance(document, Document) and document.root_document_id is not None:
+ logger.debug(
+ "Skipping workflow execution for version document %s",
+ document.pk,
+ )
+ return None
+
if original_file is None:
original_file = (
document.source_path if not use_overrides else document.original_file
diff --git a/src/documents/tasks.py b/src/documents/tasks.py
index 9df9ee2cb..ff25adbc7 100644
--- a/src/documents/tasks.py
+++ b/src/documents/tasks.py
@@ -4,11 +4,13 @@ import logging
import shutil
import uuid
import zipfile
+from collections.abc import Callable
+from collections.abc import Iterable
from pathlib import Path
from tempfile import TemporaryDirectory
from tempfile import mkstemp
+from typing import TypeVar
-import tqdm
from celery import Task
from celery import shared_task
from celery import states
@@ -67,11 +69,19 @@ from paperless_ai.indexing import llm_index_add_or_update_document
from paperless_ai.indexing import llm_index_remove_document
from paperless_ai.indexing import update_llm_index
+_T = TypeVar("_T")
+IterWrapper = Callable[[Iterable[_T]], Iterable[_T]]
+
+
if settings.AUDIT_LOG_ENABLED:
from auditlog.models import LogEntry
logger = logging.getLogger("paperless.tasks")
+def _identity(iterable: Iterable[_T]) -> Iterable[_T]:
+ return iterable
+
+
@shared_task
def index_optimize() -> None:
ix = index.open_index()
@@ -79,13 +89,13 @@ def index_optimize() -> None:
writer.commit(optimize=True)
-def index_reindex(*, progress_bar_disable=False) -> None:
+def index_reindex(*, iter_wrapper: IterWrapper[Document] = _identity) -> None:
documents = Document.objects.all()
ix = index.open_index(recreate=True)
with AsyncWriter(ix) as writer:
- for document in tqdm.tqdm(documents, disable=progress_bar_disable):
+ for document in iter_wrapper(documents):
index.update_document(writer, document)
@@ -228,20 +238,30 @@ def consume_file(
@shared_task
def sanity_check(*, scheduled=True, raise_on_error=True):
messages = sanity_checker.check_sanity(scheduled=scheduled)
-
messages.log_messages()
+ if not messages.has_error and not messages.has_warning and not messages.has_info:
+ return "No issues detected."
+
+ parts: list[str] = []
+ if messages.document_error_count:
+ parts.append(f"{messages.document_error_count} document(s) with errors")
+ if messages.document_warning_count:
+ parts.append(f"{messages.document_warning_count} document(s) with warnings")
+ if messages.document_info_count:
+ parts.append(f"{messages.document_info_count} document(s) with infos")
+ if messages.global_warning_count:
+ parts.append(f"{messages.global_warning_count} global warning(s)")
+
+ summary = ", ".join(parts) + " found."
+
if messages.has_error:
- message = "Sanity check exited with errors. See log."
+ message = summary + " Check logs for details."
if raise_on_error:
raise SanityCheckFailedException(message)
return message
- elif messages.has_warning:
- return "Sanity check exited with warnings. See log."
- elif len(messages) > 0:
- return "Sanity check exited with infos. See log."
- else:
- return "No issues detected."
+
+ return summary
@shared_task
@@ -266,7 +286,6 @@ def bulk_update_documents(document_ids) -> None:
ai_config = AIConfig()
if ai_config.llm_index_enabled:
update_llm_index(
- progress_bar_disable=True,
rebuild=False,
)
@@ -453,13 +472,22 @@ def check_scheduled_workflows() -> None:
match trigger.schedule_date_field:
case WorkflowTrigger.ScheduleDateField.ADDED:
- documents = Document.objects.filter(added__lte=threshold)
+ documents = Document.objects.filter(
+ root_document__isnull=True,
+ added__lte=threshold,
+ )
case WorkflowTrigger.ScheduleDateField.CREATED:
- documents = Document.objects.filter(created__lte=threshold)
+ documents = Document.objects.filter(
+ root_document__isnull=True,
+ created__lte=threshold,
+ )
case WorkflowTrigger.ScheduleDateField.MODIFIED:
- documents = Document.objects.filter(modified__lte=threshold)
+ documents = Document.objects.filter(
+ root_document__isnull=True,
+ modified__lte=threshold,
+ )
case WorkflowTrigger.ScheduleDateField.CUSTOM_FIELD:
# cap earliest date to avoid massive scans
@@ -497,7 +525,10 @@ def check_scheduled_workflows() -> None:
)
]
- documents = Document.objects.filter(id__in=matched_ids)
+ documents = Document.objects.filter(
+ root_document__isnull=True,
+ id__in=matched_ids,
+ )
if documents.count() > 0:
documents = prefilter_documents_by_workflowtrigger(
@@ -600,7 +631,7 @@ def update_document_parent_tags(tag: Tag, new_parent: Tag) -> None:
@shared_task
def llmindex_index(
*,
- progress_bar_disable=True,
+ iter_wrapper: IterWrapper[Document] = _identity,
rebuild=False,
scheduled=True,
auto=False,
@@ -623,7 +654,7 @@ def llmindex_index(
try:
result = update_llm_index(
- progress_bar_disable=progress_bar_disable,
+ iter_wrapper=iter_wrapper,
rebuild=rebuild,
)
task.status = states.SUCCESS
diff --git a/src/documents/tests/conftest.py b/src/documents/tests/conftest.py
index 8c88cee9f..a33771fd1 100644
--- a/src/documents/tests/conftest.py
+++ b/src/documents/tests/conftest.py
@@ -1,10 +1,96 @@
+import shutil
import zoneinfo
+from dataclasses import dataclass
+from pathlib import Path
+from typing import TYPE_CHECKING
+import filelock
import pytest
from django.contrib.auth import get_user_model
from pytest_django.fixtures import SettingsWrapper
from rest_framework.test import APIClient
+from documents.tests.factories import DocumentFactory
+
+if TYPE_CHECKING:
+ from documents.models import Document
+
+
+@dataclass(frozen=True, slots=True)
+class PaperlessDirs:
+ """Standard Paperless-ngx directory layout for tests."""
+
+ media: Path
+ originals: Path
+ archive: Path
+ thumbnails: Path
+
+
+@pytest.fixture(scope="session")
+def samples_dir() -> Path:
+ """Path to the shared test sample documents."""
+ return Path(__file__).parent / "samples" / "documents"
+
+
+@pytest.fixture()
+def paperless_dirs(tmp_path: Path) -> PaperlessDirs:
+ """Create and return the directory structure for testing."""
+ media = tmp_path / "media"
+ dirs = PaperlessDirs(
+ media=media,
+ originals=media / "documents" / "originals",
+ archive=media / "documents" / "archive",
+ thumbnails=media / "documents" / "thumbnails",
+ )
+ for d in (dirs.originals, dirs.archive, dirs.thumbnails):
+ d.mkdir(parents=True)
+ return dirs
+
+
+@pytest.fixture()
+def _media_settings(paperless_dirs: PaperlessDirs, settings) -> None:
+ """Configure Django settings to point at temp directories."""
+ settings.MEDIA_ROOT = paperless_dirs.media
+ settings.ORIGINALS_DIR = paperless_dirs.originals
+ settings.ARCHIVE_DIR = paperless_dirs.archive
+ settings.THUMBNAIL_DIR = paperless_dirs.thumbnails
+ settings.MEDIA_LOCK = paperless_dirs.media / "media.lock"
+ settings.IGNORABLE_FILES = {".DS_Store", "Thumbs.db", "desktop.ini"}
+ settings.APP_LOGO = ""
+
+
+@pytest.fixture()
+def sample_doc(
+ paperless_dirs: PaperlessDirs,
+ _media_settings: None,
+ samples_dir: Path,
+) -> "Document":
+ """Create a document with valid files and matching checksums."""
+ with filelock.FileLock(paperless_dirs.media / "media.lock"):
+ shutil.copy(
+ samples_dir / "originals" / "0000001.pdf",
+ paperless_dirs.originals / "0000001.pdf",
+ )
+ shutil.copy(
+ samples_dir / "archive" / "0000001.pdf",
+ paperless_dirs.archive / "0000001.pdf",
+ )
+ shutil.copy(
+ samples_dir / "thumbnails" / "0000001.webp",
+ paperless_dirs.thumbnails / "0000001.webp",
+ )
+
+ return DocumentFactory(
+ title="test",
+ checksum="42995833e01aea9b3edee44bbfdd7ce1",
+ archive_checksum="62acb0bcbfbcaa62ca6ad3668e4e404b",
+ content="test content",
+ pk=1,
+ filename="0000001.pdf",
+ mime_type="application/pdf",
+ archive_filename="0000001.pdf",
+ )
+
@pytest.fixture()
def settings_timezone(settings: SettingsWrapper) -> zoneinfo.ZoneInfo:
@@ -28,3 +114,14 @@ def authenticated_rest_api_client(rest_api_client: APIClient):
user = UserModel.objects.create_user(username="testuser", password="password")
rest_api_client.force_authenticate(user=user)
yield rest_api_client
+
+
+@pytest.fixture(scope="session", autouse=True)
+def faker_session_locale():
+ """Set Faker locale for reproducibility."""
+ return "en_US"
+
+
+@pytest.fixture(scope="session", autouse=True)
+def faker_seed():
+ return 12345
diff --git a/src/documents/tests/factories.py b/src/documents/tests/factories.py
index de41bbd02..d1d88587c 100644
--- a/src/documents/tests/factories.py
+++ b/src/documents/tests/factories.py
@@ -1,17 +1,67 @@
-from factory import Faker
+"""
+Factory-boy factories for documents app models.
+"""
+
+from __future__ import annotations
+
+import factory
from factory.django import DjangoModelFactory
from documents.models import Correspondent
from documents.models import Document
+from documents.models import DocumentType
+from documents.models import MatchingModel
+from documents.models import StoragePath
+from documents.models import Tag
class CorrespondentFactory(DjangoModelFactory):
class Meta:
model = Correspondent
- name = Faker("name")
+ name = factory.Sequence(lambda n: f"{factory.Faker('company')} {n}")
+ match = ""
+ matching_algorithm = MatchingModel.MATCH_NONE
+
+
+class DocumentTypeFactory(DjangoModelFactory):
+ class Meta:
+ model = DocumentType
+
+ name = factory.Sequence(lambda n: f"{factory.Faker('bs')} {n}")
+ match = ""
+ matching_algorithm = MatchingModel.MATCH_NONE
+
+
+class TagFactory(DjangoModelFactory):
+ class Meta:
+ model = Tag
+
+ name = factory.Sequence(lambda n: f"{factory.Faker('word')} {n}")
+ match = ""
+ matching_algorithm = MatchingModel.MATCH_NONE
+ is_inbox_tag = False
+
+
+class StoragePathFactory(DjangoModelFactory):
+ class Meta:
+ model = StoragePath
+
+ name = factory.Sequence(
+ lambda n: f"{factory.Faker('file_path', depth=2, extension='')} {n}",
+ )
+ path = factory.LazyAttribute(lambda o: f"{o.name}/{{title}}")
+ match = ""
+ matching_algorithm = MatchingModel.MATCH_NONE
class DocumentFactory(DjangoModelFactory):
class Meta:
model = Document
+
+ title = factory.Faker("sentence", nb_words=4)
+ checksum = factory.Faker("md5")
+ content = factory.Faker("paragraph")
+ correspondent = None
+ document_type = None
+ storage_path = None
diff --git a/src/documents/tests/management/test_management_sanity_checker.py b/src/documents/tests/management/test_management_sanity_checker.py
new file mode 100644
index 000000000..64f21e966
--- /dev/null
+++ b/src/documents/tests/management/test_management_sanity_checker.py
@@ -0,0 +1,193 @@
+"""Tests for the document_sanity_checker management command.
+
+Verifies Rich rendering (table, panel, summary) and end-to-end CLI behavior.
+"""
+
+from __future__ import annotations
+
+from io import StringIO
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+import pytest
+from django.core.management import call_command
+from rich.console import Console
+
+from documents.management.commands.document_sanity_checker import Command
+from documents.sanity_checker import SanityCheckMessages
+from documents.tests.factories import DocumentFactory
+
+if TYPE_CHECKING:
+ from documents.models import Document
+ from documents.tests.conftest import PaperlessDirs
+
+
+def _render_to_string(messages: SanityCheckMessages) -> str:
+ """Render command output to a plain string for assertion."""
+ buf = StringIO()
+ cmd = Command()
+ cmd.console = Console(file=buf, width=120, no_color=True)
+ cmd._render_results(messages)
+ return buf.getvalue()
+
+
+# ---------------------------------------------------------------------------
+# Rich rendering
+# ---------------------------------------------------------------------------
+
+
+class TestRenderResultsNoIssues:
+ """No DB access needed -- renders an empty SanityCheckMessages."""
+
+ def test_shows_panel(self) -> None:
+ output = _render_to_string(SanityCheckMessages())
+ assert "No issues detected" in output
+ assert "Sanity Check" in output
+
+
+@pytest.mark.django_db
+class TestRenderResultsWithIssues:
+ def test_error_row(self, sample_doc: Document) -> None:
+ msgs = SanityCheckMessages()
+ msgs.error(sample_doc.pk, "Original missing")
+ output = _render_to_string(msgs)
+ assert "Sanity Check Results" in output
+ assert "ERROR" in output
+ assert "Original missing" in output
+ assert f"#{sample_doc.pk}" in output
+ assert sample_doc.title in output
+
+ def test_warning_row(self, sample_doc: Document) -> None:
+ msgs = SanityCheckMessages()
+ msgs.warning(sample_doc.pk, "Suspicious file")
+ output = _render_to_string(msgs)
+ assert "WARN" in output
+ assert "Suspicious file" in output
+
+ def test_info_row(self, sample_doc: Document) -> None:
+ msgs = SanityCheckMessages()
+ msgs.info(sample_doc.pk, "No OCR data")
+ output = _render_to_string(msgs)
+ assert "INFO" in output
+ assert "No OCR data" in output
+
+ @pytest.mark.usefixtures("_media_settings")
+ def test_global_message(self) -> None:
+ msgs = SanityCheckMessages()
+ msgs.warning(None, "Orphaned file: /tmp/stray.pdf")
+ output = _render_to_string(msgs)
+ assert "(global)" in output
+ assert "Orphaned file" in output
+
+ def test_multiple_messages_same_doc(self, sample_doc: Document) -> None:
+ msgs = SanityCheckMessages()
+ msgs.error(sample_doc.pk, "Thumbnail missing")
+ msgs.error(sample_doc.pk, "Checksum mismatch")
+ output = _render_to_string(msgs)
+ assert "Thumbnail missing" in output
+ assert "Checksum mismatch" in output
+
+ @pytest.mark.usefixtures("_media_settings")
+ def test_unknown_doc_pk(self) -> None:
+ msgs = SanityCheckMessages()
+ msgs.error(99999, "Ghost document")
+ output = _render_to_string(msgs)
+ assert "#99999" in output
+ assert "Unknown" in output
+
+
+@pytest.mark.django_db
+class TestRenderResultsSummary:
+ def test_errors_only(self, sample_doc: Document) -> None:
+ msgs = SanityCheckMessages()
+ msgs.error(sample_doc.pk, "broken")
+ output = _render_to_string(msgs)
+ assert "1 document(s) with" in output
+ assert "errors" in output
+
+ def test_warnings_only(self, sample_doc: Document) -> None:
+ msgs = SanityCheckMessages()
+ msgs.warning(sample_doc.pk, "odd")
+ output = _render_to_string(msgs)
+ assert "1 document(s) with" in output
+ assert "warnings" in output
+
+ def test_infos_only(self, sample_doc: Document) -> None:
+ msgs = SanityCheckMessages()
+ msgs.info(sample_doc.pk, "no OCR")
+ output = _render_to_string(msgs)
+ assert "1 document(s) with infos" in output
+
+ def test_empty_messages(self) -> None:
+ msgs = SanityCheckMessages()
+ output = _render_to_string(msgs)
+ assert "No issues detected." in output
+
+ def test_document_errors_and_global_warnings(self, sample_doc: Document) -> None:
+ msgs = SanityCheckMessages()
+ msgs.error(sample_doc.pk, "broken")
+ msgs.warning(None, "orphan")
+ output = _render_to_string(msgs)
+ assert "1 document(s) with" in output
+ assert "errors" in output
+ assert "1 global warning(s)" in output
+ assert "2 document(s)" not in output
+
+ def test_global_warnings_only(self) -> None:
+ msgs = SanityCheckMessages()
+ msgs.warning(None, "extra file")
+ output = _render_to_string(msgs)
+ assert "1 global warning(s)" in output
+ assert "document(s) with" not in output
+
+ def test_all_levels_combined(self, sample_doc: Document) -> None:
+ msgs = SanityCheckMessages()
+ msgs.error(sample_doc.pk, "broken")
+ msgs.warning(sample_doc.pk, "odd")
+ msgs.info(sample_doc.pk, "fyi")
+ msgs.warning(None, "extra file")
+ output = _render_to_string(msgs)
+ assert "1 document(s) with errors" in output
+ assert "1 document(s) with warnings" in output
+ assert "1 document(s) with infos" in output
+ assert "1 global warning(s)" in output
+
+
+# ---------------------------------------------------------------------------
+# End-to-end command execution
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.django_db
+@pytest.mark.management
+class TestDocumentSanityCheckerCommand:
+ def test_no_issues(self, sample_doc: Document) -> None:
+ out = StringIO()
+ call_command("document_sanity_checker", "--no-progress-bar", stdout=out)
+ assert "No issues detected" in out.getvalue()
+
+ def test_missing_original(self, sample_doc: Document) -> None:
+ Path(sample_doc.source_path).unlink()
+ out = StringIO()
+ call_command("document_sanity_checker", "--no-progress-bar", stdout=out)
+ output = out.getvalue()
+ assert "ERROR" in output
+ assert "Original of document does not exist" in output
+
+ @pytest.mark.usefixtures("_media_settings")
+ def test_checksum_mismatch(self, paperless_dirs: PaperlessDirs) -> None:
+ """Lightweight document with zero-byte files triggers checksum mismatch."""
+ doc = DocumentFactory(
+ title="test",
+ content="test",
+ filename="test.pdf",
+ checksum="abc",
+ )
+ Path(doc.source_path).touch()
+ Path(doc.thumbnail_path).touch()
+
+ out = StringIO()
+ call_command("document_sanity_checker", "--no-progress-bar", stdout=out)
+ output = out.getvalue()
+ assert "ERROR" in output
+ assert "Checksum mismatch. Stored: abc, actual:" in output
diff --git a/src/documents/tests/test_api_bulk_edit.py b/src/documents/tests/test_api_bulk_edit.py
index deb1d5586..81d972bd4 100644
--- a/src/documents/tests/test_api_bulk_edit.py
+++ b/src/documents/tests/test_api_bulk_edit.py
@@ -773,6 +773,22 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
],
)
+ def test_api_selection_data_requires_view_permission(self):
+ self.doc2.owner = self.user
+ self.doc2.save()
+
+ user1 = User.objects.create(username="user1")
+ self.client.force_authenticate(user=user1)
+
+ response = self.client.post(
+ "/api/documents/selection_data/",
+ json.dumps({"documents": [self.doc2.id]}),
+ content_type="application/json",
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+ self.assertEqual(response.content, b"Insufficient permissions")
+
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_set_permissions(self, m) -> None:
self.setup_mock(m, "set_permissions")
diff --git a/src/documents/tests/test_api_documents.py b/src/documents/tests/test_api_documents.py
index b7996aac0..5ddfc8538 100644
--- a/src/documents/tests/test_api_documents.py
+++ b/src/documents/tests/test_api_documents.py
@@ -2963,6 +2963,54 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
)
self.assertEqual(resp.status_code, status.HTTP_200_OK)
+ def test_create_share_link_requires_view_permission_for_document(self):
+ """
+ GIVEN:
+ - A user with add_sharelink but without view permission on a document
+ WHEN:
+ - API request is made to create a share link for that document
+ THEN:
+ - Share link creation is denied until view permission is granted
+ """
+ user1 = User.objects.create_user(username="test1")
+ user1.user_permissions.add(*Permission.objects.filter(codename="add_sharelink"))
+ user1.save()
+
+ user2 = User.objects.create_user(username="test2")
+ user2.save()
+
+ doc = Document.objects.create(
+ title="test",
+ mime_type="application/pdf",
+ content="this is a document which will be protected",
+ owner=user2,
+ )
+
+ self.client.force_authenticate(user1)
+
+ create_resp = self.client.post(
+ "/api/share_links/",
+ data={
+ "document": doc.pk,
+ "file_version": "original",
+ },
+ format="json",
+ )
+ self.assertEqual(create_resp.status_code, status.HTTP_403_FORBIDDEN)
+
+ assign_perm("view_document", user1, doc)
+
+ create_resp = self.client.post(
+ "/api/share_links/",
+ data={
+ "document": doc.pk,
+ "file_version": "original",
+ },
+ format="json",
+ )
+ self.assertEqual(create_resp.status_code, status.HTTP_201_CREATED)
+ self.assertEqual(create_resp.data["document"], doc.pk)
+
def test_next_asn(self) -> None:
"""
GIVEN:
diff --git a/src/documents/tests/test_api_uisettings.py b/src/documents/tests/test_api_uisettings.py
index ec973e2f1..811b77286 100644
--- a/src/documents/tests/test_api_uisettings.py
+++ b/src/documents/tests/test_api_uisettings.py
@@ -21,6 +21,16 @@ class TestApiUiSettings(DirectoriesMixin, APITestCase):
self.test_user.save()
self.client.force_authenticate(user=self.test_user)
+ @override_settings(
+ APP_TITLE=None,
+ APP_LOGO=None,
+ AUDIT_LOG_ENABLED=True,
+ EMPTY_TRASH_DELAY=30,
+ ENABLE_UPDATE_CHECK="default",
+ EMAIL_ENABLED=False,
+ GMAIL_OAUTH_ENABLED=False,
+ OUTLOOK_OAUTH_ENABLED=False,
+ )
def test_api_get_ui_settings(self) -> None:
response = self.client.get(self.ENDPOINT, format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
diff --git a/src/documents/tests/test_barcodes.py b/src/documents/tests/test_barcodes.py
index 2d9ac58f0..b1847f2b4 100644
--- a/src/documents/tests/test_barcodes.py
+++ b/src/documents/tests/test_barcodes.py
@@ -919,6 +919,7 @@ class TestTagBarcode(DirectoriesMixin, SampleDirMixin, GetReaderPluginMixin, Tes
@override_settings(
CONSUMER_ENABLE_TAG_BARCODE=True,
CONSUMER_TAG_BARCODE_MAPPING={"ASN(.*)": "\\g<1>"},
+ CONSUMER_ENABLE_ASN_BARCODE=False,
)
def test_scan_file_for_many_custom_tags(self) -> None:
"""
diff --git a/src/documents/tests/test_file_handling.py b/src/documents/tests/test_file_handling.py
index 9b6f96ce1..1e35b96bc 100644
--- a/src/documents/tests/test_file_handling.py
+++ b/src/documents/tests/test_file_handling.py
@@ -329,14 +329,14 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
FILENAME_FORMAT="{added_year}-{added_month}-{added_day}",
)
def test_added_year_month_day(self) -> None:
- d1 = timezone.make_aware(datetime.datetime(232, 1, 9, 1, 1, 1))
+ d1 = timezone.make_aware(datetime.datetime(1232, 1, 9, 1, 1, 1))
doc1 = Document.objects.create(
title="doc1",
mime_type="application/pdf",
added=d1,
)
- self.assertEqual(generate_filename(doc1), Path("232-01-09.pdf"))
+ self.assertEqual(generate_filename(doc1), Path("1232-01-09.pdf"))
doc1.added = timezone.make_aware(datetime.datetime(2020, 11, 16, 1, 1, 1))
diff --git a/src/documents/tests/test_management.py b/src/documents/tests/test_management.py
index 074e8039a..03959a85b 100644
--- a/src/documents/tests/test_management.py
+++ b/src/documents/tests/test_management.py
@@ -134,6 +134,7 @@ class TestRenamer(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
self.assertIsFile(doc2.archive_path)
+@pytest.mark.management
class TestCreateClassifier(TestCase):
@mock.patch(
"documents.management.commands.document_create_classifier.train_classifier",
@@ -144,32 +145,6 @@ class TestCreateClassifier(TestCase):
m.assert_called_once()
-@pytest.mark.management
-class TestSanityChecker(DirectoriesMixin, TestCase):
- def test_no_issues(self) -> None:
- with self.assertLogs() as capture:
- call_command("document_sanity_checker")
-
- self.assertEqual(len(capture.output), 1)
- self.assertIn("Sanity checker detected no issues.", capture.output[0])
-
- def test_errors(self) -> None:
- doc = Document.objects.create(
- title="test",
- content="test",
- filename="test.pdf",
- checksum="abc",
- )
- Path(doc.source_path).touch()
- Path(doc.thumbnail_path).touch()
-
- with self.assertLogs() as capture:
- call_command("document_sanity_checker")
-
- self.assertEqual(len(capture.output), 2)
- self.assertIn("Checksum mismatch. Stored: abc, actual:", capture.output[1])
-
-
@pytest.mark.management
class TestConvertMariaDBUUID(TestCase):
@mock.patch("django.db.connection.schema_editor")
diff --git a/src/documents/tests/test_management_exporter.py b/src/documents/tests/test_management_exporter.py
index e6daf5991..2d17aaec6 100644
--- a/src/documents/tests/test_management_exporter.py
+++ b/src/documents/tests/test_management_exporter.py
@@ -288,7 +288,7 @@ class TestExportImport(
self.assertEqual(Permission.objects.count(), num_permission_objects)
messages = check_sanity()
# everything is alright after the test
- self.assertEqual(len(messages), 0)
+ self.assertEqual(messages.total_issue_count, 0)
def test_exporter_with_filename_format(self) -> None:
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
diff --git a/src/documents/tests/test_management_fuzzy.py b/src/documents/tests/test_management_fuzzy.py
index b3d03ecea..195a3450d 100644
--- a/src/documents/tests/test_management_fuzzy.py
+++ b/src/documents/tests/test_management_fuzzy.py
@@ -140,7 +140,7 @@ class TestFuzzyMatchCommand(TestCase):
mime_type="application/pdf",
filename="final_test.pdf",
)
- stdout, _ = self.call_command("--no-progress-bar")
+ stdout, _ = self.call_command("--no-progress-bar", "--processes", "1")
lines = [x.strip() for x in stdout.splitlines() if x.strip()]
self.assertEqual(len(lines), 3)
for line in lines:
@@ -183,7 +183,12 @@ class TestFuzzyMatchCommand(TestCase):
self.assertEqual(Document.objects.count(), 3)
- stdout, _ = self.call_command("--delete", "--no-progress-bar")
+ stdout, _ = self.call_command(
+ "--delete",
+ "--no-progress-bar",
+ "--processes",
+ "1",
+ )
self.assertIn(
"The command is configured to delete documents. Use with caution",
diff --git a/src/documents/tests/test_management_retagger.py b/src/documents/tests/test_management_retagger.py
index 29b322c28..3f669c00e 100644
--- a/src/documents/tests/test_management_retagger.py
+++ b/src/documents/tests/test_management_retagger.py
@@ -1,298 +1,442 @@
+"""
+Tests for the document_retagger management command.
+"""
+
+from __future__ import annotations
+
import pytest
from django.core.management import call_command
from django.core.management.base import CommandError
-from django.test import TestCase
from documents.models import Correspondent
from documents.models import Document
from documents.models import DocumentType
+from documents.models import MatchingModel
from documents.models import StoragePath
from documents.models import Tag
+from documents.tests.factories import CorrespondentFactory
+from documents.tests.factories import DocumentFactory
+from documents.tests.factories import DocumentTypeFactory
+from documents.tests.factories import StoragePathFactory
+from documents.tests.factories import TagFactory
from documents.tests.utils import DirectoriesMixin
+# ---------------------------------------------------------------------------
+# Module-level type aliases
+# ---------------------------------------------------------------------------
+
+StoragePathTuple = tuple[StoragePath, StoragePath, StoragePath]
+TagTuple = tuple[Tag, Tag, Tag, Tag, Tag]
+CorrespondentTuple = tuple[Correspondent, Correspondent]
+DocumentTypeTuple = tuple[DocumentType, DocumentType]
+DocumentTuple = tuple[Document, Document, Document, Document]
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture()
+def storage_paths(db) -> StoragePathTuple:
+ """Three storage paths with varying match rules."""
+ sp1 = StoragePathFactory(
+ path="{created_data}/{title}",
+ match="auto document",
+ matching_algorithm=MatchingModel.MATCH_LITERAL,
+ )
+ sp2 = StoragePathFactory(
+ path="{title}",
+ match="^first|^unrelated",
+ matching_algorithm=MatchingModel.MATCH_REGEX,
+ )
+ sp3 = StoragePathFactory(
+ path="{title}",
+ match="^blah",
+ matching_algorithm=MatchingModel.MATCH_REGEX,
+ )
+ return sp1, sp2, sp3
+
+
+@pytest.fixture()
+def tags(db) -> TagTuple:
+ """Tags covering the common matching scenarios."""
+ tag_first = TagFactory(match="first", matching_algorithm=Tag.MATCH_ANY)
+ tag_second = TagFactory(match="second", matching_algorithm=Tag.MATCH_ANY)
+ tag_inbox = TagFactory(is_inbox_tag=True)
+ tag_no_match = TagFactory()
+ tag_auto = TagFactory(matching_algorithm=Tag.MATCH_AUTO)
+ return tag_first, tag_second, tag_inbox, tag_no_match, tag_auto
+
+
+@pytest.fixture()
+def correspondents(db) -> CorrespondentTuple:
+ """Two correspondents matching 'first' and 'second' content."""
+ c_first = CorrespondentFactory(
+ match="first",
+ matching_algorithm=MatchingModel.MATCH_ANY,
+ )
+ c_second = CorrespondentFactory(
+ match="second",
+ matching_algorithm=MatchingModel.MATCH_ANY,
+ )
+ return c_first, c_second
+
+
+@pytest.fixture()
+def document_types(db) -> DocumentTypeTuple:
+ """Two document types matching 'first' and 'second' content."""
+ dt_first = DocumentTypeFactory(
+ match="first",
+ matching_algorithm=MatchingModel.MATCH_ANY,
+ )
+ dt_second = DocumentTypeFactory(
+ match="second",
+ matching_algorithm=MatchingModel.MATCH_ANY,
+ )
+ return dt_first, dt_second
+
+
+@pytest.fixture()
+def documents(storage_paths: StoragePathTuple, tags: TagTuple) -> DocumentTuple:
+ """Four documents with varied content used across most retagger tests."""
+ _, _, sp3 = storage_paths
+ _, _, tag_inbox, tag_no_match, tag_auto = tags
+
+ d1 = DocumentFactory(checksum="A", title="A", content="first document")
+ d2 = DocumentFactory(checksum="B", title="B", content="second document")
+ d3 = DocumentFactory(
+ checksum="C",
+ title="C",
+ content="unrelated document",
+ storage_path=sp3,
+ )
+ d4 = DocumentFactory(checksum="D", title="D", content="auto document")
+
+ d3.tags.add(tag_inbox, tag_no_match)
+ d4.tags.add(tag_auto)
+
+ return d1, d2, d3, d4
+
+
+def _get_docs() -> DocumentTuple:
+ return (
+ Document.objects.get(title="A"),
+ Document.objects.get(title="B"),
+ Document.objects.get(title="C"),
+ Document.objects.get(title="D"),
+ )
+
+
+# ---------------------------------------------------------------------------
+# Tag assignment
+# ---------------------------------------------------------------------------
+
@pytest.mark.management
-class TestRetagger(DirectoriesMixin, TestCase):
- def make_models(self) -> None:
- self.sp1 = StoragePath.objects.create(
- name="dummy a",
- path="{created_data}/{title}",
- match="auto document",
- matching_algorithm=StoragePath.MATCH_LITERAL,
- )
- self.sp2 = StoragePath.objects.create(
- name="dummy b",
- path="{title}",
- match="^first|^unrelated",
- matching_algorithm=StoragePath.MATCH_REGEX,
- )
-
- self.sp3 = StoragePath.objects.create(
- name="dummy c",
- path="{title}",
- match="^blah",
- matching_algorithm=StoragePath.MATCH_REGEX,
- )
-
- self.d1 = Document.objects.create(
- checksum="A",
- title="A",
- content="first document",
- )
- self.d2 = Document.objects.create(
- checksum="B",
- title="B",
- content="second document",
- )
- self.d3 = Document.objects.create(
- checksum="C",
- title="C",
- content="unrelated document",
- storage_path=self.sp3,
- )
- self.d4 = Document.objects.create(
- checksum="D",
- title="D",
- content="auto document",
- )
-
- self.tag_first = Tag.objects.create(
- name="tag1",
- match="first",
- matching_algorithm=Tag.MATCH_ANY,
- )
- self.tag_second = Tag.objects.create(
- name="tag2",
- match="second",
- matching_algorithm=Tag.MATCH_ANY,
- )
- self.tag_inbox = Tag.objects.create(name="test", is_inbox_tag=True)
- self.tag_no_match = Tag.objects.create(name="test2")
- self.tag_auto = Tag.objects.create(
- name="tagauto",
- matching_algorithm=Tag.MATCH_AUTO,
- )
-
- self.d3.tags.add(self.tag_inbox)
- self.d3.tags.add(self.tag_no_match)
- self.d4.tags.add(self.tag_auto)
-
- self.correspondent_first = Correspondent.objects.create(
- name="c1",
- match="first",
- matching_algorithm=Correspondent.MATCH_ANY,
- )
- self.correspondent_second = Correspondent.objects.create(
- name="c2",
- match="second",
- matching_algorithm=Correspondent.MATCH_ANY,
- )
-
- self.doctype_first = DocumentType.objects.create(
- name="dt1",
- match="first",
- matching_algorithm=DocumentType.MATCH_ANY,
- )
- self.doctype_second = DocumentType.objects.create(
- name="dt2",
- match="second",
- matching_algorithm=DocumentType.MATCH_ANY,
- )
-
- def get_updated_docs(self):
- return (
- Document.objects.get(title="A"),
- Document.objects.get(title="B"),
- Document.objects.get(title="C"),
- Document.objects.get(title="D"),
- )
-
- def setUp(self) -> None:
- super().setUp()
- self.make_models()
-
- def test_add_tags(self) -> None:
+@pytest.mark.django_db
+class TestRetaggerTags(DirectoriesMixin):
+ @pytest.mark.usefixtures("documents")
+ def test_add_tags(self, tags: TagTuple) -> None:
+ tag_first, tag_second, *_ = tags
call_command("document_retagger", "--tags")
- d_first, d_second, d_unrelated, d_auto = self.get_updated_docs()
+ d_first, d_second, d_unrelated, d_auto = _get_docs()
- self.assertEqual(d_first.tags.count(), 1)
- self.assertEqual(d_second.tags.count(), 1)
- self.assertEqual(d_unrelated.tags.count(), 2)
- self.assertEqual(d_auto.tags.count(), 1)
+ assert d_first.tags.count() == 1
+ assert d_second.tags.count() == 1
+ assert d_unrelated.tags.count() == 2
+ assert d_auto.tags.count() == 1
+ assert d_first.tags.first() == tag_first
+ assert d_second.tags.first() == tag_second
- self.assertEqual(d_first.tags.first(), self.tag_first)
- self.assertEqual(d_second.tags.first(), self.tag_second)
-
- def test_add_type(self) -> None:
- call_command("document_retagger", "--document_type")
- d_first, d_second, _, _ = self.get_updated_docs()
-
- self.assertEqual(d_first.document_type, self.doctype_first)
- self.assertEqual(d_second.document_type, self.doctype_second)
-
- def test_add_correspondent(self) -> None:
- call_command("document_retagger", "--correspondent")
- d_first, d_second, _, _ = self.get_updated_docs()
-
- self.assertEqual(d_first.correspondent, self.correspondent_first)
- self.assertEqual(d_second.correspondent, self.correspondent_second)
-
- def test_overwrite_preserve_inbox(self) -> None:
- self.d1.tags.add(self.tag_second)
+ def test_overwrite_removes_stale_tags_and_preserves_inbox(
+ self,
+ documents: DocumentTuple,
+ tags: TagTuple,
+ ) -> None:
+ d1, *_ = documents
+ tag_first, tag_second, tag_inbox, tag_no_match, _ = tags
+ d1.tags.add(tag_second)
call_command("document_retagger", "--tags", "--overwrite")
- d_first, d_second, d_unrelated, d_auto = self.get_updated_docs()
+ d_first, d_second, d_unrelated, d_auto = _get_docs()
- self.assertIsNotNone(Tag.objects.get(id=self.tag_second.id))
+ assert Tag.objects.filter(id=tag_second.id).exists()
+ assert list(d_first.tags.values_list("id", flat=True)) == [tag_first.id]
+ assert list(d_second.tags.values_list("id", flat=True)) == [tag_second.id]
+ assert set(d_unrelated.tags.values_list("id", flat=True)) == {
+ tag_inbox.id,
+ tag_no_match.id,
+ }
+ assert d_auto.tags.count() == 0
- self.assertCountEqual(
- [tag.id for tag in d_first.tags.all()],
- [self.tag_first.id],
+ @pytest.mark.usefixtures("documents")
+ @pytest.mark.parametrize(
+ "extra_args",
+ [
+ pytest.param([], id="no_base_url"),
+ pytest.param(["--base-url=http://localhost"], id="with_base_url"),
+ ],
+ )
+ def test_suggest_does_not_apply_tags(self, extra_args: list[str]) -> None:
+ call_command("document_retagger", "--tags", "--suggest", *extra_args)
+ d_first, d_second, _, d_auto = _get_docs()
+
+ assert d_first.tags.count() == 0
+ assert d_second.tags.count() == 0
+ assert d_auto.tags.count() == 1
+
+
+# ---------------------------------------------------------------------------
+# Document type assignment
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.management
+@pytest.mark.django_db
+class TestRetaggerDocumentType(DirectoriesMixin):
+ @pytest.mark.usefixtures("documents")
+ def test_add_type(self, document_types: DocumentTypeTuple) -> None:
+ dt_first, dt_second = document_types
+ call_command("document_retagger", "--document_type")
+ d_first, d_second, _, _ = _get_docs()
+
+ assert d_first.document_type == dt_first
+ assert d_second.document_type == dt_second
+
+ @pytest.mark.usefixtures("documents", "document_types")
+ @pytest.mark.parametrize(
+ "extra_args",
+ [
+ pytest.param([], id="no_base_url"),
+ pytest.param(["--base-url=http://localhost"], id="with_base_url"),
+ ],
+ )
+ def test_suggest_does_not_apply_document_type(self, extra_args: list[str]) -> None:
+ call_command("document_retagger", "--document_type", "--suggest", *extra_args)
+ d_first, d_second, _, _ = _get_docs()
+
+ assert d_first.document_type is None
+ assert d_second.document_type is None
+
+ @pytest.mark.parametrize(
+ ("use_first_flag", "expects_assignment"),
+ [
+ pytest.param(["--use-first"], True, id="use_first_assigns_first_match"),
+ pytest.param([], False, id="no_use_first_skips_ambiguous_match"),
+ ],
+ )
+ def test_use_first_with_multiple_matches(
+ self,
+ use_first_flag: list[str],
+ *,
+ expects_assignment: bool,
+ ) -> None:
+ DocumentTypeFactory(
+ match="ambiguous",
+ matching_algorithm=MatchingModel.MATCH_ANY,
)
- self.assertCountEqual(
- [tag.id for tag in d_second.tags.all()],
- [self.tag_second.id],
+ DocumentTypeFactory(
+ match="ambiguous",
+ matching_algorithm=MatchingModel.MATCH_ANY,
)
- self.assertCountEqual(
- [tag.id for tag in d_unrelated.tags.all()],
- [self.tag_inbox.id, self.tag_no_match.id],
+ doc = DocumentFactory(content="ambiguous content")
+
+ call_command("document_retagger", "--document_type", *use_first_flag)
+
+ doc.refresh_from_db()
+ assert (doc.document_type is not None) is expects_assignment
+
+
+# ---------------------------------------------------------------------------
+# Correspondent assignment
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.management
+@pytest.mark.django_db
+class TestRetaggerCorrespondent(DirectoriesMixin):
+ @pytest.mark.usefixtures("documents")
+ def test_add_correspondent(self, correspondents: CorrespondentTuple) -> None:
+ c_first, c_second = correspondents
+ call_command("document_retagger", "--correspondent")
+ d_first, d_second, _, _ = _get_docs()
+
+ assert d_first.correspondent == c_first
+ assert d_second.correspondent == c_second
+
+ @pytest.mark.usefixtures("documents", "correspondents")
+ @pytest.mark.parametrize(
+ "extra_args",
+ [
+ pytest.param([], id="no_base_url"),
+ pytest.param(["--base-url=http://localhost"], id="with_base_url"),
+ ],
+ )
+ def test_suggest_does_not_apply_correspondent(self, extra_args: list[str]) -> None:
+ call_command("document_retagger", "--correspondent", "--suggest", *extra_args)
+ d_first, d_second, _, _ = _get_docs()
+
+ assert d_first.correspondent is None
+ assert d_second.correspondent is None
+
+ @pytest.mark.parametrize(
+ ("use_first_flag", "expects_assignment"),
+ [
+ pytest.param(["--use-first"], True, id="use_first_assigns_first_match"),
+ pytest.param([], False, id="no_use_first_skips_ambiguous_match"),
+ ],
+ )
+ def test_use_first_with_multiple_matches(
+ self,
+ use_first_flag: list[str],
+ *,
+ expects_assignment: bool,
+ ) -> None:
+ CorrespondentFactory(
+ match="ambiguous",
+ matching_algorithm=MatchingModel.MATCH_ANY,
)
- self.assertEqual(d_auto.tags.count(), 0)
-
- def test_add_tags_suggest(self) -> None:
- call_command("document_retagger", "--tags", "--suggest")
- d_first, d_second, _, d_auto = self.get_updated_docs()
-
- self.assertEqual(d_first.tags.count(), 0)
- self.assertEqual(d_second.tags.count(), 0)
- self.assertEqual(d_auto.tags.count(), 1)
-
- def test_add_type_suggest(self) -> None:
- call_command("document_retagger", "--document_type", "--suggest")
- d_first, d_second, _, _ = self.get_updated_docs()
-
- self.assertIsNone(d_first.document_type)
- self.assertIsNone(d_second.document_type)
-
- def test_add_correspondent_suggest(self) -> None:
- call_command("document_retagger", "--correspondent", "--suggest")
- d_first, d_second, _, _ = self.get_updated_docs()
-
- self.assertIsNone(d_first.correspondent)
- self.assertIsNone(d_second.correspondent)
-
- def test_add_tags_suggest_url(self) -> None:
- call_command(
- "document_retagger",
- "--tags",
- "--suggest",
- "--base-url=http://localhost",
+ CorrespondentFactory(
+ match="ambiguous",
+ matching_algorithm=MatchingModel.MATCH_ANY,
)
- d_first, d_second, _, d_auto = self.get_updated_docs()
+ doc = DocumentFactory(content="ambiguous content")
- self.assertEqual(d_first.tags.count(), 0)
- self.assertEqual(d_second.tags.count(), 0)
- self.assertEqual(d_auto.tags.count(), 1)
+ call_command("document_retagger", "--correspondent", *use_first_flag)
- def test_add_type_suggest_url(self) -> None:
- call_command(
- "document_retagger",
- "--document_type",
- "--suggest",
- "--base-url=http://localhost",
- )
- d_first, d_second, _, _ = self.get_updated_docs()
+ doc.refresh_from_db()
+ assert (doc.correspondent is not None) is expects_assignment
- self.assertIsNone(d_first.document_type)
- self.assertIsNone(d_second.document_type)
- def test_add_correspondent_suggest_url(self) -> None:
- call_command(
- "document_retagger",
- "--correspondent",
- "--suggest",
- "--base-url=http://localhost",
- )
- d_first, d_second, _, _ = self.get_updated_docs()
+# ---------------------------------------------------------------------------
+# Storage path assignment
+# ---------------------------------------------------------------------------
- self.assertIsNone(d_first.correspondent)
- self.assertIsNone(d_second.correspondent)
- def test_add_storage_path(self) -> None:
+@pytest.mark.management
+@pytest.mark.django_db
+class TestRetaggerStoragePath(DirectoriesMixin):
+ @pytest.mark.usefixtures("documents")
+ def test_add_storage_path(self, storage_paths: StoragePathTuple) -> None:
"""
- GIVEN:
- - 2 storage paths with documents which match them
- - 1 document which matches but has a storage path
- WHEN:
- - document retagger is called
- THEN:
- - Matching document's storage paths updated
- - Non-matching documents have no storage path
- - Existing storage patch left unchanged
+ GIVEN documents matching various storage path rules
+ WHEN document_retagger --storage_path is called
+ THEN matching documents get the correct path; existing path is unchanged
"""
- call_command(
- "document_retagger",
- "--storage_path",
- )
- d_first, d_second, d_unrelated, d_auto = self.get_updated_docs()
+ sp1, sp2, sp3 = storage_paths
+ call_command("document_retagger", "--storage_path")
+ d_first, d_second, d_unrelated, d_auto = _get_docs()
- self.assertEqual(d_first.storage_path, self.sp2)
- self.assertEqual(d_auto.storage_path, self.sp1)
- self.assertIsNone(d_second.storage_path)
- self.assertEqual(d_unrelated.storage_path, self.sp3)
+ assert d_first.storage_path == sp2
+ assert d_auto.storage_path == sp1
+ assert d_second.storage_path is None
+ assert d_unrelated.storage_path == sp3
- def test_overwrite_storage_path(self) -> None:
+ @pytest.mark.usefixtures("documents")
+ def test_overwrite_storage_path(self, storage_paths: StoragePathTuple) -> None:
"""
- GIVEN:
- - 2 storage paths with documents which match them
- - 1 document which matches but has a storage path
- WHEN:
- - document retagger is called with overwrite
- THEN:
- - Matching document's storage paths updated
- - Non-matching documents have no storage path
- - Existing storage patch overwritten
+ GIVEN a document with an existing storage path that matches a different rule
+ WHEN document_retagger --storage_path --overwrite is called
+ THEN the existing path is replaced by the newly matched path
"""
+ sp1, sp2, _ = storage_paths
call_command("document_retagger", "--storage_path", "--overwrite")
- d_first, d_second, d_unrelated, d_auto = self.get_updated_docs()
+ d_first, d_second, d_unrelated, d_auto = _get_docs()
- self.assertEqual(d_first.storage_path, self.sp2)
- self.assertEqual(d_auto.storage_path, self.sp1)
- self.assertIsNone(d_second.storage_path)
- self.assertEqual(d_unrelated.storage_path, self.sp2)
+ assert d_first.storage_path == sp2
+ assert d_auto.storage_path == sp1
+ assert d_second.storage_path is None
+ assert d_unrelated.storage_path == sp2
- def test_id_range_parameter(self) -> None:
- commandOutput = ""
- Document.objects.create(
- checksum="E",
- title="E",
- content="NOT the first document",
+ @pytest.mark.parametrize(
+ ("use_first_flag", "expects_assignment"),
+ [
+ pytest.param(["--use-first"], True, id="use_first_assigns_first_match"),
+ pytest.param([], False, id="no_use_first_skips_ambiguous_match"),
+ ],
+ )
+ def test_use_first_with_multiple_matches(
+ self,
+ use_first_flag: list[str],
+ *,
+ expects_assignment: bool,
+ ) -> None:
+ StoragePathFactory(
+ match="ambiguous",
+ matching_algorithm=MatchingModel.MATCH_ANY,
)
- call_command("document_retagger", "--tags", "--id-range", "1", "2")
- # The retagger shouldn`t apply the 'first' tag to our new document
- self.assertEqual(Document.objects.filter(tags__id=self.tag_first.id).count(), 1)
+ StoragePathFactory(
+ match="ambiguous",
+ matching_algorithm=MatchingModel.MATCH_ANY,
+ )
+ doc = DocumentFactory(content="ambiguous content")
- try:
- commandOutput = call_command("document_retagger", "--tags", "--id-range")
- except CommandError:
- # Just ignore the error
- None
- self.assertIn(commandOutput, "Error: argument --id-range: expected 2 arguments")
+ call_command("document_retagger", "--storage_path", *use_first_flag)
- try:
- commandOutput = call_command(
- "document_retagger",
- "--tags",
- "--id-range",
- "a",
- "b",
- )
- except CommandError:
- # Just ignore the error
- None
- self.assertIn(commandOutput, "error: argument --id-range: invalid int value:")
+ doc.refresh_from_db()
+ assert (doc.storage_path is not None) is expects_assignment
- call_command("document_retagger", "--tags", "--id-range", "1", "9999")
- # Now we should have 2 documents
- self.assertEqual(Document.objects.filter(tags__id=self.tag_first.id).count(), 2)
+
+# ---------------------------------------------------------------------------
+# ID range filtering
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.management
+@pytest.mark.django_db
+class TestRetaggerIdRange(DirectoriesMixin):
+ @pytest.mark.usefixtures("documents")
+ @pytest.mark.parametrize(
+ ("id_range_args", "expected_count"),
+ [
+ pytest.param(["1", "2"], 1, id="narrow_range_limits_scope"),
+ pytest.param(["1", "9999"], 2, id="wide_range_tags_all_matches"),
+ ],
+ )
+ def test_id_range_limits_scope(
+ self,
+ tags: TagTuple,
+ id_range_args: list[str],
+ expected_count: int,
+ ) -> None:
+ DocumentFactory(content="NOT the first document")
+ call_command("document_retagger", "--tags", "--id-range", *id_range_args)
+ tag_first, *_ = tags
+ assert Document.objects.filter(tags__id=tag_first.id).count() == expected_count
+
+ @pytest.mark.usefixtures("documents")
+ @pytest.mark.parametrize(
+ "args",
+ [
+ pytest.param(["--tags", "--id-range"], id="missing_both_values"),
+ pytest.param(["--tags", "--id-range", "a", "b"], id="non_integer_values"),
+ ],
+ )
+ def test_id_range_invalid_arguments_raise(self, args: list[str]) -> None:
+ with pytest.raises((CommandError, SystemExit)):
+ call_command("document_retagger", *args)
+
+
+# ---------------------------------------------------------------------------
+# Edge cases
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.management
+@pytest.mark.django_db
+class TestRetaggerEdgeCases(DirectoriesMixin):
+ @pytest.mark.usefixtures("documents")
+ def test_no_targets_exits_cleanly(self) -> None:
+ """Calling the retagger with no classifier targets should not raise."""
+ call_command("document_retagger")
+
+ @pytest.mark.usefixtures("documents")
+ def test_inbox_only_skips_non_inbox_documents(self) -> None:
+ """--inbox-only must restrict processing to documents with an inbox tag."""
+ call_command("document_retagger", "--tags", "--inbox-only")
+ d_first, _, d_unrelated, _ = _get_docs()
+
+ assert d_first.tags.count() == 0
+ assert d_unrelated.tags.count() == 2
diff --git a/src/documents/tests/test_sanity_check.py b/src/documents/tests/test_sanity_check.py
index 415b0967f..e62c17303 100644
--- a/src/documents/tests/test_sanity_check.py
+++ b/src/documents/tests/test_sanity_check.py
@@ -1,192 +1,295 @@
-import logging
-import shutil
-from pathlib import Path
+"""Tests for the sanity checker module.
-import filelock
-from django.conf import settings
-from django.test import TestCase
-from django.test import override_settings
+Tests exercise ``check_sanity`` as a whole, verifying document validation,
+orphan detection, task recording, and the iter_wrapper contract.
+"""
+
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+import pytest
from documents.models import Document
+from documents.models import PaperlessTask
from documents.sanity_checker import check_sanity
-from documents.tests.utils import DirectoriesMixin
+
+if TYPE_CHECKING:
+ from collections.abc import Iterable
+
+ from documents.tests.conftest import PaperlessDirs
-class TestSanityCheck(DirectoriesMixin, TestCase):
- def make_test_data(self):
- with filelock.FileLock(settings.MEDIA_LOCK):
- # just make sure that the lockfile is present.
- shutil.copy(
- (
- Path(__file__).parent
- / "samples"
- / "documents"
- / "originals"
- / "0000001.pdf"
- ),
- Path(self.dirs.originals_dir) / "0000001.pdf",
- )
- shutil.copy(
- (
- Path(__file__).parent
- / "samples"
- / "documents"
- / "archive"
- / "0000001.pdf"
- ),
- Path(self.dirs.archive_dir) / "0000001.pdf",
- )
- shutil.copy(
- (
- Path(__file__).parent
- / "samples"
- / "documents"
- / "thumbnails"
- / "0000001.webp"
- ),
- Path(self.dirs.thumbnail_dir) / "0000001.webp",
- )
+@pytest.mark.django_db
+class TestCheckSanityNoDocuments:
+ """Sanity checks against an empty archive."""
- return Document.objects.create(
- title="test",
- checksum="42995833e01aea9b3edee44bbfdd7ce1",
- archive_checksum="62acb0bcbfbcaa62ca6ad3668e4e404b",
- content="test",
- pk=1,
- filename="0000001.pdf",
- mime_type="application/pdf",
- archive_filename="0000001.pdf",
- )
-
- def assertSanityError(self, doc: Document, messageRegex) -> None:
+ @pytest.mark.usefixtures("_media_settings")
+ def test_no_documents(self) -> None:
messages = check_sanity()
- self.assertTrue(messages.has_error)
- with self.assertLogs() as capture:
+ assert not messages.has_error
+ assert not messages.has_warning
+ assert messages.total_issue_count == 0
+
+ @pytest.mark.usefixtures("_media_settings")
+ def test_no_issues_logs_clean(self, caplog: pytest.LogCaptureFixture) -> None:
+ messages = check_sanity()
+ with caplog.at_level(logging.INFO, logger="paperless.sanity_checker"):
messages.log_messages()
- self.assertEqual(
- capture.records[0].message,
- f"Detected following issue(s) with document #{doc.pk}, titled {doc.title}",
- )
- self.assertRegex(capture.records[1].message, messageRegex)
+ assert "Sanity checker detected no issues." in caplog.text
- def test_no_issues(self) -> None:
- self.make_test_data()
+
+@pytest.mark.django_db
+class TestCheckSanityHealthyDocument:
+ def test_no_errors(self, sample_doc: Document) -> None:
messages = check_sanity()
- self.assertFalse(messages.has_error)
- self.assertFalse(messages.has_warning)
- with self.assertLogs() as capture:
- messages.log_messages()
- self.assertEqual(len(capture.output), 1)
- self.assertEqual(capture.records[0].levelno, logging.INFO)
- self.assertEqual(
- capture.records[0].message,
- "Sanity checker detected no issues.",
- )
+ assert not messages.has_error
+ assert not messages.has_warning
+ assert messages.total_issue_count == 0
- def test_no_docs(self) -> None:
- self.assertEqual(len(check_sanity()), 0)
- def test_success(self) -> None:
- self.make_test_data()
- self.assertEqual(len(check_sanity()), 0)
-
- def test_no_thumbnail(self) -> None:
- doc = self.make_test_data()
- Path(doc.thumbnail_path).unlink()
- self.assertSanityError(doc, "Thumbnail of document does not exist")
-
- def test_thumbnail_no_access(self) -> None:
- doc = self.make_test_data()
- Path(doc.thumbnail_path).chmod(0o000)
- self.assertSanityError(doc, "Cannot read thumbnail file of document")
- Path(doc.thumbnail_path).chmod(0o777)
-
- def test_no_original(self) -> None:
- doc = self.make_test_data()
- Path(doc.source_path).unlink()
- self.assertSanityError(doc, "Original of document does not exist.")
-
- def test_original_no_access(self) -> None:
- doc = self.make_test_data()
- Path(doc.source_path).chmod(0o000)
- self.assertSanityError(doc, "Cannot read original file of document")
- Path(doc.source_path).chmod(0o777)
-
- def test_original_checksum_mismatch(self) -> None:
- doc = self.make_test_data()
- doc.checksum = "WOW"
- doc.save()
- self.assertSanityError(doc, "Checksum mismatch. Stored: WOW, actual: ")
-
- def test_no_archive(self) -> None:
- doc = self.make_test_data()
- Path(doc.archive_path).unlink()
- self.assertSanityError(doc, "Archived version of document does not exist.")
-
- def test_archive_no_access(self) -> None:
- doc = self.make_test_data()
- Path(doc.archive_path).chmod(0o000)
- self.assertSanityError(doc, "Cannot read archive file of document")
- Path(doc.archive_path).chmod(0o777)
-
- def test_archive_checksum_mismatch(self) -> None:
- doc = self.make_test_data()
- doc.archive_checksum = "WOW"
- doc.save()
- self.assertSanityError(doc, "Checksum mismatch of archived document")
-
- def test_empty_content(self) -> None:
- doc = self.make_test_data()
- doc.content = ""
- doc.save()
+@pytest.mark.django_db
+class TestCheckSanityThumbnail:
+ def test_missing(self, sample_doc: Document) -> None:
+ Path(sample_doc.thumbnail_path).unlink()
messages = check_sanity()
- self.assertFalse(messages.has_error)
- self.assertFalse(messages.has_warning)
- self.assertEqual(len(messages), 1)
- self.assertRegex(
- messages[doc.pk][0]["message"],
- "Document contains no OCR data",
+ assert messages.has_error
+ assert any(
+ "Thumbnail of document does not exist" in m["message"]
+ for m in messages[sample_doc.pk]
)
- def test_orphaned_file(self) -> None:
- self.make_test_data()
- Path(self.dirs.originals_dir, "orphaned").touch()
+ def test_unreadable(self, sample_doc: Document) -> None:
+ thumb = Path(sample_doc.thumbnail_path)
+ thumb.chmod(0o000)
+ try:
+ messages = check_sanity()
+ assert messages.has_error
+ assert any(
+ "Cannot read thumbnail" in m["message"] for m in messages[sample_doc.pk]
+ )
+ finally:
+ thumb.chmod(0o644)
+
+
+@pytest.mark.django_db
+class TestCheckSanityOriginal:
+ def test_missing(self, sample_doc: Document) -> None:
+ Path(sample_doc.source_path).unlink()
messages = check_sanity()
- self.assertTrue(messages.has_warning)
- self.assertRegex(
- messages._messages[None][0]["message"],
- "Orphaned file in media dir",
+ assert messages.has_error
+ assert any(
+ "Original of document does not exist" in m["message"]
+ for m in messages[sample_doc.pk]
)
- @override_settings(
- APP_LOGO="logo/logo.png",
+ def test_checksum_mismatch(self, sample_doc: Document) -> None:
+ sample_doc.checksum = "badhash"
+ sample_doc.save()
+ messages = check_sanity()
+ assert messages.has_error
+ assert any(
+ "Checksum mismatch" in m["message"] and "badhash" in m["message"]
+ for m in messages[sample_doc.pk]
+ )
+
+ def test_unreadable(self, sample_doc: Document) -> None:
+ src = Path(sample_doc.source_path)
+ src.chmod(0o000)
+ try:
+ messages = check_sanity()
+ assert messages.has_error
+ assert any(
+ "Cannot read original" in m["message"] for m in messages[sample_doc.pk]
+ )
+ finally:
+ src.chmod(0o644)
+
+
+@pytest.mark.django_db
+class TestCheckSanityArchive:
+ def test_checksum_without_filename(self, sample_doc: Document) -> None:
+ sample_doc.archive_filename = None
+ sample_doc.save()
+ messages = check_sanity()
+ assert messages.has_error
+ assert any(
+ "checksum, but no archive filename" in m["message"]
+ for m in messages[sample_doc.pk]
+ )
+
+ def test_filename_without_checksum(self, sample_doc: Document) -> None:
+ sample_doc.archive_checksum = None
+ sample_doc.save()
+ messages = check_sanity()
+ assert messages.has_error
+ assert any(
+ "checksum is missing" in m["message"] for m in messages[sample_doc.pk]
+ )
+
+ def test_missing_file(self, sample_doc: Document) -> None:
+ Path(sample_doc.archive_path).unlink()
+ messages = check_sanity()
+ assert messages.has_error
+ assert any(
+ "Archived version of document does not exist" in m["message"]
+ for m in messages[sample_doc.pk]
+ )
+
+ def test_checksum_mismatch(self, sample_doc: Document) -> None:
+ sample_doc.archive_checksum = "wronghash"
+ sample_doc.save()
+ messages = check_sanity()
+ assert messages.has_error
+ assert any(
+ "Checksum mismatch of archived document" in m["message"]
+ for m in messages[sample_doc.pk]
+ )
+
+ def test_unreadable(self, sample_doc: Document) -> None:
+ archive = Path(sample_doc.archive_path)
+ archive.chmod(0o000)
+ try:
+ messages = check_sanity()
+ assert messages.has_error
+ assert any(
+ "Cannot read archive" in m["message"] for m in messages[sample_doc.pk]
+ )
+ finally:
+ archive.chmod(0o644)
+
+ def test_no_archive_at_all(self, sample_doc: Document) -> None:
+ """Document with neither archive checksum nor filename is valid."""
+ Path(sample_doc.archive_path).unlink()
+ sample_doc.archive_checksum = None
+ sample_doc.archive_filename = None
+ sample_doc.save()
+ messages = check_sanity()
+ assert not messages.has_error
+
+
+@pytest.mark.django_db
+class TestCheckSanityContent:
+ @pytest.mark.parametrize(
+ "content",
+ [
+ pytest.param("", id="empty-string"),
+ ],
)
- def test_ignore_logo(self) -> None:
- self.make_test_data()
- logo_dir = Path(self.dirs.media_dir, "logo")
- logo_dir.mkdir(parents=True, exist_ok=True)
- Path(self.dirs.media_dir, "logo", "logo.png").touch()
+ def test_no_content(self, sample_doc: Document, content: str) -> None:
+ sample_doc.content = content
+ sample_doc.save()
messages = check_sanity()
- self.assertFalse(messages.has_warning)
+ assert not messages.has_error
+ assert not messages.has_warning
+ assert any("no OCR data" in m["message"] for m in messages[sample_doc.pk])
- def test_ignore_ignorable_files(self) -> None:
- self.make_test_data()
- Path(self.dirs.media_dir, ".DS_Store").touch()
- Path(self.dirs.media_dir, "desktop.ini").touch()
+
+@pytest.mark.django_db
+class TestCheckSanityOrphans:
+ def test_orphaned_file(
+ self,
+ sample_doc: Document,
+ paperless_dirs: PaperlessDirs,
+ ) -> None:
+ (paperless_dirs.originals / "orphan.pdf").touch()
messages = check_sanity()
- self.assertFalse(messages.has_warning)
+ assert messages.has_warning
+ assert any("Orphaned file" in m["message"] for m in messages[None])
- def test_archive_filename_no_checksum(self) -> None:
- doc = self.make_test_data()
- doc.archive_checksum = None
- doc.save()
- self.assertSanityError(doc, "has an archive file, but its checksum is missing.")
+ @pytest.mark.usefixtures("_media_settings")
+ def test_ignorable_files_not_flagged(
+ self,
+ paperless_dirs: PaperlessDirs,
+ ) -> None:
+ (paperless_dirs.media / ".DS_Store").touch()
+ (paperless_dirs.media / "desktop.ini").touch()
+ messages = check_sanity()
+ assert not messages.has_warning
- def test_archive_checksum_no_filename(self) -> None:
- doc = self.make_test_data()
- doc.archive_filename = None
- doc.save()
- self.assertSanityError(
- doc,
- "has an archive file checksum, but no archive filename.",
- )
+
+@pytest.mark.django_db
+class TestCheckSanityIterWrapper:
+ def test_wrapper_receives_documents(self, sample_doc: Document) -> None:
+ seen: list[Document] = []
+
+ def tracking(iterable: Iterable[Document]) -> Iterable[Document]:
+ for item in iterable:
+ seen.append(item)
+ yield item
+
+ check_sanity(iter_wrapper=tracking)
+ assert len(seen) == 1
+ assert seen[0].pk == sample_doc.pk
+
+ def test_default_works_without_wrapper(self, sample_doc: Document) -> None:
+ messages = check_sanity()
+ assert not messages.has_error
+
+
+@pytest.mark.django_db
+class TestCheckSanityTaskRecording:
+ @pytest.mark.parametrize(
+ ("expected_type", "scheduled"),
+ [
+ pytest.param(PaperlessTask.TaskType.SCHEDULED_TASK, True, id="scheduled"),
+ pytest.param(PaperlessTask.TaskType.MANUAL_TASK, False, id="manual"),
+ ],
+ )
+ @pytest.mark.usefixtures("_media_settings")
+ def test_task_type(self, expected_type: str, *, scheduled: bool) -> None:
+ check_sanity(scheduled=scheduled)
+ task = PaperlessTask.objects.latest("date_created")
+ assert task.task_name == PaperlessTask.TaskName.CHECK_SANITY
+ assert task.type == expected_type
+
+ def test_success_status(self, sample_doc: Document) -> None:
+ check_sanity()
+ task = PaperlessTask.objects.latest("date_created")
+ assert task.status == "SUCCESS"
+
+ def test_failure_status(self, sample_doc: Document) -> None:
+ Path(sample_doc.source_path).unlink()
+ check_sanity()
+ task = PaperlessTask.objects.latest("date_created")
+ assert task.status == "FAILURE"
+ assert "Check logs for details" in task.result
+
+
+@pytest.mark.django_db
+class TestCheckSanityLogMessages:
+ def test_logs_doc_issues(
+ self,
+ sample_doc: Document,
+ caplog: pytest.LogCaptureFixture,
+ ) -> None:
+ Path(sample_doc.source_path).unlink()
+ messages = check_sanity()
+ with caplog.at_level(logging.INFO, logger="paperless.sanity_checker"):
+ messages.log_messages()
+ assert f"document #{sample_doc.pk}" in caplog.text
+ assert "Original of document does not exist" in caplog.text
+
+ def test_logs_global_issues(
+ self,
+ sample_doc: Document,
+ paperless_dirs: PaperlessDirs,
+ caplog: pytest.LogCaptureFixture,
+ ) -> None:
+ (paperless_dirs.originals / "orphan.pdf").touch()
+ messages = check_sanity()
+ with caplog.at_level(logging.WARNING, logger="paperless.sanity_checker"):
+ messages.log_messages()
+ assert "Orphaned file" in caplog.text
+
+ @pytest.mark.usefixtures("_media_settings")
+ def test_logs_unknown_doc_pk(self, caplog: pytest.LogCaptureFixture) -> None:
+ """A doc PK not in the DB logs 'Unknown' as the title."""
+ messages = check_sanity()
+ messages.error(99999, "Ghost document")
+ with caplog.at_level(logging.INFO, logger="paperless.sanity_checker"):
+ messages.log_messages()
+ assert "#99999" in caplog.text
+ assert "Unknown" in caplog.text
diff --git a/src/documents/tests/test_tasks.py b/src/documents/tests/test_tasks.py
index 4647c19ba..37f1e6fed 100644
--- a/src/documents/tests/test_tasks.py
+++ b/src/documents/tests/test_tasks.py
@@ -3,6 +3,7 @@ from datetime import timedelta
from pathlib import Path
from unittest import mock
+import pytest
from celery import states
from django.conf import settings
from django.test import TestCase
@@ -105,55 +106,83 @@ class TestClassifier(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
self.assertNotEqual(mtime2, mtime3)
-class TestSanityCheck(DirectoriesMixin, TestCase):
- @mock.patch("documents.tasks.sanity_checker.check_sanity")
- def test_sanity_check_success(self, m) -> None:
- m.return_value = SanityCheckMessages()
- self.assertEqual(tasks.sanity_check(), "No issues detected.")
- m.assert_called_once()
+@pytest.mark.django_db
+class TestSanityCheck:
+ @pytest.fixture
+ def mock_check_sanity(self, mocker) -> mock.MagicMock:
+ return mocker.patch("documents.tasks.sanity_checker.check_sanity")
- @mock.patch("documents.tasks.sanity_checker.check_sanity")
- def test_sanity_check_error(self, m) -> None:
- messages = SanityCheckMessages()
- messages.error(None, "Some error")
- m.return_value = messages
- self.assertRaises(SanityCheckFailedException, tasks.sanity_check)
- m.assert_called_once()
+ def test_sanity_check_success(self, mock_check_sanity: mock.MagicMock) -> None:
+ mock_check_sanity.return_value = SanityCheckMessages()
+ assert tasks.sanity_check() == "No issues detected."
+ mock_check_sanity.assert_called_once()
- @mock.patch("documents.tasks.sanity_checker.check_sanity")
- def test_sanity_check_error_no_raise(self, m) -> None:
+ def test_sanity_check_error_raises(
+ self,
+ mock_check_sanity: mock.MagicMock,
+ sample_doc: Document,
+ ) -> None:
messages = SanityCheckMessages()
- messages.error(None, "Some error")
- m.return_value = messages
- # No exception should be raised
+ messages.error(sample_doc.pk, "some error")
+ mock_check_sanity.return_value = messages
+ with pytest.raises(SanityCheckFailedException):
+ tasks.sanity_check()
+ mock_check_sanity.assert_called_once()
+
+ def test_sanity_check_error_no_raise(
+ self,
+ mock_check_sanity: mock.MagicMock,
+ sample_doc: Document,
+ ) -> None:
+ messages = SanityCheckMessages()
+ messages.error(sample_doc.pk, "some error")
+ mock_check_sanity.return_value = messages
result = tasks.sanity_check(raise_on_error=False)
- self.assertEqual(
- result,
- "Sanity check exited with errors. See log.",
- )
- m.assert_called_once()
+ assert "1 document(s) with errors" in result
+ assert "Check logs for details." in result
+ mock_check_sanity.assert_called_once()
- @mock.patch("documents.tasks.sanity_checker.check_sanity")
- def test_sanity_check_warning(self, m) -> None:
+ def test_sanity_check_warning_only(
+ self,
+ mock_check_sanity: mock.MagicMock,
+ ) -> None:
messages = SanityCheckMessages()
- messages.warning(None, "Some warning")
- m.return_value = messages
- self.assertEqual(
- tasks.sanity_check(),
- "Sanity check exited with warnings. See log.",
- )
- m.assert_called_once()
+ messages.warning(None, "extra file")
+ mock_check_sanity.return_value = messages
+ result = tasks.sanity_check()
+ assert result == "1 global warning(s) found."
+ mock_check_sanity.assert_called_once()
- @mock.patch("documents.tasks.sanity_checker.check_sanity")
- def test_sanity_check_info(self, m) -> None:
+ def test_sanity_check_info_only(
+ self,
+ mock_check_sanity: mock.MagicMock,
+ sample_doc: Document,
+ ) -> None:
messages = SanityCheckMessages()
- messages.info(None, "Some info")
- m.return_value = messages
- self.assertEqual(
- tasks.sanity_check(),
- "Sanity check exited with infos. See log.",
- )
- m.assert_called_once()
+ messages.info(sample_doc.pk, "some info")
+ mock_check_sanity.return_value = messages
+ result = tasks.sanity_check()
+ assert result == "1 document(s) with infos found."
+ mock_check_sanity.assert_called_once()
+
+ def test_sanity_check_errors_warnings_and_infos(
+ self,
+ mock_check_sanity: mock.MagicMock,
+ sample_doc: Document,
+ ) -> None:
+ messages = SanityCheckMessages()
+ messages.error(sample_doc.pk, "broken")
+ messages.warning(sample_doc.pk, "odd")
+ messages.info(sample_doc.pk, "fyi")
+ messages.warning(None, "extra file")
+ mock_check_sanity.return_value = messages
+ result = tasks.sanity_check(raise_on_error=False)
+ assert "1 document(s) with errors" in result
+ assert "1 document(s) with warnings" in result
+ assert "1 document(s) with infos" in result
+ assert "1 global warning(s)" in result
+ assert "Check logs for details." in result
+ mock_check_sanity.assert_called_once()
class TestBulkUpdate(DirectoriesMixin, TestCase):
diff --git a/src/documents/tests/test_workflows.py b/src/documents/tests/test_workflows.py
index bca50eb80..c0f6801e9 100644
--- a/src/documents/tests/test_workflows.py
+++ b/src/documents/tests/test_workflows.py
@@ -1744,6 +1744,48 @@ class TestWorkflows(
self.assertEqual(doc.title, "Doc {created_year]")
+ def test_document_updated_workflow_ignores_version_documents(self) -> None:
+ trigger = WorkflowTrigger.objects.create(
+ type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
+ )
+ action = WorkflowAction.objects.create(
+ assign_title="Doc assign owner",
+ assign_owner=self.user2,
+ )
+ workflow = Workflow.objects.create(
+ name="Workflow 1",
+ order=0,
+ )
+ workflow.triggers.add(trigger)
+ workflow.actions.add(action)
+
+ root_doc = Document.objects.create(
+ title="root",
+ correspondent=self.c,
+ original_filename="root.pdf",
+ )
+ version_doc = Document.objects.create(
+ title="version",
+ correspondent=self.c,
+ original_filename="version.pdf",
+ root_document=root_doc,
+ )
+
+ run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, version_doc)
+
+ root_doc.refresh_from_db()
+ version_doc.refresh_from_db()
+
+ self.assertIsNone(root_doc.owner)
+ self.assertIsNone(version_doc.owner)
+ self.assertFalse(
+ WorkflowRun.objects.filter(
+ workflow=workflow,
+ type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
+ document=version_doc,
+ ).exists(),
+ )
+
def test_document_updated_workflow(self) -> None:
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
@@ -2042,6 +2084,60 @@ class TestWorkflows(
doc.refresh_from_db()
self.assertEqual(doc.owner, self.user2)
+ def test_workflow_scheduled_trigger_ignores_version_documents(self) -> None:
+ trigger = WorkflowTrigger.objects.create(
+ type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
+ schedule_offset_days=1,
+ schedule_date_field=WorkflowTrigger.ScheduleDateField.ADDED,
+ )
+ action = WorkflowAction.objects.create(
+ assign_title="Doc assign owner",
+ assign_owner=self.user2,
+ )
+ workflow = Workflow.objects.create(
+ name="Workflow 1",
+ order=0,
+ )
+ workflow.triggers.add(trigger)
+ workflow.actions.add(action)
+
+ root_doc = Document.objects.create(
+ title="root",
+ correspondent=self.c,
+ original_filename="root.pdf",
+ added=timezone.now() - timedelta(days=10),
+ )
+ version_doc = Document.objects.create(
+ title="version",
+ correspondent=self.c,
+ original_filename="version.pdf",
+ root_document=root_doc,
+ added=timezone.now() - timedelta(days=10),
+ )
+
+ tasks.check_scheduled_workflows()
+
+ root_doc.refresh_from_db()
+ version_doc.refresh_from_db()
+
+ self.assertEqual(root_doc.owner, self.user2)
+ self.assertIsNone(version_doc.owner)
+ self.assertEqual(
+ WorkflowRun.objects.filter(
+ workflow=workflow,
+ type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
+ document=root_doc,
+ ).count(),
+ 1,
+ )
+ self.assertFalse(
+ WorkflowRun.objects.filter(
+ workflow=workflow,
+ type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
+ document=version_doc,
+ ).exists(),
+ )
+
@mock.patch("documents.models.Document.objects.filter", autospec=True)
def test_workflow_scheduled_trigger_modified(self, mock_filter) -> None:
"""
diff --git a/src/documents/tests/utils.py b/src/documents/tests/utils.py
index f099cd92e..346d895aa 100644
--- a/src/documents/tests/utils.py
+++ b/src/documents/tests/utils.py
@@ -33,11 +33,11 @@ from documents.plugins.helpers import ProgressStatusOptions
def setup_directories():
dirs = namedtuple("Dirs", ())
- dirs.data_dir = Path(tempfile.mkdtemp())
- dirs.scratch_dir = Path(tempfile.mkdtemp())
- dirs.media_dir = Path(tempfile.mkdtemp())
- dirs.consumption_dir = Path(tempfile.mkdtemp())
- dirs.static_dir = Path(tempfile.mkdtemp())
+ dirs.data_dir = Path(tempfile.mkdtemp()).resolve()
+ dirs.scratch_dir = Path(tempfile.mkdtemp()).resolve()
+ dirs.media_dir = Path(tempfile.mkdtemp()).resolve()
+ dirs.consumption_dir = Path(tempfile.mkdtemp()).resolve()
+ dirs.static_dir = Path(tempfile.mkdtemp()).resolve()
dirs.index_dir = dirs.data_dir / "index"
dirs.originals_dir = dirs.media_dir / "documents" / "originals"
dirs.thumbnail_dir = dirs.media_dir / "documents" / "thumbnails"
diff --git a/src/documents/views.py b/src/documents/views.py
index 546da6e83..a367ef881 100644
--- a/src/documents/views.py
+++ b/src/documents/views.py
@@ -1758,6 +1758,11 @@ class DocumentViewSet(
.order_by("-id")
.first()
)
+
+ document_updated.send(
+ sender=self.__class__,
+ document=root_doc,
+ )
return Response(
{
"result": "OK",
@@ -1827,6 +1832,11 @@ class DocumentViewSet(
},
)
+ document_updated.send(
+ sender=self.__class__,
+ document=root_doc,
+ )
+
return Response(
{
"id": version_doc.id,
@@ -2434,6 +2444,13 @@ class SelectionDataView(GenericAPIView):
serializer.is_valid(raise_exception=True)
ids = serializer.validated_data.get("documents")
+ permitted_documents = get_objects_for_user_owner_aware(
+ request.user,
+ "documents.view_document",
+ Document,
+ )
+ if permitted_documents.filter(pk__in=ids).count() != len(ids):
+ return HttpResponseForbidden("Insufficient permissions")
correspondents = Correspondent.objects.annotate(
document_count=Count(
diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po
index be2e043e1..a8dc2f627 100644
--- a/src/locale/en_US/LC_MESSAGES/django.po
+++ b/src/locale/en_US/LC_MESSAGES/django.po
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-02-26 18:09+0000\n"
+"POT-Creation-Date: 2026-03-02 16:22+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
@@ -1299,47 +1299,47 @@ msgstr ""
msgid "workflow runs"
msgstr ""
-#: documents/serialisers.py:462
+#: documents/serialisers.py:463 documents/serialisers.py:2333
msgid "Insufficient permissions."
msgstr ""
-#: documents/serialisers.py:650
+#: documents/serialisers.py:651
msgid "Invalid color."
msgstr ""
-#: documents/serialisers.py:1955
+#: documents/serialisers.py:1956
#, python-format
msgid "File type %(type)s not supported"
msgstr ""
-#: documents/serialisers.py:1999
+#: documents/serialisers.py:2000
#, python-format
msgid "Custom field id must be an integer: %(id)s"
msgstr ""
-#: documents/serialisers.py:2006
+#: documents/serialisers.py:2007
#, python-format
msgid "Custom field with id %(id)s does not exist"
msgstr ""
-#: documents/serialisers.py:2023 documents/serialisers.py:2033
+#: documents/serialisers.py:2024 documents/serialisers.py:2034
msgid ""
"Custom fields must be a list of integers or an object mapping ids to values."
msgstr ""
-#: documents/serialisers.py:2028
+#: documents/serialisers.py:2029
msgid "Some custom fields don't exist or were specified twice."
msgstr ""
-#: documents/serialisers.py:2175
+#: documents/serialisers.py:2176
msgid "Invalid variable detected."
msgstr ""
-#: documents/serialisers.py:2377
+#: documents/serialisers.py:2389
msgid "Duplicate document identifiers are not allowed."
msgstr ""
-#: documents/serialisers.py:2407 documents/views.py:3310
+#: documents/serialisers.py:2419 documents/views.py:3327
#, python-format
msgid "Documents not found: %(ids)s"
msgstr ""
@@ -1603,20 +1603,20 @@ msgstr ""
msgid "Unable to parse URI {value}"
msgstr ""
-#: documents/views.py:3322
+#: documents/views.py:3339
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr ""
-#: documents/views.py:3365
+#: documents/views.py:3382
msgid "Bundle is already being processed."
msgstr ""
-#: documents/views.py:3422
+#: documents/views.py:3439
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
-#: documents/views.py:3432
+#: documents/views.py:3449
msgid "The share link bundle is unavailable."
msgstr ""
@@ -1856,151 +1856,151 @@ msgstr ""
msgid "paperless application settings"
msgstr ""
-#: paperless/settings.py:819
+#: paperless/settings/__init__.py:746
msgid "English (US)"
msgstr ""
-#: paperless/settings.py:820
+#: paperless/settings/__init__.py:747
msgid "Arabic"
msgstr ""
-#: paperless/settings.py:821
+#: paperless/settings/__init__.py:748
msgid "Afrikaans"
msgstr ""
-#: paperless/settings.py:822
+#: paperless/settings/__init__.py:749
msgid "Belarusian"
msgstr ""
-#: paperless/settings.py:823
+#: paperless/settings/__init__.py:750
msgid "Bulgarian"
msgstr ""
-#: paperless/settings.py:824
+#: paperless/settings/__init__.py:751
msgid "Catalan"
msgstr ""
-#: paperless/settings.py:825
+#: paperless/settings/__init__.py:752
msgid "Czech"
msgstr ""
-#: paperless/settings.py:826
+#: paperless/settings/__init__.py:753
msgid "Danish"
msgstr ""
-#: paperless/settings.py:827
+#: paperless/settings/__init__.py:754
msgid "German"
msgstr ""
-#: paperless/settings.py:828
+#: paperless/settings/__init__.py:755
msgid "Greek"
msgstr ""
-#: paperless/settings.py:829
+#: paperless/settings/__init__.py:756
msgid "English (GB)"
msgstr ""
-#: paperless/settings.py:830
+#: paperless/settings/__init__.py:757
msgid "Spanish"
msgstr ""
-#: paperless/settings.py:831
+#: paperless/settings/__init__.py:758
msgid "Persian"
msgstr ""
-#: paperless/settings.py:832
+#: paperless/settings/__init__.py:759
msgid "Finnish"
msgstr ""
-#: paperless/settings.py:833
+#: paperless/settings/__init__.py:760
msgid "French"
msgstr ""
-#: paperless/settings.py:834
+#: paperless/settings/__init__.py:761
msgid "Hungarian"
msgstr ""
-#: paperless/settings.py:835
+#: paperless/settings/__init__.py:762
msgid "Indonesian"
msgstr ""
-#: paperless/settings.py:836
+#: paperless/settings/__init__.py:763
msgid "Italian"
msgstr ""
-#: paperless/settings.py:837
+#: paperless/settings/__init__.py:764
msgid "Japanese"
msgstr ""
-#: paperless/settings.py:838
+#: paperless/settings/__init__.py:765
msgid "Korean"
msgstr ""
-#: paperless/settings.py:839
+#: paperless/settings/__init__.py:766
msgid "Luxembourgish"
msgstr ""
-#: paperless/settings.py:840
+#: paperless/settings/__init__.py:767
msgid "Norwegian"
msgstr ""
-#: paperless/settings.py:841
+#: paperless/settings/__init__.py:768
msgid "Dutch"
msgstr ""
-#: paperless/settings.py:842
+#: paperless/settings/__init__.py:769
msgid "Polish"
msgstr ""
-#: paperless/settings.py:843
+#: paperless/settings/__init__.py:770
msgid "Portuguese (Brazil)"
msgstr ""
-#: paperless/settings.py:844
+#: paperless/settings/__init__.py:771
msgid "Portuguese"
msgstr ""
-#: paperless/settings.py:845
+#: paperless/settings/__init__.py:772
msgid "Romanian"
msgstr ""
-#: paperless/settings.py:846
+#: paperless/settings/__init__.py:773
msgid "Russian"
msgstr ""
-#: paperless/settings.py:847
+#: paperless/settings/__init__.py:774
msgid "Slovak"
msgstr ""
-#: paperless/settings.py:848
+#: paperless/settings/__init__.py:775
msgid "Slovenian"
msgstr ""
-#: paperless/settings.py:849
+#: paperless/settings/__init__.py:776
msgid "Serbian"
msgstr ""
-#: paperless/settings.py:850
+#: paperless/settings/__init__.py:777
msgid "Swedish"
msgstr ""
-#: paperless/settings.py:851
+#: paperless/settings/__init__.py:778
msgid "Turkish"
msgstr ""
-#: paperless/settings.py:852
+#: paperless/settings/__init__.py:779
msgid "Ukrainian"
msgstr ""
-#: paperless/settings.py:853
+#: paperless/settings/__init__.py:780
msgid "Vietnamese"
msgstr ""
-#: paperless/settings.py:854
+#: paperless/settings/__init__.py:781
msgid "Chinese Simplified"
msgstr ""
-#: paperless/settings.py:855
+#: paperless/settings/__init__.py:782
msgid "Chinese Traditional"
msgstr ""
diff --git a/src/paperless/checks.py b/src/paperless/checks.py
index 7df85d146..c0c7f49e1 100644
--- a/src/paperless/checks.py
+++ b/src/paperless/checks.py
@@ -202,3 +202,43 @@ def audit_log_check(app_configs, **kwargs):
)
return result
+
+
+@register()
+def check_deprecated_db_settings(
+ app_configs: object,
+ **kwargs: object,
+) -> list[Warning]:
+ """Check for deprecated database environment variables.
+
+ Detects legacy advanced options that should be migrated to
+ PAPERLESS_DB_OPTIONS. Returns one Warning per deprecated variable found.
+ """
+ deprecated_vars: dict[str, str] = {
+ "PAPERLESS_DB_TIMEOUT": "timeout",
+ "PAPERLESS_DB_POOLSIZE": "pool.min_size / pool.max_size",
+ "PAPERLESS_DBSSLMODE": "sslmode",
+ "PAPERLESS_DBSSLROOTCERT": "sslrootcert",
+ "PAPERLESS_DBSSLCERT": "sslcert",
+ "PAPERLESS_DBSSLKEY": "sslkey",
+ }
+
+ warnings: list[Warning] = []
+
+ for var_name, db_option_key in deprecated_vars.items():
+ if not os.getenv(var_name):
+ continue
+ warnings.append(
+ Warning(
+ f"Deprecated environment variable: {var_name}",
+ hint=(
+ 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'Example: PAPERLESS_DB_OPTIONS=\'{{"{db_option_key}": ""}}\'. '
+ "See https://docs.paperless-ngx.com/migration/ for the full reference."
+ ),
+ id="paperless.W001",
+ ),
+ )
+
+ return warnings
diff --git a/src/paperless/settings.py b/src/paperless/settings/__init__.py
similarity index 93%
rename from src/paperless/settings.py
rename to src/paperless/settings/__init__.py
index f1434605d..b1f512fea 100644
--- a/src/paperless/settings.py
+++ b/src/paperless/settings/__init__.py
@@ -17,6 +17,8 @@ from dateparser.languages.loader import LocaleDataLoader
from django.utils.translation import gettext_lazy as _
from dotenv import load_dotenv
+from paperless.settings.custom import parse_db_settings
+
logger = logging.getLogger("paperless.settings")
# Tap paperless.conf if it's available
@@ -282,7 +284,7 @@ DEBUG = __get_boolean("PAPERLESS_DEBUG", "NO")
# Directories #
###############################################################################
-BASE_DIR: Path = Path(__file__).resolve().parent.parent
+BASE_DIR: Path = Path(__file__).resolve().parent.parent.parent
STATIC_ROOT = __get_path("PAPERLESS_STATICDIR", BASE_DIR.parent / "static")
@@ -728,83 +730,8 @@ EMAIL_CERTIFICATE_FILE = __get_optional_path("PAPERLESS_EMAIL_CERTIFICATE_LOCATI
###############################################################################
# Database #
###############################################################################
-def _parse_db_settings() -> dict:
- databases = {
- "default": {
- "ENGINE": "django.db.backends.sqlite3",
- "NAME": DATA_DIR / "db.sqlite3",
- "OPTIONS": {},
- },
- }
- if os.getenv("PAPERLESS_DBHOST"):
- # Have sqlite available as a second option for management commands
- # This is important when migrating to/from sqlite
- databases["sqlite"] = databases["default"].copy()
- databases["default"] = {
- "HOST": os.getenv("PAPERLESS_DBHOST"),
- "NAME": os.getenv("PAPERLESS_DBNAME", "paperless"),
- "USER": os.getenv("PAPERLESS_DBUSER", "paperless"),
- "PASSWORD": os.getenv("PAPERLESS_DBPASS", "paperless"),
- "OPTIONS": {},
- }
- if os.getenv("PAPERLESS_DBPORT"):
- databases["default"]["PORT"] = os.getenv("PAPERLESS_DBPORT")
-
- # Leave room for future extensibility
- if os.getenv("PAPERLESS_DBENGINE") == "mariadb":
- engine = "django.db.backends.mysql"
- # Contrary to Postgres, Django does not natively support connection pooling for MariaDB.
- # However, since MariaDB uses threads instead of forks, establishing connections is significantly faster
- # compared to PostgreSQL, so the lack of pooling is not an issue
- options = {
- "read_default_file": "/etc/mysql/my.cnf",
- "charset": "utf8mb4",
- "ssl_mode": os.getenv("PAPERLESS_DBSSLMODE", "PREFERRED"),
- "ssl": {
- "ca": os.getenv("PAPERLESS_DBSSLROOTCERT", None),
- "cert": os.getenv("PAPERLESS_DBSSLCERT", None),
- "key": os.getenv("PAPERLESS_DBSSLKEY", None),
- },
- }
-
- else: # Default to PostgresDB
- engine = "django.db.backends.postgresql"
- options = {
- "sslmode": os.getenv("PAPERLESS_DBSSLMODE", "prefer"),
- "sslrootcert": os.getenv("PAPERLESS_DBSSLROOTCERT", None),
- "sslcert": os.getenv("PAPERLESS_DBSSLCERT", None),
- "sslkey": os.getenv("PAPERLESS_DBSSLKEY", None),
- }
- if int(os.getenv("PAPERLESS_DB_POOLSIZE", 0)) > 0:
- options.update(
- {
- "pool": {
- "min_size": 1,
- "max_size": int(os.getenv("PAPERLESS_DB_POOLSIZE")),
- },
- },
- )
-
- databases["default"]["ENGINE"] = engine
- databases["default"]["OPTIONS"].update(options)
-
- if os.getenv("PAPERLESS_DB_TIMEOUT") is not None:
- if databases["default"]["ENGINE"] == "django.db.backends.sqlite3":
- databases["default"]["OPTIONS"].update(
- {"timeout": int(os.getenv("PAPERLESS_DB_TIMEOUT"))},
- )
- else:
- databases["default"]["OPTIONS"].update(
- {"connect_timeout": int(os.getenv("PAPERLESS_DB_TIMEOUT"))},
- )
- databases["sqlite"]["OPTIONS"].update(
- {"timeout": int(os.getenv("PAPERLESS_DB_TIMEOUT"))},
- )
- return databases
-
-
-DATABASES = _parse_db_settings()
+DATABASES = parse_db_settings(DATA_DIR)
if os.getenv("PAPERLESS_DBENGINE") == "mariadb":
# Silence Django error on old MariaDB versions.
diff --git a/src/paperless/settings/custom.py b/src/paperless/settings/custom.py
new file mode 100644
index 000000000..6f7d94201
--- /dev/null
+++ b/src/paperless/settings/custom.py
@@ -0,0 +1,122 @@
+import os
+from pathlib import Path
+from typing import Any
+
+from paperless.settings.parsers import get_choice_from_env
+from paperless.settings.parsers import get_int_from_env
+from paperless.settings.parsers import parse_dict_from_str
+
+
+def parse_db_settings(data_dir: Path) -> dict[str, dict[str, Any]]:
+ """Parse database settings from environment variables.
+
+ Core connection variables (no deprecation):
+ - PAPERLESS_DBENGINE (sqlite/postgresql/mariadb)
+ - PAPERLESS_DBHOST, PAPERLESS_DBPORT
+ - PAPERLESS_DBNAME, PAPERLESS_DBUSER, PAPERLESS_DBPASS
+
+ Advanced options can be set via:
+ - Legacy individual env vars (deprecated in v3.0, removed in v3.2)
+ - PAPERLESS_DB_OPTIONS (recommended v3+ approach)
+
+ Args:
+ data_dir: The data directory path for SQLite database location.
+
+ Returns:
+ A databases dict suitable for Django DATABASES setting.
+ """
+ try:
+ engine = get_choice_from_env(
+ "PAPERLESS_DBENGINE",
+ {"sqlite", "postgresql", "mariadb"},
+ default="sqlite",
+ )
+ except ValueError:
+ # MariaDB users already had to set PAPERLESS_DBENGINE, so it was picked up above
+ # SQLite users didn't need to set anything
+ engine = "postgresql" if "PAPERLESS_DBHOST" in os.environ else "sqlite"
+
+ db_config: dict[str, Any]
+ base_options: dict[str, Any]
+
+ match engine:
+ case "sqlite":
+ db_config = {
+ "ENGINE": "django.db.backends.sqlite3",
+ "NAME": str((data_dir / "db.sqlite3").resolve()),
+ }
+ base_options = {}
+
+ case "postgresql":
+ db_config = {
+ "ENGINE": "django.db.backends.postgresql",
+ "HOST": os.getenv("PAPERLESS_DBHOST"),
+ "NAME": os.getenv("PAPERLESS_DBNAME", "paperless"),
+ "USER": os.getenv("PAPERLESS_DBUSER", "paperless"),
+ "PASSWORD": os.getenv("PAPERLESS_DBPASS", "paperless"),
+ }
+
+ base_options = {
+ "sslmode": os.getenv("PAPERLESS_DBSSLMODE", "prefer"),
+ "sslrootcert": os.getenv("PAPERLESS_DBSSLROOTCERT"),
+ "sslcert": os.getenv("PAPERLESS_DBSSLCERT"),
+ "sslkey": os.getenv("PAPERLESS_DBSSLKEY"),
+ }
+
+ if (pool_size := get_int_from_env("PAPERLESS_DB_POOLSIZE")) is not None:
+ base_options["pool"] = {
+ "min_size": 1,
+ "max_size": pool_size,
+ }
+
+ case "mariadb":
+ db_config = {
+ "ENGINE": "django.db.backends.mysql",
+ "HOST": os.getenv("PAPERLESS_DBHOST"),
+ "NAME": os.getenv("PAPERLESS_DBNAME", "paperless"),
+ "USER": os.getenv("PAPERLESS_DBUSER", "paperless"),
+ "PASSWORD": os.getenv("PAPERLESS_DBPASS", "paperless"),
+ }
+
+ base_options = {
+ "read_default_file": "/etc/mysql/my.cnf",
+ "charset": "utf8mb4",
+ "collation": "utf8mb4_unicode_ci",
+ "ssl_mode": os.getenv("PAPERLESS_DBSSLMODE", "PREFERRED"),
+ "ssl": {
+ "ca": os.getenv("PAPERLESS_DBSSLROOTCERT"),
+ "cert": os.getenv("PAPERLESS_DBSSLCERT"),
+ "key": os.getenv("PAPERLESS_DBSSLKEY"),
+ },
+ }
+ case _: # pragma: no cover
+ raise NotImplementedError(engine)
+
+ # Handle port setting for external databases
+ if (
+ engine in ("postgresql", "mariadb")
+ and (port := get_int_from_env("PAPERLESS_DBPORT")) is not None
+ ):
+ db_config["PORT"] = port
+
+ # Handle timeout setting (common across all engines, different key names)
+ if (timeout := get_int_from_env("PAPERLESS_DB_TIMEOUT")) is not None:
+ timeout_key = "timeout" if engine == "sqlite" else "connect_timeout"
+ base_options[timeout_key] = timeout
+
+ # Apply PAPERLESS_DB_OPTIONS overrides
+ db_config["OPTIONS"] = parse_dict_from_str(
+ os.getenv("PAPERLESS_DB_OPTIONS"),
+ defaults=base_options,
+ separator=";",
+ type_map={
+ # SQLite options
+ "timeout": int,
+ # Postgres/MariaDB options
+ "connect_timeout": int,
+ "pool.min_size": int,
+ "pool.max_size": int,
+ },
+ )
+
+ return {"default": db_config}
diff --git a/src/paperless/settings/parsers.py b/src/paperless/settings/parsers.py
new file mode 100644
index 000000000..e6960abf1
--- /dev/null
+++ b/src/paperless/settings/parsers.py
@@ -0,0 +1,192 @@
+import copy
+import os
+from collections.abc import Callable
+from collections.abc import Mapping
+from pathlib import Path
+from typing import Any
+from typing import TypeVar
+from typing import overload
+
+T = TypeVar("T")
+
+
+def str_to_bool(value: str) -> bool:
+ """
+ Converts a string representation of truth to a boolean value.
+
+ Recognizes 'true', '1', 't', 'y', 'yes' as True, and
+ 'false', '0', 'f', 'n', 'no' as False. Case-insensitive.
+
+ Args:
+ value: The string to convert.
+
+ Returns:
+ The boolean representation of the string.
+
+ Raises:
+ ValueError: If the string is not a recognized boolean value.
+ """
+ val_lower = value.strip().lower()
+ if val_lower in ("true", "1", "t", "y", "yes"):
+ return True
+ elif val_lower in ("false", "0", "f", "n", "no"):
+ return False
+ raise ValueError(f"Cannot convert '{value}' to a boolean.")
+
+
+@overload
+def get_int_from_env(key: str) -> int | None: ...
+
+
+@overload
+def get_int_from_env(key: str, default: None) -> int | None: ...
+
+
+@overload
+def get_int_from_env(key: str, default: int) -> int: ...
+
+
+def get_int_from_env(key: str, default: int | None = None) -> int | None:
+ """
+ Return an integer value based on the environment variable.
+ If default is provided, returns that value when key is missing.
+ If default is None, returns None when key is missing.
+ """
+ if key not in os.environ:
+ return default
+
+ return int(os.environ[key])
+
+
+def parse_dict_from_str(
+ env_str: str | None,
+ defaults: dict[str, Any] | None = None,
+ type_map: Mapping[str, Callable[[str], Any]] | None = None,
+ separator: str = ",",
+) -> dict[str, Any]:
+ """
+ Parses a key-value string into a dictionary, applying defaults and casting types.
+
+ Supports nested keys via dot-notation, e.g.:
+ "database.host=localhost,database.port=5432"
+
+ Args:
+ env_str: The string from the environment variable (e.g., "port=9090,debug=true").
+ defaults: A dictionary of default values (can contain nested dicts).
+ type_map: A dictionary mapping keys (dot-notation allowed) to a type or a parsing
+ function (e.g., {'port': int, 'debug': bool, 'database.port': int}).
+ The special `bool` type triggers custom boolean parsing.
+ separator: The character used to separate key-value pairs. Defaults to ','.
+
+ Returns:
+ A dictionary with the parsed and correctly-typed settings.
+
+ Raises:
+ ValueError: If a value cannot be cast to its specified type.
+ """
+
+ def _set_nested(d: dict, keys: list[str], value: Any) -> None:
+ """Set a nested value, creating intermediate dicts as needed."""
+ cur = d
+ for k in keys[:-1]:
+ if k not in cur or not isinstance(cur[k], dict):
+ cur[k] = {}
+ cur = cur[k]
+ cur[keys[-1]] = value
+
+ def _get_nested(d: dict, keys: list[str]) -> Any:
+ """Get nested value or raise KeyError if not present."""
+ cur = d
+ for k in keys:
+ if not isinstance(cur, dict) or k not in cur:
+ raise KeyError
+ cur = cur[k]
+ return cur
+
+ def _has_nested(d: dict, keys: list[str]) -> bool:
+ try:
+ _get_nested(d, keys)
+ return True
+ except KeyError:
+ return False
+
+ settings: dict[str, Any] = copy.deepcopy(defaults) if defaults else {}
+ _type_map = type_map if type_map else {}
+
+ if not env_str:
+ return settings
+
+ # Parse the environment string using the specified separator
+ pairs = [p.strip() for p in env_str.split(separator) if p.strip()]
+ for pair in pairs:
+ if "=" not in pair:
+ # ignore malformed pairs
+ continue
+ key, val = pair.split("=", 1)
+ key = key.strip()
+ val = val.strip()
+ if not key:
+ continue
+ parts = key.split(".")
+ _set_nested(settings, parts, val)
+
+ # Apply type casting to the updated settings (supports nested keys in type_map)
+ for key, caster in _type_map.items():
+ key_parts = key.split(".")
+ if _has_nested(settings, key_parts):
+ raw_val = _get_nested(settings, key_parts)
+ # Only cast if it's a string (i.e. from env parsing). If defaults already provided
+ # a different type we leave it as-is.
+ if isinstance(raw_val, str):
+ try:
+ if caster is bool:
+ parsed = str_to_bool(raw_val)
+ elif caster is Path:
+ parsed = Path(raw_val).resolve()
+ else:
+ parsed = caster(raw_val)
+ except (ValueError, TypeError) as e:
+ caster_name = getattr(caster, "__name__", repr(caster))
+ raise ValueError(
+ f"Error casting key '{key}' with value '{raw_val}' "
+ f"to type '{caster_name}'",
+ ) from e
+ _set_nested(settings, key_parts, parsed)
+
+ return settings
+
+
+def get_choice_from_env(
+ env_key: str,
+ choices: set[str],
+ default: str | None = None,
+) -> str:
+ """
+ Gets and validates an environment variable against a set of allowed choices.
+
+ Args:
+ env_key: The environment variable key to validate
+ choices: Set of valid choices for the environment variable
+ default: Optional default value if environment variable is not set
+
+ Returns:
+ The validated environment variable value
+
+ Raises:
+ ValueError: If the environment variable value is not in choices
+ or if no default is provided and env var is missing
+ """
+ value = os.environ.get(env_key, default)
+
+ if value is None:
+ raise ValueError(
+ f"Environment variable '{env_key}' is required but not set.",
+ )
+
+ if value not in choices:
+ raise ValueError(
+ f"Environment variable '{env_key}' has invalid value '{value}'. "
+ f"Valid choices are: {', '.join(sorted(choices))}",
+ )
+
+ return value
diff --git a/src/paperless/tests/settings/__init__.py b/src/paperless/tests/settings/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/paperless/tests/settings/test_custom_parsers.py b/src/paperless/tests/settings/test_custom_parsers.py
new file mode 100644
index 000000000..e297d9e04
--- /dev/null
+++ b/src/paperless/tests/settings/test_custom_parsers.py
@@ -0,0 +1,266 @@
+import os
+from pathlib import Path
+
+import pytest
+from pytest_mock import MockerFixture
+
+from paperless.settings.custom import parse_db_settings
+
+
+class TestParseDbSettings:
+ """Test suite for parse_db_settings function."""
+
+ @pytest.mark.parametrize(
+ ("env_vars", "expected_database_settings"),
+ [
+ pytest.param(
+ {},
+ {
+ "default": {
+ "ENGINE": "django.db.backends.sqlite3",
+ "NAME": None, # Will be replaced with tmp_path
+ "OPTIONS": {},
+ },
+ },
+ id="default-sqlite",
+ ),
+ pytest.param(
+ {
+ "PAPERLESS_DBENGINE": "sqlite",
+ "PAPERLESS_DB_OPTIONS": "timeout=30",
+ },
+ {
+ "default": {
+ "ENGINE": "django.db.backends.sqlite3",
+ "NAME": None, # Will be replaced with tmp_path
+ "OPTIONS": {
+ "timeout": 30,
+ },
+ },
+ },
+ id="sqlite-with-timeout-override",
+ ),
+ pytest.param(
+ {
+ "PAPERLESS_DBENGINE": "postgresql",
+ "PAPERLESS_DBHOST": "localhost",
+ },
+ {
+ "default": {
+ "ENGINE": "django.db.backends.postgresql",
+ "HOST": "localhost",
+ "NAME": "paperless",
+ "USER": "paperless",
+ "PASSWORD": "paperless",
+ "OPTIONS": {
+ "sslmode": "prefer",
+ "sslrootcert": None,
+ "sslcert": None,
+ "sslkey": None,
+ },
+ },
+ },
+ id="postgresql-defaults",
+ ),
+ pytest.param(
+ {
+ "PAPERLESS_DBENGINE": "postgresql",
+ "PAPERLESS_DBHOST": "paperless-db-host",
+ "PAPERLESS_DBPORT": "1111",
+ "PAPERLESS_DBNAME": "customdb",
+ "PAPERLESS_DBUSER": "customuser",
+ "PAPERLESS_DBPASS": "custompass",
+ "PAPERLESS_DB_OPTIONS": "pool.max_size=50;pool.min_size=2;sslmode=require",
+ },
+ {
+ "default": {
+ "ENGINE": "django.db.backends.postgresql",
+ "HOST": "paperless-db-host",
+ "PORT": 1111,
+ "NAME": "customdb",
+ "USER": "customuser",
+ "PASSWORD": "custompass",
+ "OPTIONS": {
+ "sslmode": "require",
+ "sslrootcert": None,
+ "sslcert": None,
+ "sslkey": None,
+ "pool": {
+ "min_size": 2,
+ "max_size": 50,
+ },
+ },
+ },
+ },
+ id="postgresql-overrides",
+ ),
+ pytest.param(
+ {
+ "PAPERLESS_DBENGINE": "postgresql",
+ "PAPERLESS_DBHOST": "pghost",
+ "PAPERLESS_DB_POOLSIZE": "10",
+ },
+ {
+ "default": {
+ "ENGINE": "django.db.backends.postgresql",
+ "HOST": "pghost",
+ "NAME": "paperless",
+ "USER": "paperless",
+ "PASSWORD": "paperless",
+ "OPTIONS": {
+ "sslmode": "prefer",
+ "sslrootcert": None,
+ "sslcert": None,
+ "sslkey": None,
+ "pool": {
+ "min_size": 1,
+ "max_size": 10,
+ },
+ },
+ },
+ },
+ id="postgresql-legacy-poolsize",
+ ),
+ pytest.param(
+ {
+ "PAPERLESS_DBENGINE": "postgresql",
+ "PAPERLESS_DBHOST": "pghost",
+ "PAPERLESS_DBSSLMODE": "require",
+ "PAPERLESS_DBSSLROOTCERT": "/certs/ca.crt",
+ "PAPERLESS_DB_TIMEOUT": "30",
+ },
+ {
+ "default": {
+ "ENGINE": "django.db.backends.postgresql",
+ "HOST": "pghost",
+ "NAME": "paperless",
+ "USER": "paperless",
+ "PASSWORD": "paperless",
+ "OPTIONS": {
+ "sslmode": "require",
+ "sslrootcert": "/certs/ca.crt",
+ "sslcert": None,
+ "sslkey": None,
+ "connect_timeout": 30,
+ },
+ },
+ },
+ id="postgresql-legacy-ssl-and-timeout",
+ ),
+ pytest.param(
+ {
+ "PAPERLESS_DBENGINE": "mariadb",
+ "PAPERLESS_DBHOST": "localhost",
+ },
+ {
+ "default": {
+ "ENGINE": "django.db.backends.mysql",
+ "HOST": "localhost",
+ "NAME": "paperless",
+ "USER": "paperless",
+ "PASSWORD": "paperless",
+ "OPTIONS": {
+ "read_default_file": "/etc/mysql/my.cnf",
+ "charset": "utf8mb4",
+ "collation": "utf8mb4_unicode_ci",
+ "ssl_mode": "PREFERRED",
+ "ssl": {
+ "ca": None,
+ "cert": None,
+ "key": None,
+ },
+ },
+ },
+ },
+ id="mariadb-defaults",
+ ),
+ pytest.param(
+ {
+ "PAPERLESS_DBENGINE": "mariadb",
+ "PAPERLESS_DBHOST": "paperless-mariadb-host",
+ "PAPERLESS_DBPORT": "5555",
+ "PAPERLESS_DBUSER": "my-cool-user",
+ "PAPERLESS_DBPASS": "my-secure-password",
+ "PAPERLESS_DB_OPTIONS": "ssl.ca=/path/to/ca.pem;ssl_mode=REQUIRED",
+ },
+ {
+ "default": {
+ "ENGINE": "django.db.backends.mysql",
+ "HOST": "paperless-mariadb-host",
+ "PORT": 5555,
+ "NAME": "paperless",
+ "USER": "my-cool-user",
+ "PASSWORD": "my-secure-password",
+ "OPTIONS": {
+ "read_default_file": "/etc/mysql/my.cnf",
+ "charset": "utf8mb4",
+ "collation": "utf8mb4_unicode_ci",
+ "ssl_mode": "REQUIRED",
+ "ssl": {
+ "ca": "/path/to/ca.pem",
+ "cert": None,
+ "key": None,
+ },
+ },
+ },
+ },
+ id="mariadb-overrides",
+ ),
+ pytest.param(
+ {
+ "PAPERLESS_DBENGINE": "mariadb",
+ "PAPERLESS_DBHOST": "mariahost",
+ "PAPERLESS_DBSSLMODE": "REQUIRED",
+ "PAPERLESS_DBSSLROOTCERT": "/certs/ca.pem",
+ "PAPERLESS_DBSSLCERT": "/certs/client.pem",
+ "PAPERLESS_DBSSLKEY": "/certs/client.key",
+ "PAPERLESS_DB_TIMEOUT": "25",
+ },
+ {
+ "default": {
+ "ENGINE": "django.db.backends.mysql",
+ "HOST": "mariahost",
+ "NAME": "paperless",
+ "USER": "paperless",
+ "PASSWORD": "paperless",
+ "OPTIONS": {
+ "read_default_file": "/etc/mysql/my.cnf",
+ "charset": "utf8mb4",
+ "collation": "utf8mb4_unicode_ci",
+ "ssl_mode": "REQUIRED",
+ "ssl": {
+ "ca": "/certs/ca.pem",
+ "cert": "/certs/client.pem",
+ "key": "/certs/client.key",
+ },
+ "connect_timeout": 25,
+ },
+ },
+ },
+ id="mariadb-legacy-ssl-and-timeout",
+ ),
+ ],
+ )
+ def test_parse_db_settings(
+ self,
+ tmp_path: Path,
+ mocker: MockerFixture,
+ env_vars: dict[str, str],
+ expected_database_settings: dict[str, dict],
+ ) -> None:
+ """Test various database configurations with defaults and overrides."""
+ # Clear environment and set test vars
+ mocker.patch.dict(os.environ, env_vars, clear=True)
+
+ # Update expected paths with actual tmp_path
+ if (
+ "default" in expected_database_settings
+ and expected_database_settings["default"]["NAME"] is None
+ ):
+ expected_database_settings["default"]["NAME"] = str(
+ tmp_path / "db.sqlite3",
+ )
+
+ settings = parse_db_settings(tmp_path)
+
+ assert settings == expected_database_settings
diff --git a/src/paperless/tests/settings/test_environment_parsers.py b/src/paperless/tests/settings/test_environment_parsers.py
new file mode 100644
index 000000000..ecce0ea16
--- /dev/null
+++ b/src/paperless/tests/settings/test_environment_parsers.py
@@ -0,0 +1,414 @@
+import os
+from pathlib import Path
+
+import pytest
+from pytest_mock import MockerFixture
+
+from paperless.settings.parsers import get_choice_from_env
+from paperless.settings.parsers import get_int_from_env
+from paperless.settings.parsers import parse_dict_from_str
+from paperless.settings.parsers import str_to_bool
+
+
+class TestStringToBool:
+ @pytest.mark.parametrize(
+ "true_value",
+ [
+ pytest.param("true", id="lowercase_true"),
+ pytest.param("1", id="digit_1"),
+ pytest.param("T", id="capital_T"),
+ pytest.param("y", id="lowercase_y"),
+ pytest.param("YES", id="uppercase_YES"),
+ pytest.param(" True ", id="whitespace_true"),
+ ],
+ )
+ def test_true_conversion(self, true_value: str):
+ """Test that various 'true' strings correctly evaluate to True."""
+ assert str_to_bool(true_value) is True
+
+ @pytest.mark.parametrize(
+ "false_value",
+ [
+ pytest.param("false", id="lowercase_false"),
+ pytest.param("0", id="digit_0"),
+ pytest.param("f", id="capital_f"),
+ pytest.param("N", id="capital_N"),
+ pytest.param("no", id="lowercase_no"),
+ pytest.param(" False ", id="whitespace_false"),
+ ],
+ )
+ def test_false_conversion(self, false_value: str):
+ """Test that various 'false' strings correctly evaluate to False."""
+ assert str_to_bool(false_value) is False
+
+ def test_invalid_conversion(self):
+ """Test that an invalid string raises a ValueError."""
+ with pytest.raises(ValueError, match="Cannot convert 'maybe' to a boolean\\."):
+ str_to_bool("maybe")
+
+
+class TestParseDictFromString:
+ def test_empty_and_none_input(self):
+ """Test behavior with None or empty string input."""
+ assert parse_dict_from_str(None) == {}
+ assert parse_dict_from_str("") == {}
+ defaults = {"a": 1}
+ res = parse_dict_from_str(None, defaults=defaults)
+ assert res == defaults
+ # Ensure it returns a copy, not the original object
+ assert res is not defaults
+
+ def test_basic_parsing(self):
+ """Test simple key-value parsing without defaults or types."""
+ env_str = "key1=val1, key2=val2"
+ expected = {"key1": "val1", "key2": "val2"}
+ assert parse_dict_from_str(env_str) == expected
+
+ def test_with_defaults(self):
+ """Test that environment values override defaults correctly."""
+ defaults = {"host": "localhost", "port": 8000, "user": "default"}
+ env_str = "port=9090, host=db.example.com"
+ expected = {"host": "db.example.com", "port": "9090", "user": "default"}
+ result = parse_dict_from_str(env_str, defaults=defaults)
+ assert result == expected
+
+ def test_type_casting(self):
+ """Test successful casting of values to specified types."""
+ env_str = "port=9090, debug=true, timeout=12.5, user=admin"
+ type_map = {"port": int, "debug": bool, "timeout": float}
+ expected = {"port": 9090, "debug": True, "timeout": 12.5, "user": "admin"}
+ result = parse_dict_from_str(env_str, type_map=type_map)
+ assert result == expected
+
+ def test_type_casting_with_defaults(self):
+ """Test casting when values come from both defaults and env string."""
+ defaults = {"port": 8000, "debug": False, "retries": 3}
+ env_str = "port=9090, debug=true"
+ type_map = {"port": int, "debug": bool, "retries": int}
+
+ # The 'retries' value comes from defaults and is already an int,
+ # so it should not be processed by the caster.
+ expected = {"port": 9090, "debug": True, "retries": 3}
+ result = parse_dict_from_str(env_str, defaults=defaults, type_map=type_map)
+ assert result == expected
+ assert isinstance(result["retries"], int)
+
+ def test_path_casting(self, tmp_path: Path):
+ """Test successful casting of a string to a resolved pathlib.Path object."""
+ # Create a dummy file to resolve against
+ test_file = tmp_path / "test_file.txt"
+ test_file.touch()
+
+ env_str = f"config_path={test_file}"
+ type_map = {"config_path": Path}
+ result = parse_dict_from_str(env_str, type_map=type_map)
+
+ # The result should be a resolved Path object
+ assert isinstance(result["config_path"], Path)
+ assert result["config_path"] == test_file.resolve()
+
+ def test_custom_separator(self):
+ """Test parsing with a custom separator like a semicolon."""
+ env_str = "host=db; port=5432; user=test"
+ expected = {"host": "db", "port": "5432", "user": "test"}
+ result = parse_dict_from_str(env_str, separator=";")
+ assert result == expected
+
+ def test_edge_cases_in_string(self):
+ """Test malformed strings to ensure robustness."""
+ # Malformed pair 'debug' is skipped, extra comma is ignored
+ env_str = "key=val,, debug, foo=bar"
+ expected = {"key": "val", "foo": "bar"}
+ assert parse_dict_from_str(env_str) == expected
+
+ # Value can contain the equals sign
+ env_str = "url=postgres://user:pass@host:5432/db"
+ expected = {"url": "postgres://user:pass@host:5432/db"}
+ assert parse_dict_from_str(env_str) == expected
+
+ def test_casting_error_handling(self):
+ """Test that a ValueError is raised for invalid casting."""
+ env_str = "port=not-a-number"
+ type_map = {"port": int}
+
+ with pytest.raises(ValueError) as excinfo:
+ parse_dict_from_str(env_str, type_map=type_map)
+
+ assert "Error casting key 'port'" in str(excinfo.value)
+ assert "value 'not-a-number'" in str(excinfo.value)
+ assert "to type 'int'" in str(excinfo.value)
+
+ def test_bool_casting_error(self):
+ """Test that an invalid boolean string raises a ValueError."""
+ env_str = "debug=maybe"
+ type_map = {"debug": bool}
+ with pytest.raises(ValueError, match="Error casting key 'debug'"):
+ parse_dict_from_str(env_str, type_map=type_map)
+
+ def test_nested_key_parsing_basic(self):
+ """Basic nested key parsing using dot-notation."""
+ env_str = "database.host=db.example.com, database.port=5432, logging.level=INFO"
+ result = parse_dict_from_str(env_str)
+ assert result == {
+ "database": {"host": "db.example.com", "port": "5432"},
+ "logging": {"level": "INFO"},
+ }
+
+ def test_nested_overrides_defaults_and_deepcopy(self):
+ """Nested env keys override defaults and defaults are deep-copied."""
+ defaults = {"database": {"host": "127.0.0.1", "port": 3306, "user": "default"}}
+ env_str = "database.host=db.example.com, debug=true"
+ result = parse_dict_from_str(
+ env_str,
+ defaults=defaults,
+ type_map={"debug": bool},
+ )
+
+ assert result["database"]["host"] == "db.example.com"
+ # Unchanged default preserved
+ assert result["database"]["port"] == 3306
+ assert result["database"]["user"] == "default"
+ # Default object was deep-copied (no same nested object identity)
+ assert result is not defaults
+ assert result["database"] is not defaults["database"]
+
+ def test_nested_type_casting(self):
+ """Type casting for nested keys (dot-notation) should work."""
+ env_str = "database.host=db.example.com, database.port=5433, debug=false"
+ type_map = {"database.port": int, "debug": bool}
+ result = parse_dict_from_str(env_str, type_map=type_map)
+
+ assert result["database"]["host"] == "db.example.com"
+ assert result["database"]["port"] == 5433
+ assert isinstance(result["database"]["port"], int)
+ assert result["debug"] is False
+ assert isinstance(result["debug"], bool)
+
+ def test_nested_casting_error_message(self):
+ """Error messages should include the full dotted key name on failure."""
+ env_str = "database.port=not-a-number"
+ type_map = {"database.port": int}
+ with pytest.raises(ValueError) as excinfo:
+ parse_dict_from_str(env_str, type_map=type_map)
+
+ msg = str(excinfo.value)
+ assert "Error casting key 'database.port'" in msg
+ assert "value 'not-a-number'" in msg
+ assert "to type 'int'" in msg
+
+ def test_type_map_does_not_recast_non_string_defaults(self):
+ """If a default already provides a non-string value, the caster should skip it."""
+ defaults = {"database": {"port": 3306}}
+ type_map = {"database.port": int}
+ result = parse_dict_from_str(None, defaults=defaults, type_map=type_map)
+ assert result["database"]["port"] == 3306
+ assert isinstance(result["database"]["port"], int)
+
+
+class TestGetIntFromEnv:
+ @pytest.mark.parametrize(
+ ("env_value", "expected"),
+ [
+ pytest.param("42", 42, id="positive"),
+ pytest.param("-10", -10, id="negative"),
+ pytest.param("0", 0, id="zero"),
+ pytest.param("999", 999, id="large_positive"),
+ pytest.param("-999", -999, id="large_negative"),
+ ],
+ )
+ def test_existing_env_var_valid_ints(self, mocker, env_value, expected):
+ """Test that existing environment variables with valid integers return correct values."""
+ mocker.patch.dict(os.environ, {"INT_VAR": env_value})
+ assert get_int_from_env("INT_VAR") == expected
+
+ @pytest.mark.parametrize(
+ ("default", "expected"),
+ [
+ pytest.param(100, 100, id="positive_default"),
+ pytest.param(0, 0, id="zero_default"),
+ pytest.param(-50, -50, id="negative_default"),
+ pytest.param(None, None, id="none_default"),
+ ],
+ )
+ def test_missing_env_var_with_defaults(self, mocker, default, expected):
+ """Test that missing environment variables return provided defaults."""
+ mocker.patch.dict(os.environ, {}, clear=True)
+ assert get_int_from_env("MISSING_VAR", default=default) == expected
+
+ def test_missing_env_var_no_default(self, mocker):
+ """Test that missing environment variable with no default returns None."""
+ mocker.patch.dict(os.environ, {}, clear=True)
+ assert get_int_from_env("MISSING_VAR") is None
+
+ @pytest.mark.parametrize(
+ "invalid_value",
+ [
+ pytest.param("not_a_number", id="text"),
+ pytest.param("42.5", id="float"),
+ pytest.param("42a", id="alpha_suffix"),
+ pytest.param("", id="empty"),
+ pytest.param(" ", id="whitespace"),
+ pytest.param("true", id="boolean"),
+ pytest.param("1.0", id="decimal"),
+ ],
+ )
+ def test_invalid_int_values_raise_error(self, mocker, invalid_value):
+ """Test that invalid integer values raise ValueError."""
+ mocker.patch.dict(os.environ, {"INVALID_INT": invalid_value})
+ with pytest.raises(ValueError):
+ get_int_from_env("INVALID_INT")
+
+
+class TestGetEnvChoice:
+ @pytest.fixture
+ def valid_choices(self) -> set[str]:
+ """Fixture providing a set of valid environment choices."""
+ return {"development", "staging", "production"}
+
+ def test_returns_valid_env_value(
+ self,
+ mocker: MockerFixture,
+ valid_choices: set[str],
+ ) -> None:
+ """Test that function returns the environment value when it's valid."""
+ mocker.patch.dict("os.environ", {"TEST_ENV": "development"})
+
+ result = get_choice_from_env("TEST_ENV", valid_choices)
+
+ assert result == "development"
+
+ def test_returns_default_when_env_not_set(
+ self,
+ mocker: MockerFixture,
+ valid_choices: set[str],
+ ) -> None:
+ """Test that function returns default value when env var is not set."""
+ mocker.patch.dict("os.environ", {}, clear=True)
+
+ result = get_choice_from_env("TEST_ENV", valid_choices, default="staging")
+
+ assert result == "staging"
+
+ def test_raises_error_when_env_not_set_and_no_default(
+ self,
+ mocker: MockerFixture,
+ valid_choices: set[str],
+ ) -> None:
+ """Test that function raises ValueError when env var is missing and no default."""
+ mocker.patch.dict("os.environ", {}, clear=True)
+
+ with pytest.raises(ValueError) as exc_info:
+ get_choice_from_env("TEST_ENV", valid_choices)
+
+ assert "Environment variable 'TEST_ENV' is required but not set" in str(
+ exc_info.value,
+ )
+
+ def test_raises_error_when_env_value_invalid(
+ self,
+ mocker: MockerFixture,
+ valid_choices: set[str],
+ ) -> None:
+ """Test that function raises ValueError when env value is not in choices."""
+ mocker.patch.dict("os.environ", {"TEST_ENV": "invalid_value"})
+
+ with pytest.raises(ValueError) as exc_info:
+ get_choice_from_env("TEST_ENV", valid_choices)
+
+ error_msg = str(exc_info.value)
+ assert (
+ "Environment variable 'TEST_ENV' has invalid value 'invalid_value'"
+ in error_msg
+ )
+ assert "Valid choices are:" in error_msg
+ assert "development" in error_msg
+ assert "staging" in error_msg
+ assert "production" in error_msg
+
+ def test_raises_error_when_default_invalid(
+ self,
+ mocker: MockerFixture,
+ valid_choices: set[str],
+ ) -> None:
+ """Test that function raises ValueError when default value is not in choices."""
+ mocker.patch.dict("os.environ", {}, clear=True)
+
+ with pytest.raises(ValueError) as exc_info:
+ get_choice_from_env("TEST_ENV", valid_choices, default="invalid_default")
+
+ error_msg = str(exc_info.value)
+ assert (
+ "Environment variable 'TEST_ENV' has invalid value 'invalid_default'"
+ in error_msg
+ )
+
+ def test_case_sensitive_validation(
+ self,
+ mocker: MockerFixture,
+ valid_choices: set[str],
+ ) -> None:
+ """Test that validation is case sensitive."""
+ mocker.patch.dict("os.environ", {"TEST_ENV": "DEVELOPMENT"})
+
+ with pytest.raises(ValueError):
+ get_choice_from_env("TEST_ENV", valid_choices)
+
+ def test_empty_string_env_value(
+ self,
+ mocker: MockerFixture,
+ valid_choices: set[str],
+ ) -> None:
+ """Test behavior with empty string environment value."""
+ mocker.patch.dict("os.environ", {"TEST_ENV": ""})
+
+ with pytest.raises(ValueError) as exc_info:
+ get_choice_from_env("TEST_ENV", valid_choices)
+
+ assert "has invalid value ''" in str(exc_info.value)
+
+ def test_whitespace_env_value(
+ self,
+ mocker: MockerFixture,
+ valid_choices: set[str],
+ ) -> None:
+ """Test behavior with whitespace-only environment value."""
+ mocker.patch.dict("os.environ", {"TEST_ENV": " development "})
+
+ with pytest.raises(ValueError):
+ get_choice_from_env("TEST_ENV", valid_choices)
+
+ def test_single_choice_set(self, mocker: MockerFixture) -> None:
+ """Test function works correctly with single choice set."""
+ single_choice: set[str] = {"production"}
+ mocker.patch.dict("os.environ", {"TEST_ENV": "production"})
+
+ result = get_choice_from_env("TEST_ENV", single_choice)
+
+ assert result == "production"
+
+ def test_large_choice_set(self, mocker: MockerFixture) -> None:
+ """Test function works correctly with large choice set."""
+ large_choices: set[str] = {f"option_{i}" for i in range(100)}
+ mocker.patch.dict("os.environ", {"TEST_ENV": "option_50"})
+
+ result = get_choice_from_env("TEST_ENV", large_choices)
+
+ assert result == "option_50"
+
+ def test_different_env_keys(
+ self,
+ mocker: MockerFixture,
+ valid_choices: set[str],
+ ) -> None:
+ """Test function works with different environment variable keys."""
+ test_cases = [
+ ("DJANGO_ENV", "development"),
+ ("DATABASE_BACKEND", "staging"),
+ ("LOG_LEVEL", "production"),
+ ("APP_MODE", "development"),
+ ]
+
+ for env_key, env_value in test_cases:
+ mocker.patch.dict("os.environ", {env_key: env_value})
+ result = get_choice_from_env(env_key, valid_choices)
+ assert result == env_value
diff --git a/src/paperless/tests/test_adapter.py b/src/paperless/tests/test_adapter.py
index 767749dd2..fcbd21b12 100644
--- a/src/paperless/tests/test_adapter.py
+++ b/src/paperless/tests/test_adapter.py
@@ -78,11 +78,15 @@ class TestCustomAccountAdapter(TestCase):
adapter = get_adapter()
# Test when PAPERLESS_URL is None
- expected_url = f"https://foo.org{reverse('account_reset_password_from_key', kwargs={'uidb36': 'UID', 'key': 'KEY'})}"
- self.assertEqual(
- adapter.get_reset_password_from_key_url("UID-KEY"),
- expected_url,
- )
+ with override_settings(
+ PAPERLESS_URL=None,
+ ACCOUNT_DEFAULT_HTTP_PROTOCOL="https",
+ ):
+ expected_url = f"https://foo.org{reverse('account_reset_password_from_key', kwargs={'uidb36': 'UID', 'key': 'KEY'})}"
+ self.assertEqual(
+ adapter.get_reset_password_from_key_url("UID-KEY"),
+ expected_url,
+ )
# Test when PAPERLESS_URL is not None
with override_settings(PAPERLESS_URL="https://bar.com"):
diff --git a/src/paperless/tests/test_checks.py b/src/paperless/tests/test_checks.py
index fc6150826..513cfb870 100644
--- a/src/paperless/tests/test_checks.py
+++ b/src/paperless/tests/test_checks.py
@@ -2,13 +2,17 @@ import os
from pathlib import Path
from unittest import mock
+import pytest
+from django.core.checks import Warning
from django.test import TestCase
from django.test import override_settings
+from pytest_mock import MockerFixture
from documents.tests.utils import DirectoriesMixin
from documents.tests.utils import FileSystemAssertsMixin
from paperless.checks import audit_log_check
from paperless.checks import binaries_check
+from paperless.checks import check_deprecated_db_settings
from paperless.checks import debug_mode_check
from paperless.checks import paths_check
from paperless.checks import settings_values_check
@@ -237,3 +241,157 @@ class TestAuditLogChecks(TestCase):
("auditlog table was found but audit log is disabled."),
msg.msg,
)
+
+
+DEPRECATED_VARS: dict[str, str] = {
+ "PAPERLESS_DB_TIMEOUT": "timeout",
+ "PAPERLESS_DB_POOLSIZE": "pool.min_size / pool.max_size",
+ "PAPERLESS_DBSSLMODE": "sslmode",
+ "PAPERLESS_DBSSLROOTCERT": "sslrootcert",
+ "PAPERLESS_DBSSLCERT": "sslcert",
+ "PAPERLESS_DBSSLKEY": "sslkey",
+}
+
+
+class TestDeprecatedDbSettings:
+ """Test suite for the check_deprecated_db_settings system check."""
+
+ def test_no_deprecated_vars_returns_empty(
+ self,
+ mocker: MockerFixture,
+ ) -> None:
+ """No warnings when none of the deprecated vars are present."""
+ # clear=True ensures vars from the outer test environment do not leak in
+ mocker.patch.dict(os.environ, {}, clear=True)
+ result = check_deprecated_db_settings(None)
+ assert result == []
+
+ @pytest.mark.parametrize(
+ ("env_var", "db_option_key"),
+ [
+ ("PAPERLESS_DB_TIMEOUT", "timeout"),
+ ("PAPERLESS_DB_POOLSIZE", "pool.min_size / pool.max_size"),
+ ("PAPERLESS_DBSSLMODE", "sslmode"),
+ ("PAPERLESS_DBSSLROOTCERT", "sslrootcert"),
+ ("PAPERLESS_DBSSLCERT", "sslcert"),
+ ("PAPERLESS_DBSSLKEY", "sslkey"),
+ ],
+ ids=[
+ "db-timeout",
+ "db-poolsize",
+ "ssl-mode",
+ "ssl-rootcert",
+ "ssl-cert",
+ "ssl-key",
+ ],
+ )
+ def test_single_deprecated_var_produces_one_warning(
+ self,
+ mocker: MockerFixture,
+ env_var: str,
+ db_option_key: str,
+ ) -> None:
+ """Each deprecated var in isolation produces exactly one warning."""
+ mocker.patch.dict(os.environ, {env_var: "some_value"}, clear=True)
+ result = check_deprecated_db_settings(None)
+
+ assert len(result) == 1
+ warning = result[0]
+ assert isinstance(warning, Warning)
+ assert warning.id == "paperless.W001"
+ assert env_var in warning.hint
+ assert db_option_key in warning.hint
+
+ def test_multiple_deprecated_vars_produce_one_warning_each(
+ self,
+ mocker: MockerFixture,
+ ) -> None:
+ """Each deprecated var present in the environment gets its own warning."""
+ set_vars = {
+ "PAPERLESS_DB_TIMEOUT": "30",
+ "PAPERLESS_DB_POOLSIZE": "10",
+ "PAPERLESS_DBSSLMODE": "require",
+ }
+ mocker.patch.dict(os.environ, set_vars, clear=True)
+ result = check_deprecated_db_settings(None)
+
+ assert len(result) == len(set_vars)
+ assert all(isinstance(w, Warning) for w in result)
+ assert all(w.id == "paperless.W001" for w in result)
+ all_hints = " ".join(w.hint for w in result)
+ for var_name in set_vars:
+ assert var_name in all_hints
+
+ def test_all_deprecated_vars_produces_one_warning_each(
+ self,
+ mocker: MockerFixture,
+ ) -> None:
+ """All deprecated vars set simultaneously produces one warning per var."""
+ all_vars = dict.fromkeys(DEPRECATED_VARS, "some_value")
+ mocker.patch.dict(os.environ, all_vars, clear=True)
+ result = check_deprecated_db_settings(None)
+
+ assert len(result) == len(DEPRECATED_VARS)
+ assert all(isinstance(w, Warning) for w in result)
+ assert all(w.id == "paperless.W001" for w in result)
+
+ def test_unset_vars_not_mentioned_in_warnings(
+ self,
+ mocker: MockerFixture,
+ ) -> None:
+ """Vars absent from the environment do not appear in any warning."""
+ mocker.patch.dict(
+ os.environ,
+ {"PAPERLESS_DB_TIMEOUT": "30"},
+ clear=True,
+ )
+ result = check_deprecated_db_settings(None)
+
+ assert len(result) == 1
+ assert "PAPERLESS_DB_TIMEOUT" in result[0].hint
+ unset_vars = [v for v in DEPRECATED_VARS if v != "PAPERLESS_DB_TIMEOUT"]
+ for var_name in unset_vars:
+ assert var_name not in result[0].hint
+
+ def test_empty_string_var_not_treated_as_set(
+ self,
+ mocker: MockerFixture,
+ ) -> None:
+ """A var set to an empty string is not flagged as a deprecated setting."""
+ mocker.patch.dict(
+ os.environ,
+ {"PAPERLESS_DB_TIMEOUT": ""},
+ clear=True,
+ )
+ result = check_deprecated_db_settings(None)
+ assert result == []
+
+ def test_warning_mentions_migration_target(
+ self,
+ mocker: MockerFixture,
+ ) -> None:
+ """Each warning hints at PAPERLESS_DB_OPTIONS as the migration target."""
+ mocker.patch.dict(
+ os.environ,
+ {"PAPERLESS_DBSSLMODE": "require"},
+ clear=True,
+ )
+ result = check_deprecated_db_settings(None)
+
+ assert len(result) == 1
+ assert "PAPERLESS_DB_OPTIONS" in result[0].hint
+
+ def test_warning_message_identifies_var(
+ self,
+ mocker: MockerFixture,
+ ) -> None:
+ """The warning message (not just the hint) identifies the offending var."""
+ mocker.patch.dict(
+ os.environ,
+ {"PAPERLESS_DBSSLCERT": "/path/to/cert.pem"},
+ clear=True,
+ )
+ result = check_deprecated_db_settings(None)
+
+ assert len(result) == 1
+ assert "PAPERLESS_DBSSLCERT" in result[0].msg
diff --git a/src/paperless/tests/test_settings.py b/src/paperless/tests/test_settings.py
index 02db82ef2..cc9ad2081 100644
--- a/src/paperless/tests/test_settings.py
+++ b/src/paperless/tests/test_settings.py
@@ -9,7 +9,6 @@ from celery.schedules import crontab
from paperless.settings import _parse_base_paths
from paperless.settings import _parse_beat_schedule
from paperless.settings import _parse_dateparser_languages
-from paperless.settings import _parse_db_settings
from paperless.settings import _parse_ignore_dates
from paperless.settings import _parse_paperless_url
from paperless.settings import _parse_redis_url
@@ -378,64 +377,6 @@ class TestCeleryScheduleParsing(TestCase):
)
-class TestDBSettings(TestCase):
- def test_db_timeout_with_sqlite(self) -> None:
- """
- GIVEN:
- - PAPERLESS_DB_TIMEOUT is set
- WHEN:
- - Settings are parsed
- THEN:
- - PAPERLESS_DB_TIMEOUT set for sqlite
- """
- with mock.patch.dict(
- os.environ,
- {
- "PAPERLESS_DB_TIMEOUT": "10",
- },
- ):
- databases = _parse_db_settings()
-
- self.assertDictEqual(
- {
- "timeout": 10.0,
- },
- databases["default"]["OPTIONS"],
- )
-
- def test_db_timeout_with_not_sqlite(self) -> None:
- """
- GIVEN:
- - PAPERLESS_DB_TIMEOUT is set but db is not sqlite
- WHEN:
- - Settings are parsed
- THEN:
- - PAPERLESS_DB_TIMEOUT set correctly in non-sqlite db & for fallback sqlite db
- """
- with mock.patch.dict(
- os.environ,
- {
- "PAPERLESS_DBHOST": "127.0.0.1",
- "PAPERLESS_DB_TIMEOUT": "10",
- },
- ):
- databases = _parse_db_settings()
-
- self.assertDictEqual(
- databases["default"]["OPTIONS"],
- databases["default"]["OPTIONS"]
- | {
- "connect_timeout": 10.0,
- },
- )
- self.assertDictEqual(
- {
- "timeout": 10.0,
- },
- databases["sqlite"]["OPTIONS"],
- )
-
-
class TestPaperlessURLSettings(TestCase):
def test_paperless_url(self) -> None:
"""
diff --git a/src/paperless/tests/test_views.py b/src/paperless/tests/test_views.py
index 76bf694fc..ab8941538 100644
--- a/src/paperless/tests/test_views.py
+++ b/src/paperless/tests/test_views.py
@@ -1,7 +1,7 @@
import tempfile
from pathlib import Path
-from django.conf import settings
+from django.test import override_settings
def test_favicon_view(client):
@@ -11,15 +11,14 @@ def test_favicon_view(client):
favicon_path.parent.mkdir(parents=True, exist_ok=True)
favicon_path.write_bytes(b"FAKE ICON DATA")
- settings.STATIC_ROOT = static_dir
-
- response = client.get("/favicon.ico")
- assert response.status_code == 200
- assert response["Content-Type"] == "image/x-icon"
- assert b"".join(response.streaming_content) == b"FAKE ICON DATA"
+ with override_settings(STATIC_ROOT=static_dir):
+ response = client.get("/favicon.ico")
+ assert response.status_code == 200
+ assert response["Content-Type"] == "image/x-icon"
+ assert b"".join(response.streaming_content) == b"FAKE ICON DATA"
def test_favicon_view_missing_file(client):
- settings.STATIC_ROOT = Path(tempfile.mkdtemp())
- response = client.get("/favicon.ico")
- assert response.status_code == 404
+ with override_settings(STATIC_ROOT=Path(tempfile.mkdtemp())):
+ response = client.get("/favicon.ico")
+ assert response.status_code == 404
diff --git a/src/paperless/version.py b/src/paperless/version.py
index a2f677230..ab46b36db 100644
--- a/src/paperless/version.py
+++ b/src/paperless/version.py
@@ -1,6 +1,6 @@
from typing import Final
-__version__: Final[tuple[int, int, int]] = (2, 20, 8)
+__version__: Final[tuple[int, int, int]] = (2, 20, 9)
# Version string like X.Y.Z
__full_version_str__: Final[str] = ".".join(map(str, __version__))
# Version string like X.Y
diff --git a/src/paperless/views.py b/src/paperless/views.py
index f9aa68297..05a0997fb 100644
--- a/src/paperless/views.py
+++ b/src/paperless/views.py
@@ -378,7 +378,6 @@ class ApplicationConfigurationViewSet(ModelViewSet):
):
# AI index was just enabled and vector store file does not exist
llmindex_index.delay(
- progress_bar_disable=True,
rebuild=True,
scheduled=False,
auto=True,
diff --git a/src/paperless_ai/indexing.py b/src/paperless_ai/indexing.py
index 654c56f3b..53e4a9796 100644
--- a/src/paperless_ai/indexing.py
+++ b/src/paperless_ai/indexing.py
@@ -1,11 +1,13 @@
import logging
import shutil
+from collections.abc import Callable
+from collections.abc import Iterable
from datetime import timedelta
from pathlib import Path
+from typing import TypeVar
import faiss
import llama_index.core.settings as llama_settings
-import tqdm
from celery import states
from django.conf import settings
from django.utils import timezone
@@ -29,6 +31,14 @@ from paperless_ai.embedding import build_llm_index_text
from paperless_ai.embedding import get_embedding_dim
from paperless_ai.embedding import get_embedding_model
+_T = TypeVar("_T")
+IterWrapper = Callable[[Iterable[_T]], Iterable[_T]]
+
+
+def _identity(iterable: Iterable[_T]) -> Iterable[_T]:
+ return iterable
+
+
logger = logging.getLogger("paperless_ai.indexing")
@@ -156,7 +166,11 @@ def vector_store_file_exists():
return Path(settings.LLM_INDEX_DIR / "default__vector_store.json").exists()
-def update_llm_index(*, progress_bar_disable=False, rebuild=False) -> str:
+def update_llm_index(
+ *,
+ iter_wrapper: IterWrapper[Document] = _identity,
+ rebuild=False,
+) -> str:
"""
Rebuild or update the LLM index.
"""
@@ -176,7 +190,7 @@ def update_llm_index(*, progress_bar_disable=False, rebuild=False) -> str:
embed_model = get_embedding_model()
llama_settings.Settings.embed_model = embed_model
storage_context = get_or_create_storage_context(rebuild=True)
- for document in tqdm.tqdm(documents, disable=progress_bar_disable):
+ for document in iter_wrapper(documents):
document_nodes = build_document_node(document)
nodes.extend(document_nodes)
@@ -184,7 +198,7 @@ def update_llm_index(*, progress_bar_disable=False, rebuild=False) -> str:
nodes=nodes,
storage_context=storage_context,
embed_model=embed_model,
- show_progress=not progress_bar_disable,
+ show_progress=False,
)
msg = "LLM index rebuilt successfully."
else:
@@ -196,7 +210,7 @@ def update_llm_index(*, progress_bar_disable=False, rebuild=False) -> str:
for node in index.docstore.get_nodes(all_node_ids)
}
- for document in tqdm.tqdm(documents, disable=progress_bar_disable):
+ for document in iter_wrapper(documents):
doc_id = str(document.id)
document_modified = document.modified.isoformat()
diff --git a/src/paperless_mail/parsers.py b/src/paperless_mail/parsers.py
index 22835de89..86cba23ab 100644
--- a/src/paperless_mail/parsers.py
+++ b/src/paperless_mail/parsers.py
@@ -5,6 +5,7 @@ from pathlib import Path
from bleach import clean
from bleach import linkify
from django.conf import settings
+from django.utils import timezone
from django.utils.timezone import is_naive
from django.utils.timezone import make_aware
from gotenberg_client import GotenbergClient
@@ -332,7 +333,9 @@ class MailDocumentParser(DocumentParser):
if data["attachments"]:
data["attachments_label"] = "Attachments"
- data["date"] = clean_html(mail.date.astimezone().strftime("%Y-%m-%d %H:%M"))
+ data["date"] = clean_html(
+ timezone.localtime(mail.date).strftime("%Y-%m-%d %H:%M"),
+ )
data["content"] = clean_html(mail.text.strip())
from django.template.loader import render_to_string
diff --git a/src/paperless_mail/templates/package-lock.json b/src/paperless_mail/templates/package-lock.json
index 38817d673..a042125df 100644
--- a/src/paperless_mail/templates/package-lock.json
+++ b/src/paperless_mail/templates/package-lock.json
@@ -1,11 +1,11 @@
{
"name": "templates",
- "lockfileVersion": 2,
+ "lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"devDependencies": {
- "tailwindcss": "^3.4.4"
+ "tailwindcss": "^3.4.19"
}
},
"node_modules/@alloc/quick-lru": {
@@ -13,6 +13,7 @@
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=10"
},
@@ -20,35 +21,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/@isaacs/cliui": {
- "version": "8.0.2",
- "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
- "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
- "dev": true,
- "dependencies": {
- "string-width": "^5.1.2",
- "string-width-cjs": "npm:string-width@^4.2.0",
- "strip-ansi": "^7.0.1",
- "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
- "wrap-ansi": "^8.1.0",
- "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
- },
- "engines": {
- "node": ">=12"
- }
- },
"node_modules/@jridgewell/gen-mapping": {
- "version": "0.3.5",
- "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
- "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==",
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@jridgewell/set-array": "^1.2.1",
- "@jridgewell/sourcemap-codec": "^1.4.10",
+ "@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
- },
- "engines": {
- "node": ">=6.0.0"
}
},
"node_modules/@jridgewell/resolve-uri": {
@@ -56,30 +37,24 @@
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/@jridgewell/set-array": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
- "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
- "dev": true,
+ "license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.4.15",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
- "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==",
- "dev": true
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
- "version": "0.3.25",
- "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
- "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
@@ -90,6 +65,7 @@
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9"
@@ -103,6 +79,7 @@
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">= 8"
}
@@ -112,6 +89,7 @@
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
"fastq": "^1.6.0"
@@ -120,51 +98,19 @@
"node": ">= 8"
}
},
- "node_modules/@pkgjs/parseargs": {
- "version": "0.11.0",
- "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
- "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
- "dev": true,
- "optional": true,
- "engines": {
- "node": ">=14"
- }
- },
- "node_modules/ansi-regex": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
- "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
- "dev": true,
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-regex?sponsor=1"
- }
- },
- "node_modules/ansi-styles": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
- "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
- "dev": true,
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
- }
- },
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/anymatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
- "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==",
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
+ "license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
@@ -177,30 +123,20 @@
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
- "dev": true
- },
- "node_modules/balanced-match": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
- "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/binary-extensions": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
- "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=8"
- }
- },
- "node_modules/brace-expansion": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
- "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
- "dev": true,
- "dependencies": {
- "balanced-match": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/braces": {
@@ -208,6 +144,7 @@
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
@@ -220,21 +157,17 @@
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/chokidar": {
- "version": "3.5.3",
- "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
- "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
- "funding": [
- {
- "type": "individual",
- "url": "https://paulmillr.com/funding/"
- }
- ],
+ "license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
@@ -247,6 +180,9 @@
"engines": {
"node": ">= 8.10.0"
},
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
"optionalDependencies": {
"fsevents": "~2.3.2"
}
@@ -256,6 +192,7 @@
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
+ "license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
@@ -263,52 +200,22 @@
"node": ">= 6"
}
},
- "node_modules/color-convert": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
- "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
- "dev": true,
- "dependencies": {
- "color-name": "~1.1.4"
- },
- "engines": {
- "node": ">=7.0.0"
- }
- },
- "node_modules/color-name": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
- "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
- "dev": true
- },
"node_modules/commander": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">= 6"
}
},
- "node_modules/cross-spawn": {
- "version": "7.0.3",
- "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
- "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
- "dev": true,
- "dependencies": {
- "path-key": "^3.1.0",
- "shebang-command": "^2.0.0",
- "which": "^2.0.1"
- },
- "engines": {
- "node": ">= 8"
- }
- },
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"dev": true,
+ "license": "MIT",
"bin": {
"cssesc": "bin/cssesc"
},
@@ -320,37 +227,28 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
- "dev": true
+ "dev": true,
+ "license": "Apache-2.0"
},
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
- "dev": true
- },
- "node_modules/eastasianwidth": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
- "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
- "dev": true
- },
- "node_modules/emoji-regex": {
- "version": "9.2.2",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
- "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/fast-glob": {
- "version": "3.3.2",
- "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
- "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==",
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
"glob-parent": "^5.1.2",
"merge2": "^1.3.0",
- "micromatch": "^4.0.4"
+ "micromatch": "^4.0.8"
},
"engines": {
"node": ">=8.6.0"
@@ -361,6 +259,7 @@
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
+ "license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
@@ -369,10 +268,11 @@
}
},
"node_modules/fastq": {
- "version": "1.17.1",
- "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
- "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==",
+ "version": "1.20.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dev": true,
+ "license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
}
@@ -382,6 +282,7 @@
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
@@ -389,28 +290,13 @@
"node": ">=8"
}
},
- "node_modules/foreground-child": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz",
- "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==",
- "dev": true,
- "dependencies": {
- "cross-spawn": "^7.0.0",
- "signal-exit": "^4.0.1"
- },
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
"node_modules/fsevents": {
- "version": "2.3.2",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
- "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
+ "license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -424,35 +310,17 @@
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
+ "license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/glob": {
- "version": "10.5.0",
- "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
- "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
- "dev": true,
- "dependencies": {
- "foreground-child": "^3.1.0",
- "jackspeak": "^3.1.2",
- "minimatch": "^9.0.4",
- "minipass": "^7.1.2",
- "package-json-from-dist": "^1.0.0",
- "path-scurry": "^1.11.1"
- },
- "bin": {
- "glob": "dist/esm/bin.mjs"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
+ "license": "ISC",
"dependencies": {
"is-glob": "^4.0.3"
},
@@ -465,6 +333,7 @@
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
@@ -477,6 +346,7 @@
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
},
@@ -485,12 +355,16 @@
}
},
"node_modules/is-core-module": {
- "version": "2.13.1",
- "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz",
- "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==",
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+ "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "hasown": "^2.0.0"
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -499,26 +373,19 @@
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
- "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
- "node_modules/is-fullwidth-code-point": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
- "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
- "dev": true,
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
@@ -531,81 +398,57 @@
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
- "node_modules/isexe": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
- "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
- "dev": true
- },
- "node_modules/jackspeak": {
- "version": "3.4.0",
- "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.0.tgz",
- "integrity": "sha512-JVYhQnN59LVPFCEcVa2C3CrEKYacvjRfqIQl+h8oi91aLYQVWRYbxjPcv1bUiUy/kLmQaANrYfNMCO3kuEDHfw==",
- "dev": true,
- "dependencies": {
- "@isaacs/cliui": "^8.0.2"
- },
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- },
- "optionalDependencies": {
- "@pkgjs/parseargs": "^0.11.0"
- }
- },
"node_modules/jiti": {
- "version": "1.21.6",
- "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz",
- "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==",
+ "version": "1.21.7",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
+ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
+ "license": "MIT",
"bin": {
"jiti": "bin/jiti.js"
}
},
"node_modules/lilconfig": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
- "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==",
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
"dev": true,
+ "license": "MIT",
"engines": {
- "node": ">=10"
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antonk52"
}
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
- "dev": true
- },
- "node_modules/lru-cache": {
- "version": "10.2.2",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz",
- "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==",
"dev": true,
- "engines": {
- "node": "14 || >=16.14"
- }
+ "license": "MIT"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/micromatch": {
- "version": "4.0.7",
- "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz",
- "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==",
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
@@ -614,35 +457,12 @@
"node": ">=8.6"
}
},
- "node_modules/minimatch": {
- "version": "9.0.4",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
- "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
- "dev": true,
- "dependencies": {
- "brace-expansion": "^2.0.1"
- },
- "engines": {
- "node": ">=16 || 14 >=14.17"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/minipass": {
- "version": "7.1.2",
- "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
- "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
- "dev": true,
- "engines": {
- "node": ">=16 || 14 >=14.17"
- }
- },
"node_modules/mz": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"any-promise": "^1.0.0",
"object-assign": "^4.0.1",
@@ -650,9 +470,9 @@
}
},
"node_modules/nanoid": {
- "version": "3.3.7",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
- "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [
{
@@ -660,6 +480,7 @@
"url": "https://github.com/sponsors/ai"
}
],
+ "license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
@@ -672,6 +493,7 @@
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=0.10.0"
}
@@ -681,6 +503,7 @@
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=0.10.0"
}
@@ -690,58 +513,31 @@
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">= 6"
}
},
- "node_modules/package-json-from-dist": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
- "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
- "dev": true
- },
- "node_modules/path-key": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
- "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
- "dev": true,
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
- "dev": true
- },
- "node_modules/path-scurry": {
- "version": "1.11.1",
- "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
- "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"dev": true,
- "dependencies": {
- "lru-cache": "^10.2.0",
- "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
- },
- "engines": {
- "node": ">=16 || 14 >=14.18"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
+ "license": "MIT"
},
"node_modules/picocolors": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
- "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==",
- "dev": true
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=8.6"
},
@@ -754,23 +550,25 @@
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/pirates": {
- "version": "4.0.6",
- "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz",
- "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==",
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
+ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/postcss": {
- "version": "8.4.38",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz",
- "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==",
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"funding": [
{
@@ -786,10 +584,11 @@
"url": "https://github.com/sponsors/ai"
}
],
+ "license": "MIT",
"dependencies": {
- "nanoid": "^3.3.7",
- "picocolors": "^1.0.0",
- "source-map-js": "^1.2.0"
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
@@ -800,6 +599,7 @@
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
"integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.0.0",
"read-cache": "^1.0.0",
@@ -813,28 +613,9 @@
}
},
"node_modules/postcss-js": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz",
- "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==",
- "dev": true,
- "dependencies": {
- "camelcase-css": "^2.0.1"
- },
- "engines": {
- "node": "^12 || ^14 || >= 16"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/postcss/"
- },
- "peerDependencies": {
- "postcss": "^8.4.21"
- }
- },
- "node_modules/postcss-load-config": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz",
- "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==",
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
+ "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
"dev": true,
"funding": [
{
@@ -846,62 +627,92 @@
"url": "https://github.com/sponsors/ai"
}
],
+ "license": "MIT",
"dependencies": {
- "lilconfig": "^3.0.0",
- "yaml": "^2.3.4"
+ "camelcase-css": "^2.0.1"
},
"engines": {
- "node": ">= 14"
+ "node": "^12 || ^14 || >= 16"
},
"peerDependencies": {
+ "postcss": "^8.4.21"
+ }
+ },
+ "node_modules/postcss-load-config": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
+ "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "lilconfig": "^3.1.1"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "jiti": ">=1.21.0",
"postcss": ">=8.0.9",
- "ts-node": ">=9.0.0"
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
},
"peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ },
"postcss": {
"optional": true
},
- "ts-node": {
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
"optional": true
}
}
},
- "node_modules/postcss-load-config/node_modules/lilconfig": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz",
- "integrity": "sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==",
- "dev": true,
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/sponsors/antonk52"
- }
- },
"node_modules/postcss-nested": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz",
- "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==",
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
+ "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
"dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
"dependencies": {
- "postcss-selector-parser": "^6.0.11"
+ "postcss-selector-parser": "^6.1.1"
},
"engines": {
"node": ">=12.0"
},
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/postcss/"
- },
"peerDependencies": {
"postcss": "^8.2.14"
}
},
"node_modules/postcss-selector-parser": {
- "version": "6.1.0",
- "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.0.tgz",
- "integrity": "sha512-UMz42UD0UY0EApS0ZL9o1XnLhSTtvvvLe5Dc2H2O56fvRZi+KulDyf5ctDhhtYJBGKStV2FL1fy6253cmLgqVQ==",
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
+ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -914,7 +725,8 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/queue-microtask": {
"version": "1.2.3",
@@ -934,13 +746,15 @@
"type": "consulting",
"url": "https://feross.org/support"
}
- ]
+ ],
+ "license": "MIT"
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"pify": "^2.3.0"
}
@@ -950,6 +764,7 @@
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
},
@@ -958,27 +773,32 @@
}
},
"node_modules/resolve": {
- "version": "1.22.8",
- "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
- "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==",
+ "version": "1.22.11",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
+ "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "is-core-module": "^2.13.0",
+ "is-core-module": "^2.16.1",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
"bin": {
"resolve": "bin/resolve"
},
+ "engines": {
+ "node": ">= 0.4"
+ },
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/reusify": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
- "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true,
+ "license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
@@ -1003,160 +823,34 @@
"url": "https://feross.org/support"
}
],
+ "license": "MIT",
"dependencies": {
"queue-microtask": "^1.2.2"
}
},
- "node_modules/shebang-command": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
- "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
- "dev": true,
- "dependencies": {
- "shebang-regex": "^3.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/shebang-regex": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
- "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
- "dev": true,
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/signal-exit": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
- "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
- "dev": true,
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
"node_modules/source-map-js": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
- "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
+ "license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
- "node_modules/string-width": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
- "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
- "dev": true,
- "dependencies": {
- "eastasianwidth": "^0.2.0",
- "emoji-regex": "^9.2.2",
- "strip-ansi": "^7.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/string-width-cjs": {
- "name": "string-width",
- "version": "4.2.3",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
- "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
- "dev": true,
- "dependencies": {
- "emoji-regex": "^8.0.0",
- "is-fullwidth-code-point": "^3.0.0",
- "strip-ansi": "^6.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/string-width-cjs/node_modules/ansi-regex": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "dev": true,
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/string-width-cjs/node_modules/emoji-regex": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
- "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
- "dev": true
- },
- "node_modules/string-width-cjs/node_modules/strip-ansi": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dev": true,
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/strip-ansi": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
- "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
- "dev": true,
- "dependencies": {
- "ansi-regex": "^6.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/strip-ansi?sponsor=1"
- }
- },
- "node_modules/strip-ansi-cjs": {
- "name": "strip-ansi",
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dev": true,
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "dev": true,
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/sucrase": {
- "version": "3.35.0",
- "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
- "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==",
+ "version": "3.35.1",
+ "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
+ "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.2",
"commander": "^4.0.0",
- "glob": "^10.3.10",
"lines-and-columns": "^1.1.6",
"mz": "^2.7.0",
"pirates": "^4.0.1",
+ "tinyglobby": "^0.2.11",
"ts-interface-checker": "^0.1.9"
},
"bin": {
@@ -1172,6 +866,7 @@
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">= 0.4"
},
@@ -1180,33 +875,34 @@
}
},
"node_modules/tailwindcss": {
- "version": "3.4.4",
- "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.4.tgz",
- "integrity": "sha512-ZoyXOdJjISB7/BcLTR6SEsLgKtDStYyYZVLsUtWChO4Ps20CBad7lfJKVDiejocV4ME1hLmyY0WJE3hSDcmQ2A==",
+ "version": "3.4.19",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
+ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
- "chokidar": "^3.5.3",
+ "chokidar": "^3.6.0",
"didyoumean": "^1.2.2",
"dlv": "^1.1.3",
- "fast-glob": "^3.3.0",
+ "fast-glob": "^3.3.2",
"glob-parent": "^6.0.2",
"is-glob": "^4.0.3",
- "jiti": "^1.21.0",
- "lilconfig": "^2.1.0",
- "micromatch": "^4.0.5",
+ "jiti": "^1.21.7",
+ "lilconfig": "^3.1.3",
+ "micromatch": "^4.0.8",
"normalize-path": "^3.0.0",
"object-hash": "^3.0.0",
- "picocolors": "^1.0.0",
- "postcss": "^8.4.23",
+ "picocolors": "^1.1.1",
+ "postcss": "^8.4.47",
"postcss-import": "^15.1.0",
"postcss-js": "^4.0.1",
- "postcss-load-config": "^4.0.1",
- "postcss-nested": "^6.0.1",
- "postcss-selector-parser": "^6.0.11",
- "resolve": "^1.22.2",
- "sucrase": "^3.32.0"
+ "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
+ "postcss-nested": "^6.2.0",
+ "postcss-selector-parser": "^6.1.2",
+ "resolve": "^1.22.8",
+ "sucrase": "^3.35.0"
},
"bin": {
"tailwind": "lib/cli.js",
@@ -1221,6 +917,7 @@
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"any-promise": "^1.0.0"
}
@@ -1230,6 +927,7 @@
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"thenify": ">= 3.1.0 < 4"
},
@@ -1237,11 +935,60 @@
"node": ">=0.8"
}
},
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
@@ -1253,1076 +1000,15 @@
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
- "dev": true
+ "dev": true,
+ "license": "Apache-2.0"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
- "dev": true
- },
- "node_modules/which": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
- "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
- "dependencies": {
- "isexe": "^2.0.0"
- },
- "bin": {
- "node-which": "bin/node-which"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/wrap-ansi": {
- "version": "8.1.0",
- "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
- "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
- "dev": true,
- "dependencies": {
- "ansi-styles": "^6.1.0",
- "string-width": "^5.0.1",
- "strip-ansi": "^7.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
- }
- },
- "node_modules/wrap-ansi-cjs": {
- "name": "wrap-ansi",
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
- "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
- "dev": true,
- "dependencies": {
- "ansi-styles": "^4.0.0",
- "string-width": "^4.1.0",
- "strip-ansi": "^6.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
- }
- },
- "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "dev": true,
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
- "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
- "dev": true,
- "dependencies": {
- "color-convert": "^2.0.1"
- },
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
- }
- },
- "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
- "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
- "dev": true
- },
- "node_modules/wrap-ansi-cjs/node_modules/string-width": {
- "version": "4.2.3",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
- "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
- "dev": true,
- "dependencies": {
- "emoji-regex": "^8.0.0",
- "is-fullwidth-code-point": "^3.0.0",
- "strip-ansi": "^6.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dev": true,
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/yaml": {
- "version": "2.4.5",
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.5.tgz",
- "integrity": "sha512-aBx2bnqDzVOyNKfsysjA2ms5ZlnjSAW2eG3/L5G/CSujfjLJTJsEw1bGw8kCf04KodQWk1pxlGnZ56CRxiawmg==",
- "dev": true,
- "bin": {
- "yaml": "bin.mjs"
- },
- "engines": {
- "node": ">= 14"
- }
- }
- },
- "dependencies": {
- "@alloc/quick-lru": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
- "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
- "dev": true
- },
- "@isaacs/cliui": {
- "version": "8.0.2",
- "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
- "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
- "dev": true,
- "requires": {
- "string-width": "^5.1.2",
- "string-width-cjs": "npm:string-width@^4.2.0",
- "strip-ansi": "^7.0.1",
- "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
- "wrap-ansi": "^8.1.0",
- "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
- }
- },
- "@jridgewell/gen-mapping": {
- "version": "0.3.5",
- "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
- "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==",
- "dev": true,
- "requires": {
- "@jridgewell/set-array": "^1.2.1",
- "@jridgewell/sourcemap-codec": "^1.4.10",
- "@jridgewell/trace-mapping": "^0.3.24"
- }
- },
- "@jridgewell/resolve-uri": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
- "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
- "dev": true
- },
- "@jridgewell/set-array": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
- "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
- "dev": true
- },
- "@jridgewell/sourcemap-codec": {
- "version": "1.4.15",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
- "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==",
- "dev": true
- },
- "@jridgewell/trace-mapping": {
- "version": "0.3.25",
- "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
- "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
- "dev": true,
- "requires": {
- "@jridgewell/resolve-uri": "^3.1.0",
- "@jridgewell/sourcemap-codec": "^1.4.14"
- }
- },
- "@nodelib/fs.scandir": {
- "version": "2.1.5",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
- "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
- "dev": true,
- "requires": {
- "@nodelib/fs.stat": "2.0.5",
- "run-parallel": "^1.1.9"
- }
- },
- "@nodelib/fs.stat": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
- "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
- "dev": true
- },
- "@nodelib/fs.walk": {
- "version": "1.2.8",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
- "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
- "dev": true,
- "requires": {
- "@nodelib/fs.scandir": "2.1.5",
- "fastq": "^1.6.0"
- }
- },
- "@pkgjs/parseargs": {
- "version": "0.11.0",
- "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
- "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
- "dev": true,
- "optional": true
- },
- "ansi-regex": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
- "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
- "dev": true
- },
- "ansi-styles": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
- "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
- "dev": true
- },
- "any-promise": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
- "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
- "dev": true
- },
- "anymatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
- "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==",
- "dev": true,
- "requires": {
- "normalize-path": "^3.0.0",
- "picomatch": "^2.0.4"
- }
- },
- "arg": {
- "version": "5.0.2",
- "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
- "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
- "dev": true
- },
- "balanced-match": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
- "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
- "dev": true
- },
- "binary-extensions": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
- "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
- "dev": true
- },
- "brace-expansion": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
- "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
- "dev": true,
- "requires": {
- "balanced-match": "^1.0.0"
- }
- },
- "braces": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
- "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
- "dev": true,
- "requires": {
- "fill-range": "^7.1.1"
- }
- },
- "camelcase-css": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
- "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
- "dev": true
- },
- "chokidar": {
- "version": "3.5.3",
- "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
- "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
- "dev": true,
- "requires": {
- "anymatch": "~3.1.2",
- "braces": "~3.0.2",
- "fsevents": "~2.3.2",
- "glob-parent": "~5.1.2",
- "is-binary-path": "~2.1.0",
- "is-glob": "~4.0.1",
- "normalize-path": "~3.0.0",
- "readdirp": "~3.6.0"
- },
- "dependencies": {
- "glob-parent": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
- "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
- "dev": true,
- "requires": {
- "is-glob": "^4.0.1"
- }
- }
- }
- },
- "color-convert": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
- "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
- "dev": true,
- "requires": {
- "color-name": "~1.1.4"
- }
- },
- "color-name": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
- "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
- "dev": true
- },
- "commander": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
- "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
- "dev": true
- },
- "cross-spawn": {
- "version": "7.0.3",
- "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
- "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
- "dev": true,
- "requires": {
- "path-key": "^3.1.0",
- "shebang-command": "^2.0.0",
- "which": "^2.0.1"
- }
- },
- "cssesc": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
- "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
- "dev": true
- },
- "didyoumean": {
- "version": "1.2.2",
- "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
- "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
- "dev": true
- },
- "dlv": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
- "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
- "dev": true
- },
- "eastasianwidth": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
- "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
- "dev": true
- },
- "emoji-regex": {
- "version": "9.2.2",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
- "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
- "dev": true
- },
- "fast-glob": {
- "version": "3.3.2",
- "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
- "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==",
- "dev": true,
- "requires": {
- "@nodelib/fs.stat": "^2.0.2",
- "@nodelib/fs.walk": "^1.2.3",
- "glob-parent": "^5.1.2",
- "merge2": "^1.3.0",
- "micromatch": "^4.0.4"
- },
- "dependencies": {
- "glob-parent": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
- "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
- "dev": true,
- "requires": {
- "is-glob": "^4.0.1"
- }
- }
- }
- },
- "fastq": {
- "version": "1.17.1",
- "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
- "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==",
- "dev": true,
- "requires": {
- "reusify": "^1.0.4"
- }
- },
- "fill-range": {
- "version": "7.1.1",
- "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
- "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
- "dev": true,
- "requires": {
- "to-regex-range": "^5.0.1"
- }
- },
- "foreground-child": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz",
- "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==",
- "dev": true,
- "requires": {
- "cross-spawn": "^7.0.0",
- "signal-exit": "^4.0.1"
- }
- },
- "fsevents": {
- "version": "2.3.2",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
- "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
- "dev": true,
- "optional": true
- },
- "function-bind": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
- "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
- "dev": true
- },
- "glob": {
- "version": "10.5.0",
- "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
- "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
- "dev": true,
- "requires": {
- "foreground-child": "^3.1.0",
- "jackspeak": "^3.1.2",
- "minimatch": "^9.0.4",
- "minipass": "^7.1.2",
- "package-json-from-dist": "^1.0.0",
- "path-scurry": "^1.11.1"
- }
- },
- "glob-parent": {
- "version": "6.0.2",
- "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
- "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
- "dev": true,
- "requires": {
- "is-glob": "^4.0.3"
- }
- },
- "hasown": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
- "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
- "dev": true,
- "requires": {
- "function-bind": "^1.1.2"
- }
- },
- "is-binary-path": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
- "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
- "dev": true,
- "requires": {
- "binary-extensions": "^2.0.0"
- }
- },
- "is-core-module": {
- "version": "2.13.1",
- "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz",
- "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==",
- "dev": true,
- "requires": {
- "hasown": "^2.0.0"
- }
- },
- "is-extglob": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
- "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=",
- "dev": true
- },
- "is-fullwidth-code-point": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
- "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
- "dev": true
- },
- "is-glob": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
- "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
- "dev": true,
- "requires": {
- "is-extglob": "^2.1.1"
- }
- },
- "is-number": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
- "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
- "dev": true
- },
- "isexe": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
- "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
- "dev": true
- },
- "jackspeak": {
- "version": "3.4.0",
- "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.0.tgz",
- "integrity": "sha512-JVYhQnN59LVPFCEcVa2C3CrEKYacvjRfqIQl+h8oi91aLYQVWRYbxjPcv1bUiUy/kLmQaANrYfNMCO3kuEDHfw==",
- "dev": true,
- "requires": {
- "@isaacs/cliui": "^8.0.2",
- "@pkgjs/parseargs": "^0.11.0"
- }
- },
- "jiti": {
- "version": "1.21.6",
- "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz",
- "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==",
- "dev": true
- },
- "lilconfig": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
- "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==",
- "dev": true
- },
- "lines-and-columns": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
- "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
- "dev": true
- },
- "lru-cache": {
- "version": "10.2.2",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz",
- "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==",
- "dev": true
- },
- "merge2": {
- "version": "1.4.1",
- "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
- "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
- "dev": true
- },
- "micromatch": {
- "version": "4.0.7",
- "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz",
- "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==",
- "dev": true,
- "requires": {
- "braces": "^3.0.3",
- "picomatch": "^2.3.1"
- }
- },
- "minimatch": {
- "version": "9.0.4",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
- "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
- "dev": true,
- "requires": {
- "brace-expansion": "^2.0.1"
- }
- },
- "minipass": {
- "version": "7.1.2",
- "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
- "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
- "dev": true
- },
- "mz": {
- "version": "2.7.0",
- "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
- "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
- "dev": true,
- "requires": {
- "any-promise": "^1.0.0",
- "object-assign": "^4.0.1",
- "thenify-all": "^1.0.0"
- }
- },
- "nanoid": {
- "version": "3.3.7",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
- "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
- "dev": true
- },
- "normalize-path": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
- "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
- "dev": true
- },
- "object-assign": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
- "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
- "dev": true
- },
- "object-hash": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
- "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
- "dev": true
- },
- "package-json-from-dist": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
- "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
- "dev": true
- },
- "path-key": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
- "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
- "dev": true
- },
- "path-parse": {
- "version": "1.0.7",
- "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
- "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
- "dev": true
- },
- "path-scurry": {
- "version": "1.11.1",
- "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
- "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
- "dev": true,
- "requires": {
- "lru-cache": "^10.2.0",
- "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
- }
- },
- "picocolors": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
- "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==",
- "dev": true
- },
- "picomatch": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
- "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
- "dev": true
- },
- "pify": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
- "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
- "dev": true
- },
- "pirates": {
- "version": "4.0.6",
- "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz",
- "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==",
- "dev": true
- },
- "postcss": {
- "version": "8.4.38",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz",
- "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==",
- "dev": true,
- "requires": {
- "nanoid": "^3.3.7",
- "picocolors": "^1.0.0",
- "source-map-js": "^1.2.0"
- }
- },
- "postcss-import": {
- "version": "15.1.0",
- "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
- "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
- "dev": true,
- "requires": {
- "postcss-value-parser": "^4.0.0",
- "read-cache": "^1.0.0",
- "resolve": "^1.1.7"
- }
- },
- "postcss-js": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz",
- "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==",
- "dev": true,
- "requires": {
- "camelcase-css": "^2.0.1"
- }
- },
- "postcss-load-config": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz",
- "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==",
- "dev": true,
- "requires": {
- "lilconfig": "^3.0.0",
- "yaml": "^2.3.4"
- },
- "dependencies": {
- "lilconfig": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz",
- "integrity": "sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==",
- "dev": true
- }
- }
- },
- "postcss-nested": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz",
- "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==",
- "dev": true,
- "requires": {
- "postcss-selector-parser": "^6.0.11"
- }
- },
- "postcss-selector-parser": {
- "version": "6.1.0",
- "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.0.tgz",
- "integrity": "sha512-UMz42UD0UY0EApS0ZL9o1XnLhSTtvvvLe5Dc2H2O56fvRZi+KulDyf5ctDhhtYJBGKStV2FL1fy6253cmLgqVQ==",
- "dev": true,
- "requires": {
- "cssesc": "^3.0.0",
- "util-deprecate": "^1.0.2"
- }
- },
- "postcss-value-parser": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
- "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
- "dev": true
- },
- "queue-microtask": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
- "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
- "dev": true
- },
- "read-cache": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
- "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
- "dev": true,
- "requires": {
- "pify": "^2.3.0"
- }
- },
- "readdirp": {
- "version": "3.6.0",
- "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
- "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
- "dev": true,
- "requires": {
- "picomatch": "^2.2.1"
- }
- },
- "resolve": {
- "version": "1.22.8",
- "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
- "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==",
- "dev": true,
- "requires": {
- "is-core-module": "^2.13.0",
- "path-parse": "^1.0.7",
- "supports-preserve-symlinks-flag": "^1.0.0"
- }
- },
- "reusify": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
- "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
- "dev": true
- },
- "run-parallel": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
- "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
- "dev": true,
- "requires": {
- "queue-microtask": "^1.2.2"
- }
- },
- "shebang-command": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
- "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
- "dev": true,
- "requires": {
- "shebang-regex": "^3.0.0"
- }
- },
- "shebang-regex": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
- "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
- "dev": true
- },
- "signal-exit": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
- "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
- "dev": true
- },
- "source-map-js": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
- "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
- "dev": true
- },
- "string-width": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
- "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
- "dev": true,
- "requires": {
- "eastasianwidth": "^0.2.0",
- "emoji-regex": "^9.2.2",
- "strip-ansi": "^7.0.1"
- }
- },
- "string-width-cjs": {
- "version": "npm:string-width@4.2.3",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
- "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
- "dev": true,
- "requires": {
- "emoji-regex": "^8.0.0",
- "is-fullwidth-code-point": "^3.0.0",
- "strip-ansi": "^6.0.1"
- },
- "dependencies": {
- "ansi-regex": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "dev": true
- },
- "emoji-regex": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
- "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
- "dev": true
- },
- "strip-ansi": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dev": true,
- "requires": {
- "ansi-regex": "^5.0.1"
- }
- }
- }
- },
- "strip-ansi": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
- "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
- "dev": true,
- "requires": {
- "ansi-regex": "^6.0.1"
- }
- },
- "strip-ansi-cjs": {
- "version": "npm:strip-ansi@6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dev": true,
- "requires": {
- "ansi-regex": "^5.0.1"
- },
- "dependencies": {
- "ansi-regex": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "dev": true
- }
- }
- },
- "sucrase": {
- "version": "3.35.0",
- "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
- "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==",
- "dev": true,
- "requires": {
- "@jridgewell/gen-mapping": "^0.3.2",
- "commander": "^4.0.0",
- "glob": "^10.3.10",
- "lines-and-columns": "^1.1.6",
- "mz": "^2.7.0",
- "pirates": "^4.0.1",
- "ts-interface-checker": "^0.1.9"
- }
- },
- "supports-preserve-symlinks-flag": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
- "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
- "dev": true
- },
- "tailwindcss": {
- "version": "3.4.4",
- "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.4.tgz",
- "integrity": "sha512-ZoyXOdJjISB7/BcLTR6SEsLgKtDStYyYZVLsUtWChO4Ps20CBad7lfJKVDiejocV4ME1hLmyY0WJE3hSDcmQ2A==",
- "dev": true,
- "requires": {
- "@alloc/quick-lru": "^5.2.0",
- "arg": "^5.0.2",
- "chokidar": "^3.5.3",
- "didyoumean": "^1.2.2",
- "dlv": "^1.1.3",
- "fast-glob": "^3.3.0",
- "glob-parent": "^6.0.2",
- "is-glob": "^4.0.3",
- "jiti": "^1.21.0",
- "lilconfig": "^2.1.0",
- "micromatch": "^4.0.5",
- "normalize-path": "^3.0.0",
- "object-hash": "^3.0.0",
- "picocolors": "^1.0.0",
- "postcss": "^8.4.23",
- "postcss-import": "^15.1.0",
- "postcss-js": "^4.0.1",
- "postcss-load-config": "^4.0.1",
- "postcss-nested": "^6.0.1",
- "postcss-selector-parser": "^6.0.11",
- "resolve": "^1.22.2",
- "sucrase": "^3.32.0"
- }
- },
- "thenify": {
- "version": "3.3.1",
- "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
- "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
- "dev": true,
- "requires": {
- "any-promise": "^1.0.0"
- }
- },
- "thenify-all": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
- "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
- "dev": true,
- "requires": {
- "thenify": ">= 3.1.0 < 4"
- }
- },
- "to-regex-range": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
- "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
- "dev": true,
- "requires": {
- "is-number": "^7.0.0"
- }
- },
- "ts-interface-checker": {
- "version": "0.1.13",
- "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
- "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
- "dev": true
- },
- "util-deprecate": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
- "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
- "dev": true
- },
- "which": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
- "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
- "dev": true,
- "requires": {
- "isexe": "^2.0.0"
- }
- },
- "wrap-ansi": {
- "version": "8.1.0",
- "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
- "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
- "dev": true,
- "requires": {
- "ansi-styles": "^6.1.0",
- "string-width": "^5.0.1",
- "strip-ansi": "^7.0.1"
- }
- },
- "wrap-ansi-cjs": {
- "version": "npm:wrap-ansi@7.0.0",
- "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
- "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
- "dev": true,
- "requires": {
- "ansi-styles": "^4.0.0",
- "string-width": "^4.1.0",
- "strip-ansi": "^6.0.0"
- },
- "dependencies": {
- "ansi-regex": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "dev": true
- },
- "ansi-styles": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
- "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
- "dev": true,
- "requires": {
- "color-convert": "^2.0.1"
- }
- },
- "emoji-regex": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
- "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
- "dev": true
- },
- "string-width": {
- "version": "4.2.3",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
- "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
- "dev": true,
- "requires": {
- "emoji-regex": "^8.0.0",
- "is-fullwidth-code-point": "^3.0.0",
- "strip-ansi": "^6.0.1"
- }
- },
- "strip-ansi": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dev": true,
- "requires": {
- "ansi-regex": "^5.0.1"
- }
- }
- }
- },
- "yaml": {
- "version": "2.4.5",
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.5.tgz",
- "integrity": "sha512-aBx2bnqDzVOyNKfsysjA2ms5ZlnjSAW2eG3/L5G/CSujfjLJTJsEw1bGw8kCf04KodQWk1pxlGnZ56CRxiawmg==",
- "dev": true
+ "license": "MIT"
}
}
}
diff --git a/src/paperless_mail/templates/package.json b/src/paperless_mail/templates/package.json
index 3b204fa88..7249f90c6 100644
--- a/src/paperless_mail/templates/package.json
+++ b/src/paperless_mail/templates/package.json
@@ -1,5 +1,5 @@
{
"devDependencies": {
- "tailwindcss": "^3.4.4"
+ "tailwindcss": "^3.4.19"
}
}
diff --git a/src/paperless_mail/tests/test_parsers.py b/src/paperless_mail/tests/test_parsers.py
index 061771d47..9746731f8 100644
--- a/src/paperless_mail/tests/test_parsers.py
+++ b/src/paperless_mail/tests/test_parsers.py
@@ -6,6 +6,7 @@ from unittest import mock
import httpx
import pytest
from django.test.html import parse_html
+from django.utils import timezone
from pytest_django.fixtures import SettingsWrapper
from pytest_httpx import HTTPXMock
from pytest_mock import MockerFixture
@@ -634,13 +635,14 @@ class TestParser:
THEN:
- Resulting HTML is as expected
"""
- mail = mail_parser.parse_file_to_message(html_email_file)
- html_file = mail_parser.mail_to_html(mail)
+ with timezone.override("UTC"):
+ mail = mail_parser.parse_file_to_message(html_email_file)
+ html_file = mail_parser.mail_to_html(mail)
- expected_html = parse_html(html_email_html_file.read_text())
- actual_html = parse_html(html_file.read_text())
+ expected_html = parse_html(html_email_html_file.read_text())
+ actual_html = parse_html(html_file.read_text())
- assert expected_html == actual_html
+ assert expected_html == actual_html
def test_generate_pdf_from_mail(
self,
diff --git a/src/paperless_tesseract/tests/test_parser.py b/src/paperless_tesseract/tests/test_parser.py
index ce8ef4c9e..e9c05b03e 100644
--- a/src/paperless_tesseract/tests/test_parser.py
+++ b/src/paperless_tesseract/tests/test_parser.py
@@ -1,5 +1,6 @@
import shutil
import tempfile
+import unicodedata
import uuid
from pathlib import Path
from unittest import mock
@@ -847,8 +848,18 @@ class TestParser(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
"application/pdf",
)
- # Copied from the PDF to here. Don't even look at it
- self.assertIn("ةﯾﻠﺧﺎدﻻ ةرازو", parser.get_text())
+ # OCR output for RTL text varies across platforms/versions due to
+ # bidi controls and presentation forms; normalize before assertion.
+ normalized_text = "".join(
+ char
+ for char in unicodedata.normalize("NFKC", parser.get_text())
+ if unicodedata.category(char) != "Cf" and not char.isspace()
+ )
+
+ self.assertIn("ةرازو", normalized_text)
+ self.assertTrue(
+ any(token in normalized_text for token in ("ةیلخادلا", "الاخليد")),
+ )
@mock.patch("ocrmypdf.ocr")
def test_gs_rendering_error(self, m) -> None:
diff --git a/uv.lock b/uv.lock
index add260533..db1675dfc 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1342,11 +1342,11 @@ wheels = [
[[package]]
name = "faker"
-version = "40.1.2"
+version = "40.5.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/5e/77/1c3ff07b6739b9a1d23ca01ec0a90a309a33b78e345a3eb52f9ce9240e36/faker-40.1.2.tar.gz", hash = "sha256:b76a68163aa5f171d260fc24827a8349bc1db672f6a665359e8d0095e8135d30", size = 1949802, upload-time = "2026-01-13T20:51:49.917Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/03/2a/96fff3edcb10f6505143448a4b91535f77b74865cec45be52690ee280443/faker-40.5.1.tar.gz", hash = "sha256:70222361cd82aa10cb86066d1a4e8f47f2bcdc919615c412045a69c4e6da0cd3", size = 1952684, upload-time = "2026-02-23T21:34:38.362Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/46/ec/91a434c8a53d40c3598966621dea9c50512bec6ce8e76fa1751015e74cef/faker-40.1.2-py3-none-any.whl", hash = "sha256:93503165c165d330260e4379fd6dc07c94da90c611ed3191a0174d2ab9966a42", size = 1985633, upload-time = "2026-01-13T20:51:47.982Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/a9/1eed4db92d0aec2f9bfdf1faae0ab0418b5e121dda5701f118a7a4f0cd6a/faker-40.5.1-py3-none-any.whl", hash = "sha256:c69640c1e13bad49b4bcebcbf1b52f9f1a872b6ea186c248ada34d798f1661bf", size = 1987053, upload-time = "2026-02-23T21:34:36.418Z" },
]
[[package]]
@@ -3033,7 +3033,7 @@ wheels = [
[[package]]
name = "paperless-ngx"
-version = "2.20.8"
+version = "2.20.9"
source = { virtual = "." }
dependencies = [
{ name = "azure-ai-documentintelligence", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
@@ -3121,6 +3121,7 @@ webserver = [
dev = [
{ name = "daphne", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "factory-boy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
+ { name = "faker", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "imagehash", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "prek", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
@@ -3145,6 +3146,7 @@ lint = [
testing = [
{ name = "daphne", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "factory-boy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
+ { name = "faker", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "imagehash", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "pytest-cov", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
@@ -3257,6 +3259,7 @@ provides-extras = ["mariadb", "postgres", "webserver"]
dev = [
{ name = "daphne" },
{ name = "factory-boy", specifier = "~=3.3.1" },
+ { name = "faker", specifier = "~=40.5.1" },
{ name = "imagehash" },
{ name = "prek", specifier = "~=0.3.0" },
{ name = "pytest", specifier = "~=9.0.0" },
@@ -3279,6 +3282,7 @@ lint = [
testing = [
{ name = "daphne" },
{ name = "factory-boy", specifier = "~=3.3.1" },
+ { name = "faker", specifier = "~=40.5.1" },
{ name = "imagehash" },
{ name = "pytest", specifier = "~=9.0.0" },
{ name = "pytest-cov", specifier = "~=7.0.0" },
@@ -5906,11 +5910,11 @@ wheels = [
[[package]]
name = "whitenoise"
-version = "6.11.0"
+version = "6.12.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/15/95/8c81ec6b6ebcbf8aca2de7603070ccf37dbb873b03f20708e0f7c1664bc6/whitenoise-6.11.0.tar.gz", hash = "sha256:0f5bfce6061ae6611cd9396a8231e088722e4fc67bc13a111be74c738d99375f", size = 26432, upload-time = "2025-09-18T09:16:10.995Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/6c/e9/4366332f9295fe0647d7d3251ce18f5615fbcb12d02c79a26f8dba9221b3/whitenoise-6.11.0-py3-none-any.whl", hash = "sha256:b2aeb45950597236f53b5342b3121c5de69c8da0109362aee506ce88e022d258", size = 20197, upload-time = "2025-09-18T09:16:09.754Z" },
+ { url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" },
]
[[package]]
diff --git a/zensical.toml b/zensical.toml
index 4dbd2bf2e..d78ed4b39 100644
--- a/zensical.toml
+++ b/zensical.toml
@@ -18,7 +18,10 @@ nav = [
"setup.md",
"usage.md",
"configuration.md",
- "administration.md",
+ { Administration = [
+ "administration.md",
+ { "v3 Migration Guide" = "migration-v3.md" },
+ ] },
"advanced_usage.md",
"api.md",
"development.md",