mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-17 16:23:18 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e47562d0d8 | ||
|
|
d131dd9c28 | ||
|
|
9f535bebf4 | ||
|
|
9fd40dead8 | ||
|
|
b38e0555f0 | ||
|
|
0d64d09973 | ||
|
|
c3d24db024 | ||
|
|
3e57fb5f2c |
@@ -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.
|
||||||
@@ -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: 0–9, bzip2: 1–9, zstd: -22–22; 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
@@ -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(', ')"/></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>"<x id="PH" equiv-text="items[0].name"/>"</source>
|
<source>"<x id="PH" equiv-text="items[0].name"/>"</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>"<x id="PH" equiv-text="items[0].name"/>" and "<x id="PH_1" equiv-text="items[1].name"/>"</source>
|
<source>"<x id="PH" equiv-text="items[0].name"/>" and "<x id="PH_1" equiv-text="items[1].name"/>"</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 'modify "tag1" and "tag2"'</note>
|
<note priority="1" from="description">This is for messages like 'modify "tag1" and "tag2"'</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
@@ -8591,7 +8542,7 @@
|
|||||||
<source><x id="PH" equiv-text="list"/> and "<x id="PH_1" equiv-text="items[items.length - 1].name"/>"</source>
|
<source><x id="PH" equiv-text="list"/> and "<x id="PH_1" equiv-text="items[items.length - 1].name"/>"</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 'modify "tag1", "tag2" and "tag3"'</note>
|
<note priority="1" from="description">this is for messages like 'modify "tag1", "tag2" and "tag3"'</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 "<x id="PH" equiv-text="tag.name"/>" to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
<source>This operation will add the tag "<x id="PH" equiv-text="tag.name"/>" 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 "<x id="PH" equiv-text="tag.name"/>" from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
<source>This operation will remove the tag "<x id="PH" equiv-text="tag.name"/>" 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 "<x id="PH" equiv-text="correspondent.name"/>" to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
<source>This operation will assign the correspondent "<x id="PH" equiv-text="correspondent.name"/>" 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 "<x id="PH" equiv-text="documentType.name"/>" to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
<source>This operation will assign the document type "<x id="PH" equiv-text="documentType.name"/>" 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 "<x id="PH" equiv-text="storagePath.name"/>" to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
<source>This operation will assign the storage path "<x id="PH" equiv-text="storagePath.name"/>" 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 "<x id="PH" equiv-text="customField.name"/>" to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
<source>This operation will assign the custom field "<x id="PH" equiv-text="customField.name"/>" 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 "<x id="PH" equiv-text="customField.name"/>" from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
<source>This operation will remove the custom field "<x id="PH" equiv-text="customField.name"/>" 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
|
||||||
|
|||||||
+2
-2
@@ -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>
|
||||||
|
|||||||
+2
-2
@@ -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>
|
||||||
|
|||||||
+2
-2
@@ -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>
|
||||||
|
|||||||
+3
-3
@@ -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>
|
||||||
|
|||||||
+4
-63
@@ -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')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
+9
-25
@@ -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>
|
||||||
|
|||||||
+3
-3
@@ -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">
|
||||||
|
|||||||
-52
@@ -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(
|
||||||
|
|||||||
-53
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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({
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
+36
-38
@@ -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>
|
||||||
|
|||||||
+1
-1
@@ -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()
|
||||||
|
|||||||
+1
-1
@@ -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"
|
||||||
|
|||||||
+4
-4
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
+908
-1415
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+911
-1418
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+910
-1417
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+934
-1441
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+1098
-1605
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+2152
-2658
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+945
-1452
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+910
-1417
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+908
-1415
File diff suppressed because it is too large
Load Diff
+29
-68
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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"):
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user