Compare commits

..
Author SHA1 Message Date
stumpylog e47562d0d8 Updates after reviewing and updating compat lirary 2026-08-13 15:28:47 -07:00
stumpylog d131dd9c28 docs: flag where the transition spec and plan describe a moved API
The library changed after these were written and more changes are already
decided upstream. Records what is wrong today, what to write toward, the
one question still open, and the fast-JSON-field trap, rather than
silently leaving code that would fail on contact.
2026-08-13 14:20:48 -07:00
stumpylog 9f535bebf4 chore: update transition guidance for the current whoosh-compat API
Field references became a typed value rather than a dotted string, so
diagnostics carry one too and the registry exposes a single resolver.
Also records that the JSON fields must stay non-fast while existence
checks against a fast JSON field return inverted results.
2026-08-13 14:20:48 -07:00
stumpylogandClaude Fable 5 9fd40dead8 chore: build typed search errors from structured diagnostic data
Diagnostics now carry field and raw_value, so the transition guidance
points at those instead of parsing human-readable message text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 14:20:48 -07:00
stumpylogandClaude Sonnet 5 b38e0555f0 docs: add whoosh-compat transition implementation plan
16 bite-sized, TDD tasks across the design spec's 4-PR stack, each with
a suggested subagent type/model for delegated execution. Test/fixture
code in the acceptance-corpus and API-expansion tasks was verified
against the real codebase (documents/tests/search/conftest.py's
existing backend/index fixtures, test_backend.py's pytestmark
convention, CustomFieldInstance's typed value_text field) rather than
guessed, and the date-grammar parity test's AST-shape assumption was
confirmed by actually running whoosh_compat.parse() against a real
DATE FieldRegistry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 14:20:48 -07:00
stumpylogandClaude Sonnet 5 0d64d09973 docs: fold agent-review findings into whoosh-compat transition spec
Agent review (source-verified against both repos) confirmed the spec's
claims accurate throughout, with one real gap: the JSON-subpath
tantivy-py carve-out (index.parse_query fallback for notes.*/
custom_fields.* until tantivy-py#716 ships) interacts with paperless's
pinned tantivy~=0.26.0 and wasn't mentioned. Also added two footnotes:
FieldRegistry forces date_only=True on any DATE spec regardless of the
PublicField default, and the date-grammar parity audit implicitly
grants new keyword vocabulary as a side effect.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 14:20:48 -07:00
stumpylogandClaude Sonnet 5 c3d24db024 docs: add whoosh-compat transition design spec
Design for replacing _translate.py/_dates.py with whoosh-compat: shared
field-definition table driving both the Tantivy schema and the query
FieldRegistry, diagnostics->exception mapping (aggregating all errors,
not just the first), a 4-PR stack with no rollout flag, and a
result-level acceptance corpus + date-grammar parity audit as the
safety net instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 14:20:48 -07:00
stumpylogandClaude Fable 5 3e57fb5f2c chore: add whoosh-compat transition skill
Encodes the settled integration decisions for replacing the
hand-maintained search translation layer with whoosh-compat:
user-typed query surface policy, analyzer seam, diagnostics-before-emit
contract, mandatory date parity audit, test churn, and rollout plan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 14:20:48 -07:00
167 changed files with 63494 additions and 98540 deletions
@@ -0,0 +1,66 @@
---
name: whoosh-compat-transition
description: Use when integrating the whoosh-compat library into paperless-ngx search, replacing src/documents/search/_translate.py or _dates.py, building the search FieldRegistry, or changing user query parsing during the whoosh-to-tantivy transition
---
# whoosh-compat transition
## Overview
whoosh-compat (github.com/stumpylog/whoosh-compat; local checkout usually at `../whoosh-compat`) replaces the hand-maintained translation layer (`src/documents/search/_translate.py`, `_dates.py`): it parses user queries with a faithful fork of whoosh's real grammar into a typed AST and emits programmatic tantivy queries. Read its README and ARCHITECTURE.md before wiring anything; its DIVERGENCES.md lists intended behavior differences and is the authority on "is this difference a bug".
## Decisions already made (do not re-derive)
- **Queries are user-typed free text.** The advanced search box passes whatever the user types straight to the parser (that is how the issue #13568 queries exist). Do NOT try to infer the supported field surface from frontend code; the frontend only generates a few date filter strings, everything else is typed by users.
- **The field surface is a policy decision, not `KNOWN_FIELDS`.** Today's `KNOWN_FIELDS` accepts internal ID fields (`tag_id`, `owner_id`, `viewer_id`, other `*_id`) that are undocumented in `docs/usage.md` and were ruled not user-searchable by the maintainer: exclude them from the `FieldRegistry` (they stay as programmatic permission/filter fields in `build_permission_filter`, which never touches user query text). The registry is built from documented syntax in `docs/usage.md` plus the v2-compat aliases (`type`, `path`, `type_id`-style aliases follow their canonical field's fate). Undocumented-but-working fields (`asn`, `page_count`, `num_notes`, `original_filename`, `checksum`) need an explicit maintainer yes/no; since users type freely, silently dropping one breaks any saved view using it, so a drop must be a visible, documented decision.
- **Analyzer seam:** `FieldSpec.analyzer` binds the live registered tantivy analyzer's `.analyze` (the same Rust analyzer used at index time; language-keyed, so rebuild the registry when `SEARCH_LANGUAGE` changes, on the same trigger as `register_tokenizers`). `pattern_normalizer` is `_tokenizer.ascii_fold`: character-level lowercase+fold only, NEVER stemming.
- **Diagnostics before emit:** `whoosh_compat.parse()` never raises on bad input. Check `ParseResult.diagnostics` and map to `SearchQueryError`/`InvalidDateQuery` (HTTP 400) BEFORE calling `emit()`; also catch the emitter's `UnsupportedQueryError` into a 400. Never carry forward the legacy raw-string fallback (`except Exception: query_str = raw_query`) into the new path; it masks integration bugs.
- **Build typed errors from structured diagnostic data, never by parsing `message`.** Each `Diagnostic` carries `kind`, `startchar`/`endchar`, and `field`/`raw_value`. `message` is human-readable text whose wording can change. For a range that fails on one bound, `raw_value` is the bound that actually failed.
- `diagnostic.field` is a **`FieldRef`**, not a string: use `str(diagnostic.field)` for the canonical dotted name (`created`, `notes.user`) or `diagnostic.field.name` for the field alone. Note the name is canonical, so an aliased query (`type:`) reports the field it resolves to (`document_type`), and the diagnostic span covers the offending value rather than the field name, so the text the user typed for the field is not recoverable.
- **The registry has one resolver.** `registry.make_ref(raw)` turns a raw field string into a `FieldRef` or `None` for an unknown field, and `registry.resolve(ref)` returns a `ResolvedField | None`, not a bare spec: read `.spec` for the `FieldSpec`, `.json_path` for the subpath (or `None`), `.is_subpath` and `.dotted_name` are convenience properties. There is no `resolve_json()`; a dotted name is interpreted only inside `make_ref`. Write `resolved = registry.resolve(ref)` then `resolved.spec.kind`, not `spec = registry.resolve(ref)` then `spec.kind`.
- **`notes` and `custom_fields` are JSON fields** with fixed subpaths (`notes.user`/`notes.note`, `custom_fields.name`/`custom_fields.value`); the registry stays a static, language-keyed singleton, never per-request.
- **`emit()`'s signature is `emit(node, *, index, registry)`, with no `schema` parameter.** Do not write a call site passing `schema=`. `emit()` calls the library's own `analyze()` pipeline stage internally (token analysis, multitoken resolution, zero-token drop), so paperless-ngx never needs to call `analyze()` itself.
- **A wildcard/prefix pattern on a JSON subpath reports a parse-time diagnostic**, not a silent whole-field query: `custom_fields.value:abc*` reports `DiagnosticKind.UNSUPPORTED_PATTERN` (the same kind used for a wildcard on a numeric or BOOLEAN_EXISTS field) instead of matching against the wrong encoded bytes. Relevant here because `custom_fields.value` is exactly the kind of JSON subpath a user might expect to pattern-match; the error-mapping code needs a case for `UNSUPPORTED_PATTERN`, not just `BAD_DATE`/`BAD_NUMBER`.
## Mandatory before deleting old code
- Date-grammar parity audit, line by line: every keyword, relative unit, and abbreviation `_dates.py` and `_translate.py` accept today (including the whoosh-era abbreviations kept for old saved views) must have an accepted form in whoosh-compat's dateparse grammar. Silent keyword loss is the saved-view breakage class behind issue #13568.
- Acceptance corpus compared by matched-document-ID sets, not query strings: the #13568 queries verbatim, real saved-view strings, every date keyword, field aliases, comma lists, date and numeric ranges, wildcards with bracket classes, boosts, JSON subpaths.
## Tests: what goes, what comes
Removed with their modules (do not port their string-level assertions):
- `src/documents/tests/search/test_translate.py`: its subject is deleted; string-translation unit cases are whoosh-compat's own responsibility now. Cases that encode real user-visible behavior get reincarnated as result-level acceptance cases, not string assertions.
- Date-keyword unit tests tied to `_dates.py` internals: same treatment.
- `test_query.py` cases asserting `parse_user_query` internals or intermediate query strings: rewritten against the new pipeline, asserting on matched results.
Kept: `test_migration_fulltext_query_field_prefixes.py` (data migration, orthogonal), `test_schema.py`, `test_tokenizer.py`, permission-filter and simple-search tests.
Added:
- A result-level acceptance module (paperless's analogue of whoosh-compat's `test_acceptance_e2e.py`): the corpus above against a real index built from `build_schema()`, asserting document-ID sets. Use `pytest.param(..., id="...")` for every case.
- Registry unit tests: internal `*_id` names rejected, aliases resolve to canonical fields, JSON subpaths match `docs/usage.md`, construction deterministic per language.
- One `Multitoken` case nested inside a top-level `OR` (whoosh-compat DIVERGENCES entry on Multitoken.DEFAULT) to prove it does not matter for paperless's data.
- If acceptance work surfaces a new whoosh-compat divergence, that is a whoosh-compat-repo change (its `differential-triage` skill applies), not a silent paperless workaround.
## Fast JSON field existence checks
Existence checks against a fast JSON field work correctly, both whole-field (`notes:*`, which internally requires `json_subpaths=True`) and subpath-scoped (`custom_fields.value:*`, which checks only that subpath's own fast column). Both are covered by whoosh-compat's own test suite; see `DIVERGENCES.md` entry 20 for the exists-strategy design and its subpath-scoping note.
Whether to mark `notes`/`custom_fields` fast is a paperless-ngx-side tradeoff (fast fields cost index size/build time for cheaper existence/range queries) independent of whoosh-compat's correctness — worth a maintainer decision, not assumed by this document.
## Coordination
- whoosh-compat is pre-1.0: pin an exact version or git SHA; upgrades are deliberate, reviewed changes.
- JSON subpath emission depends on the installed tantivy-py version (fallback until quickwit-oss/tantivy-py#716 ships). The whoosh-compat repo has a `carve-out-retirement` skill; coordinate tantivy pin bumps with it, in a separate PR from the parser migration.
- Rollout: no feature flag, no shadow-compare period. Safety comes from the date-grammar parity audit and the result-level acceptance corpus instead; `_translate.py`/`_dates.py` are deleted once those are green.
## Common mistakes
- Inferring the field surface from frontend code (users type queries directly).
- Copying `KNOWN_FIELDS` into the registry wholesale (resurfaces internal fields).
- Wiring stemming into `pattern_normalizer`.
- Calling `emit()` unconditionally, or porting the legacy raw-string fallback.
- Deleting `_dates.py` without the parity audit.
- Porting `test_translate.py`'s string assertions instead of writing result-level tests.
-15
View File
@@ -299,8 +299,6 @@ optional arguments:
-sm, --split-manifest -sm, --split-manifest
-z, --zip -z, --zip
-zn, --zip-name -zn, --zip-name
--zip-compression
--zip-compression-level
--data-only --data-only
--no-progress-bar --no-progress-bar
--passphrase --passphrase
@@ -363,19 +361,6 @@ If `-z` or `--zip` is provided, the export will be a zip file
in the target directory, named according to the current local date or the in the target directory, named according to the current local date or the
value set in `-zn` or `--zip-name`. value set in `-zn` or `--zip-name`.
The compression method for the zip can be set with `--zip-compression`
(`stored`, `deflated` (default), `bzip2`, `lzma`, or `zstd`) and tuned with
`--zip-compression-level` (deflated: 09, bzip2: 19, zstd: -2222; ignored
for `stored` and `lzma`). Both options require `--zip`.
!!! warning
`zstd` compression requires Python 3.14 or newer on **both** the machine
creating the export and any machine importing it. An archive compressed with
`zstd` (or `lzma`/`bzip2` where those modules are unavailable) cannot be
imported on a runtime that lacks the codec; the importer will refuse it with
a clear error. The default `deflated` is universally readable.
If `--data-only` is provided, only the database will be exported. This option is intended If `--data-only` is provided, only the database will be exported. This option is intended
to facilitate database upgrades without needing to clean documents and thumbnails from the media directory. to facilitate database upgrades without needing to clean documents and thumbnails from the media directory.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,502 @@
# whoosh-compat transition design
Date: 2026-08-07
Status: approved
Related skill: `whoosh-compat-transition`
> **API reference used by this design.** `FieldRegistry` exposes one
> resolution path: `registry.make_ref(raw) -> FieldRef | None` interprets a
> raw, possibly dotted field string (an unknown field or an unknown subpath
> both return `None`), and `registry.resolve(ref) -> ResolvedField | None`
> looks up the resolved ref. `ResolvedField` carries `.spec` (the
> `FieldSpec`), `.json_path` (the subpath, or `None`), `.is_subpath`, and
> `.dotted_name` — read `resolved.spec.kind`, not `spec.kind` off a bare
> `FieldSpec`. `Diagnostic.field` is a `FieldRef`, not a string: use
> `str(d.field)` for the canonical dotted name, or `d.field.name` for the
> field alone (the name is canonical, so an aliased query like `type:`
> reports `document_type`). Every field-carrying AST leaf holds a `FieldRef`.
> `emit()`'s signature is `emit(node, *, index, registry) -> tantivy.Query`,
> with no `schema` parameter; it calls the library's own `analyze()` pipeline
> stage internally (token analysis, multitoken resolution, zero-token drop)
> before visiting the tree, so this design's call sites never invoke
> `analyze()` themselves. `FieldSpec.subpaths` is stored internally as
> `Mapping[str, SubpathSpec]`, though construction still accepts a plain
> `tuple[str, ...]` as sugar and normalizes it automatically — this design's
> own `PublicField.subpaths: tuple[str, ...]` (below) passes a tuple into
> `FieldSpec(..., subpaths=...)` and needs nothing further.
> `DiagnosticKind` has four members: `BAD_DATE`, `BAD_NUMBER`, `TOO_DEEP`, and
> `UNSUPPORTED_PATTERN`; the error-mapping code below needs cases for all
> four.
>
> A few library behaviors worth knowing before writing code against it:
> `parse()` validates its own configuration eagerly — an empty or unknown
> `default_fields`, or a `field_boosts` key that resolves to neither a known
> field nor an alias, raises `ValueError` at the `parse()` call itself, and an
> alias in either argument resolves normally. A naive `basedate` is rejected
> (`ValueError`) rather than silently read in the host machine's local
> timezone; pass an aware datetime. A wildcard/prefix pattern on a numeric
> (`U64`) field, a `BOOLEAN_EXISTS` field, or a JSON subpath produces a
> parse-time `Diagnostic(kind=UNSUPPORTED_PATTERN)` instead of silently
> mangling to an exact-match term or matching the wrong encoded bytes — this
> is directly relevant to `custom_fields.value`: a user typing
> `custom_fields.value:abc*` gets a diagnostic, not a query that silently
> matches the wrong documents. A bare JSON field name with no subpath
> (`notes:foo`) demotes to an ordinary text search for the literal string,
> the same treatment an unknown field or unknown subpath gets. Registry
> construction validates its input eagerly: exists-target cycles, empty
> field/alias names, duplicate aliases, dotted canonical names,
> invalid-character or empty JSON subpath strings, and a subpath that would
> shadow a registered plain field are all rejected at `FieldRegistry.__init__`
> with an actionable message, not deferred to query time.
>
> Fast-field existence checks against a JSON field are correct, both for
> whole-field existence (`notes:*`) and the per-subpath case
> (`custom_fields.value:*`, which checks only that subpath's own fast
> column). Marking `notes`/`custom_fields` fast is therefore a plain
> paperless-ngx-side index-size/query-cost tradeoff, independent of
> whoosh-compat correctness — worth a maintainer decision, not something this
> document settles.
## Summary
Replace paperless-ngx's hand-maintained query-translation layer
(`src/documents/search/_translate.py`, `src/documents/search/_dates.py`)
with [whoosh-compat](https://github.com/stumpylog/whoosh-compat): a typed
Whoosh-grammar parser that emits programmatically constructed
`tantivy.Query` objects instead of building an intermediate Tantivy query
_string_. The integration point is narrow: `parse_user_query()` in
`src/documents/search/_query.py` is the only function whose implementation
changes; `_backend.py`, `_tokenizer.py`, simple/title search, CJK handling,
and permission filtering are all unaffected.
Delivered as a stack of four paperless-ngx PRs plus one prerequisite change
in whoosh-compat itself (same maintainer, no cross-repo coordination
overhead), landed with no feature flag and no shadow-compare rollout period
— safety comes from a result-level acceptance test corpus and a
date-grammar parity audit instead.
## Architecture
```
raw_query (user-typed)
wc.parse(raw_query, registry=FIELD_REGISTRY, default_fields=DEFAULT_SEARCH_FIELDS,
field_boosts=_FIELD_BOOSTS, tz=tz)
ParseResult(ast, diagnostics)
├─ diagnostics non-empty? → map ALL diagnostics to SearchQueryError
│ subclass(es) → HTTP 400 (never just the first diagnostic)
emit(ast, index=index, registry=FIELD_REGISTRY)
│ (calls whoosh-compat's own analyze() pipeline stage internally, then
│ raises UnsupportedQueryError → mapped to SearchQueryError → 400,
│ for constructs that parse but can't execute against tantivy)
tantivy.Query
existing clause assembly in parse_user_query(): Should(exact) + optional
fuzzy re-parse of raw_query + optional CJK bigram query, unchanged from today
_apply_permission_filter() in _backend.py wraps the result with
build_permission_filter() — entirely independent of whoosh-compat, unchanged
```
Permission filtering is explicitly out of scope for this migration:
`build_permission_filter()` builds its `tantivy.Query` directly against
`owner_id`/`viewer_id`/`viewer_group_id`, never through the parser or
registry, and those fields are exactly the internal `*_id` fields excluded
from the `FieldRegistry` (see "Field surface" below). Nothing in this
migration's diff touches it.
## PR stack
Each PR is independently buildable, reviewable, and CI-able; later PRs
rebase on earlier ones. No PR depends on whoosh-compat behavior it hasn't
already proven correct in isolation.
1. **Refactor `_schema.py` to a shared field-definition table.** Pure
refactor — `build_schema()`'s output is byte-identical before and after.
`test_schema.py` (existing) proves it.
2. **Pin whoosh-compat as a real dependency; build `FieldRegistry`.** New
`_registry.py` built from the same table PR 1 introduced. Registry unit
tests only — no wiring into search yet.
3. **Date-grammar parity audit.** A transitional, executable differential
test using the still-present `_dates.py`/`_translate.py` as the oracle.
Any gap found is fixed in whoosh-compat directly before this PR closes.
A whoosh-compat PyPI release is expected around this point (see
"Dependency pinning").
4. **Wire it in; delete the old path.** Rewrite `parse_user_query()`,
diagnostics→exception mapping, add the result-level acceptance corpus,
expand `test_api_search.py`, delete `_translate.py`/`_dates.py`/
`test_translate.py` and the internals-testing classes in `test_query.py`,
update `docs/usage.md` and changelog.
`Diagnostic` carries `field: FieldRef | None` and `raw_value: str | None`,
populated at its construction sites (`dateparse.py`'s `_error()`,
`default.py`'s `BAD_NUMBER` sites), so paperless can build typed exceptions
without parsing whoosh-compat's human-readable `message` text. `field` is a
`FieldRef`, not a plain string; see the API reference at the top of this
document.
## Field surface
The `FieldRegistry` covers only query-syntax-addressable fields — a subset
of the full Tantivy schema. Internal-only schema fields with no query-syntax
meaning of their own (`title_sort`/`correspondent_sort`/`type_sort` shadow
sort fields, `bigram_*` CJK fields, `simple_title`/`simple_content`,
`autocomplete_word`, `notes_text`) stay hardcoded `sb.add_*` calls in
`_schema.py`, untouched by the shared table.
**Decision: keep and document all five currently-undocumented-but-working
fields** (`asn`, `page_count`, `num_notes`, `original_filename`,
`checksum`) rather than dropping them — least risk of silently breaking an
existing saved view. `docs/usage.md`'s advanced-search section gets these
added with examples, as part of PR 4.
**Decision: `archive_checksum` stays out of scope.** Unlike `checksum`, it
isn't indexed in the Tantivy schema at all today (confirmed: `_schema.py`
only adds `checksum`; `_build_tantivy_doc` only calls
`doc.add_text("checksum", document.checksum)`). Making it searchable is a
schema-level change (new indexed field, new document population code), not
a parser-migration concern — left as a separate follow-up.
**Decision: internal `*_id` fields (`tag_id`, `correspondent_id`,
`document_type_id`, `storage_path_id`, `owner_id`, `viewer_id`,
`viewer_group_id`) are excluded from the `FieldRegistry` entirely.** They
remain Tantivy-schema-only, used exclusively by `build_permission_filter()`.
Because whoosh-compat folds any unrecognized `field:` prefix into literal
text (Whoosh-parity leniency, confirmed in `FieldsPlugin.do_fieldnames`
not an error), a saved view typed as `tag_id:5` won't 400: it silently
becomes a text search for the literal string `tag_id:5`, most likely
returning zero results. This is a real behavior change and gets a
**changelog callout**, not just a docs update, since a docs addition alone
wouldn't surface it to someone skimming release notes.
## Shared field-definition table (`_fields.py`)
```python
from whoosh_compat import FieldKind # reused directly — no parallel enum
@dataclass(frozen=True, slots=True)
class PublicField:
name: str
kind: FieldKind
aliases: tuple[str, ...] = ()
comma_values: bool = False
date_only: bool = False
fast: bool = False
subpaths: tuple[str, ...] = () # JSON kind only
PUBLIC_FIELDS = (
PublicField("title", FieldKind.TEXT),
PublicField("content", FieldKind.TEXT),
PublicField("correspondent", FieldKind.TEXT),
PublicField("document_type", FieldKind.TEXT, aliases=("type",)),
PublicField("storage_path", FieldKind.TEXT, aliases=("path",)),
PublicField("original_filename", FieldKind.TEXT),
PublicField("tag", FieldKind.TEXT, comma_values=True),
PublicField("checksum", FieldKind.KEYWORD),
PublicField("asn", FieldKind.U64, fast=True),
PublicField("page_count", FieldKind.U64, fast=True),
PublicField("num_notes", FieldKind.U64, fast=True),
PublicField("created", FieldKind.DATE, date_only=True, fast=True),
PublicField("modified", FieldKind.DATETIME, fast=True),
PublicField("added", FieldKind.DATETIME, fast=True),
PublicField("notes", FieldKind.JSON, subpaths=("user", "note")),
PublicField("custom_fields", FieldKind.JSON, subpaths=("name", "value")),
)
```
`build_schema()` derives its `sb.add_*` call and tokenizer from `kind`
(TEXT/KEYWORD → `add_text_field` with `paperless_text`/`raw` tokenizer
respectively; U64 → `add_unsigned_field`; DATE/DATETIME → `add_date_field`;
JSON → `add_json_field`). The `notes_text` snippet-companion field stays a
separate hardcoded line right after the `notes` entry — schema-only
plumbing with no query-syntax meaning.
`_registry.py` maps each `PublicField` to a `whoosh_compat.FieldSpec`,
kept as one flat dataclass (no kind-specific subclassing) to mirror
whoosh-compat's own `FieldSpec` design, which validates kind-conditional
attributes (e.g. JSON requires non-empty `subpaths`) at
`FieldRegistry.__init__` rather than in the type system.
Footnote for whoever writes `_registry.py`: `FieldRegistry.__init__` forces
`date_only=True` on _any_ `FieldKind.DATE` spec regardless of what's
passed, unconditionally — `PublicField.date_only` isn't an independent
knob for DATE fields the way it might look; it only matters in the sense
that `created` sets it explicitly for clarity, while `modified`/`added`
use `FieldKind.DATETIME` instead of relying on that override.
**`PublicField.subpaths` stays `tuple[str, ...]`, not a nested structure.**
Confirmed against whoosh-compat's own `FieldRegistry.make_ref()`: it splits a
dotted query term on the _first_ dot only and matches the remainder as an
exact string against `spec.subpaths` — even the docstring's own
`"metadata.author.name"` example is a single opaque string in the tuple,
not a recursive tree. (`FieldSpec.subpaths` itself now stores a `Mapping[str,
SubpathSpec]` internally, normalized from whatever tuple is passed at
construction; that's an implementation detail of `FieldSpec.__post_init__`,
not something `PublicField`'s own table needs to mirror — passing a plain
tuple into `FieldSpec(..., subpaths=...)` still works exactly as written
here.) A tuple of strings is exactly as expressive as the library it feeds;
inventing richer structure in `PublicField` now would just get flattened
back to strings at the registry-construction boundary. Real recursive
nesting, if ever needed, is new whoosh-compat capability first (the
per-subpath `SubpathSpec` container exists specifically to make that a
later, additive change).
**JSON document population stays separate from `subpaths`.** `subpaths` is
query-side only — it declares which dotted names are legal to type and
which JSON keys the emitter should address. It says nothing about how
`_backend.py::_build_tantivy_doc` builds the JSON documents at index-write
time, and that logic isn't uniform attribute access (`note.user.username`
needs a null guard and isn't `note.user`; `cfi.value_for_search` is a
property, not a literal `value` attribute), so a generic
`getattr(obj, subpath_name)` scheme would silently do the wrong thing for
both. That code stays hand-written, unchanged by this migration. Mitigation
instead: a coupling test (PR 2, alongside the registry unit tests) asserting
the literal JSON keys used in `_build_tantivy_doc`'s `doc.add_json(...)`
calls match `PUBLIC_FIELDS`' `notes`/`custom_fields` `subpaths` exactly, so
drift between the two is caught rather than silently becoming an
unqueryable (or silently unindexed) field.
**JSON subpath queries (`notes.*`, `custom_fields.*`) route through
`index.parse_query()`, not programmatic construction, given paperless's
pinned tantivy version.** Installed `tantivy-py`'s `Query.term_query`
cannot resolve a JSON subpath by exact field name — it raises as if the
field didn't exist. Until
[tantivy-py#716](https://github.com/quickwit-oss/tantivy-py/pull/716) lands
and ships, whoosh-compat's `TantivyEmitter._json_paths_supported()` feature-
detects this per process and falls back to a strictly escaped, single-leaf
`index.parse_query()` call for just that one leaf (whoosh-compat's README/
ARCHITECTURE.md call this out as "the JSON subpath carve-out"). Paperless
pins `tantivy~=0.26.0`, squarely inside the affected range (whoosh-compat's
`tantivy` extra only requires `tantivy>=0.24`, so nothing prevents this
combination). Nothing needs to change in this design because of it — the
carve-out is self-retiring on whoosh-compat's side once tantivy-py catches
up — but the acceptance corpus's `notes.user:`/`custom_fields.name:` cases
(PR 4) are exercising that fallback escaping path specifically, not the
programmatic path every other field goes through, and that's worth knowing
if one of those cases ever behaves oddly around quoting/escaping. A
multi-token JSON subpath value with `Multitoken.AND`/`OR` now gets correct
combinator semantics through this fallback (each token becomes its own
`index.parse_query()`-backed leaf, `Must`/`Should`-combined normally,
instead of collapsing into one space-joined phrase-shaped query); a genuine
quoted phrase on a JSON subpath still cannot carry an explicit slop through
this fallback (silently ignored, `~N` has no effect) until the carve-out
retires. Also worth knowing given `custom_fields.value` is JSON: this
fallback's `index.parse_query()` call gives a JSON subpath term free
numeric/boolean type inference tantivy's own query grammar provides (a
query like `custom_fields.value:100` matches both a stored JSON number `100`
and a stored JSON string `"100"`); the future programmatic path (once
tantivy-py#716 ships) has no equivalent union and would need this
re-evaluated for numeric/boolean custom field values specifically
(whoosh-compat's `DIVERGENCES.md` entry 22 tracks this open question).
**Analyzer wiring**: `FieldSpec.analyzer` reuses the same `tantivy
.TextAnalyzer` objects `_tokenizer.py` already builds (`_paperless_text
(language)`, etc.) — standalone objects not dependent on index
registration, so `_registry.py` calls the same builder functions and binds
`.analyze` directly; `checksum` (KEYWORD, `raw` tokenizer) gets an identity
analyzer (`lambda t: [t]`). `pattern_normalizer` for every field is
`_tokenizer.ascii_fold` (character-fold only, never stemming) per the
skill's explicit instruction. The whole `FieldRegistry` is built once,
cached keyed by `settings.SEARCH_LANGUAGE`, rebuilt on the same trigger
`register_tokenizers()` already uses.
## Error handling
```python
class SearchQueryError(ValueError): ... # unchanged, base
class InvalidDateQuery(SearchQueryError): # unchanged
def __init__(self, field, value): ...
class InvalidNumberQuery(SearchQueryError): # new
def __init__(self, field: str | None, value: str | None) -> None:
self.field = field
self.value = value
super().__init__(f"Invalid numeric value {value!r} for field {field!r}.")
class MultipleSearchQueryErrors(SearchQueryError): # new
"""Aggregates every user-fixable error from one parse, not just the first."""
def __init__(self, errors: Sequence[SearchQueryError]) -> None:
self.errors = tuple(errors)
super().__init__("; ".join(str(e) for e in self.errors))
```
```python
def parse_user_query(index, raw_query, tz):
registry = get_field_registry(settings.SEARCH_LANGUAGE)
result = wc.parse(
raw_query, registry=registry, default_fields=DEFAULT_SEARCH_FIELDS,
field_boosts=_FIELD_BOOSTS, tz=tz,
)
if result.diagnostics:
raise _diagnostics_to_error(result.diagnostics) # ALL diagnostics, not [0]
try:
exact = tantivy_emit(result.ast, index=index, registry=registry)
except UnsupportedQueryError as e:
raise SearchQueryError(str(e)) from e
# CJK: unchanged — already re-parses raw_query directly via index.parse_query,
# never went through translate_query, so nothing here changes.
cjk_query = _build_cjk_query(index, raw_query, _CJK_ALL_FIELDS) if _has_cjk(raw_query) else None
clauses = [(tantivy.Occur.Should, exact)]
threshold = settings.ADVANCED_FUZZY_SEARCH_THRESHOLD
if threshold is not None:
# Fuzzy re-parses raw_query (not the AST) — no clean AST-level fuzzy
# equivalent exists; fuzzy matching was always an approximate,
# secondary clause, so this divergence from the exact-match path is
# acceptable.
fuzzy = index.parse_query(raw_query, DEFAULT_SEARCH_FIELDS, field_boosts=_FIELD_BOOSTS,
fuzzy_fields={f: (True, 1, True) for f in DEFAULT_SEARCH_FIELDS})
clauses.append((tantivy.Occur.Should, tantivy.Query.boost_query(fuzzy, 0.1)))
if cjk_query is not None:
clauses.append((tantivy.Occur.Should, cjk_query))
return exact if len(clauses) == 1 else tantivy.Query.boolean_query(clauses)
def _diagnostics_to_error(diagnostics: tuple[Diagnostic, ...]) -> SearchQueryError:
errors = [_single_diagnostic_to_error(d) for d in diagnostics]
return errors[0] if len(errors) == 1 else MultipleSearchQueryErrors(errors)
def _single_diagnostic_to_error(d: Diagnostic) -> SearchQueryError:
# d.field is a FieldRef, not a string: str(d.field) gives the canonical
# dotted name (e.g. "created", "custom_fields.value"); an aliased query
# (type:) reports the field it resolves to (document_type).
field_name = str(d.field) if d.field is not None else None
if d.kind is DiagnosticKind.BAD_DATE:
return InvalidDateQuery(field_name, d.raw_value)
if d.kind is DiagnosticKind.BAD_NUMBER:
return InvalidNumberQuery(field_name, d.raw_value)
# TOO_DEEP (pathological paren nesting) and UNSUPPORTED_PATTERN (a
# wildcard/prefix pattern on a numeric, BOOLEAN_EXISTS, or JSON-subpath
# field) both fall through to the generic message; a typed subclass for
# either isn't warranted unless a caller needs to branch on it.
return SearchQueryError(d.message)
```
No `except Exception: query_str = raw_query` fallback — per the skill, that
legacy defensive branch is explicitly not carried forward. A bug in the new
path must surface as a real error, not silently degrade to stale behavior.
`views.py`'s existing `except SearchQueryError as e: raise
ValidationError({"query": [str(e)]}) from e` handler gets one added branch
to surface every aggregated message instead of just one:
```python
except SearchQueryError as e:
messages = [str(sub) for sub in e.errors] if isinstance(e, MultipleSearchQueryErrors) else [str(e)]
raise ValidationError({"query": messages}) from e
```
`d.field`/`d.raw_value` are populated for `BAD_DATE` and `BAD_NUMBER`
diagnostics; if either is `None` for a diagnostic kind that doesn't populate
them, `_single_diagnostic_to_error`'s fallthrough to
`SearchQueryError(d.message)` still applies.
Deferred, explicitly out of scope for this PR stack: any frontend use of
`startchar`/`endchar` (already present on `Diagnostic` today) to highlight
the offending span in the search box. Backend-only for now, per explicit
decision.
## Testing
Existing test inventory (`src/documents/tests/search/` and
`test_api_search.py`):
| File | Fate |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `test_translate.py` | Deleted (PR 4) — subject deleted |
| `test_query.py`: `TestCreatedDateField`, `TestDateTimeFields`, `TestWhooshQueryRewriting`, `TestYearRangeRewriting`, `TestNonDateFieldsNotRewritten`, `TestPassthrough`, `TestNormalizeQuery` | Deleted (PR 4) — test `_translate.py`/`_dates.py` internals or intermediate query strings |
| `test_query.py`: `TestParseUserQuery` | Reviewed at plan time; result-level assertions folded into the new acceptance module, internals-only assertions dropped |
| `test_query.py`: `TestParseSimpleTextHighlightQuery`, `TestPermissionFilter` | Unchanged — never touched `translate_query` |
| `test_schema.py`, `test_tokenizer.py`, `test_backend.py`, `test_lock_backoff.py`, `test_migration_fulltext_query_field_prefixes.py` | Unchanged |
| `test_api_search.py` (`TestDocumentSearchApi`, 43 tests) | **Stays green across every PR in the stack** (hard gate, not just PR 4) — full HTTP+DB+index integration coverage catches wiring mistakes none of the narrower tests would |
New tests per PR:
- **PR 2**: `test_registry.py` — internal `*_id` names rejected; `type`/
`path` aliases resolve to canonical fields; JSON subpaths match
`docs/usage.md`; registry construction deterministic per language; the
`notes`/`custom_fields` dict-key coupling test described above.
- **PR 3**: `test_date_grammar_parity.py` — transitional, parametrized over
every keyword/unit `_dates.py`/`_translate.py` accept today
(`_DATE_KEYWORDS`, all of `_UNIT_ALIASES`'s Whoosh-era abbreviations —
`yrs`/`mos`/`wks`/`hrs`/`mins`/`secs` etc. — digit-precision forms, ISO
dash forms, `now-7d`/`now+1h`/`now-30m` compact offsets, open/reversed
ranges). Each case parses through `wc.parse()` against a DATE-kind
`FieldRegistry` and asserts no diagnostics come back — a coverage check
only (does whoosh-compat accept this input at all), not a check on the
bounds or AST shape it parses to, which is whoosh-compat's own
differential-testing responsibility against a real whoosh oracle, not
something to re-verify here against `_translate.py` as a second, weaker
oracle. If the team wants confidence that actual search _behavior_ at a
given keyword didn't change, that belongs in the PR 4 result-level
acceptance corpus (real indexed documents at date boundaries, matched-ID
assertions), not an AST/bounds comparison.
Deleted again in PR 4 along with the legacy code it audits, superseded by
the permanent acceptance corpus. This audit is scoped to _parity_ only —
whoosh-compat's date grammar is a strict superset of what `_dates.py`
accepts today (e.g. `tomorrow`, `now`, `midnight`, `noon`, weekday names
like `next monday`), so the migration also grants new date vocabulary for
free. That's a nice side effect, not something this PR needs to test or
document beyond noting it in the changelog alongside the other behavior
changes.
- **PR 4**:
- Result-level acceptance module (paperless's analogue of whoosh-compat's
`test_acceptance_e2e.py`): a real index built via `build_schema()`, the
issue #13568 queries verbatim, real saved-view strings, every date
keyword/unit, field aliases, comma lists, numeric/date ranges,
bracket-class wildcards, boosts, JSON subpaths — asserted by matched
document-ID set, `pytest.param(..., id=...)` per case. Plus a
multi-diagnostic case (two bad fields → `MultipleSearchQueryErrors`
with both messages present) and one `Multitoken` case nested inside a
top-level `OR` (proves DIVERGENCES entry 15 doesn't matter for
paperless's data, per the skill).
- `test_api_search.py` expanded: a multi-bad-field query (e.g.
`created:notadate AND asn:notanumber`) asserting the 400 response's
`query` list contains both messages; end-to-end searches on the five
newly-documented fields (`asn:`, `page_count:`, `num_notes:`,
`original_filename:`, `checksum:`) returning the right documents
through the real index.
## Dependency pinning
Stays `path = "../whoosh-compat"` in `[tool.uv.sources]` through the whole
PR stack — both repos are being actively co-developed. The final swap
happens at PR 4:
- **Primary plan**: whoosh-compat is released to PyPI around PR 3 (per
your stated intent), assuming the parity audit doesn't turn up anything
needing a second round. PR 4 switches to a pinned PyPI version
(`whoosh-compat[tantivy]==X.Y.Z` in `dependencies`, the
`[tool.uv.sources]` override removed entirely).
- **Fallback**: if the PyPI release slips past PR 4's start, pin an exact
git commit SHA instead (`whoosh-compat[tantivy] @ git+https://github.com/
stumpylog/whoosh-compat@<sha>`), per the skill's "pre-1.0: pin an exact
version or git SHA, upgrades are deliberate" guidance.
The `TODO` comment already sitting in `pyproject.toml` (from the earlier
smoke-test setup) gets updated to reflect this — "release, else pinned SHA"
— rather than committing hard to one path before it's known which applies.
## Explicitly out of scope
- `archive_checksum` indexing/search (separate schema-level follow-up).
- Frontend consumption of `Diagnostic.startchar`/`endchar` for in-box error
highlighting (backend-only for this PR stack).
- A feature flag or shadow-compare rollout period — explicitly decided
against; safety comes from the acceptance corpus and parity audit instead.
- Any change to `build_permission_filter()`/`_apply_permission_filter()`
confirmed untouched by this migration.
+83 -132
View File
@@ -343,7 +343,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context> <context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
<context context-type="linenumber">59</context> <context context-type="linenumber">58</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/management-list/management-list.component.html</context> <context context-type="sourcefile">src/app/components/manage/document-attributes/management-list/management-list.component.html</context>
@@ -539,7 +539,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/confirm-dialog/confirm-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/confirm-dialog/confirm-dialog.component.ts</context>
<context context-type="linenumber">54</context> <context context-type="linenumber">47</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html</context>
@@ -2655,11 +2655,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context> <context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
<context context-type="linenumber">32,33</context> <context context-type="linenumber">31,32</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context> <context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
<context context-type="linenumber">50</context> <context context-type="linenumber">49</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.html</context> <context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.html</context>
@@ -2962,11 +2962,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context> <context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
<context context-type="linenumber">31,32</context> <context context-type="linenumber">30,31</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context> <context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
<context context-type="linenumber">47</context> <context context-type="linenumber">46</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/management-list/management-list.component.html</context> <context context-type="sourcefile">src/app/components/manage/document-attributes/management-list/management-list.component.html</context>
@@ -3098,15 +3098,15 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">915</context> <context context-type="linenumber">919</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">951</context> <context context-type="linenumber">955</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">974</context> <context context-type="linenumber">978</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.ts</context> <context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.ts</context>
@@ -3684,14 +3684,14 @@
<source>Confirmation</source> <source>Confirmation</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/confirm-dialog/confirm-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/confirm-dialog/confirm-dialog.component.ts</context>
<context context-type="linenumber">30</context> <context context-type="linenumber">23</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="9178182467454450952" datatype="html"> <trans-unit id="9178182467454450952" datatype="html">
<source>Confirm</source> <source>Confirm</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/confirm-dialog/confirm-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/confirm-dialog/confirm-dialog.component.ts</context>
<context context-type="linenumber">42</context> <context context-type="linenumber">35</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.html</context>
@@ -3703,27 +3703,27 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">547</context> <context context-type="linenumber">556</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">587</context> <context context-type="linenumber">596</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">625</context> <context context-type="linenumber">634</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">663</context> <context context-type="linenumber">672</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">725</context> <context context-type="linenumber">734</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">863</context> <context context-type="linenumber">867</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="994016933065248559" datatype="html"> <trans-unit id="994016933065248559" datatype="html">
@@ -5743,7 +5743,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">867</context> <context context-type="linenumber">871</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4522609911791833187" datatype="html"> <trans-unit id="4522609911791833187" datatype="html">
@@ -5965,7 +5965,7 @@
<source>Not assigned</source> <source>Not assigned</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts</context> <context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts</context>
<context context-type="linenumber">104</context> <context context-type="linenumber">100</context>
</context-group> </context-group>
<note priority="1" from="description">Filter drop down element to filter for documents with no correspondent/type/tag assigned</note> <note priority="1" from="description">Filter drop down element to filter for documents with no correspondent/type/tag assigned</note>
</trans-unit> </trans-unit>
@@ -5973,7 +5973,7 @@
<source>Open <x id="PH" equiv-text="this.title"/> filter</source> <source>Open <x id="PH" equiv-text="this.title"/> filter</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts</context> <context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts</context>
<context context-type="linenumber">835</context> <context context-type="linenumber">828</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7005745151564974365" datatype="html"> <trans-unit id="7005745151564974365" datatype="html">
@@ -6382,6 +6382,27 @@
<context context-type="linenumber">94</context> <context context-type="linenumber">94</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5947558132119506443" datatype="html">
<source>My documents</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.html</context>
<context context-type="linenumber">25,26</context>
</context-group>
</trans-unit>
<trans-unit id="231920238966427751" datatype="html">
<source>Shared with me</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.html</context>
<context context-type="linenumber">35,36</context>
</context-group>
</trans-unit>
<trans-unit id="175385209536581523" datatype="html">
<source>Shared by me</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.html</context>
<context context-type="linenumber">45,46</context>
</context-group>
</trans-unit>
<trans-unit id="5151074932731293042" datatype="html"> <trans-unit id="5151074932731293042" datatype="html">
<source>Unowned</source> <source>Unowned</source>
<context-group purpose="location"> <context-group purpose="location">
@@ -6396,76 +6417,6 @@
<context context-type="linenumber">85</context> <context context-type="linenumber">85</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5947558132119506443" datatype="html">
<source>My documents</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.ts</context>
<context context-type="linenumber">101</context>
</context-group>
</trans-unit>
<trans-unit id="1930869169119109336" datatype="html">
<source>Owned by <x id="PH" equiv-text="username"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.ts</context>
<context context-type="linenumber">106</context>
</context-group>
</trans-unit>
<trans-unit id="5339682692608120628" datatype="html">
<source>Owned by another user</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.ts</context>
<context context-type="linenumber">107</context>
</context-group>
</trans-unit>
<trans-unit id="231920238966427751" datatype="html">
<source>Shared with me</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.ts</context>
<context context-type="linenumber">117</context>
</context-group>
</trans-unit>
<trans-unit id="1894556100995563325" datatype="html">
<source>Not owned by <x id="PH" equiv-text="usernames.join(&apos;, &apos;)"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.ts</context>
<context context-type="linenumber">124</context>
</context-group>
</trans-unit>
<trans-unit id="4647949080250052038" datatype="html">
<source>Not owned by another user</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.ts</context>
<context context-type="linenumber">127</context>
</context-group>
</trans-unit>
<trans-unit id="8858352775080403297" datatype="html">
<source>Not owned by selected users</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.ts</context>
<context context-type="linenumber">128</context>
</context-group>
</trans-unit>
<trans-unit id="175385209536581523" datatype="html">
<source>Shared by me</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.ts</context>
<context context-type="linenumber">136</context>
</context-group>
</trans-unit>
<trans-unit id="5140574576358170412" datatype="html">
<source>Shared by <x id="PH" equiv-text="username"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.ts</context>
<context context-type="linenumber">141</context>
</context-group>
</trans-unit>
<trans-unit id="391557549689505150" datatype="html">
<source>Shared by another user</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.ts</context>
<context context-type="linenumber">142</context>
</context-group>
</trans-unit>
<trans-unit id="941924371433275463" datatype="html"> <trans-unit id="941924371433275463" datatype="html">
<source>Global permissions define what areas of the app and API endpoints users can access.</source> <source>Global permissions define what areas of the app and API endpoints users can access.</source>
<context-group purpose="location"> <context-group purpose="location">
@@ -7662,7 +7613,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">492</context> <context context-type="linenumber">501</context>
</context-group> </context-group>
<note priority="1" from="description">this string is used to separate processing, failed and added on the file upload widget</note> <note priority="1" from="description">this string is used to separate processing, failed and added on the file upload widget</note>
</trans-unit> </trans-unit>
@@ -8197,7 +8148,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">865</context> <context context-type="linenumber">869</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7295637485862454066" datatype="html"> <trans-unit id="7295637485862454066" datatype="html">
@@ -8215,7 +8166,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">911</context> <context context-type="linenumber">915</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2951161989614003846" datatype="html"> <trans-unit id="2951161989614003846" datatype="html">
@@ -8572,18 +8523,18 @@
<source>&quot;<x id="PH" equiv-text="items[0].name"/>&quot;</source> <source>&quot;<x id="PH" equiv-text="items[0].name"/>&quot;</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">484</context> <context context-type="linenumber">493</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">490</context> <context context-type="linenumber">499</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8639884465898458690" datatype="html"> <trans-unit id="8639884465898458690" datatype="html">
<source>&quot;<x id="PH" equiv-text="items[0].name"/>&quot; and &quot;<x id="PH_1" equiv-text="items[1].name"/>&quot;</source> <source>&quot;<x id="PH" equiv-text="items[0].name"/>&quot; and &quot;<x id="PH_1" equiv-text="items[1].name"/>&quot;</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">486</context> <context context-type="linenumber">495</context>
</context-group> </context-group>
<note priority="1" from="description">This is for messages like &apos;modify &quot;tag1&quot; and &quot;tag2&quot;&apos;</note> <note priority="1" from="description">This is for messages like &apos;modify &quot;tag1&quot; and &quot;tag2&quot;&apos;</note>
</trans-unit> </trans-unit>
@@ -8591,7 +8542,7 @@
<source><x id="PH" equiv-text="list"/> and &quot;<x id="PH_1" equiv-text="items[items.length - 1].name"/>&quot;</source> <source><x id="PH" equiv-text="list"/> and &quot;<x id="PH_1" equiv-text="items[items.length - 1].name"/>&quot;</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">494,496</context> <context context-type="linenumber">503,505</context>
</context-group> </context-group>
<note priority="1" from="description">this is for messages like &apos;modify &quot;tag1&quot;, &quot;tag2&quot; and &quot;tag3&quot;&apos;</note> <note priority="1" from="description">this is for messages like &apos;modify &quot;tag1&quot;, &quot;tag2&quot; and &quot;tag3&quot;&apos;</note>
</trans-unit> </trans-unit>
@@ -8599,14 +8550,14 @@
<source>Confirm tags assignment</source> <source>Confirm tags assignment</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">511</context> <context context-type="linenumber">520</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6619516195038467207" datatype="html"> <trans-unit id="6619516195038467207" datatype="html">
<source>This operation will add the tag &quot;<x id="PH" equiv-text="tag.name"/>&quot; to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source> <source>This operation will add the tag &quot;<x id="PH" equiv-text="tag.name"/>&quot; to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">517</context> <context context-type="linenumber">526</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1894412783609570695" datatype="html"> <trans-unit id="1894412783609570695" datatype="html">
@@ -8615,14 +8566,14 @@
)"/> to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source> )"/> to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">522,524</context> <context context-type="linenumber">531,533</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7181166515756808573" datatype="html"> <trans-unit id="7181166515756808573" datatype="html">
<source>This operation will remove the tag &quot;<x id="PH" equiv-text="tag.name"/>&quot; from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source> <source>This operation will remove the tag &quot;<x id="PH" equiv-text="tag.name"/>&quot; from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">530</context> <context context-type="linenumber">539</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3819792277998068944" datatype="html"> <trans-unit id="3819792277998068944" datatype="html">
@@ -8631,7 +8582,7 @@
)"/> from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source> )"/> from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">535,537</context> <context context-type="linenumber">544,546</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2739066218579571288" datatype="html"> <trans-unit id="2739066218579571288" datatype="html">
@@ -8642,84 +8593,84 @@
)"/> on <x id="PH_2" equiv-text="this.getSelectionSize()"/> selected document(s).</source> )"/> on <x id="PH_2" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">539,543</context> <context context-type="linenumber">548,552</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2996713129519325161" datatype="html"> <trans-unit id="2996713129519325161" datatype="html">
<source>Confirm correspondent assignment</source> <source>Confirm correspondent assignment</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">580</context> <context context-type="linenumber">589</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6900893559485781849" datatype="html"> <trans-unit id="6900893559485781849" datatype="html">
<source>This operation will assign the correspondent &quot;<x id="PH" equiv-text="correspondent.name"/>&quot; to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source> <source>This operation will assign the correspondent &quot;<x id="PH" equiv-text="correspondent.name"/>&quot; to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">582</context> <context context-type="linenumber">591</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1257522660364398440" datatype="html"> <trans-unit id="1257522660364398440" datatype="html">
<source>This operation will remove the correspondent from <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source> <source>This operation will remove the correspondent from <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">584</context> <context context-type="linenumber">593</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5393409374423140648" datatype="html"> <trans-unit id="5393409374423140648" datatype="html">
<source>Confirm document type assignment</source> <source>Confirm document type assignment</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">618</context> <context context-type="linenumber">627</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="332180123895325027" datatype="html"> <trans-unit id="332180123895325027" datatype="html">
<source>This operation will assign the document type &quot;<x id="PH" equiv-text="documentType.name"/>&quot; to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source> <source>This operation will assign the document type &quot;<x id="PH" equiv-text="documentType.name"/>&quot; to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">620</context> <context context-type="linenumber">629</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2236642492594872779" datatype="html"> <trans-unit id="2236642492594872779" datatype="html">
<source>This operation will remove the document type from <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source> <source>This operation will remove the document type from <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">622</context> <context context-type="linenumber">631</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6386555513013840736" datatype="html"> <trans-unit id="6386555513013840736" datatype="html">
<source>Confirm storage path assignment</source> <source>Confirm storage path assignment</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">656</context> <context context-type="linenumber">665</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8750527458618415924" datatype="html"> <trans-unit id="8750527458618415924" datatype="html">
<source>This operation will assign the storage path &quot;<x id="PH" equiv-text="storagePath.name"/>&quot; to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source> <source>This operation will assign the storage path &quot;<x id="PH" equiv-text="storagePath.name"/>&quot; to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">658</context> <context context-type="linenumber">667</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="60728365335056946" datatype="html"> <trans-unit id="60728365335056946" datatype="html">
<source>This operation will remove the storage path from <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source> <source>This operation will remove the storage path from <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">660</context> <context context-type="linenumber">669</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4187352575310415704" datatype="html"> <trans-unit id="4187352575310415704" datatype="html">
<source>Confirm custom field assignment</source> <source>Confirm custom field assignment</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">689</context> <context context-type="linenumber">698</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7966494636326273856" datatype="html"> <trans-unit id="7966494636326273856" datatype="html">
<source>This operation will assign the custom field &quot;<x id="PH" equiv-text="customField.name"/>&quot; to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source> <source>This operation will assign the custom field &quot;<x id="PH" equiv-text="customField.name"/>&quot; to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">695</context> <context context-type="linenumber">704</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5789455969634598553" datatype="html"> <trans-unit id="5789455969634598553" datatype="html">
@@ -8728,14 +8679,14 @@
)"/> to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source> )"/> to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">700,702</context> <context context-type="linenumber">709,711</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5648572354333199245" datatype="html"> <trans-unit id="5648572354333199245" datatype="html">
<source>This operation will remove the custom field &quot;<x id="PH" equiv-text="customField.name"/>&quot; from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source> <source>This operation will remove the custom field &quot;<x id="PH" equiv-text="customField.name"/>&quot; from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">708</context> <context context-type="linenumber">717</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6666899594015948817" datatype="html"> <trans-unit id="6666899594015948817" datatype="html">
@@ -8744,7 +8695,7 @@
)"/> from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source> )"/> from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">713,715</context> <context context-type="linenumber">722,724</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8050047262594964176" datatype="html"> <trans-unit id="8050047262594964176" datatype="html">
@@ -8755,91 +8706,91 @@
)"/> on <x id="PH_2" equiv-text="this.getSelectionSize()"/> selected document(s).</source> )"/> on <x id="PH_2" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">717,721</context> <context context-type="linenumber">726,730</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8615059324209654051" datatype="html"> <trans-unit id="8615059324209654051" datatype="html">
<source>Move <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s) to the trash?</source> <source>Move <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s) to the trash?</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">864</context> <context context-type="linenumber">868</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8585195717323764335" datatype="html"> <trans-unit id="8585195717323764335" datatype="html">
<source>This operation will permanently recreate the archive files for <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source> <source>This operation will permanently recreate the archive files for <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">912</context> <context context-type="linenumber">916</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7366623494074776040" datatype="html"> <trans-unit id="7366623494074776040" datatype="html">
<source>The archive files will be re-generated with the current settings.</source> <source>The archive files will be re-generated with the current settings.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">913</context> <context context-type="linenumber">917</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6555329262222566158" datatype="html"> <trans-unit id="6555329262222566158" datatype="html">
<source>Rotate confirm</source> <source>Rotate confirm</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">948</context> <context context-type="linenumber">952</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5203024009814367559" datatype="html"> <trans-unit id="5203024009814367559" datatype="html">
<source>This operation will add rotated versions of the <x id="PH" equiv-text="this.getSelectionSize()"/> document(s).</source> <source>This operation will add rotated versions of the <x id="PH" equiv-text="this.getSelectionSize()"/> document(s).</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">949</context> <context context-type="linenumber">953</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7910756456450124185" datatype="html"> <trans-unit id="7910756456450124185" datatype="html">
<source>Merge confirm</source> <source>Merge confirm</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">972</context> <context context-type="linenumber">976</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7643543647233874431" datatype="html"> <trans-unit id="7643543647233874431" datatype="html">
<source>This operation will merge <x id="PH" equiv-text="this.getSelectionSize()"/> selected documents into a new document.</source> <source>This operation will merge <x id="PH" equiv-text="this.getSelectionSize()"/> selected documents into a new document.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">973</context> <context context-type="linenumber">977</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7869008840945899895" datatype="html"> <trans-unit id="7869008840945899895" datatype="html">
<source>Merged document will be queued for consumption.</source> <source>Merged document will be queued for consumption.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">996</context> <context context-type="linenumber">1000</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="476913782630693351" datatype="html"> <trans-unit id="476913782630693351" datatype="html">
<source>Custom fields updated.</source> <source>Custom fields updated.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">1021</context> <context context-type="linenumber">1025</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3873496751167944011" datatype="html"> <trans-unit id="3873496751167944011" datatype="html">
<source>Error updating custom fields.</source> <source>Error updating custom fields.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">1030</context> <context context-type="linenumber">1034</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6144801143088984138" datatype="html"> <trans-unit id="6144801143088984138" datatype="html">
<source>Share link bundle creation requested.</source> <source>Share link bundle creation requested.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">1078</context> <context context-type="linenumber">1082</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="46019676931295023" datatype="html"> <trans-unit id="46019676931295023" datatype="html">
<source>Share link bundle creation is not available yet.</source> <source>Share link bundle creation is not available yet.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">1085</context> <context context-type="linenumber">1089</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6307402210351946694" datatype="html"> <trans-unit id="6307402210351946694" datatype="html">
@@ -9677,7 +9628,7 @@
<source>Filter Documents (<x id="INTERPOLATION" equiv-text="{{ field.document_count }}"/>)</source> <source>Filter Documents (<x id="INTERPOLATION" equiv-text="{{ field.document_count }}"/>)</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context> <context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
<context context-type="linenumber">39,40</context> <context context-type="linenumber">38,39</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/management-list/management-list.component.html</context> <context context-type="sourcefile">src/app/components/manage/document-attributes/management-list/management-list.component.html</context>
@@ -9700,7 +9651,7 @@
<source>No fields defined.</source> <source>No fields defined.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context> <context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
<context context-type="linenumber">70,72</context> <context context-type="linenumber">68,70</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3032792139967609806" datatype="html"> <trans-unit id="3032792139967609806" datatype="html">
@@ -576,7 +576,7 @@ describe('TasksComponent', () => {
expect(dismissSpy).toHaveBeenCalledWith(new Set([tasks[0].id, tasks[1].id])) expect(dismissSpy).toHaveBeenCalledWith(new Set([tasks[0].id, tasks[1].id]))
expect(toastSpy).toHaveBeenCalledWith('Error dismissing tasks', error) expect(toastSpy).toHaveBeenCalledWith('Error dismissing tasks', error)
expect(modal.componentInstance.buttonsEnabled()).toBe(true) expect(modal.componentInstance.buttonsEnabled).toBe(true)
expect(component.selectedTasks.size).toBe(0) expect(component.selectedTasks.size).toBe(0)
}) })
@@ -642,7 +642,7 @@ describe('TasksComponent', () => {
expect(dismissSpy).toHaveBeenCalled() expect(dismissSpy).toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalledWith('Error dismissing tasks', error) expect(toastSpy).toHaveBeenCalledWith('Error dismissing tasks', error)
expect(modal.componentInstance.buttonsEnabled()).toBe(true) expect(modal.componentInstance.buttonsEnabled).toBe(true)
}) })
it('should dismiss the currently visible scoped and filtered tasks', () => { it('should dismiss the currently visible scoped and filtered tasks', () => {
@@ -316,7 +316,7 @@ export class TasksComponent
modal.componentInstance.btnClass = 'btn-warning' modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Dismiss` modal.componentInstance.btnCaption = $localize`Dismiss`
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => { modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled = false
modal.close() modal.close()
this.tasksService.dismissTasks(tasks).subscribe({ this.tasksService.dismissTasks(tasks).subscribe({
next: () => { next: () => {
@@ -324,7 +324,7 @@ export class TasksComponent
}, },
error: (e) => { error: (e) => {
this.toastService.showError($localize`Error dismissing tasks`, e) this.toastService.showError($localize`Error dismissing tasks`, e)
modal.componentInstance.buttonsEnabled.set(true) modal.componentInstance.buttonsEnabled = true
}, },
}) })
this.clearSelection() this.clearSelection()
@@ -350,7 +350,7 @@ export class TasksComponent
modal.componentInstance.btnClass = 'btn-warning' modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Dismiss` modal.componentInstance.btnCaption = $localize`Dismiss`
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => { modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled = false
modal.close() modal.close()
this.tasksService.dismissAllTasks().subscribe({ this.tasksService.dismissAllTasks().subscribe({
next: () => { next: () => {
@@ -358,7 +358,7 @@ export class TasksComponent
}, },
error: (e) => { error: (e) => {
this.toastService.showError($localize`Error dismissing tasks`, e) this.toastService.showError($localize`Error dismissing tasks`, e)
modal.componentInstance.buttonsEnabled.set(true) modal.componentInstance.buttonsEnabled = true
}, },
}) })
this.clearSelection() this.clearSelection()
@@ -2,8 +2,3 @@
.d-block.d-sm-none .dropdown-toggle::after { .d-block.d-sm-none .dropdown-toggle::after {
display: none; display: none;
} }
tbody tr:last-child td,
table:not(:has(tbody tr)) thead th {
border-bottom: none;
}
@@ -82,7 +82,7 @@ export class TrashComponent
modal.componentInstance.confirmClicked modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => { .subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled = false
this.trashService.emptyTrash([document.id]).subscribe({ this.trashService.emptyTrash([document.id]).subscribe({
next: () => { next: () => {
this.toastService.showInfo( this.toastService.showInfo(
@@ -146,7 +146,7 @@ export class UsersAndGroupsComponent
modal.componentInstance.btnClass = 'btn-danger' modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed` modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => { modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled = false
this.usersService.delete(user).subscribe({ this.usersService.delete(user).subscribe({
next: () => { next: () => {
modal.close() modal.close()
@@ -199,7 +199,7 @@ export class UsersAndGroupsComponent
modal.componentInstance.btnClass = 'btn-danger' modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed` modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => { modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled = false
this.groupsService.delete(group).subscribe({ this.groupsService.delete(group).subscribe({
next: () => { next: () => {
modal.close() modal.close()
@@ -19,11 +19,6 @@
height: 0.8em; height: 0.8em;
} }
.sidebar-heading,
.text-uppercase {
letter-spacing: 0.06em;
}
.view-name { .view-name {
max-width: calc(100% - 50px) max-width: calc(100% - 50px)
} }
@@ -52,12 +47,11 @@
.search-container { .search-container {
max-height: 4.5rem; max-height: 4.5rem;
overflow: visible; overflow: hidden;
transition: max-height .2s ease, opacity .2s ease, padding-top .2s ease, padding-bottom .2s ease; transition: max-height .2s ease, opacity .2s ease, padding-top .2s ease, padding-bottom .2s ease;
&.mobile-hidden { &.mobile-hidden {
max-height: 0; max-height: 0;
overflow: hidden;
opacity: 0; opacity: 0;
padding-top: 0 !important; padding-top: 0 !important;
padding-bottom: 0 !important; padding-bottom: 0 !important;
@@ -86,7 +80,7 @@ main {
.sidebar li.nav-item span, .sidebar li.nav-item span,
.sidebar .sidebar-heading span { .sidebar .sidebar-heading span {
transition: opacity .1s ease; transition: all .1s ease;
} }
@media(min-width: 768px) { @media(min-width: 768px) {
@@ -154,7 +148,7 @@ main {
z-index: 996; z-index: 996;
--bs-btn-padding-x: 0.35rem; --bs-btn-padding-x: 0.35rem;
--bs-btn-padding-y: 0.125rem; --bs-btn-padding-y: 0.125rem;
transition: left .2s ease; transition: all .2s ease;
} }
.sidebar.slim .sidebar-slim-toggler { .sidebar.slim .sidebar-slim-toggler {
@@ -186,8 +180,6 @@ main {
.sidebar .nav-link { .sidebar .nav-link {
font-weight: 500; font-weight: 500;
white-space: nowrap; white-space: nowrap;
border-left: 2px solid transparent;
transition: color .15s ease-in-out;
&:hover, &.active, &:focus { &:hover, &.active, &:focus {
color: var(--bs-primary); color: var(--bs-primary);
@@ -200,7 +192,6 @@ main {
&.active { &.active {
font-weight: bold; font-weight: bold;
border-left-color: var(--bs-primary);
} }
i-bs { i-bs {
@@ -209,17 +200,6 @@ main {
} }
} }
// sub-page gets marker only
.nav-item:has(.attributes-submenu.show .nav-link.active) > .attributes-row > .nav-link.active {
border-left-color: transparent;
}
// bring sub-menu markers back out to L edge
.attributes-submenu .nav-link {
margin-left: -0.5rem;
padding-left: calc(var(--bs-nav-link-padding-x) + 0.5rem);
}
.attributes-row .attributes-expand-btn { .attributes-row .attributes-expand-btn {
opacity: 0.2; opacity: 0.2;
transition: opacity 0.15s ease-in-out; transition: opacity 0.15s ease-in-out;
@@ -12,10 +12,10 @@
} }
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()"> <button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span> <span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button> </button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled()"> <button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
<span> <span>
{{btnCaption}} {{btnCaption}}
<span class="visually-hidden">{{ seconds | number: '1.0-0' }} seconds</span> <span class="visually-hidden">{{ seconds | number: '1.0-0' }} seconds</span>
@@ -25,7 +25,7 @@
} }
</button> </button>
@if (alternativeBtnCaption) { @if (alternativeBtnCaption) {
<button type="button" class="btn" [class]="alternativeBtnClass" (click)="alternative()" [disabled]="!alternativeButtonEnabled || !buttonsEnabled()"> <button type="button" class="btn" [class]="alternativeBtnClass" (click)="alternative()" [disabled]="!alternativeButtonEnabled || !buttonsEnabled">
{{alternativeBtnCaption}} {{alternativeBtnCaption}}
</button> </button>
} }
@@ -64,22 +64,6 @@ describe('ConfirmDialogComponent', () => {
expect(confirmSubjectResult).toBeTruthy() expect(confirmSubjectResult).toBeTruthy()
}) })
it('should re-render the buttons when they are toggled from outside', async () => {
const confirmButton: HTMLButtonElement =
fixture.nativeElement.querySelectorAll('.modal-footer button')[1]
expect(confirmButton.disabled).toBeFalsy()
// Deliberately no detectChanges: a request callback toggling this is all
// that happens, and nothing else schedules a render for the modal
component.buttonsEnabled.set(false)
await fixture.whenStable()
expect(confirmButton.disabled).toBeTruthy()
component.buttonsEnabled.set(true)
await fixture.whenStable()
expect(confirmButton.disabled).toBeFalsy()
})
it('should support cancel & close modal', () => { it('should support cancel & close modal', () => {
let confirmSubjectResult let confirmSubjectResult
const closeModalSpy = jest.spyOn(modal, 'close') const closeModalSpy = jest.spyOn(modal, 'close')
@@ -1,12 +1,5 @@
import { DecimalPipe } from '@angular/common' import { DecimalPipe } from '@angular/common'
import { import { Component, EventEmitter, Input, Output, inject } from '@angular/core'
Component,
EventEmitter,
Input,
Output,
inject,
signal,
} from '@angular/core'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { Subject } from 'rxjs' import { Subject } from 'rxjs'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
@@ -53,7 +46,8 @@ export class ConfirmDialogComponent extends LoadingComponentWithPermissions {
@Input() @Input()
cancelBtnCaption = $localize`Cancel` cancelBtnCaption = $localize`Cancel`
readonly buttonsEnabled = signal(true) @Input()
buttonsEnabled = true
confirmButtonEnabled = true confirmButtonEnabled = true
alternativeButtonEnabled = true alternativeButtonEnabled = true
@@ -56,10 +56,10 @@
} }
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()"> <button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span> <span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button> </button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled()"> <button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
{{btnCaption}} {{btnCaption}}
</button> </button>
</div> </div>
@@ -57,7 +57,7 @@
class="btn" class="btn"
[class]="cancelBtnClass" [class]="cancelBtnClass"
(click)="cancel()" (click)="cancel()"
[disabled]="!buttonsEnabled()" [disabled]="!buttonsEnabled"
> >
<span class="d-inline-block" style="padding-bottom: 1px;"> <span class="d-inline-block" style="padding-bottom: 1px;">
{{cancelBtnCaption}} {{cancelBtnCaption}}
@@ -68,7 +68,7 @@
class="btn" class="btn"
[class]="btnClass" [class]="btnClass"
(click)="confirm()" (click)="confirm()"
[disabled]="!confirmButtonEnabled || !buttonsEnabled()" [disabled]="!confirmButtonEnabled || !buttonsEnabled"
> >
{{btnCaption}} {{btnCaption}}
</button> </button>
@@ -34,10 +34,10 @@
<p class="mb-0 small"><b>{{messageBold}}</b></p> <p class="mb-0 small"><b>{{messageBold}}</b></p>
} }
</div> </div>
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()"> <button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span> <span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button> </button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled() || degrees === 0"> <button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled || degrees === 0">
{{btnCaption}} {{btnCaption}}
@if (!confirmButtonEnabled) { @if (!confirmButtonEnabled) {
<ngb-progressbar style="height: 1px;" type="dark" [max]="secondsTotal" [value]="seconds"></ngb-progressbar> <ngb-progressbar style="height: 1px;" type="dark" [max]="secondsTotal" [value]="seconds"></ngb-progressbar>
@@ -1,4 +1,4 @@
<div class="btn-group w-100" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown" (keydown)="listKeyDown($event)" [popperOptions]="popperOptions" [autoClose]="!creating()"> <div class="btn-group w-100" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown" (keydown)="listKeyDown($event)" [popperOptions]="popperOptions">
<button class="btn btn-sm" id="dropdown_{{name}}" ngbDropdownToggle [ngClass]="!editing && selectionModel.selectionSize() > 0 ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled"> <button class="btn btn-sm" id="dropdown_{{name}}" ngbDropdownToggle [ngClass]="!editing && selectionModel.selectionSize() > 0 ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
<i-bs name="{{icon}}"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div> <i-bs name="{{icon}}"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
@if (!editing && selectionModel.totalCount > 0) { @if (!editing && selectionModel.totalCount > 0) {
@@ -49,7 +49,7 @@
</cdk-virtual-scroll-viewport> </cdk-virtual-scroll-viewport>
} }
@if (editing) { @if (editing) {
@if (filteredItems.length === 0 && createRef !== undefined && filterText?.length > 0) { @if (filteredItems.length === 0 && createRef !== undefined) {
<button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled"> <button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled">
<small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small> <small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small>
<i-bs width="1.5em" height="1em" name="plus"></i-bs> <i-bs width="1.5em" height="1em" name="plus"></i-bs>
@@ -62,7 +62,7 @@
</button> </button>
} }
} }
@if (extraButtonTitle && (showExtraButtonIfEmpty || filteredItems?.length > 0)) { @if (extraButtonTitle) {
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="extraButtonClicked($event)" [disabled]="disabled"> <button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="extraButtonClicked($event)" [disabled]="disabled">
<small class="ms-2 fw-bold">{{extraButtonTitle}}</small> <small class="ms-2 fw-bold">{{extraButtonTitle}}</small>
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs> <i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
@@ -3,7 +3,6 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing' import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { NEVER, Subject } from 'rxjs'
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type' import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
import { import {
DEFAULT_MATCHING_ALGORITHM, DEFAULT_MATCHING_ALGORITHM,
@@ -49,7 +48,6 @@ const negativeNullItem = {
let selectionModel: FilterableDropdownSelectionModel let selectionModel: FilterableDropdownSelectionModel
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
const createModalRef = () => ({ closed: NEVER, dismissed: NEVER }) as any
describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => { describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => {
let component: FilterableDropdownComponent let component: FilterableDropdownComponent
@@ -870,7 +868,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0) expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0)
}) })
it('should keep the dropdown open while the create modal is active', async () => { it('should set support create, keep open model and call createRef method', async () => {
component.selectionModel.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
component.selectionModel = selectionModel component.selectionModel = selectionModel
@@ -884,44 +882,20 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
fixture.detectChanges() fixture.detectChanges()
component.filterText = 'Test Filter Text' component.filterText = 'Test Filter Text'
const modalClosed = new Subject<void>() component.createRef = jest.fn()
component.createRef = jest.fn(
() =>
({
closed: modalClosed,
dismissed: NEVER,
}) as any
)
component.createClicked() component.createClicked()
expect(component.creating()).toBeTruthy() expect(component.creating).toBeTruthy()
expect(component.createRef).toHaveBeenCalledWith('Test Filter Text') expect(component.createRef).toHaveBeenCalledWith('Test Filter Text')
fixture.detectChanges()
expect(component.dropdown.autoClose).toBeFalsy()
document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
document.body.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
await wait(10)
expect(component.dropdown.isOpen()).toBeTruthy()
// Also cover a close that was already scheduled before autoClose changed.
const openSpy = jest.spyOn(component.dropdown, 'open') const openSpy = jest.spyOn(component.dropdown, 'open')
component.dropdownOpenChange(false) component.dropdownOpenChange(false)
expect(openSpy).toHaveBeenCalled() // should keep open expect(openSpy).toHaveBeenCalled() // should keep open
component.dropdownOpenChange(false)
expect(openSpy).toHaveBeenCalledTimes(2) // modal interactions keep it open
modalClosed.next()
fixture.detectChanges()
expect(component.creating()).toBeFalsy()
expect(component.dropdown.autoClose).toBeTruthy()
expect(component.dropdown.isOpen()).toBeTruthy()
}) })
it('should call create on enter inside filter field if 0 items remain while editing', async () => { it('should call create on enter inside filter field if 0 items remain while editing', async () => {
component.selectionModel.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
component.editing = true component.editing = true
component.createRef = jest.fn(createModalRef) component.createRef = jest.fn()
const createSpy = jest.spyOn(component, 'createClicked') const createSpy = jest.spyOn(component, 'createClicked')
expect(component.selectionModel.getSelectedItems()).toEqual([]) expect(component.selectionModel.getSelectedItems()).toEqual([])
fixture.nativeElement fixture.nativeElement
@@ -937,25 +911,6 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(createSpy).toHaveBeenCalled() expect(createSpy).toHaveBeenCalled()
}) })
it('should only show create when a non-empty filter has no matches', () => {
component.selectionModel.items = []
component.icon = 'tag-fill'
component.editing = true
component.createRef = jest.fn(createModalRef)
fixture.detectChanges()
expect(fixture.nativeElement.textContent).not.toContain('Create')
component.listFilterEnter()
expect(component.createRef).not.toHaveBeenCalled()
const filterInput: HTMLInputElement =
fixture.nativeElement.querySelector('input[type="text"]')
filterInput.value = 'FooBar'
filterInput.dispatchEvent(new Event('input'))
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Create "FooBar"')
})
it('should exclude item and trigger change event', () => { it('should exclude item and trigger change event', () => {
const id = 1 const id = 1
const state = ToggleableItemState.Selected const state = ToggleableItemState.Selected
@@ -1015,18 +970,4 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(extraButtonClicked).toBeTruthy() expect(extraButtonClicked).toBeTruthy()
expect(applied).toBeFalsy() expect(applied).toBeFalsy()
}) })
it('should only show the extra button for an empty result when enabled', () => {
component.selectionModel.items = items
component.icon = 'tag-fill'
component.extraButtonTitle = 'Extra'
component.filterText = 'FooBar'
fixture.detectChanges()
expect(fixture.nativeElement.textContent).not.toContain('Extra')
fixture.componentRef.setInput('showExtraButtonIfEmpty', true)
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Extra')
})
}) })
@@ -15,13 +15,9 @@ import {
signal, signal,
} from '@angular/core' } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
NgbDropdown,
NgbDropdownModule,
NgbModalRef,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subject, filter, first, merge, takeUntil } from 'rxjs' import { Subject, filter, takeUntil } from 'rxjs'
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type' import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
import { MatchingModel } from 'src/app/data/matching-model' import { MatchingModel } from 'src/app/data/matching-model'
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions' import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
@@ -763,7 +759,7 @@ export class FilterableDropdownComponent
disabled = false disabled = false
@Input() @Input()
createRef: (name: string) => NgbModalRef createRef: (name) => void
@Input() @Input()
set documentCounts(counts: SelectionDataItem[]) { set documentCounts(counts: SelectionDataItem[]) {
@@ -778,10 +774,7 @@ export class FilterableDropdownComponent
@Input() @Input()
extraButtonTitle: string extraButtonTitle: string
@Input() creating: boolean = false
showExtraButtonIfEmpty: boolean = false
readonly creating = signal(false)
@Output() @Output()
apply = new EventEmitter<ChangedItems>() apply = new EventEmitter<ChangedItems>()
@@ -858,18 +851,12 @@ export class FilterableDropdownComponent
} }
createClicked() { createClicked() {
this.creating.set(true) this.creating = true
const modal = this.createRef(this.filterText) this.createRef(this.filterText)
merge(modal.closed, modal.dismissed)
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe(() => this.creating.set(false))
} }
dropdownOpenChange(open: boolean): void { dropdownOpenChange(open: boolean): void {
if (open) { if (open) {
// Dont let a create modal close this
if (this.creating()) return
setTimeout(() => { setTimeout(() => {
this.listFilterTextInput?.nativeElement.focus() this.listFilterTextInput?.nativeElement.focus()
this.buttonsViewport?.checkViewportSize() this.buttonsViewport?.checkViewportSize()
@@ -882,8 +869,9 @@ export class FilterableDropdownComponent
this.editing && !this.selectionModel.manyToOne this.editing && !this.selectionModel.manyToOne
this.opened.next(this) this.opened.next(this)
} else { } else {
if (this.creating()) { if (this.creating) {
this.dropdown?.open() this.dropdown?.open()
this.creating = false
} else { } else {
this.filterText = '' this.filterText = ''
if (this.applyOnClose && this.selectionModel.isDirty()) { if (this.applyOnClose && this.selectionModel.isDirty()) {
@@ -904,11 +892,7 @@ export class FilterableDropdownComponent
this.dropdown.close() this.dropdown.close()
} }
}, 200) }, 200)
} else if ( } else if (filtered.length == 0 && this.createRef) {
filtered.length == 0 &&
this.createRef &&
this.filterText?.length > 0
) {
this.createClicked() this.createClicked()
} }
} }
@@ -100,7 +100,7 @@
</div> </div>
} }
<div class="form-group ms-md-auto"> <div class="form-group ms-md-auto">
<button type="button" class="btn me-2" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()">{{ cancelBtnCaption }}</button> <button type="button" class="btn me-2" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">{{ cancelBtnCaption }}</button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="pages.length === 0">{{ btnCaption }}</button> <button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="pages.length === 0">{{ btnCaption }}</button>
</div> </div>
</div> </div>
@@ -22,7 +22,7 @@
} }
</div> </div>
<div class="me-1"> <div class="me-1">
<small>{{ownerFilterLabel}}</small> <small i18n>My documents</small>
</div> </div>
</button> </button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NOT_SELF)" [disabled]="disabled"> <button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NOT_SELF)" [disabled]="disabled">
@@ -32,7 +32,7 @@
} }
</div> </div>
<div class="me-1"> <div class="me-1">
<small>{{ownerExclusionFilterLabel}}</small> <small i18n>Shared with me</small>
</div> </div>
</button> </button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SHARED_BY_ME)" [disabled]="disabled"> <button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SHARED_BY_ME)" [disabled]="disabled">
@@ -42,7 +42,7 @@
} }
</div> </div>
<div class="me-1"> <div class="me-1">
<small>{{sharedByFilterLabel}}</small> <small i18n>Shared by me</small>
</div> </div>
</button> </button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.UNOWNED)" [disabled]="disabled"> <button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.UNOWNED)" [disabled]="disabled">
@@ -94,58 +94,6 @@ describe('PermissionsFilterDropdownComponent', () => {
expect(component.isActive).toBeTruthy() expect(component.isActive).toBeTruthy()
}) })
it('should describe concrete user filters honestly', () => {
component.selectionModel.ownerFilter = OwnerFilterType.SELF
component.selectionModel.userID = 1
expect(component.ownerFilterLabel).toEqual('Owned by user1')
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
component.selectionModel.excludeUsers = [1]
expect(component.ownerExclusionFilterLabel).toEqual('Not owned by user1')
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME
component.selectionModel.userID = 1
expect(component.sharedByFilterLabel).toEqual('Shared by user1')
})
it('should describe concrete filters when usernames are unavailable', () => {
component.selectionModel.ownerFilter = OwnerFilterType.SELF
component.selectionModel.userID = 99
expect(component.ownerFilterLabel).toEqual('Owned by another user')
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
component.selectionModel.excludeUsers = [99]
expect(component.ownerExclusionFilterLabel).toEqual(
'Not owned by another user'
)
component.selectionModel.excludeUsers = [98, 99]
expect(component.ownerExclusionFilterLabel).toEqual(
'Not owned by selected users'
)
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME
component.selectionModel.userID = 99
expect(component.sharedByFilterLabel).toEqual('Shared by another user')
})
it('should retain relative labels for filters bound to the current user', () => {
component.selectionModel.userID = currentUserID
expect(component.ownerFilterLabel).toEqual('My documents')
expect(component.sharedByFilterLabel).toEqual('Shared by me')
component.selectionModel.excludeUsers = [currentUserID]
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
})
it('should retain relative labels for inactive filter choices', () => {
component.selectionModel.ownerFilter = OwnerFilterType.NONE
expect(component.ownerFilterLabel).toEqual('My documents')
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
expect(component.sharedByFilterLabel).toEqual('Shared by me')
})
it('should support reset', () => { it('should support reset', () => {
component.setFilter(OwnerFilterType.OTHERS) component.setFilter(OwnerFilterType.OTHERS)
expect(component.selectionModel.ownerFilter).not.toEqual( expect(component.selectionModel.ownerFilter).not.toEqual(
@@ -93,55 +93,6 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
) )
} }
get ownerFilterLabel(): string {
if (
this.selectionModel?.ownerFilter !== OwnerFilterType.SELF ||
this.selectionModel?.userID === this.settingsService.currentUser()?.id
) {
return $localize`My documents`
}
const username = this.getUsername(this.selectionModel?.userID)
return username
? $localize`Owned by ${username}`
: $localize`Owned by another user`
}
get ownerExclusionFilterLabel(): string {
const excludedUsers = this.selectionModel?.excludeUsers ?? []
if (
this.selectionModel?.ownerFilter !== OwnerFilterType.NOT_SELF ||
(excludedUsers.length === 1 &&
excludedUsers[0] === this.settingsService.currentUser()?.id)
) {
return $localize`Shared with me`
}
const usernames = excludedUsers
.map((id) => this.getUsername(id))
.filter(Boolean)
if (usernames.length === excludedUsers.length && usernames.length > 0) {
return $localize`Not owned by ${usernames.join(', ')}`
}
return excludedUsers.length === 1
? $localize`Not owned by another user`
: $localize`Not owned by selected users`
}
get sharedByFilterLabel(): string {
if (
this.selectionModel?.ownerFilter !== OwnerFilterType.SHARED_BY_ME ||
this.selectionModel?.userID === this.settingsService.currentUser()?.id
) {
return $localize`Shared by me`
}
const username = this.getUsername(this.selectionModel?.userID)
return username
? $localize`Shared by ${username}`
: $localize`Shared by another user`
}
constructor() { constructor() {
const userService = inject(UserService) const userService = inject(UserService)
@@ -213,8 +164,4 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
} }
this.onChange() this.onChange()
} }
private getUsername(userID: number): string {
return this.users().find((user) => user.id === userID)?.username
}
} }
@@ -119,7 +119,7 @@
type="button" type="button"
class="btn btn-primary btn-sm d-inline-flex align-items-center gap-2 text-nowrap" class="btn btn-primary btn-sm d-inline-flex align-items-center gap-2 text-nowrap"
(click)="submit()" (click)="submit()"
[disabled]="loading() || !buttonsEnabled()"> [disabled]="loading() || !buttonsEnabled">
@if (loading()) { @if (loading()) {
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> <span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
} }
@@ -69,7 +69,7 @@ describe('ShareLinkBundleDialogComponent', () => {
file_version: FileVersion.Original, file_version: FileVersion.Original,
expiration_days: 3, expiration_days: 3,
}) })
expect(component.buttonsEnabled()).toBe(false) expect(component.buttonsEnabled).toBe(false)
expect(confirmSpy).toHaveBeenCalled() expect(confirmSpy).toHaveBeenCalled()
component.form.setValue({ component.form.setValue({
@@ -78,7 +78,7 @@ export class ShareLinkBundleDialogComponent extends ConfirmDialogComponent {
: FileVersion.Original, : FileVersion.Original,
expiration_days: this.form.value.expirationDays, expiration_days: this.form.value.expirationDays,
} }
this.buttonsEnabled.set(false) this.buttonsEnabled = false
super.confirm() super.confirm()
} }
@@ -1564,7 +1564,7 @@ describe('DocumentDetailComponent', () => {
dialog.confirmClicked.next() dialog.confirmClicked.next()
await openModal.result await openModal.result
expect(dialog.buttonsEnabled()).toBe(false) expect(dialog.buttonsEnabled).toBe(false)
expect(reloadSpy).toHaveBeenCalled() expect(reloadSpy).toHaveBeenCalled()
expect((component as any).incomingUpdateModal).toBeNull() expect((component as any).incomingUpdateModal).toBeNull()
}) })
@@ -1789,7 +1789,7 @@ describe('DocumentDetailComponent', () => {
expect(errorSpy).toHaveBeenCalled() expect(errorSpy).toHaveBeenCalled()
expect(component.networkActive()).toBe(false) expect(component.networkActive()).toBe(false)
expect(dialog.buttonsEnabled()).toBe(true) expect(dialog.buttonsEnabled).toBe(true)
}) })
it('should refresh the document when removing password in update mode', () => { it('should refresh the document when removing password in update mode', () => {
@@ -659,7 +659,7 @@ export class DocumentDetailComponent
modal.componentInstance.cancelBtnCaption = $localize`Dismiss` modal.componentInstance.cancelBtnCaption = $localize`Dismiss`
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => { modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled = false
modal.close() modal.close()
this.reloadRemoteVersion() this.reloadRemoteVersion()
}) })
@@ -1374,7 +1374,7 @@ export class DocumentDetailComponent
modal.componentInstance.confirmClicked modal.componentInstance.confirmClicked
.pipe( .pipe(
switchMap(() => { switchMap(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled = false
return this.documentsService.delete(this.document()) return this.documentsService.delete(this.document())
}) })
) )
@@ -1386,7 +1386,7 @@ export class DocumentDetailComponent
}, },
error: (error) => { error: (error) => {
this.toastService.showError($localize`Error deleting document`, error) this.toastService.showError($localize`Error deleting document`, error)
modal.componentInstance.buttonsEnabled.set(true) modal.componentInstance.buttonsEnabled = true
this.subscribeModalDelete(modal) this.subscribeModalDelete(modal)
}, },
}) })
@@ -1411,7 +1411,7 @@ export class DocumentDetailComponent
modal.componentInstance.btnClass = 'btn-danger' modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed` modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => { modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled = false
this.documentsService this.documentsService
.reprocessDocuments({ documents: [this.document().id] }) .reprocessDocuments({ documents: [this.document().id] })
.subscribe({ .subscribe({
@@ -1425,7 +1425,7 @@ export class DocumentDetailComponent
}, },
error: (error) => { error: (error) => {
if (modal) { if (modal) {
modal.componentInstance.buttonsEnabled.set(true) modal.componentInstance.buttonsEnabled = true
} }
this.toastService.showError( this.toastService.showError(
$localize`Error executing operation`, $localize`Error executing operation`,
@@ -1798,7 +1798,7 @@ export class DocumentDetailComponent
modal.componentInstance.confirmClicked modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => { .subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled = false
this.documentsService this.documentsService
.editPdfDocuments([sourceDocumentId], { .editPdfDocuments([sourceDocumentId], {
operations: modal.componentInstance.getOperations(), operations: modal.componentInstance.getOperations(),
@@ -1821,7 +1821,7 @@ export class DocumentDetailComponent
}, },
error: (error) => { error: (error) => {
if (modal) { if (modal) {
modal.componentInstance.buttonsEnabled.set(true) modal.componentInstance.buttonsEnabled = true
} }
this.toastService.showError( this.toastService.showError(
$localize`Error executing PDF edit operation`, $localize`Error executing PDF edit operation`,
@@ -1855,7 +1855,7 @@ export class DocumentDetailComponent
const sourceDocumentId = this.selectedVersionId() ?? this.document().id const sourceDocumentId = this.selectedVersionId() ?? this.document().id
const dialog = const dialog =
modal.componentInstance as PasswordRemovalConfirmDialogComponent modal.componentInstance as PasswordRemovalConfirmDialogComponent
dialog.buttonsEnabled.set(false) dialog.buttonsEnabled = false
this.networkActive.set(true) this.networkActive.set(true)
this.documentsService this.documentsService
.removePasswordDocuments([sourceDocumentId], { .removePasswordDocuments([sourceDocumentId], {
@@ -1880,7 +1880,7 @@ export class DocumentDetailComponent
} }
}, },
error: (error) => { error: (error) => {
dialog.buttonsEnabled.set(true) dialog.buttonsEnabled = true
this.networkActive.set(false) this.networkActive.set(false)
this.toastService.showError( this.toastService.showError(
$localize`Error executing password removal operation`, $localize`Error executing password removal operation`,
@@ -1683,7 +1683,7 @@ describe('BulkEditorComponent', () => {
expiration_days: 7, expiration_days: 7,
}, },
loading: signal(false), loading: signal(false),
buttonsEnabled: signal(true), buttonsEnabled: true,
copied: signal(false), copied: signal(false),
}, },
} }
@@ -1715,7 +1715,7 @@ describe('BulkEditorComponent', () => {
expiration_days: 7, expiration_days: 7,
}) })
expect(dialogInstance.loading()).toBe(false) expect(dialogInstance.loading()).toBe(false)
expect(dialogInstance.buttonsEnabled()).toBe(false) expect(dialogInstance.buttonsEnabled).toBe(false)
expect(dialogInstance.createdBundle).toEqual({ id: 42 }) expect(dialogInstance.createdBundle).toEqual({ id: 42 })
expect(typeof dialogInstance.onOpenManage).toBe('function') expect(typeof dialogInstance.onOpenManage).toBe('function')
expect(toastInfoSpy).toHaveBeenCalledWith( expect(toastInfoSpy).toHaveBeenCalledWith(
@@ -1755,7 +1755,7 @@ describe('BulkEditorComponent', () => {
expiration_days: null, expiration_days: null,
}, },
loading: signal(false), loading: signal(false),
buttonsEnabled: signal(true), buttonsEnabled: true,
}, },
} }
@@ -1777,7 +1777,7 @@ describe('BulkEditorComponent', () => {
expect.any(Error) expect.any(Error)
) )
expect(dialogInstance.loading()).toBe(false) expect(dialogInstance.loading()).toBe(false)
expect(dialogInstance.buttonsEnabled()).toBe(true) expect(dialogInstance.buttonsEnabled).toBe(true)
openSpy.mockRestore() openSpy.mockRestore()
}) })
@@ -273,7 +273,7 @@ export class BulkEditorComponent
overrideSelection?: DocumentSelectionQuery overrideSelection?: DocumentSelectionQuery
) { ) {
if (modal) { if (modal) {
modal.componentInstance.buttonsEnabled.set(false) this.setModalButtonsEnabled(modal, false)
} }
this.documentService this.documentService
.bulkEdit(overrideSelection ?? this.getSelectionQuery(), method, args) .bulkEdit(overrideSelection ?? this.getSelectionQuery(), method, args)
@@ -290,7 +290,7 @@ export class BulkEditorComponent
options: { deleteOriginals?: boolean } = {} options: { deleteOriginals?: boolean } = {}
) { ) {
if (modal) { if (modal) {
modal.componentInstance.buttonsEnabled.set(false) this.setModalButtonsEnabled(modal, false)
} }
request.pipe(first()).subscribe({ request.pipe(first()).subscribe({
next: () => { next: () => {
@@ -320,7 +320,7 @@ export class BulkEditorComponent
private handleOperationError(modal: NgbModalRef, error: any) { private handleOperationError(modal: NgbModalRef, error: any) {
if (modal) { if (modal) {
modal.componentInstance.buttonsEnabled.set(true) this.setModalButtonsEnabled(modal, true)
} }
this.toastService.showError( this.toastService.showError(
$localize`Error executing bulk operation`, $localize`Error executing bulk operation`,
@@ -328,6 +328,15 @@ export class BulkEditorComponent
) )
} }
private setModalButtonsEnabled(modal: NgbModalRef, enabled: boolean) {
const buttonsEnabled = modal.componentInstance.buttonsEnabled
if (typeof buttonsEnabled?.set === 'function') {
buttonsEnabled.set(enabled)
} else {
modal.componentInstance.buttonsEnabled = enabled
}
}
private applySelectionData( private applySelectionData(
items: SelectionDataItem[], items: SelectionDataItem[],
selectionModel: FilterableDropdownSelectionModel selectionModel: FilterableDropdownSelectionModel
@@ -762,7 +771,6 @@ export class BulkEditorComponent
this.tagSelectionModel.items = flattenTags(tags.results) this.tagSelectionModel.items = flattenTags(tags.results)
this.tagSelectionModel.toggle(newTag.id) this.tagSelectionModel.toggle(newTag.id)
}) })
return modal
} }
createCorrespondent(name: string) { createCorrespondent(name: string) {
@@ -786,7 +794,6 @@ export class BulkEditorComponent
this.correspondentSelectionModel.items = correspondents.results this.correspondentSelectionModel.items = correspondents.results
this.correspondentSelectionModel.toggle(newCorrespondent.id) this.correspondentSelectionModel.toggle(newCorrespondent.id)
}) })
return modal
} }
createDocumentType(name: string) { createDocumentType(name: string) {
@@ -808,7 +815,6 @@ export class BulkEditorComponent
this.documentTypeSelectionModel.items = documentTypes.results this.documentTypeSelectionModel.items = documentTypes.results
this.documentTypeSelectionModel.toggle(newDocumentType.id) this.documentTypeSelectionModel.toggle(newDocumentType.id)
}) })
return modal
} }
createStoragePath(name: string) { createStoragePath(name: string) {
@@ -830,7 +836,6 @@ export class BulkEditorComponent
this.storagePathsSelectionModel.items = storagePaths.results this.storagePathsSelectionModel.items = storagePaths.results
this.storagePathsSelectionModel.toggle(newStoragePath.id) this.storagePathsSelectionModel.toggle(newStoragePath.id)
}) })
return modal
} }
createCustomField(name: string) { createCustomField(name: string) {
@@ -852,7 +857,6 @@ export class BulkEditorComponent
this.customFieldsSelectionModel.items = customFields.results this.customFieldsSelectionModel.items = customFields.results
this.customFieldsSelectionModel.toggle(newCustomField.id) this.customFieldsSelectionModel.toggle(newCustomField.id)
}) })
return modal
} }
applyDelete() { applyDelete() {
@@ -868,7 +872,7 @@ export class BulkEditorComponent
modal.componentInstance.confirmClicked modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => { .subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled = false
this.executeDocumentAction( this.executeDocumentAction(
modal, modal,
this.documentService.deleteDocuments(this.getSelectionQuery()) this.documentService.deleteDocuments(this.getSelectionQuery())
@@ -916,7 +920,7 @@ export class BulkEditorComponent
modal.componentInstance.confirmClicked modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => { .subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled = false
this.executeDocumentAction( this.executeDocumentAction(
modal, modal,
this.documentService.reprocessDocuments(this.getSelectionQuery()) this.documentService.reprocessDocuments(this.getSelectionQuery())
@@ -953,7 +957,7 @@ export class BulkEditorComponent
rotateDialog.confirmClicked rotateDialog.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => { .subscribe(() => {
rotateDialog.buttonsEnabled.set(false) rotateDialog.buttonsEnabled = false
this.executeDocumentAction( this.executeDocumentAction(
modal, modal,
this.documentService.rotateDocuments( this.documentService.rotateDocuments(
@@ -986,7 +990,7 @@ export class BulkEditorComponent
if (mergeDialog.archiveFallback()) { if (mergeDialog.archiveFallback()) {
args.archive_fallback = true args.archive_fallback = true
} }
mergeDialog.buttonsEnabled.set(false) mergeDialog.buttonsEnabled = false
this.executeDocumentAction( this.executeDocumentAction(
modal, modal,
this.documentService.mergeDocuments(mergeDialog.documentIDs(), args), this.documentService.mergeDocuments(mergeDialog.documentIDs(), args),
@@ -1059,14 +1063,14 @@ export class BulkEditorComponent
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => { .subscribe(() => {
dialog.loading.set(true) dialog.loading.set(true)
dialog.buttonsEnabled.set(false) dialog.buttonsEnabled = false
this.shareLinkBundleService this.shareLinkBundleService
.createBundle(dialog.payload) .createBundle(dialog.payload)
.pipe(first()) .pipe(first())
.subscribe({ .subscribe({
next: (result) => { next: (result) => {
dialog.loading.set(false) dialog.loading.set(false)
dialog.buttonsEnabled.set(false) dialog.buttonsEnabled = false
dialog.createdBundle = result dialog.createdBundle = result
dialog.copied.set(false) dialog.copied.set(false)
dialog.payload = null dialog.payload = null
@@ -1080,7 +1084,7 @@ export class BulkEditorComponent
}, },
error: (error) => { error: (error) => {
dialog.loading.set(false) dialog.loading.set(false)
dialog.buttonsEnabled.set(true) dialog.buttonsEnabled = true
this.toastService.showError( this.toastService.showError(
$localize`Share link bundle creation is not available yet.`, $localize`Share link bundle creation is not available yet.`,
error error
@@ -64,13 +64,6 @@ $paperless-card-breakpoints: (
} }
} }
// Popper may place a dropdown above its toggle when the virtual keyboard
// reduces the available viewport, increase the z-index so navbar doesn't
// obscure it. See github.com/paperless-ngx/paperless-ngx/pull/13694
:host ::ng-deep .sticky-top:has(.dropdown-menu.show) {
z-index: 1040;
}
@media (max-width: 579.98px) { @media (max-width: 579.98px) {
:host-context(main.mobile-search-hidden) .sticky-top { :host-context(main.mobile-search-hidden) .sticky-top {
top: calc(3.5rem - 2px); // height of navbar only when search is hidden top: calc(3.5rem - 2px); // height of navbar only when search is hidden
@@ -21,47 +21,45 @@
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editField(field)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.CustomField)">{{field.name}}</button></div> <div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editField(field)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.CustomField)">{{field.name}}</button></div>
<div class="col d-flex align-items-center">{{getDataType(field)}}</div> <div class="col d-flex align-items-center">{{getDataType(field)}}</div>
<div class="col"> <div class="col">
<div class="btn-toolbar gap-2"> <div class="btn-group d-block d-sm-none">
<div class="btn-group d-block d-sm-none"> <div ngbDropdown container="body" class="d-inline-block">
<div ngbDropdown container="body" class="d-inline-block"> <button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle> <i-bs name="three-dots-vertical"></i-bs>
<i-bs name="three-dots-vertical"></i-bs> </button>
</button> <div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile"> <button (click)="editField(field)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" ngbDropdownItem i18n>Edit</button>
<button (click)="editField(field)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" ngbDropdownItem i18n>Edit</button> <button class="text-danger" (click)="deleteField(field)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.CustomField }" ngbDropdownItem i18n>Delete</button>
<button class="text-danger" (click)="deleteField(field)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.CustomField }" ngbDropdownItem i18n>Delete</button> @if (field.document_count > 0) {
@if (field.document_count > 0) { <a
<a *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }" ngbDropdownItem
ngbDropdownItem [routerLink]="getDocumentFilterUrl(field)"
[routerLink]="getDocumentFilterUrl(field)" i18n
i18n >Filter Documents ({{ field.document_count }})</a
>Filter Documents ({{ field.document_count }})</a >
> }
}
</div>
</div> </div>
</div> </div>
<div class="btn-group d-none d-sm-inline-block">
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-secondary" type="button" (click)="editField(field)">
<i-bs width="1em" height="1em" name="pencil" class="me-1"></i-bs><ng-container i18n>Edit</ng-container>
</button>
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteField(field)">
<i-bs width="1em" height="1em" name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
</button>
</div>
@if (field.document_count > 0) {
<div class="btn-group d-none d-sm-inline-block">
<a
class="btn btn-sm btn-outline-secondary"
[routerLink]="getDocumentFilterUrl(field)"
>
<i-bs width="1em" height="1em" name="filter" class="me-1"></i-bs><ng-container i18n>Documents</ng-container
><span class="badge bg-light text-secondary ms-2">{{ field.document_count }}</span>
</a>
</div>
}
</div> </div>
<div class="btn-group d-none d-sm-inline-block">
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-secondary" type="button" (click)="editField(field)">
<i-bs width="1em" height="1em" name="pencil" class="me-1"></i-bs><ng-container i18n>Edit</ng-container>
</button>
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteField(field)">
<i-bs width="1em" height="1em" name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
</button>
</div>
@if (field.document_count > 0) {
<div class="btn-group d-none d-sm-inline-block ms-2">
<a
class="btn btn-sm btn-outline-secondary"
[routerLink]="getDocumentFilterUrl(field)"
>
<i-bs width="1em" height="1em" name="filter" class="me-1"></i-bs><ng-container i18n>Documents</ng-container
><span class="badge bg-light text-secondary ms-2">{{ field.document_count }}</span>
</a>
</div>
}
</div> </div>
</div> </div>
</li> </li>
@@ -105,7 +105,7 @@ export class CustomFieldsComponent
modal.componentInstance.btnClass = 'btn-danger' modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed` modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => { modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled = false
this.customFieldsService.delete(field).subscribe({ this.customFieldsService.delete(field).subscribe({
next: () => { next: () => {
modal.close() modal.close()
@@ -69,7 +69,7 @@
} }
</ul> </ul>
<div class="my-3"> <div class="my-3 shadow-sm">
<ng-container <ng-container
[ngComponentOutlet]="activeSection?.component" [ngComponentOutlet]="activeSection?.component"
#activeOutlet="ngComponentOutlet" #activeOutlet="ngComponentOutlet"
@@ -274,7 +274,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
activeModal.componentInstance.btnClass = 'btn-danger' activeModal.componentInstance.btnClass = 'btn-danger'
activeModal.componentInstance.btnCaption = $localize`Delete` activeModal.componentInstance.btnCaption = $localize`Delete`
activeModal.componentInstance.confirmClicked.subscribe(() => { activeModal.componentInstance.confirmClicked.subscribe(() => {
activeModal.componentInstance.buttonsEnabled.set(false) activeModal.componentInstance.buttonsEnabled = false
this.service this.service
.delete(object) .delete(object)
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
@@ -284,7 +284,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
this.reloadData() this.reloadData()
}, },
error: (error) => { error: (error) => {
activeModal.componentInstance.buttonsEnabled.set(true) activeModal.componentInstance.buttonsEnabled = true
this.toastService.showError( this.toastService.showError(
$localize`Error while deleting element`, $localize`Error while deleting element`,
error error
@@ -455,7 +455,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
modal.componentInstance.btnClass = 'btn-danger' modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed` modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => { modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled = false
this.service this.service
.bulk_edit_objects( .bulk_edit_objects(
this.allSelectionActive ? [] : Array.from(this.selectedObjects), this.allSelectionActive ? [] : Array.from(this.selectedObjects),
@@ -472,7 +472,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
this.reloadData() this.reloadData()
}, },
error: (error) => { error: (error) => {
modal.componentInstance.buttonsEnabled.set(true) modal.componentInstance.buttonsEnabled = true
this.toastService.showError( this.toastService.showError(
$localize`Error deleting objects`, $localize`Error deleting objects`,
error error
@@ -196,7 +196,7 @@ export class MailComponent
modal.componentInstance.btnClass = 'btn-danger' modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed` modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => { modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled = false
this.mailAccountService.delete(account).subscribe({ this.mailAccountService.delete(account).subscribe({
next: () => { next: () => {
modal.close() modal.close()
@@ -298,7 +298,7 @@ export class MailComponent
modal.componentInstance.btnClass = 'btn-danger' modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed` modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => { modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled = false
this.mailRuleService.delete(rule).subscribe({ this.mailRuleService.delete(rule).subscribe({
next: () => { next: () => {
modal.close() modal.close()
@@ -134,7 +134,7 @@ export class WorkflowsComponent
modal.componentInstance.btnClass = 'btn-danger' modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed` modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => { modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled = false
this.workflowService.delete(workflow).subscribe({ this.workflowService.delete(workflow).subscribe({
next: () => { next: () => {
modal.close() modal.close()
+1 -1
View File
@@ -18,7 +18,7 @@ export class DirtyFormGuard extends DirtyCheckGuard {
modal.componentInstance.btnClass = 'btn-warning' modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Leave page` modal.componentInstance.btnCaption = $localize`Leave page`
modal.componentInstance.confirmClicked.subscribe(() => { modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled = false
modal.close() modal.close()
}) })
const subject = new Subject<boolean>() const subject = new Subject<boolean>()
@@ -36,12 +36,12 @@ export class DirtySavedViewGuard {
modal.componentInstance.alternativeBtnClass = 'btn-primary' modal.componentInstance.alternativeBtnClass = 'btn-primary'
modal.componentInstance.alternativeBtnCaption = $localize`Save and close` modal.componentInstance.alternativeBtnCaption = $localize`Save and close`
modal.componentInstance.alternativeClicked.pipe(first()).subscribe(() => { modal.componentInstance.alternativeClicked.pipe(first()).subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled = false
component.saveViewConfig() component.saveViewConfig()
modal.close() modal.close()
}) })
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => { modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled = false
modal.close() modal.close()
}) })
@@ -142,7 +142,7 @@ export class OpenDocumentsService {
modal.componentInstance.btnClass = 'btn-warning' modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Close document` modal.componentInstance.btnCaption = $localize`Close document`
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => { modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled = false
modal.close() modal.close()
this.openDocuments.splice(index, 1) this.openDocuments.splice(index, 1)
this.dirtyDocuments.delete(doc.id) this.dirtyDocuments.delete(doc.id)
@@ -165,7 +165,7 @@ export class OpenDocumentsService {
modal.componentInstance.btnClass = 'btn-warning' modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Close documents` modal.componentInstance.btnCaption = $localize`Close documents`
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => { modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled = false
modal.close() modal.close()
this.openDocuments.splice(0, this.openDocuments.length) this.openDocuments.splice(0, this.openDocuments.length)
this.dirtyDocuments.clear() this.dirtyDocuments.clear()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+29 -68
View File
@@ -127,27 +127,6 @@ table .btn-link {
background-color: var(--bs-body-bg); background-color: var(--bs-body-bg);
} }
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3, h4, h5, h6,
.h1, .h2, .h3, .h4, .h5, .h6 {
font-weight: 600;
}
pngx-page-header h3 {
font-size: 1.5rem;
}
table,
.badge,
.card-info,
.pagination {
font-variant-numeric: tabular-nums;
}
.bg-primary { .bg-primary {
background-color: var(--bs-primary) !important; background-color: var(--bs-primary) !important;
color: var(--pngx-primary-text-contrast); color: var(--pngx-primary-text-contrast);
@@ -234,8 +213,7 @@ table,
} }
.form-switch .form-check-input:focus { .form-switch .form-check-input:focus {
// neutral knob in place of bootstrap's blue, which clashes with the theme colour background-image: escape-svg(url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'><circle r='3' fill='#bbb'/></svg>"));
--bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23bbb'/%3e%3c/svg%3e");
} }
.nav-item a:focus-visible { .nav-item a:focus-visible {
@@ -291,44 +269,33 @@ a.btn-link:focus-visible,
box-shadow: 0 0 0 3px rgba(255, 255, 255, .25); box-shadow: 0 0 0 3px rgba(255, 255, 255, .25);
} }
// Sortable table headers .asc {
th[pngxSortable] { background-color: #f8f9fa!important;
cursor: pointer;
user-select: none;
white-space: nowrap;
&::after {
content: '';
display: inline-block;
vertical-align: -0.15em;
width: 0.8rem;
height: 0.8rem;
margin-left: 0.25rem;
// chevron, matched to the bootstrap-icons set used elsewhere in the app
mask: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e") no-repeat center / 0.8rem;
background-color: currentColor;
opacity: 0;
transition: opacity 0.15s ease-in-out;
}
// hint that an unsorted column can be sorted
&:hover::after {
opacity: 0.35;
}
} }
.asc, .asc:after {
.des { content: '';
color: var(--bs-primary);
--bs-table-color-state: var(--bs-primary); // bootstrap sets cell color at higher specificity
&::after {
opacity: 1 !important;
}
}
.asc::after {
transform: rotate(180deg); transform: rotate(180deg);
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAmxJREFUeAHtmksrRVEUx72fH8CIGQNJkpGUUmakDEiZSJRIZsRQmCkTJRmZmJgQE0kpX0D5DJKJgff7v+ru2u3O3vvc67TOvsdatdrnnP1Y///v7HvvubdbUiIhBISAEBACQkAICAEhIAQ4CXSh2DnyDfmCPEG2Iv9F9MPlM/LHyAecdyMzHYNwR3fdNK/OH9HXl1UCozD24TCvILxizEDWIEzA0FcM8woCgRrJCoS5PIwrANQSMAJX1LEI9bqpQo4JYNFFKRSvIgsxHDVnqZgIkPnNBM0rIGtYk9YOOsqgbgepRCfdbmFtqhFkVEDVPjJp0+Z6e6hRHhqBKgg6ZDCvYBygVmUoEGoh5JTRvIJwhJo1aUOoh4CLPMyvxxi7EWOMgnCGsXXI1GIXlZUYX7ucU+kbR8NW8lh3O7cue0Pk32MKndfUxQFAwxdirk3fHappAnc0oqDPzDfGTBrCfHP04dM4oTV8cxr0SVzH9FF07xD3ib6xCDE+M+aUcVygtWzzbtGX2rPBrEUYfecfQkaFzYi6HjVnGBdtL7epqAlc1+jRdAap74RrnPc4BCijttY2tRcdN0g17w7HqZrXhdJTYAuS3hd8z+vKgK3V1zWPae0mZDMykadBn1hTQBLnZNwVrJpSe/NwEeDsEwCctEOsJTsgxLvCqUl2ACftEGvJDgjxrnBqkh3ASTvEWrIDQrwrnJpkB3DSDrGW7IAQ7wqnJtkBnLRztejXXVu4+mxz/nQ9jR1w5VB86ejLTFcnnDwhzV+F6T+CHZlx6THSjn76eyyBIOPHyDakhBAQAkJACAgBISAEhIAQYCLwC8JxpAmsEGt6AAAAAElFTkSuQmCC") no-repeat;
height: 1rem;
width: 1rem;
display: block;
background-size: 1rem;
float: right;
}
.des {
background-color: #f8f9fa!important;
}
.des:after {
content: '';
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAmxJREFUeAHtmksrRVEUx72fH8CIGQNJkpGUUmakDEiZSJRIZsRQmCkTJRmZmJgQE0kpX0D5DJKJgff7v+ru2u3O3vvc67TOvsdatdrnnP1Y///v7HvvubdbUiIhBISAEBACQkAICAEhIAQ4CXSh2DnyDfmCPEG2Iv9F9MPlM/LHyAecdyMzHYNwR3fdNK/OH9HXl1UCozD24TCvILxizEDWIEzA0FcM8woCgRrJCoS5PIwrANQSMAJX1LEI9bqpQo4JYNFFKRSvIgsxHDVnqZgIkPnNBM0rIGtYk9YOOsqgbgepRCfdbmFtqhFkVEDVPjJp0+Z6e6hRHhqBKgg6ZDCvYBygVmUoEGoh5JTRvIJwhJo1aUOoh4CLPMyvxxi7EWOMgnCGsXXI1GIXlZUYX7ucU+kbR8NW8lh3O7cue0Pk32MKndfUxQFAwxdirk3fHappAnc0oqDPzDfGTBrCfHP04dM4oTV8cxr0SVzH9FF07xD3ib6xCDE+M+aUcVygtWzzbtGX2rPBrEUYfecfQkaFzYi6HjVnGBdtL7epqAlc1+jRdAap74RrnPc4BCijttY2tRcdN0g17w7HqZrXhdJTYAuS3hd8z+vKgK3V1zWPae0mZDMykadBn1hTQBLnZNwVrJpSe/NwEeDsEwCctEOsJTsgxLvCqUl2ACftEGvJDgjxrnBqkh3ASTvEWrIDQrwrnJpkB3DSDrGW7IAQ7wqnJtkBnLRztejXXVu4+mxz/nQ9jR1w5VB86ejLTFcnnDwhzV+F6T+CHZlx6THSjn76eyyBIOPHyDakhBAQAkJACAgBISAEhIAQYCLwC8JxpAmsEGt6AAAAAElFTkSuQmCC") no-repeat;
height: 1rem;
width: 1rem;
display: block;
background-size: 1rem;
float: right;
} }
.paperless-input-select { .paperless-input-select {
@@ -597,6 +564,10 @@ ul.pagination {
table.table { table.table {
--bs-table-color: var(--bs-body-color); --bs-table-color: var(--bs-body-color);
--bs-table-bg: var(--bs-light-rgb); --bs-table-bg: var(--bs-light-rgb);
.des,.asc {
background-color: var(--bs-body-bg) !important;
}
} }
.close { .close {
@@ -805,16 +776,6 @@ canvas.hiddenCanvasElement {
.document-card { .document-card {
overflow: hidden; overflow: hidden;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
&:hover:not(.card-selected) {
border-color: var(--pngx-card-hover-border);
box-shadow: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.1);
}
.document-card-check {
border-color: var(--pngx-card-hover-border) !important;
}
.card-footer i-bs svg { .card-footer i-bs svg {
vertical-align: middle; vertical-align: middle;
+12 -20
View File
@@ -23,7 +23,6 @@
--pngx-bg-darker: var(--bs-gray-100); --pngx-bg-darker: var(--bs-gray-100);
--pngx-bg-alt2: var(--bs-gray-200); // #e9ecef --pngx-bg-alt2: var(--bs-gray-200); // #e9ecef
--pngx-bg-disabled: #f7f7f7; --pngx-bg-disabled: #f7f7f7;
--pngx-card-hover-border: var(--bs-tertiary-color);
--pngx-focus-alpha: 0.3; --pngx-focus-alpha: 0.3;
--pngx-toast-max-width: 340px; --pngx-toast-max-width: 340px;
--bs-info: var(--pngx-bg-alt2); --bs-info: var(--pngx-bg-alt2);
@@ -37,22 +36,20 @@
$text-color-light-bg: #212529; $text-color-light-bg: #212529;
$text-color-dark-bg: #abb2bf; $text-color-dark-bg: #abb2bf;
$text-color-dark-bg-accent: color.adjust($text-color-dark-bg, $lightness: 10%); $text-color-dark-bg-accent: color.adjust($text-color-dark-bg, $lightness: 10%);
// url-encoded $text-color-light-bg // Taken from bootstrap
$text-color-light-bg-esc: "%23212529"; $form-check-input-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'><path fill='none' stroke='#{$text-color-light-bg}' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10l3 3l6-6'/></svg>");
// Taken from bootstrap, pre-encoded $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'><circle r='2' fill='#{$text-color-light-bg}'/></svg>");
$form-check-input-checked-bg-image-dark: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='#{$text-color-light-bg-esc}' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10l3 3l6-6'/%3e%3c/svg%3e");
$form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='#{$text-color-light-bg-esc}'/%3e%3c/svg%3e");
.primary-light { .primary-light {
--pngx-primary-text-contrast: #{$text-color-light-bg} !important; --pngx-primary-text-contrast: #{$text-color-light-bg} !important;
.form-check:not(.form-switch) { .form-check:not(.form-switch) {
.form-check-input:checked[type=checkbox] { .form-check-input:checked[type=checkbox] {
--bs-form-check-bg-image: #{$form-check-input-checked-bg-image-dark}; background-image: escape-svg($form-check-input-checked-bg-image-dark);
} }
.form-check-input:checked[type=radio] { .form-check-input:checked[type=radio] {
--bs-form-check-bg-image: #{$form-check-radio-checked-bg-image-dark}; background-image: escape-svg($form-check-radio-checked-bg-image-dark);
} }
} }
@@ -69,17 +66,6 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,%3csvg xmlns='h
color: var(--pngx-primary-text-contrast); color: var(--pngx-primary-text-contrast);
} }
.dropdown-menu > .list-group-flush:only-child {
> .list-group-item:first-child {
border-top-left-radius: var(--bs-dropdown-border-radius);
border-top-right-radius: var(--bs-dropdown-border-radius);
}
> .list-group-item:last-child {
border-bottom-left-radius: var(--bs-dropdown-border-radius);
border-bottom-right-radius: var(--bs-dropdown-border-radius);
}
}
// Dark mode // Dark mode
@mixin paperless-green-dark-mode { @mixin paperless-green-dark-mode {
--pngx-primary-lightness: 31%; --pngx-primary-lightness: 31%;
@@ -93,7 +79,6 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,%3csvg xmlns='h
--pngx-bg-alt2: #232323; --pngx-bg-alt2: #232323;
--pngx-bg-darker: #101216; --pngx-bg-darker: #101216;
--pngx-bg-disabled: var(--pngx-bg-alt); --pngx-bg-disabled: var(--pngx-bg-alt);
--pngx-card-hover-border: var(--bs-border-color);
--pngx-focus-alpha: 0.6; --pngx-focus-alpha: 0.6;
--pngx-primary-faded: var(--pngx-primary-darken-15); --pngx-primary-faded: var(--pngx-primary-darken-15);
--pngx-primary-text-contrast: var(--bs-body-color); --pngx-primary-text-contrast: var(--bs-body-color);
@@ -253,6 +238,13 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,%3csvg xmlns='h
} }
table { table {
.des,
.asc {
&::after {
filter: invert(0.8); /* arrow is a black inline png bkgd image (!) so use filter */
}
}
&.table-hover > tbody > tr:hover > * { &.table-hover > tbody > tr:hover > * {
background-color: var(--bs-light); background-color: var(--bs-light);
color: var(--pngx-body-color-accent); color: var(--pngx-body-color-accent);
+2 -15
View File
@@ -41,16 +41,7 @@ class SuggestionCacheData:
CLASSIFIER_VERSION_KEY: Final[str] = "classifier_version" CLASSIFIER_VERSION_KEY: Final[str] = "classifier_version"
CLASSIFIER_HASH_KEY: Final[str] = "classifier_hash" CLASSIFIER_HASH_KEY: Final[str] = "classifier_hash"
CLASSIFIER_MODIFIED_KEY: Final[str] = "classifier_modified" CLASSIFIER_MODIFIED_KEY: Final[str] = "classifier_modified"
# Marker distinguishing LLM suggestions from classifier-generated ones (whose LLM_CACHE_CLASSIFIER_VERSION: Final[int] = 1000 # Marker distinguishing LLM suggestions
# FORMAT_VERSION lives in a much lower range - see DocumentClassifier). Bump
# this whenever the *shape* of the cached `suggestions` dict changes, so a
# cache entry written by a previous release can never be read back by code
# that expects a different shape:
# 1000 - initial LLM suggestions cache (flat lists of resolved object ids
# per taxonomy field)
# 1001 - suggestions reshaped to {"existing_ids": [...], "new_names":
# [...]} per taxonomy field (#13676)
LLM_CACHE_CLASSIFIER_VERSION: Final[int] = 1001
CACHE_1_MINUTE: Final[int] = 60 CACHE_1_MINUTE: Final[int] = 60
CACHE_5_MINUTES: Final[int] = 5 * CACHE_1_MINUTE CACHE_5_MINUTES: Final[int] = 5 * CACHE_1_MINUTE
@@ -213,11 +204,7 @@ def get_llm_suggestion_cache(
doc_key = get_suggestion_cache_key(document_id) doc_key = get_suggestion_cache_key(document_id)
data: SuggestionCacheData = cache.get(doc_key) data: SuggestionCacheData = cache.get(doc_key)
if ( if data and data.classifier_hash == backend:
data
and data.classifier_version == LLM_CACHE_CLASSIFIER_VERSION
and data.classifier_hash == backend
):
return data return data
return None return None
-106
View File
@@ -1,106 +0,0 @@
from __future__ import annotations
import importlib
import zipfile
# ZIP_ZSTANDARD exists only on Python 3.14+ (PEP 784). None elsewhere.
ZSTD: int | None = getattr(zipfile, "ZIP_ZSTANDARD", None)
# CLI choices are fixed across runtimes so argparse never hides zstd; runtime
# availability is enforced separately in compression_available().
COMPRESSION_CHOICES: tuple[str, ...] = (
"stored",
"deflated",
"bzip2",
"lzma",
"zstd",
)
# Method name -> zipfile compression constant (zstd only when supported).
COMPRESSION_METHODS: dict[str, int] = {
"stored": zipfile.ZIP_STORED,
"deflated": zipfile.ZIP_DEFLATED,
"bzip2": zipfile.ZIP_BZIP2,
"lzma": zipfile.ZIP_LZMA,
}
if ZSTD is not None:
COMPRESSION_METHODS["zstd"] = ZSTD
# Inclusive (min, max) level bounds per method; None => level not applicable.
# Verified on CPython 3.14.3.
#
# zstd's raw library bounds are (-131072, 22)
# (compression.zstd.CompressionParameter.compression_level.bounds()) — the
# minimum is an internal implementation constant (-ZSTD_TARGETLENGTH_MAX),
# not a meaningful distinct "level"; deeper negative values than -22 buy
# nothing over -22 in practice. We expose the conventional zstd CLI range
# instead of the raw library bounds.
LEVEL_BOUNDS: dict[str, tuple[int, int] | None] = {
"stored": None,
"deflated": (0, 9),
"bzip2": (1, 9),
"lzma": None,
"zstd": (-22, 22),
}
# zipfile compress_type id -> method name.
_COMPRESS_TYPE_TO_METHOD: dict[int, str] = {
zipfile.ZIP_STORED: "stored",
zipfile.ZIP_DEFLATED: "deflated",
zipfile.ZIP_BZIP2: "bzip2",
zipfile.ZIP_LZMA: "lzma",
93: "zstd",
}
def compression_available(method: str) -> bool:
"""Whether the running interpreter can actually use the given method."""
if method in ("stored", "deflated"):
# zlib is a hard CPython dependency; stored needs nothing.
return True
if method == "bzip2":
return _module_importable("bz2")
if method == "lzma":
return _module_importable("lzma")
if method == "zstd":
return ZSTD is not None and _module_importable("compression.zstd")
return False # pragma: no cover -- method is always one of COMPRESSION_CHOICES
def _module_importable(name: str) -> bool:
try:
importlib.import_module(name)
except ImportError:
return False
return True
def level_error(method: str, level: int | None) -> str | None:
"""Return a human message if (method, level) is invalid, else None."""
if level is None:
return None
bounds = LEVEL_BOUNDS[method]
if bounds is None:
return f"--zip-compression-level has no effect for '{method}'"
low, high = bounds
if not (low <= level <= high):
return (
f"--zip-compression-level for '{method}' must be between {low} and {high}"
)
return None
def compress_type_readable(compress_type: int) -> bool:
"""Whether this interpreter can decompress an entry of the given type."""
method = _COMPRESS_TYPE_TO_METHOD.get(compress_type)
if method is None:
return False
return compression_available(method)
def unreadable_method_names(compress_types: set[int]) -> set[str]:
"""Map a set of compress_type ids to human method names for error messages."""
names: set[str] = set()
for ct in compress_types:
names.add(_COMPRESS_TYPE_TO_METHOD.get(ct, f"method {ct}"))
return names
+2 -13
View File
@@ -243,21 +243,11 @@ class ZipExportSink(ExportSink):
added as an entry at finalize (a zip entry cannot be interleaved with others). added as an entry at finalize (a zip entry cannot be interleaved with others).
""" """
def __init__( def __init__(self, target: Path, zip_name: str, *, delete: bool = False) -> None:
self,
target: Path,
zip_name: str,
*,
delete: bool = False,
compression: int = zipfile.ZIP_DEFLATED,
compresslevel: int | None = None,
) -> None:
self._target = target.resolve() self._target = target.resolve()
self._zip_path = (self._target / zip_name).with_suffix(".zip") self._zip_path = (self._target / zip_name).with_suffix(".zip")
self._tmp_path = self._zip_path.with_name(self._zip_path.name + ".tmp") self._tmp_path = self._zip_path.with_name(self._zip_path.name + ".tmp")
self._delete = delete self._delete = delete
self._compression = compression
self._compresslevel = compresslevel
self._zip: zipfile.ZipFile | None = None self._zip: zipfile.ZipFile | None = None
self._dirs: set[str] = set() self._dirs: set[str] = set()
self._pending_manifest: tuple[Path, str] | None = None self._pending_manifest: tuple[Path, str] | None = None
@@ -268,8 +258,7 @@ class ZipExportSink(ExportSink):
self._zip = zipfile.ZipFile( self._zip = zipfile.ZipFile(
self._tmp_path, self._tmp_path,
"w", "w",
compression=self._compression, compression=zipfile.ZIP_DEFLATED,
compresslevel=self._compresslevel,
allowZip64=True, allowZip64=True,
) )
@@ -29,11 +29,6 @@ if TYPE_CHECKING:
if settings.AUDIT_LOG_ENABLED: if settings.AUDIT_LOG_ENABLED:
from auditlog.models import LogEntry from auditlog.models import LogEntry
from documents.export.compression import COMPRESSION_CHOICES
from documents.export.compression import COMPRESSION_METHODS
from documents.export.compression import ZSTD
from documents.export.compression import compression_available
from documents.export.compression import level_error
from documents.export.sinks import DirectoryExportSink from documents.export.sinks import DirectoryExportSink
from documents.export.sinks import ExportSink from documents.export.sinks import ExportSink
from documents.export.sinks import StreamingManifestWriter from documents.export.sinks import StreamingManifestWriter
@@ -197,28 +192,6 @@ class Command(CryptMixin, PaperlessCommand):
help="Sets the export zip file name", help="Sets the export zip file name",
) )
parser.add_argument(
"--zip-compression",
choices=COMPRESSION_CHOICES,
default=None,
help=(
"Compression method for the export zip (requires --zip). "
"Default: deflated. 'zstd' requires Python 3.14+ on both the "
"exporting and importing machine."
),
)
parser.add_argument(
"--zip-compression-level",
type=int,
default=None,
help=(
"Compression level for the export zip (requires --zip). "
"deflated: 0-9, bzip2: 1-9, zstd: -22..22; ignored for "
"stored/lzma."
),
)
parser.add_argument( parser.add_argument(
"--data-only", "--data-only",
default=False, default=False,
@@ -274,39 +247,12 @@ class Command(CryptMixin, PaperlessCommand):
if not os.access(self.target, os.W_OK): if not os.access(self.target, os.W_OK):
raise CommandError("That path doesn't appear to be writable") raise CommandError("That path doesn't appear to be writable")
zip_compression: str | None = options["zip_compression"]
zip_compression_level: int | None = options["zip_compression_level"]
if not self.zip_export and (
zip_compression is not None or zip_compression_level is not None
):
raise CommandError(
"--zip-compression and --zip-compression-level require --zip",
)
compression_method = zip_compression or "deflated"
if self.zip_export:
if not compression_available(compression_method):
if compression_method == "zstd" and ZSTD is None:
raise CommandError(
"zstd compression requires Python 3.14 or newer",
)
raise CommandError(
f"Compression method '{compression_method}' is not "
f"available on this Python runtime",
)
level_msg = level_error(compression_method, zip_compression_level)
if level_msg is not None:
raise CommandError(level_msg)
sink: ExportSink sink: ExportSink
if self.zip_export: if self.zip_export:
sink = ZipExportSink( sink = ZipExportSink(
self.target, self.target,
options["zip_name"], options["zip_name"],
delete=self.delete, delete=self.delete,
compression=COMPRESSION_METHODS[compression_method],
compresslevel=zip_compression_level,
) )
else: else:
sink = DirectoryExportSink( sink = DirectoryExportSink(
@@ -32,8 +32,6 @@ from django.db.models.signals import post_save
from filelock import FileLock from filelock import FileLock
from guardian.shortcuts import clear_ct_cache from guardian.shortcuts import clear_ct_cache
from documents.export.compression import compress_type_readable
from documents.export.compression import unreadable_method_names
from documents.file_handling import create_source_path_directory from documents.file_handling import create_source_path_directory
from documents.management.commands.base import PaperlessCommand from documents.management.commands.base import PaperlessCommand
from documents.management.commands.mixins import CryptMixin from documents.management.commands.mixins import CryptMixin
@@ -462,20 +460,6 @@ class Command(CryptMixin, PaperlessCommand):
with tempfile.TemporaryDirectory() as tmp_dir: with tempfile.TemporaryDirectory() as tmp_dir:
if is_zipfile(self.source): if is_zipfile(self.source):
with ZipFile(self.source) as zf: with ZipFile(self.source) as zf:
unsupported = {
info.compress_type
for info in zf.infolist()
if not compress_type_readable(info.compress_type)
}
if unsupported:
names = sorted(unreadable_method_names(unsupported))
message = (
f"This archive uses compression this Python version cannot "
f"read ({', '.join(names)})."
)
if "zstd" in names:
message += " zstd archives require Python 3.14+."
raise CommandError(message)
zf.extractall(tmp_dir) zf.extractall(tmp_dir)
self.source = Path(tmp_dir) self.source = Path(tmp_dir)
self._run_import() self._run_import()
-53
View File
@@ -1,5 +1,4 @@
from typing import Any from typing import Any
from typing import TypeVar
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
@@ -236,58 +235,6 @@ def permitted_object_ids(
).values_list("id", flat=True) ).values_list("id", flat=True)
ModelT = TypeVar("ModelT", bound=Model)
def user_is_unrestricted(user: User | None) -> bool:
"""
True when ``user`` means "no restriction at all" (an absent user, or an
*active* superuser) without needing a database check to know it.
``permitted_object_ids(None, ...)`` itself means the much narrower "only
unowned rows", which is NOT the same thing as "no user filtering
requested", so callers must special-case this before ever calling it.
A deactivated superuser is deliberately NOT unrestricted here, matching
permitted_object_ids's own is_active-before-is_superuser ordering.
Callers that can avoid a database round trip entirely when this is true
(e.g. checking a single already-loaded object's visibility rather than
filtering a queryset) should do so via this function directly, rather
than through restrict_queryset_to_visible() below.
"""
if user is None:
return True
return (
getattr(user, "is_authenticated", False)
and getattr(user, "is_active", False)
and getattr(user, "is_superuser", False)
)
def restrict_queryset_to_visible(
queryset: QuerySet[ModelT],
user: User | None,
perm: str,
) -> QuerySet[ModelT]:
"""
Restrict ``queryset`` to the rows ``user`` may see with ``perm``.
Delegates the visibility check to the database as a
``WHERE id IN (subquery)`` rather than materializing the full
permitted-id set into a Python collection first: a caller that only
needs to check a small handful of rows (a resolved-id list, a few
RAG-neighbour candidate ids) never pays for scanning or holding the
installation's entire taxonomy in memory to do it.
Returns ``queryset`` unchanged for user_is_unrestricted(user); every
other case is delegated to ``permitted_object_ids`` rather than
re-deciding the ordering here.
"""
if user_is_unrestricted(user):
return queryset
return queryset.filter(pk__in=permitted_object_ids(user, queryset.model, perm))
def permitted_document_ids( def permitted_document_ids(
user: User | None, user: User | None,
*, *,
+1 -21
View File
@@ -223,27 +223,7 @@ class WriteBatch:
) )
time.sleep(sleep_s) time.sleep(sleep_s)
# Open a fresh Index (and thus a fresh Tantivy ManagedDirectory) self._raw_writer = self._backend._index.writer()
# for the write, rather than reusing the process-local cached
# index. ManagedDirectory loads its GC bookkeeping (.managed.json)
# once, at construction, and never re-reads it; paperless runs
# several long-lived processes (Granian workers, Celery workers)
# that take turns writing under the file lock above. A cached,
# long-lived writer index would carry a stale managed-files view
# and, on commit, overwrite .managed.json with that stale view -
# permanently losing track of segment files other processes
# registered in the meantime, so they can never be garbage
# collected. Reopening fresh here always picks up the current
# on-disk state. The long-lived self._backend._index is used for
# reads only and is reloaded (not reopened) after commit below.
write_index = tantivy.Index(
build_schema(),
path=str(self._backend._path),
)
register_tokenizers(write_index, settings.SEARCH_LANGUAGE)
self._raw_writer = write_index.writer()
else:
self._raw_writer = self._backend._index.writer()
return self return self
def __exit__(self, exc_type, exc_val, exc_tb): def __exit__(self, exc_type, exc_val, exc_tb):
+25 -3
View File
@@ -85,7 +85,6 @@ from documents.permissions import set_permissions_for_object
from documents.regex import validate_regex_pattern from documents.regex import validate_regex_pattern
from documents.templating.filepath import validate_filepath_template_and_render from documents.templating.filepath import validate_filepath_template_and_render
from documents.templating.utils import convert_format_str_to_template_format from documents.templating.utils import convert_format_str_to_template_format
from documents.templating.workflows import validate_workflow_template
from documents.validators import uri_validator from documents.validators import uri_validator
from documents.validators import url_validator from documents.validators import url_validator
@@ -3186,10 +3185,33 @@ class WorkflowActionSerializer(serializers.ModelSerializer[WorkflowAction]):
attrs["assign_title"] = None attrs["assign_title"] = None
else: else:
try: try:
validate_workflow_template(attrs["assign_title"]) # test against all placeholders, see consumer.py `parse_doc_title_w_placeholders`
attrs["assign_title"].format(
correspondent="",
document_type="",
added="",
added_year="",
added_year_short="",
added_month="",
added_month_name="",
added_month_name_short="",
added_day="",
added_time="",
owner_username="",
original_filename="",
filename="",
created="",
created_year="",
created_year_short="",
created_month="",
created_month_name="",
created_month_name_short="",
created_day="",
created_time="",
)
except (ValueError, KeyError) as e: except (ValueError, KeyError) as e:
raise serializers.ValidationError( raise serializers.ValidationError(
{"assign_title": f"{e.args[0]}"}, {"assign_title": f'Invalid f-string detected: "{e.args[0]}"'},
) )
if attrs.get("assign_custom_fields_values"): if attrs.get("assign_custom_fields_values"):
-45
View File
@@ -6,11 +6,9 @@ from pathlib import Path
from django.utils.text import slugify as django_slugify from django.utils.text import slugify as django_slugify
from jinja2 import StrictUndefined from jinja2 import StrictUndefined
from jinja2 import Template from jinja2 import Template
from jinja2 import TemplateAssertionError
from jinja2 import TemplateSyntaxError from jinja2 import TemplateSyntaxError
from jinja2 import UndefinedError from jinja2 import UndefinedError
from jinja2 import make_logging_undefined from jinja2 import make_logging_undefined
from jinja2.meta import find_undeclared_variables
from jinja2.sandbox import SecurityError from jinja2.sandbox import SecurityError
from documents.templating.environment import _template_environment from documents.templating.environment import _template_environment
@@ -31,49 +29,6 @@ _template_environment.filters["slugify"] = django_slugify
_template_environment.filters["localize_date"] = localize_date _template_environment.filters["localize_date"] = localize_date
_known_placeholder_names = {
"correspondent",
"document_type",
"added",
"added_year",
"added_year_short",
"added_month",
"added_month_name",
"added_month_name_short",
"added_day",
"added_time",
"owner_username",
"original_filename",
"filename",
"created",
"created_year",
"created_year_short",
"created_month",
"created_month_name",
"created_month_name_short",
"created_day",
"created_time",
"doc_title",
"doc_url",
"doc_id",
}
def validate_workflow_template(text: str) -> None:
try:
ast = _template_environment.parse(text)
undeclared_vars = find_undeclared_variables(ast)
except TemplateAssertionError as e:
raise ValueError(f"Template assertion error: {e}")
except TemplateSyntaxError as e:
raise ValueError(f"Template syntax error: {e}")
unknown_vars = undeclared_vars - _known_placeholder_names
if unknown_vars:
raise KeyError(
f"Template references unknown placeholders: {', '.join(unknown_vars)}",
)
def parse_w_workflow_placeholders( def parse_w_workflow_placeholders(
text: str, text: str,
correspondent_name: str, correspondent_name: str,
@@ -1,208 +0,0 @@
import sys
import zipfile
import pytest
import pytest_mock
from documents.export import compression
class TestCompressionMethods:
def test_choices_always_include_zstd(self) -> None:
"""
GIVEN:
- The compression policy module's CLI choices list
WHEN:
- Read on any runtime
THEN:
- zstd is always present; availability is checked separately so
argparse never hides it based on the current Python version
"""
assert compression.COMPRESSION_CHOICES == (
"stored",
"deflated",
"bzip2",
"lzma",
"zstd",
)
@pytest.mark.parametrize(
("name", "constant"),
[
("stored", zipfile.ZIP_STORED),
("deflated", zipfile.ZIP_DEFLATED),
("bzip2", zipfile.ZIP_BZIP2),
("lzma", zipfile.ZIP_LZMA),
],
)
def test_method_maps_to_zipfile_constant(self, name: str, constant: int) -> None:
"""
GIVEN:
- A compression method name
WHEN:
- Looked up in COMPRESSION_METHODS
THEN:
- It maps to the matching zipfile compression constant
"""
assert compression.COMPRESSION_METHODS[name] == constant
def test_stored_and_deflated_always_available(self) -> None:
"""
GIVEN:
- The stored and deflated compression methods
WHEN:
- Checked with compression_available()
THEN:
- Both are always available (zlib is a hard CPython dependency)
"""
assert compression.compression_available("stored")
assert compression.compression_available("deflated")
def test_zstd_availability_tracks_runtime(self) -> None:
"""
GIVEN:
- The zstd compression method
WHEN:
- Checked with compression_available() on this runtime
THEN:
- Availability matches whether Python is 3.14+
"""
expected: bool = sys.version_info >= (3, 14)
assert compression.compression_available("zstd") == expected
def test_unimportable_module_reports_unavailable(
self,
mocker: pytest_mock.MockerFixture,
) -> None:
"""
GIVEN:
- A compression method whose backing module fails to import
(e.g. a minimal Python build without bz2/lzma compiled in)
WHEN:
- Checked with compression_available()
THEN:
- False is returned rather than the ImportError propagating
"""
mocker.patch(
"documents.export.compression.importlib.import_module",
side_effect=ImportError,
)
assert not compression.compression_available("bzip2")
class TestLevelError:
@pytest.mark.parametrize(
("method", "level"),
[
("deflated", 0),
("deflated", 9),
("bzip2", 1),
("bzip2", 9),
("zstd", -22),
("zstd", 22),
("deflated", None),
("stored", None),
],
)
def test_valid_levels_return_none(self, method: str, level: int | None) -> None:
"""
GIVEN:
- A method and a level within its valid bounds (or no level)
WHEN:
- Checked with level_error()
THEN:
- No error message is returned
"""
assert compression.level_error(method, level) is None
@pytest.mark.parametrize(
("method", "level"),
[
("deflated", 10),
("deflated", -1),
("bzip2", 0),
("bzip2", 10),
("zstd", -23),
("zstd", 23),
],
)
def test_out_of_range_levels_return_message(
self,
method: str,
level: int,
) -> None:
"""
GIVEN:
- A method and a level outside its valid bounds
WHEN:
- Checked with level_error()
THEN:
- An error message naming the valid range is returned
"""
msg: str | None = compression.level_error(method, level)
assert msg is not None
assert "between" in msg
@pytest.mark.parametrize("method", ["stored", "lzma"])
def test_level_on_levelless_method_is_rejected(self, method: str) -> None:
"""
GIVEN:
- A method that ignores compression level (stored, lzma)
WHEN:
- A level is passed to level_error() anyway
THEN:
- An error message noting the level has no effect is returned
"""
msg: str | None = compression.level_error(method, 5)
assert msg is not None
assert "no effect" in msg
class TestCompressTypeReadable:
@pytest.mark.parametrize("ct", [zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED])
def test_stored_and_deflated_always_readable(self, ct: int) -> None:
"""
GIVEN:
- A stored or deflated compress_type id
WHEN:
- Checked with compress_type_readable()
THEN:
- It is always readable
"""
assert compression.compress_type_readable(ct)
def test_zstd_compress_type_readability_tracks_runtime(self) -> None:
"""
GIVEN:
- The zstd compress_type id (93, ZIP_ZSTANDARD)
WHEN:
- Checked with compress_type_readable() on this runtime
THEN:
- Readability matches whether Python is 3.14+
"""
expected: bool = sys.version_info >= (3, 14)
assert compression.compress_type_readable(93) == expected
def test_unknown_compress_type_is_unreadable(self) -> None:
"""
GIVEN:
- An unrecognized compress_type id
WHEN:
- Checked with compress_type_readable()
THEN:
- It is reported as unreadable
"""
assert not compression.compress_type_readable(9999)
def test_unreadable_method_names_lists_methods(self) -> None:
"""
GIVEN:
- A set containing an unknown compress_type id
WHEN:
- Passed to unreadable_method_names()
THEN:
- It is reported generically as "method <id>"
"""
# An unknown method id maps to no name and is reported generically.
names: set[str] = compression.unreadable_method_names({9999})
assert names == {"method 9999"}

Some files were not shown because too many files have changed in this diff Show More