mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Compare commits
	
		
			27 Commits
		
	
	
		
			fix-codeco
			...
			bd73555ecc
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| ![dependabot[bot]](/assets/img/avatar_default.png)  | bd73555ecc | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 613c922dd2 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 1659aa08e4 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 68dfb4a930 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 3c439b970f | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 962f7994d1 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 93eea80f3e | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 5bc27eb4b2 | ||
|   | b19701cb96 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 9c552bc2d7 | ||
|   | 80fabb0b56 | ||
|   | af1c235af5 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 92ee906701 | ||
|   | d6710de486 | ||
|   | f71b13b82a | ||
|   | 3df43d828a | ||
|   | 643e2b4a8e | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 6fa896df39 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 6aeb5a5503 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 86dbeb3a27 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | e97217f267 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 05d5d7e796 | ||
|   | e8957de4a7 | ||
|   | 1717517e70 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | af544177d4 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 766af6a48a | ||
|   | e985051890 | 
							
								
								
									
										475
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										475
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							| @@ -25,7 +25,7 @@ jobs: | ||||
|     steps: | ||||
|       - name: Check if workflow should run | ||||
|         id: check | ||||
|         uses: actions/github-script@v7 | ||||
|         uses: actions/github-script@v8 | ||||
|         with: | ||||
|           github-token: ${{ secrets.GITHUB_TOKEN }} | ||||
|           script: | | ||||
| @@ -69,7 +69,7 @@ jobs: | ||||
|       - name: Checkout repository | ||||
|         uses: actions/checkout@v5 | ||||
|       - name: Install python | ||||
|         uses: actions/setup-python@v5 | ||||
|         uses: actions/setup-python@v6 | ||||
|         with: | ||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} | ||||
|       - name: Check files | ||||
| @@ -84,7 +84,7 @@ jobs: | ||||
|         uses: actions/checkout@v5 | ||||
|       - name: Set up Python | ||||
|         id: setup-python | ||||
|         uses: actions/setup-python@v5 | ||||
|         uses: actions/setup-python@v6 | ||||
|         with: | ||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} | ||||
|       - name: Install uv | ||||
| @@ -138,7 +138,7 @@ jobs: | ||||
|           docker compose --file ${{ github.workspace }}/docker/compose/docker-compose.ci-test.yml up --detach | ||||
|       - name: Set up Python | ||||
|         id: setup-python | ||||
|         uses: actions/setup-python@v5 | ||||
|         uses: actions/setup-python@v6 | ||||
|         with: | ||||
|           python-version: "${{ matrix.python-version }}" | ||||
|       - name: Install uv | ||||
| @@ -183,13 +183,11 @@ jobs: | ||||
|         if: always() | ||||
|         uses: codecov/test-results-action@v1 | ||||
|         with: | ||||
|           token: ${{ secrets.CODECOV_TOKEN }} | ||||
|           flags: backend-python-${{ matrix.python-version }} | ||||
|           files: junit.xml | ||||
|       - name: Upload backend coverage to Codecov | ||||
|         uses: codecov/codecov-action@v5 | ||||
|         with: | ||||
|           token: ${{ secrets.CODECOV_TOKEN }} | ||||
|           flags: backend-python-${{ matrix.python-version }} | ||||
|           files: coverage.xml | ||||
|       - name: Stop containers | ||||
| @@ -209,7 +207,7 @@ jobs: | ||||
|         with: | ||||
|           version: 10 | ||||
|       - name: Use Node.js 20 | ||||
|         uses: actions/setup-node@v4 | ||||
|         uses: actions/setup-node@v5 | ||||
|         with: | ||||
|           node-version: 20.x | ||||
|           cache: 'pnpm' | ||||
| @@ -242,7 +240,7 @@ jobs: | ||||
|         with: | ||||
|           version: 10 | ||||
|       - name: Use Node.js 20 | ||||
|         uses: actions/setup-node@v4 | ||||
|         uses: actions/setup-node@v5 | ||||
|         with: | ||||
|           node-version: 20.x | ||||
|           cache: 'pnpm' | ||||
| @@ -265,13 +263,11 @@ jobs: | ||||
|         uses: codecov/test-results-action@v1 | ||||
|         if: always() | ||||
|         with: | ||||
|           token: ${{ secrets.CODECOV_TOKEN }} | ||||
|           flags: frontend-node-${{ matrix.node-version }} | ||||
|           directory: src-ui/ | ||||
|       - name: Upload frontend coverage to Codecov | ||||
|         uses: codecov/codecov-action@v5 | ||||
|         with: | ||||
|           token: ${{ secrets.CODECOV_TOKEN }} | ||||
|           flags: frontend-node-${{ matrix.node-version }} | ||||
|           directory: src-ui/coverage/ | ||||
|   tests-frontend-e2e: | ||||
| @@ -292,7 +288,7 @@ jobs: | ||||
|         with: | ||||
|           version: 10 | ||||
|       - name: Use Node.js 20 | ||||
|         uses: actions/setup-node@v4 | ||||
|         uses: actions/setup-node@v5 | ||||
|         with: | ||||
|           node-version: 20.x | ||||
|           cache: 'pnpm' | ||||
| @@ -322,455 +318,6 @@ jobs: | ||||
|         run: cd src-ui && pnpm exec playwright install | ||||
|       - name: Run Playwright e2e tests | ||||
|         run: cd src-ui && pnpm exec playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }} | ||||
|   codecov-comment: | ||||
|     name: "Codecov PR Comment" | ||||
|     runs-on: ubuntu-24.04 | ||||
|     needs: | ||||
|       - tests-backend | ||||
|       - tests-frontend | ||||
|       - tests-frontend-e2e | ||||
|     if: github.event_name == 'pull_request' | ||||
|     permissions: | ||||
|       contents: read | ||||
|       pull-requests: write | ||||
|     steps: | ||||
|       - name: Gather pull request context | ||||
|         id: pr | ||||
|         uses: actions/github-script@v7 | ||||
|         with: | ||||
|           script: | | ||||
|             const pr = context.payload.pull_request; | ||||
|             if (!pr) { | ||||
|               core.info('No associated pull request. Skipping.'); | ||||
|               core.setOutput('shouldRun', 'false'); | ||||
|               return; | ||||
|             } | ||||
|  | ||||
|             core.setOutput('shouldRun', 'true'); | ||||
|             core.setOutput('prNumber', pr.number.toString()); | ||||
|             core.setOutput('headSha', pr.head.sha); | ||||
|       - name: Fetch Codecov coverage | ||||
|         id: coverage | ||||
|         if: steps.pr.outputs.shouldRun == 'true' | ||||
|         uses: actions/github-script@v7 | ||||
|         env: | ||||
|           COMMIT_SHA: ${{ steps.pr.outputs.headSha }} | ||||
|           PR_NUMBER: ${{ steps.pr.outputs.prNumber }} | ||||
|         with: | ||||
|           script: | | ||||
|             const commitSha = process.env.COMMIT_SHA; | ||||
|             const prNumber = process.env.PR_NUMBER; | ||||
|             const owner = context.repo.owner; | ||||
|             const repo = context.repo.repo; | ||||
|             const service = 'gh'; | ||||
|             const baseUrl = `https://api.codecov.io/api/v2/${service}/${owner}/repos/${repo}`; | ||||
|             const commitUrl = `${baseUrl}/commits/${commitSha}`; | ||||
|             const maxAttempts = 20; | ||||
|             const waitMs = 15000; | ||||
|             const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); | ||||
|  | ||||
|             let data; | ||||
|             for (let attempt = 1; attempt <= maxAttempts; attempt++) { | ||||
|               core.info(`Fetching Codecov report (attempt ${attempt}/${maxAttempts})`); | ||||
|               let response; | ||||
|               try { | ||||
|                 response = await fetch(commitUrl, { | ||||
|                   headers: { | ||||
|                     'Content-Type': 'application/json', | ||||
|                     Accept: 'application/json', | ||||
|                   }, | ||||
|                 }); | ||||
|               } catch (error) { | ||||
|                 core.warning(`Codecov fetch failed: ${error}. Waiting before retrying.`); | ||||
|                 await sleep(waitMs); | ||||
|                 continue; | ||||
|               } | ||||
|  | ||||
|               if (response.status === 404) { | ||||
|                 core.info('Report not ready yet (404). Waiting before retrying.'); | ||||
|                 await sleep(waitMs); | ||||
|                 continue; | ||||
|               } | ||||
|  | ||||
|               if ([429, 500, 502, 503, 504].includes(response.status)) { | ||||
|                 const text = await response.text().catch(() => ''); | ||||
|                 core.info(`Codecov API transient error ${response.status}: ${text}. Waiting before retrying.`); | ||||
|                 await sleep(waitMs); | ||||
|                 continue; | ||||
|               } | ||||
|  | ||||
|               if (!response.ok) { | ||||
|                 const text = await response.text().catch(() => ''); | ||||
|                 core.warning(`Codecov API returned ${response.status}: ${text}. Skipping comment.`); | ||||
|                 core.setOutput('shouldComment', 'false'); | ||||
|                 return; | ||||
|               } | ||||
|  | ||||
|               data = await response.json().catch((error) => { | ||||
|                 core.warning(`Failed to parse Codecov response: ${error}.`); | ||||
|                 return undefined; | ||||
|               }); | ||||
|               if (data && Object.keys(data).length > 0) { | ||||
|                 break; | ||||
|               } | ||||
|  | ||||
|               core.info('Report payload empty. Waiting before retrying.'); | ||||
|               await sleep(waitMs); | ||||
|             } | ||||
|  | ||||
|             if (!data && prNumber) { | ||||
|               core.info('Attempting to retrieve coverage from PR endpoint.'); | ||||
|               const prUrl = `${baseUrl}/pulls/${prNumber}`; | ||||
|               let prResponse; | ||||
|               try { | ||||
|                 prResponse = await fetch(prUrl, { | ||||
|                   headers: { | ||||
|                     'Content-Type': 'application/json', | ||||
|                     Accept: 'application/json', | ||||
|                   }, | ||||
|                 }); | ||||
|               } catch (error) { | ||||
|                 core.warning(`Codecov PR fetch failed: ${error}.`); | ||||
|               } | ||||
|  | ||||
|               if (prResponse) { | ||||
|                 if ([429, 500, 502, 503, 504].includes(prResponse.status)) { | ||||
|                   const text = await prResponse.text().catch(() => ''); | ||||
|                   core.info(`Codecov PR endpoint transient error ${prResponse.status}: ${text}.`); | ||||
|                 } else if (!prResponse.ok) { | ||||
|                   const text = await prResponse.text().catch(() => ''); | ||||
|                   core.warning(`Codecov PR endpoint returned ${prResponse.status}: ${text}.`); | ||||
|                 } else { | ||||
|                   const prData = await prResponse.json().catch((error) => { | ||||
|                     core.warning(`Failed to parse Codecov PR response: ${error}.`); | ||||
|                     return undefined; | ||||
|                   }); | ||||
|  | ||||
|                   if (prData?.latest_report) { | ||||
|                     data = { report: prData.latest_report }; | ||||
|                   } else if (prData?.head_totals) { | ||||
|                     const headTotals = prData.head_totals; | ||||
|                     const baseTotals = prData.base_totals; | ||||
|                     let compareTotals; | ||||
|                     if (baseTotals && headTotals) { | ||||
|                       const headCoverage = Number(headTotals.coverage); | ||||
|                       const baseCoverage = Number(baseTotals.coverage); | ||||
|                       if (Number.isFinite(headCoverage) && Number.isFinite(baseCoverage)) { | ||||
|                         compareTotals = { | ||||
|                           base_coverage: baseCoverage, | ||||
|                           coverage_change: headCoverage - baseCoverage, | ||||
|                         }; | ||||
|                       } | ||||
|                     } | ||||
|  | ||||
|                     data = { | ||||
|                       report: { | ||||
|                         totals: headTotals, | ||||
|                         compare: compareTotals ? { totals: compareTotals } : undefined, | ||||
|                         totals_by_flag: [], | ||||
|                       }, | ||||
|                       head_totals: headTotals, | ||||
|                       base_totals: baseTotals, | ||||
|                     }; | ||||
|                   } else { | ||||
|                     data = prData; | ||||
|                   } | ||||
|                 } | ||||
|               } | ||||
|             } | ||||
|  | ||||
|             if (!data) { | ||||
|               core.warning('Unable to retrieve Codecov report after multiple attempts.'); | ||||
|               core.setOutput('shouldComment', 'false'); | ||||
|               return; | ||||
|             } | ||||
|  | ||||
|             const toNumber = (value) => { | ||||
|               if (value === null || value === undefined || value === '') { | ||||
|                 return undefined; | ||||
|               } | ||||
|               const num = Number(value); | ||||
|               return Number.isFinite(num) ? num : undefined; | ||||
|             }; | ||||
|  | ||||
|             const reportData = data.report || data; | ||||
|             const totals = reportData.totals ?? data.head_totals ?? data.totals; | ||||
|             if (!totals) { | ||||
|               core.warning('Codecov response does not contain coverage totals.'); | ||||
|               core.setOutput('shouldComment', 'false'); | ||||
|               return; | ||||
|             } | ||||
|  | ||||
|             let compareTotals = reportData.compare?.totals ?? data.compare?.totals; | ||||
|             if (!compareTotals && data.base_totals) { | ||||
|               const baseCoverageValue = toNumber(data.base_totals.coverage); | ||||
|               if (baseCoverageValue !== undefined) { | ||||
|                 const headCoverageValue = toNumber((data.head_totals ?? {}).coverage); | ||||
|                 compareTotals = { | ||||
|                   base_coverage: baseCoverageValue, | ||||
|                   coverage_change: | ||||
|                     headCoverageValue !== undefined ? headCoverageValue - baseCoverageValue : undefined, | ||||
|                 }; | ||||
|               } | ||||
|             } | ||||
|  | ||||
|             const coverage = toNumber(totals.coverage); | ||||
|             const baseCoverage = toNumber(compareTotals?.base_coverage ?? compareTotals?.base); | ||||
|             let delta = toNumber( | ||||
|               compareTotals?.coverage_change ?? | ||||
|               compareTotals?.coverage_diff ?? | ||||
|               totals.delta ?? | ||||
|               totals.diff ?? | ||||
|               totals.change, | ||||
|             ); | ||||
|  | ||||
|             if (delta === undefined && coverage !== undefined && baseCoverage !== undefined) { | ||||
|               delta = coverage - baseCoverage; | ||||
|             } | ||||
|  | ||||
|             const formatPercent = (value) => { | ||||
|               if (value === undefined) return '—'; | ||||
|               return `${value.toFixed(2)}%`; | ||||
|             }; | ||||
|  | ||||
|             const formatDelta = (value) => { | ||||
|               if (value === undefined) return '—'; | ||||
|               const sign = value >= 0 ? '+' : ''; | ||||
|               return `${sign}${value.toFixed(2)}%`; | ||||
|             }; | ||||
|  | ||||
|             const shortSha = commitSha.slice(0, 7); | ||||
|             const reportBaseUrl = `https://app.codecov.io/gh/${owner}/${repo}`; | ||||
|             const commitReportUrl = `${reportBaseUrl}/commit/${commitSha}?src=pr&el=comment`; | ||||
|             const prReportUrl = prNumber | ||||
|               ? `${reportBaseUrl}/pull/${prNumber}?src=pr&el=comment` | ||||
|               : commitReportUrl; | ||||
|  | ||||
|             const findBaseCommitSha = () => | ||||
|               data?.report?.compare?.base_commitid ?? | ||||
|               data?.report?.compare?.base?.commitid ?? | ||||
|               data?.report?.base_commitid ?? | ||||
|               data?.compare?.base_commitid ?? | ||||
|               data?.compare?.base?.commitid ?? | ||||
|               data?.base_commitid ?? | ||||
|               data?.base?.commitid; | ||||
|  | ||||
|             const baseCommitSha = findBaseCommitSha(); | ||||
|             const baseCommitUrl = baseCommitSha | ||||
|               ? `${reportBaseUrl}/commit/${baseCommitSha}?src=pr&el=comment` | ||||
|               : undefined; | ||||
|             const baseShortSha = baseCommitSha ? baseCommitSha.slice(0, 7) : undefined; | ||||
|  | ||||
|             const lines = ['<!-- codecov-coverage-comment -->']; | ||||
|             lines.push(`## [Codecov](${prReportUrl}) Report`); | ||||
|             lines.push(''); | ||||
|  | ||||
|             if (coverage !== undefined) { | ||||
|               lines.push(`:white_check_mark: Project coverage for \`${shortSha}\` is ${formatPercent(coverage)}.`); | ||||
|             } else { | ||||
|               lines.push(':warning: Coverage for the head commit is unavailable.'); | ||||
|             } | ||||
|  | ||||
|             if (baseCoverage !== undefined) { | ||||
|               const changeEmoji = delta === undefined ? ':grey_question:' : delta >= 0 ? ':white_check_mark:' : ':small_red_triangle_down:'; | ||||
|               const baseCoverageText = `Base${baseShortSha ? ` \`${baseShortSha}\`` : ''} ${formatPercent(baseCoverage)}`; | ||||
|               const baseLink = baseCommitUrl ? `[${baseCoverageText}](${baseCommitUrl})` : baseCoverageText; | ||||
|               const changeText = | ||||
|                 delta !== undefined | ||||
|                   ? `${baseLink} (${formatDelta(delta)})` | ||||
|                   : `${baseLink} (change unknown)`; | ||||
|               lines.push(`${changeEmoji} ${changeText}.`); | ||||
|             } | ||||
|  | ||||
|             lines.push(`:clipboard: [View full report on Codecov](${commitReportUrl}).`); | ||||
|  | ||||
|             const normalizeTotals = (value) => { | ||||
|               if (!value) return undefined; | ||||
|               if (value.totals && typeof value.totals === 'object') return value.totals; | ||||
|               return value; | ||||
|             }; | ||||
|  | ||||
|             const headTotals = normalizeTotals(totals) ?? {}; | ||||
|             const baseTotals = | ||||
|               normalizeTotals(data.base_totals) ?? | ||||
|               normalizeTotals(reportData.base_totals) ?? | ||||
|               normalizeTotals(reportData.compare?.base_totals) ?? | ||||
|               normalizeTotals(reportData.compare?.base); | ||||
|  | ||||
|             const formatInteger = (value) => { | ||||
|               if (value === undefined) return '—'; | ||||
|               return value.toLocaleString('en-US'); | ||||
|             }; | ||||
|  | ||||
|             const formatIntegerDelta = (value) => { | ||||
|               if (value === undefined) return '—'; | ||||
|               const sign = value >= 0 ? '+' : ''; | ||||
|               return `${sign}${value.toLocaleString('en-US')}`; | ||||
|             }; | ||||
|  | ||||
|             const getInteger = (value) => { | ||||
|               const num = toNumber(value); | ||||
|               return Number.isFinite(num) ? Math.round(num) : undefined; | ||||
|             }; | ||||
|  | ||||
|             const metrics = []; | ||||
|             metrics.push({ | ||||
|               label: 'Coverage', | ||||
|               base: baseCoverage, | ||||
|               head: coverage, | ||||
|               diff: delta, | ||||
|               format: formatPercent, | ||||
|               formatDiff: formatDelta, | ||||
|             }); | ||||
|  | ||||
|             const pushIntegerMetric = (label, headValueRaw, baseValueRaw) => { | ||||
|               const headValue = getInteger(headValueRaw); | ||||
|               const baseValue = getInteger(baseValueRaw); | ||||
|               if (headValue === undefined && baseValue === undefined) { | ||||
|                 return; | ||||
|               } | ||||
|               const diff = headValue !== undefined && baseValue !== undefined ? headValue - baseValue : undefined; | ||||
|               metrics.push({ | ||||
|                 label, | ||||
|                 base: baseValue, | ||||
|                 head: headValue, | ||||
|                 diff, | ||||
|                 format: formatInteger, | ||||
|                 formatDiff: formatIntegerDelta, | ||||
|               }); | ||||
|             }; | ||||
|  | ||||
|             pushIntegerMetric('Files', headTotals.files, baseTotals?.files); | ||||
|             pushIntegerMetric('Lines', headTotals.lines, baseTotals?.lines); | ||||
|             pushIntegerMetric('Branches', headTotals.branches, baseTotals?.branches); | ||||
|             pushIntegerMetric('Hits', headTotals.hits, baseTotals?.hits); | ||||
|             pushIntegerMetric('Misses', headTotals.misses, baseTotals?.misses); | ||||
|  | ||||
|             const hasMetricData = metrics.some((metric) => metric.base !== undefined || metric.head !== undefined); | ||||
|             if (hasMetricData) { | ||||
|               lines.push(''); | ||||
|               lines.push('<details><summary>Coverage summary</summary>'); | ||||
|               lines.push(''); | ||||
|               lines.push('| Metric | Base | Head | Δ |'); | ||||
|               lines.push('| --- | --- | --- | --- |'); | ||||
|               for (const metric of metrics) { | ||||
|                 const baseValue = metric.base !== undefined ? metric.format(metric.base) : '—'; | ||||
|                 const headValue = metric.head !== undefined ? metric.format(metric.head) : '—'; | ||||
|                 const diffValue = metric.diff !== undefined ? metric.formatDiff(metric.diff) : '—'; | ||||
|                 lines.push(`| ${metric.label} | ${baseValue} | ${headValue} | ${diffValue} |`); | ||||
|               } | ||||
|               lines.push(''); | ||||
|               lines.push('</details>'); | ||||
|             } | ||||
|  | ||||
|             const normalizeEntries = (raw) => { | ||||
|               if (!raw) return []; | ||||
|               if (Array.isArray(raw)) return raw; | ||||
|               if (typeof raw === 'object') { | ||||
|                 return Object.entries(raw).map(([name, totals]) => ({ name, ...(typeof totals === 'object' ? totals : { coverage: totals }) })); | ||||
|               } | ||||
|               return []; | ||||
|             }; | ||||
|  | ||||
|             const buildTableRows = (entries) => { | ||||
|               const rows = []; | ||||
|               for (const entry of entries) { | ||||
|                 const label = entry.flag ?? entry.name ?? entry.component ?? entry.id; | ||||
|                 const entryTotals = entry.totals ?? entry; | ||||
|                 const entryCoverage = toNumber(entryTotals?.coverage); | ||||
|                 if (!label || entryCoverage === undefined) { | ||||
|                   continue; | ||||
|                 } | ||||
|                 const entryDelta = toNumber( | ||||
|                   entryTotals?.coverage_change ?? | ||||
|                   entryTotals?.coverage_diff ?? | ||||
|                   entryTotals?.delta ?? | ||||
|                   entryTotals?.diff ?? | ||||
|                   entryTotals?.change, | ||||
|                 ); | ||||
|                 const coverageText = entryCoverage !== undefined ? `\`${formatPercent(entryCoverage)}\`` : '—'; | ||||
|                 const deltaText = entryDelta !== undefined ? `\`${formatDelta(entryDelta)}\`` : '—'; | ||||
|                 rows.push(`| ${label} | ${coverageText} | ${deltaText} |`); | ||||
|               } | ||||
|               return rows; | ||||
|             }; | ||||
|  | ||||
|             const componentEntries = normalizeEntries(reportData.components ?? data.components); | ||||
|             const flagEntries = normalizeEntries(reportData.totals_by_flag ?? data.totals_by_flag); | ||||
|  | ||||
|             if (componentEntries.length) { | ||||
|               const componentsLink = prNumber | ||||
|                 ? `${reportBaseUrl}/pull/${prNumber}/components?src=pr&el=components` | ||||
|                 : `${commitReportUrl}`; | ||||
|               const componentRows = buildTableRows(componentEntries); | ||||
|               if (componentRows.length) { | ||||
|                 lines.push(''); | ||||
|                 lines.push(`[Components report](${componentsLink})`); | ||||
|                 lines.push(''); | ||||
|                 lines.push('| Component | Coverage | Δ |'); | ||||
|                 lines.push('| --- | --- | --- |'); | ||||
|                 lines.push(...componentRows); | ||||
|               } | ||||
|             } | ||||
|  | ||||
|             if (flagEntries.length) { | ||||
|               const flagsLink = prNumber | ||||
|                 ? `${reportBaseUrl}/pull/${prNumber}/flags?src=pr&el=flags` | ||||
|                 : `${commitReportUrl}`; | ||||
|               const flagRows = buildTableRows(flagEntries); | ||||
|               if (flagRows.length) { | ||||
|                 lines.push(''); | ||||
|                 lines.push(`[Flags report](${flagsLink})`); | ||||
|                 lines.push(''); | ||||
|                 lines.push('| Flag | Coverage | Δ |'); | ||||
|                 lines.push('| --- | --- | --- |'); | ||||
|                 lines.push(...flagRows); | ||||
|               } | ||||
|             } | ||||
|  | ||||
|             const commentBody = lines.join('\n'); | ||||
|             const shouldComment = coverage !== undefined; | ||||
|             core.setOutput('shouldComment', shouldComment ? 'true' : 'false'); | ||||
|             if (shouldComment) { | ||||
|               core.setOutput('commentBody', commentBody); | ||||
|             } | ||||
|       - name: Upsert coverage comment | ||||
|         if: steps.pr.outputs.shouldRun == 'true' && steps.coverage.outputs.shouldComment == 'true' | ||||
|         uses: actions/github-script@v7 | ||||
|         env: | ||||
|           PR_NUMBER: ${{ steps.pr.outputs.prNumber }} | ||||
|           COMMENT_BODY: ${{ steps.coverage.outputs.commentBody }} | ||||
|         with: | ||||
|           script: | | ||||
|             const prNumber = Number(process.env.PR_NUMBER); | ||||
|             const body = process.env.COMMENT_BODY; | ||||
|             const marker = '<!-- codecov-coverage-comment -->'; | ||||
|  | ||||
|             const { data: comments } = await github.rest.issues.listComments({ | ||||
|               owner: context.repo.owner, | ||||
|               repo: context.repo.repo, | ||||
|               issue_number: prNumber, | ||||
|               per_page: 100, | ||||
|             }); | ||||
|  | ||||
|             const existing = comments.find((comment) => comment.body?.includes(marker)); | ||||
|             if (existing) { | ||||
|               core.info(`Updating existing coverage comment (id: ${existing.id}).`); | ||||
|               await github.rest.issues.updateComment({ | ||||
|                 owner: context.repo.owner, | ||||
|                 repo: context.repo.repo, | ||||
|                 comment_id: existing.id, | ||||
|                 body, | ||||
|               }); | ||||
|             } else { | ||||
|               core.info('Creating new coverage comment.'); | ||||
|               await github.rest.issues.createComment({ | ||||
|                 owner: context.repo.owner, | ||||
|                 repo: context.repo.repo, | ||||
|                 issue_number: prNumber, | ||||
|                 body, | ||||
|               }); | ||||
|             } | ||||
|   frontend-bundle-analysis: | ||||
|     name: "Frontend Bundle Analysis" | ||||
|     runs-on: ubuntu-24.04 | ||||
| @@ -784,7 +331,7 @@ jobs: | ||||
|         with: | ||||
|           version: 10 | ||||
|       - name: Use Node.js 20 | ||||
|         uses: actions/setup-node@v4 | ||||
|         uses: actions/setup-node@v5 | ||||
|         with: | ||||
|           node-version: 20.x | ||||
|           cache: 'pnpm' | ||||
| @@ -926,7 +473,7 @@ jobs: | ||||
|         uses: actions/checkout@v5 | ||||
|       - name: Set up Python | ||||
|         id: setup-python | ||||
|         uses: actions/setup-python@v5 | ||||
|         uses: actions/setup-python@v6 | ||||
|         with: | ||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} | ||||
|       - name: Install uv | ||||
| @@ -1074,7 +621,7 @@ jobs: | ||||
|           ref: main | ||||
|       - name: Set up Python | ||||
|         id: setup-python | ||||
|         uses: actions/setup-python@v5 | ||||
|         uses: actions/setup-python@v6 | ||||
|         with: | ||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} | ||||
|       - name: Install uv | ||||
| @@ -1106,7 +653,7 @@ jobs: | ||||
|           git commit -am "Changelog ${{ needs.publish-release.outputs.version }} - GHA" | ||||
|           git push origin ${{ needs.publish-release.outputs.version }}-changelog | ||||
|       - name: Create Pull Request | ||||
|         uses: actions/github-script@v7 | ||||
|         uses: actions/github-script@v8 | ||||
|         with: | ||||
|           script: | | ||||
|             const { repo, owner } = context.repo; | ||||
|   | ||||
							
								
								
									
										8
									
								
								.github/workflows/pr-bot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								.github/workflows/pr-bot.yml
									
									
									
									
										vendored
									
									
								
							| @@ -12,7 +12,7 @@ jobs: | ||||
|     steps: | ||||
|       - name: Label PR by file path or branch name | ||||
|         # see .github/labeler.yml for the labeler config | ||||
|         uses: actions/labeler@v5 | ||||
|         uses: actions/labeler@v6 | ||||
|         with: | ||||
|           repo-token: ${{ secrets.GITHUB_TOKEN }} | ||||
|       - name: Label by size | ||||
| @@ -26,7 +26,7 @@ jobs: | ||||
|           fail_if_xl: 'false' | ||||
|           excluded_files: /\.lock$/ /\.txt$/ ^src-ui/pnpm-lock\.yaml$ ^src-ui/messages\.xlf$ ^src/locale/en_US/LC_MESSAGES/django\.po$ | ||||
|       - name: Label by PR title | ||||
|         uses: actions/github-script@v7 | ||||
|         uses: actions/github-script@v8 | ||||
|         with: | ||||
|           script: | | ||||
|             const pr = context.payload.pull_request; | ||||
| @@ -52,7 +52,7 @@ jobs: | ||||
|             } | ||||
|       - name: Label bot-generated PRs | ||||
|         if: ${{ contains(github.actor, 'dependabot') || contains(github.actor, 'crowdin-bot') }} | ||||
|         uses: actions/github-script@v7 | ||||
|         uses: actions/github-script@v8 | ||||
|         with: | ||||
|           script: | | ||||
|             const pr = context.payload.pull_request; | ||||
| @@ -77,7 +77,7 @@ jobs: | ||||
|             } | ||||
|       - name: Welcome comment | ||||
|         if: ${{ !contains(github.actor, 'bot') }} | ||||
|         uses: actions/github-script@v7 | ||||
|         uses: actions/github-script@v8 | ||||
|         with: | ||||
|           script: | | ||||
|             const pr = context.payload.pull_request; | ||||
|   | ||||
							
								
								
									
										8
									
								
								.github/workflows/repo-maintenance.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								.github/workflows/repo-maintenance.yml
									
									
									
									
										vendored
									
									
								
							| @@ -15,7 +15,7 @@ jobs: | ||||
|     if: github.repository_owner == 'paperless-ngx' | ||||
|     runs-on: ubuntu-24.04 | ||||
|     steps: | ||||
|       - uses: actions/stale@v9 | ||||
|       - uses: actions/stale@v10 | ||||
|         with: | ||||
|           days-before-stale: 7 | ||||
|           days-before-close: 14 | ||||
| @@ -57,7 +57,7 @@ jobs: | ||||
|     if: github.repository_owner == 'paperless-ngx' | ||||
|     runs-on: ubuntu-24.04 | ||||
|     steps: | ||||
|       - uses: actions/github-script@v7 | ||||
|       - uses: actions/github-script@v8 | ||||
|         with: | ||||
|           script: | | ||||
|             function sleep(ms) { | ||||
| @@ -114,7 +114,7 @@ jobs: | ||||
|     if: github.repository_owner == 'paperless-ngx' | ||||
|     runs-on: ubuntu-24.04 | ||||
|     steps: | ||||
|       - uses: actions/github-script@v7 | ||||
|       - uses: actions/github-script@v8 | ||||
|         with: | ||||
|           script: | | ||||
|             function sleep(ms) { | ||||
| @@ -206,7 +206,7 @@ jobs: | ||||
|     if: github.repository_owner == 'paperless-ngx' | ||||
|     runs-on: ubuntu-24.04 | ||||
|     steps: | ||||
|       - uses: actions/github-script@v7 | ||||
|       - uses: actions/github-script@v8 | ||||
|         with: | ||||
|           script: | | ||||
|             function sleep(ms) { | ||||
|   | ||||
							
								
								
									
										4
									
								
								.github/workflows/translate-strings.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/translate-strings.yml
									
									
									
									
										vendored
									
									
								
							| @@ -17,7 +17,7 @@ jobs: | ||||
|           ref: ${{ github.head_ref }} | ||||
|       - name: Set up Python | ||||
|         id: setup-python | ||||
|         uses: actions/setup-python@v5 | ||||
|         uses: actions/setup-python@v6 | ||||
|       - name: Install system dependencies | ||||
|         run: | | ||||
|           sudo apt-get update -qq | ||||
| @@ -38,7 +38,7 @@ jobs: | ||||
|         with: | ||||
|           version: 10 | ||||
|       - name: Use Node.js 20 | ||||
|         uses: actions/setup-node@v4 | ||||
|         uses: actions/setup-node@v5 | ||||
|         with: | ||||
|           node-version: 20.x | ||||
|           cache: 'pnpm' | ||||
|   | ||||
| @@ -49,7 +49,7 @@ repos: | ||||
|           - 'prettier-plugin-organize-imports@4.1.0' | ||||
|   # Python hooks | ||||
|   - repo: https://github.com/astral-sh/ruff-pre-commit | ||||
|     rev: v0.13.0 | ||||
|     rev: v0.13.2 | ||||
|     hooks: | ||||
|       - id: ruff-check | ||||
|       - id: ruff-format | ||||
| @@ -59,7 +59,7 @@ repos: | ||||
|       - id: pyproject-fmt | ||||
|   # Dockerfile hooks | ||||
|   - repo: https://github.com/AleksaC/hadolint-py | ||||
|     rev: v2.12.1b3 | ||||
|     rev: v2.14.0 | ||||
|     hooks: | ||||
|       - id: hadolint | ||||
|   # Shell script hooks | ||||
|   | ||||
| @@ -32,7 +32,7 @@ RUN set -eux \ | ||||
| # Purpose: Installs s6-overlay and rootfs | ||||
| # Comments: | ||||
| #  - Don't leave anything extra in here either | ||||
| FROM ghcr.io/astral-sh/uv:0.8.17-python3.12-bookworm-slim AS s6-overlay-base | ||||
| FROM ghcr.io/astral-sh/uv:0.8.22-python3.12-bookworm-slim AS s6-overlay-base | ||||
|  | ||||
| WORKDIR /usr/src/s6 | ||||
|  | ||||
|   | ||||
| @@ -32,7 +32,7 @@ services: | ||||
|     volumes: | ||||
|       - redisdata:/data | ||||
|   db: | ||||
|     image: docker.io/library/postgres:17 | ||||
|     image: docker.io/library/postgres:18 | ||||
|     restart: unless-stopped | ||||
|     volumes: | ||||
|       - pgdata:/var/lib/postgresql/data | ||||
|   | ||||
| @@ -35,7 +35,7 @@ services: | ||||
|     volumes: | ||||
|       - redisdata:/data | ||||
|   db: | ||||
|     image: docker.io/library/postgres:17 | ||||
|     image: docker.io/library/postgres:18 | ||||
|     restart: unless-stopped | ||||
|     volumes: | ||||
|       - pgdata:/var/lib/postgresql/data | ||||
|   | ||||
| @@ -31,7 +31,7 @@ services: | ||||
|     volumes: | ||||
|       - redisdata:/data | ||||
|   db: | ||||
|     image: docker.io/library/postgres:17 | ||||
|     image: docker.io/library/postgres:18 | ||||
|     restart: unless-stopped | ||||
|     volumes: | ||||
|       - pgdata:/var/lib/postgresql/data | ||||
|   | ||||
| @@ -637,7 +637,7 @@ When you first delete a document it is moved to the 'trash' until either it is e | ||||
| You can set how long documents remain in the trash before being automatically deleted with [`PAPERLESS_EMPTY_TRASH_DELAY`](configuration.md#PAPERLESS_EMPTY_TRASH_DELAY), which defaults | ||||
| to 30 days. Until the file is actually deleted (e.g. the trash is emptied), all files and database content remains intact and can be restored at any point up until that time. | ||||
|  | ||||
| Additionally you may configure a directory where deleted files are moved to when they the trash is emptied with [`PAPERLESS_EMPTY_TRASH_DIR`](configuration.md#PAPERLESS_EMPTY_TRASH_DIR). | ||||
| Additionally you may configure a directory where deleted files are moved to when the trash is emptied with [`PAPERLESS_EMPTY_TRASH_DIR`](configuration.md#PAPERLESS_EMPTY_TRASH_DIR). | ||||
| Note that the empty trash directory only stores the original file, the archive file and all database information is permanently removed once a document is fully deleted. | ||||
|  | ||||
| ## Best practices {#basic-searching} | ||||
|   | ||||
| @@ -30,10 +30,10 @@ dependencies = [ | ||||
|   "django-cachalot~=2.8.0", | ||||
|   "django-celery-results~=2.6.0", | ||||
|   "django-compression-middleware~=0.5.0", | ||||
|   "django-cors-headers~=4.8.0", | ||||
|   "django-cors-headers~=4.9.0", | ||||
|   "django-extensions~=4.1", | ||||
|   "django-filter~=25.1", | ||||
|   "django-guardian~=3.1.2", | ||||
|   "django-guardian~=3.2.0", | ||||
|   "django-multiselectfield~=1.0.1", | ||||
|   "django-soft-delete~=1.0.18", | ||||
|   "django-treenode>=0.23.2", | ||||
| @@ -54,7 +54,6 @@ dependencies = [ | ||||
|   "ocrmypdf~=16.11.0", | ||||
|   "pathvalidate~=3.3.1", | ||||
|   "pdf2image~=1.17.0", | ||||
|   "psycopg-pool", | ||||
|   "python-dateutil~=2.9.0", | ||||
|   "python-dotenv~=1.1.0", | ||||
|   "python-gnupg~=0.5.4", | ||||
|   | ||||
| @@ -174,7 +174,7 @@ test('bulk edit', async ({ page }) => { | ||||
|   await expect(page.locator('pngx-document-list')).toHaveText( | ||||
|     /Selected 61 of 61 documents/i | ||||
|   ) | ||||
|   await page.getByRole('button', { name: 'Cancel' }).click() | ||||
|   await page.getByRole('button', { name: 'None' }).click() | ||||
|  | ||||
|   await page.locator('pngx-document-card-small').nth(1).click() | ||||
|   await page.locator('pngx-document-card-small').nth(2).click() | ||||
|   | ||||
| @@ -5,14 +5,14 @@ | ||||
|       <trans-unit id="ngb.alert.close" datatype="html"> | ||||
|         <source>Close</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/alert/alert.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/alert/alert.ts</context> | ||||
|           <context context-type="linenumber">50</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.carousel.slide-number" datatype="html"> | ||||
|         <source> Slide <x id="INTERPOLATION" equiv-text="ueryList<NgbSli"/> of <x id="INTERPOLATION_1" equiv-text="EventSource = N"/> </source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/carousel/carousel.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/carousel/carousel.ts</context> | ||||
|           <context context-type="linenumber">131,135</context> | ||||
|         </context-group> | ||||
|         <note priority="1" from="description">Currently selected slide number read by screen reader</note> | ||||
| @@ -20,212 +20,212 @@ | ||||
|       <trans-unit id="ngb.carousel.previous" datatype="html"> | ||||
|         <source>Previous</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/carousel/carousel.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/carousel/carousel.ts</context> | ||||
|           <context context-type="linenumber">157,159</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.carousel.next" datatype="html"> | ||||
|         <source>Next</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/carousel/carousel.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/carousel/carousel.ts</context> | ||||
|           <context context-type="linenumber">198</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.datepicker.previous-month" datatype="html"> | ||||
|         <source>Previous month</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/datepicker/datepicker-navigation.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/datepicker/datepicker-navigation.ts</context> | ||||
|           <context context-type="linenumber">83,85</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/datepicker/datepicker-navigation.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/datepicker/datepicker-navigation.ts</context> | ||||
|           <context context-type="linenumber">112</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.datepicker.next-month" datatype="html"> | ||||
|         <source>Next month</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/datepicker/datepicker-navigation.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/datepicker/datepicker-navigation.ts</context> | ||||
|           <context context-type="linenumber">112</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/datepicker/datepicker-navigation.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/datepicker/datepicker-navigation.ts</context> | ||||
|           <context context-type="linenumber">112</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.timepicker.HH" datatype="html"> | ||||
|         <source>HH</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.toast.close-aria" datatype="html"> | ||||
|         <source>Close</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.datepicker.select-month" datatype="html"> | ||||
|         <source>Select month</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.pagination.first" datatype="html"> | ||||
|         <source>««</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.timepicker.hours" datatype="html"> | ||||
|         <source>Hours</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.pagination.previous" datatype="html"> | ||||
|         <source>«</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.timepicker.MM" datatype="html"> | ||||
|         <source>MM</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.pagination.next" datatype="html"> | ||||
|         <source>»</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.datepicker.select-year" datatype="html"> | ||||
|         <source>Select year</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.timepicker.minutes" datatype="html"> | ||||
|         <source>Minutes</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.pagination.last" datatype="html"> | ||||
|         <source>»»</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.pagination.first-aria" datatype="html"> | ||||
|         <source>First</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.timepicker.increment-hours" datatype="html"> | ||||
|         <source>Increment hours</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.pagination.previous-aria" datatype="html"> | ||||
|         <source>Previous</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.timepicker.decrement-hours" datatype="html"> | ||||
|         <source>Decrement hours</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.pagination.next-aria" datatype="html"> | ||||
|         <source>Next</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.timepicker.increment-minutes" datatype="html"> | ||||
|         <source>Increment minutes</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.pagination.last-aria" datatype="html"> | ||||
|         <source>Last</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.timepicker.decrement-minutes" datatype="html"> | ||||
|         <source>Decrement minutes</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.timepicker.SS" datatype="html"> | ||||
|         <source>SS</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.timepicker.seconds" datatype="html"> | ||||
|         <source>Seconds</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.timepicker.increment-seconds" datatype="html"> | ||||
|         <source>Increment seconds</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.timepicker.decrement-seconds" datatype="html"> | ||||
|         <source>Decrement seconds</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="ngb.timepicker.PM" datatype="html"> | ||||
|         <source><x id="INTERPOLATION"/></source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/ngb-config.ts</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
| @@ -233,7 +233,7 @@ | ||||
|         <source><x id="INTERPOLATION" equiv-text="barConfig); | ||||
| 	pu"/></source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.2.4_@angular+core@20.2.4_@angular+_db9461b4835bfc9061e01150e14e6256/node_modules/src/progressbar/progressbar.ts</context> | ||||
|           <context context-type="sourcefile">node_modules/.pnpm/@ng-bootstrap+ng-bootstrap@19.0.1_@angular+common@20.3.2_@angular+core@20.3.2_@angular+_4a8591e6ee586bf00b666f6438778cc7/node_modules/src/progressbar/progressbar.ts</context> | ||||
|           <context context-type="linenumber">41,42</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
| @@ -324,7 +324,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context> | ||||
|           <context context-type="linenumber">190</context> | ||||
|           <context context-type="linenumber">192</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.html</context> | ||||
| @@ -743,7 +743,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">114</context> | ||||
|           <context context-type="linenumber">134</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.html</context> | ||||
| @@ -1167,7 +1167,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">217</context> | ||||
|           <context context-type="linenumber">242</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/data/document.ts</context> | ||||
| @@ -1209,7 +1209,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> | ||||
|           <context context-type="linenumber">97</context> | ||||
|           <context context-type="linenumber">78</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context> | ||||
| @@ -1494,10 +1494,6 @@ | ||||
|           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context> | ||||
|           <context context-type="linenumber">182</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> | ||||
|           <context context-type="linenumber">4</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.html</context> | ||||
|           <context context-type="linenumber">81</context> | ||||
| @@ -1604,6 +1600,10 @@ | ||||
|           <context context-type="sourcefile">src/app/components/admin/trash/trash.component.html</context> | ||||
|           <context context-type="linenumber">8</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">153</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context> | ||||
|           <context context-type="linenumber">4</context> | ||||
| @@ -1755,7 +1755,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">244</context> | ||||
|           <context context-type="linenumber">269</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/data/document.ts</context> | ||||
| @@ -1808,7 +1808,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> | ||||
|           <context context-type="linenumber">103</context> | ||||
|           <context context-type="linenumber">87</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.html</context> | ||||
| @@ -2109,7 +2109,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> | ||||
|           <context context-type="linenumber">157</context> | ||||
|           <context context-type="linenumber">140</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.html</context> | ||||
| @@ -2769,11 +2769,11 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> | ||||
|           <context context-type="linenumber">21</context> | ||||
|           <context context-type="linenumber">5</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">199</context> | ||||
|           <context context-type="linenumber">224</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context> | ||||
| @@ -3001,7 +3001,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> | ||||
|           <context context-type="linenumber">129</context> | ||||
|           <context context-type="linenumber">112</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context> | ||||
| @@ -3448,8 +3448,8 @@ | ||||
|           <context context-type="linenumber">27</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> | ||||
|           <context context-type="linenumber">14</context> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">30</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="1496549861742963591" datatype="html"> | ||||
| @@ -3529,7 +3529,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">253</context> | ||||
|           <context context-type="linenumber">278</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/data/document.ts</context> | ||||
| @@ -6356,7 +6356,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">298</context> | ||||
|           <context context-type="linenumber">323</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="78870852467682010" datatype="html"> | ||||
| @@ -6371,7 +6371,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">338</context> | ||||
|           <context context-type="linenumber">363</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="157572966557284263" datatype="html"> | ||||
| @@ -6386,7 +6386,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">345</context> | ||||
|           <context context-type="linenumber">370</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="883965278435032344" datatype="html"> | ||||
| @@ -6404,7 +6404,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">366</context> | ||||
|           <context context-type="linenumber">391</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3542042671420335679" datatype="html"> | ||||
| @@ -6415,7 +6415,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">366</context> | ||||
|           <context context-type="linenumber">391</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="872092479747931526" datatype="html"> | ||||
| @@ -6585,8 +6585,8 @@ | ||||
|           <context context-type="linenumber">5</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> | ||||
|           <context context-type="linenumber">11</context> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">27</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="2266163016683537825" datatype="html"> | ||||
| @@ -6625,7 +6625,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> | ||||
|           <context context-type="linenumber">107</context> | ||||
|           <context context-type="linenumber">91</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="7049887240439736400" datatype="html"> | ||||
| @@ -6686,7 +6686,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">196</context> | ||||
|           <context context-type="linenumber">221</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> | ||||
| @@ -6723,11 +6723,11 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> | ||||
|           <context context-type="linenumber">35</context> | ||||
|           <context context-type="linenumber">19</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">186</context> | ||||
|           <context context-type="linenumber">211</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context> | ||||
| @@ -6750,11 +6750,11 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> | ||||
|           <context context-type="linenumber">49</context> | ||||
|           <context context-type="linenumber">33</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">226</context> | ||||
|           <context context-type="linenumber">251</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context> | ||||
| @@ -6777,11 +6777,11 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> | ||||
|           <context context-type="linenumber">63</context> | ||||
|           <context context-type="linenumber">47</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">235</context> | ||||
|           <context context-type="linenumber">260</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context> | ||||
| @@ -7188,25 +7188,18 @@ | ||||
|           <context context-type="linenumber">10</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="6857598786757174736" datatype="html"> | ||||
|         <source>Select:</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> | ||||
|           <context context-type="linenumber">8</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="6299008920007331381" datatype="html"> | ||||
|         <source>Edit:</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> | ||||
|           <context context-type="linenumber">19</context> | ||||
|           <context context-type="linenumber">3</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="7001227209911602786" datatype="html"> | ||||
|         <source>Filter tags</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> | ||||
|           <context context-type="linenumber">22</context> | ||||
|           <context context-type="linenumber">6</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context> | ||||
| @@ -7217,7 +7210,7 @@ | ||||
|         <source>Filter correspondents</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> | ||||
|           <context context-type="linenumber">36</context> | ||||
|           <context context-type="linenumber">20</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context> | ||||
| @@ -7228,7 +7221,7 @@ | ||||
|         <source>Filter document types</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> | ||||
|           <context context-type="linenumber">50</context> | ||||
|           <context context-type="linenumber">34</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context> | ||||
| @@ -7239,7 +7232,7 @@ | ||||
|         <source>Filter storage paths</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> | ||||
|           <context context-type="linenumber">64</context> | ||||
|           <context context-type="linenumber">48</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context> | ||||
| @@ -7250,7 +7243,7 @@ | ||||
|         <source>Custom fields</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> | ||||
|           <context context-type="linenumber">77</context> | ||||
|           <context context-type="linenumber">61</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context> | ||||
| @@ -7265,56 +7258,56 @@ | ||||
|         <source>Filter custom fields</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> | ||||
|           <context context-type="linenumber">78</context> | ||||
|           <context context-type="linenumber">62</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="5139192806922838657" datatype="html"> | ||||
|         <source>Set values</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> | ||||
|           <context context-type="linenumber">86</context> | ||||
|           <context context-type="linenumber">70</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="1050269006235116171" datatype="html"> | ||||
|         <source>Rotate</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> | ||||
|           <context context-type="linenumber">110</context> | ||||
|           <context context-type="linenumber">94</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3206542606001340679" datatype="html"> | ||||
|         <source>Merge</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> | ||||
|           <context context-type="linenumber">113</context> | ||||
|           <context context-type="linenumber">97</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="1015374532025907183" datatype="html"> | ||||
|         <source>Include:</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> | ||||
|           <context context-type="linenumber">135</context> | ||||
|           <context context-type="linenumber">118</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="1537670659786159738" datatype="html"> | ||||
|         <source>Archived files</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> | ||||
|           <context context-type="linenumber">139</context> | ||||
|           <context context-type="linenumber">122</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="2520291319362448498" datatype="html"> | ||||
|         <source>Original files</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> | ||||
|           <context context-type="linenumber">143</context> | ||||
|           <context context-type="linenumber">126</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="8009862506882713059" datatype="html"> | ||||
|         <source>Use formatted filename</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> | ||||
|           <context context-type="linenumber">148</context> | ||||
|           <context context-type="linenumber">131</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="1215215387232313677" datatype="html"> | ||||
| @@ -7614,7 +7607,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">314</context> | ||||
|           <context context-type="linenumber">339</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="106713086593101376" datatype="html"> | ||||
| @@ -7738,7 +7731,7 @@ | ||||
|         <source>Select</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">6</context> | ||||
|           <context context-type="linenumber">5</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/data/custom-field.ts</context> | ||||
| @@ -7749,36 +7742,51 @@ | ||||
|         <source>Select none</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">9</context> | ||||
|           <context context-type="linenumber">11</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="1512866475468373520" datatype="html"> | ||||
|         <source>Select page</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">10</context> | ||||
|           <context context-type="linenumber">12</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context> | ||||
|           <context context-type="linenumber">313</context> | ||||
|           <context context-type="linenumber">315</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="1494518490116523821" datatype="html"> | ||||
|         <source>Select all</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">11</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context> | ||||
|           <context context-type="linenumber">306</context> | ||||
|           <context context-type="linenumber">308</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="6252070156626006029" datatype="html"> | ||||
|         <source>None</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">23</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> | ||||
|           <context context-type="linenumber">120</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/data/matching-model.ts</context> | ||||
|           <context context-type="linenumber">45</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="8461842260159597706" datatype="html"> | ||||
|         <source>Show</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">17</context> | ||||
|           <context context-type="linenumber">37</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.html</context> | ||||
| @@ -7789,63 +7797,63 @@ | ||||
|         <source>Sort</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">48</context> | ||||
|           <context context-type="linenumber">68</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="2123659921722214537" datatype="html"> | ||||
|         <source>Views</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">74</context> | ||||
|           <context context-type="linenumber">94</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="1233494216161906927" datatype="html"> | ||||
|         <source>Save "<x id="INTERPOLATION" equiv-text="{{list.activeSavedViewTitle}}"/>"</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">93</context> | ||||
|           <context context-type="linenumber">113</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="2276119452079372898" datatype="html"> | ||||
|         <source>Save as...</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">96</context> | ||||
|           <context context-type="linenumber">116</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="1450797155766668235" datatype="html"> | ||||
|         <source>All saved views</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">97</context> | ||||
|           <context context-type="linenumber">117</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="8786996283897742947" datatype="html"> | ||||
|         <source>{VAR_PLURAL, plural, =1 {Selected <x id="INTERPOLATION"/> of one document} other {Selected <x id="INTERPOLATION"/> of <x id="INTERPOLATION_1"/> documents}}</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">117</context> | ||||
|           <context context-type="linenumber">137</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="6600548268163632449" datatype="html"> | ||||
|         <source>{VAR_PLURAL, plural, =1 {One document} other {<x id="INTERPOLATION"/> documents}}</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">121</context> | ||||
|           <context context-type="linenumber">141</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="2243770355958919528" datatype="html"> | ||||
|         <source>(filtered)</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">123</context> | ||||
|           <context context-type="linenumber">143</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="6849725902312323996" datatype="html"> | ||||
|         <source>Reset filters</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">128</context> | ||||
|           <context context-type="linenumber">148</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context> | ||||
| @@ -7856,21 +7864,21 @@ | ||||
|         <source>Error while loading documents</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">144</context> | ||||
|           <context context-type="linenumber">169</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="494022736054110363" datatype="html"> | ||||
|         <source>Sort by ASN</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">173</context> | ||||
|           <context context-type="linenumber">198</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="7517688192215738656" datatype="html"> | ||||
|         <source>ASN</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">177</context> | ||||
|           <context context-type="linenumber">202</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> | ||||
| @@ -7889,28 +7897,28 @@ | ||||
|         <source>Sort by correspondent</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">182</context> | ||||
|           <context context-type="linenumber">207</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="2066713941761361709" datatype="html"> | ||||
|         <source>Sort by title</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">191</context> | ||||
|           <context context-type="linenumber">216</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="6232673011753681091" datatype="html"> | ||||
|         <source>Sort by owner</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">204</context> | ||||
|           <context context-type="linenumber">229</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3715596725146409911" datatype="html"> | ||||
|         <source>Owner</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">208</context> | ||||
|           <context context-type="linenumber">233</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/data/document.ts</context> | ||||
| @@ -7925,49 +7933,49 @@ | ||||
|         <source>Sort by notes</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">213</context> | ||||
|           <context context-type="linenumber">238</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="5499001829734502606" datatype="html"> | ||||
|         <source>Sort by document type</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">222</context> | ||||
|           <context context-type="linenumber">247</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="6213829731736042759" datatype="html"> | ||||
|         <source>Sort by storage path</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">231</context> | ||||
|           <context context-type="linenumber">256</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3406167410329973166" datatype="html"> | ||||
|         <source>Sort by created date</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">240</context> | ||||
|           <context context-type="linenumber">265</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3769035778779263084" datatype="html"> | ||||
|         <source>Sort by added date</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">249</context> | ||||
|           <context context-type="linenumber">274</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="4874754501044009042" datatype="html"> | ||||
|         <source>Sort by number of pages</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">258</context> | ||||
|           <context context-type="linenumber">283</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3817498941817715969" datatype="html"> | ||||
|         <source>Pages</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">262</context> | ||||
|           <context context-type="linenumber">287</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/data/document.ts</context> | ||||
| @@ -7986,77 +7994,77 @@ | ||||
|         <source> Shared </source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">265,267</context> | ||||
|           <context context-type="linenumber">290,292</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="5083658411133224968" datatype="html"> | ||||
|         <source>Sort by <x id="INTERPOLATION" equiv-text="{{getDisplayCustomFieldTitle(field_id)}}"/></source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">272,273</context> | ||||
|           <context context-type="linenumber">297,298</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="2179847500064178686" datatype="html"> | ||||
|         <source>Edit document</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">306</context> | ||||
|           <context context-type="linenumber">331</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3420321797707163677" datatype="html"> | ||||
|         <source>Preview document</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">307</context> | ||||
|           <context context-type="linenumber">332</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="4512084577073831437" datatype="html"> | ||||
|         <source>Reset filters / selection</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context> | ||||
|           <context context-type="linenumber">294</context> | ||||
|           <context context-type="linenumber">296</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="4135055128446167640" datatype="html"> | ||||
|         <source>Open first [selected] document</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context> | ||||
|           <context context-type="linenumber">322</context> | ||||
|           <context context-type="linenumber">324</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3629960544875360046" datatype="html"> | ||||
|         <source>Previous page</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context> | ||||
|           <context context-type="linenumber">338</context> | ||||
|           <context context-type="linenumber">340</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3337301694210287595" datatype="html"> | ||||
|         <source>Next page</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context> | ||||
|           <context context-type="linenumber">350</context> | ||||
|           <context context-type="linenumber">352</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="2155249406916744630" datatype="html"> | ||||
|         <source>View "<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>" saved successfully.</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context> | ||||
|           <context context-type="linenumber">383</context> | ||||
|           <context context-type="linenumber">385</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="4646273665293421938" datatype="html"> | ||||
|         <source>Failed to save view "<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>".</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context> | ||||
|           <context context-type="linenumber">389</context> | ||||
|           <context context-type="linenumber">391</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="6837554170707123455" datatype="html"> | ||||
|         <source>View "<x id="PH" equiv-text="savedView.name"/>" created successfully.</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context> | ||||
|           <context context-type="linenumber">435</context> | ||||
|           <context context-type="linenumber">437</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="739880801667335279" datatype="html"> | ||||
| @@ -8861,17 +8869,6 @@ | ||||
|           <context context-type="linenumber">15</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="6252070156626006029" datatype="html"> | ||||
|         <source>None</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> | ||||
|           <context context-type="linenumber">120</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/data/matching-model.ts</context> | ||||
|           <context context-type="linenumber">45</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="211408744872436427" datatype="html"> | ||||
|         <source>Successfully created <x id="PH" equiv-text="this.typeName"/>.</source> | ||||
|         <context-group purpose="location"> | ||||
|   | ||||
| @@ -11,17 +11,17 @@ | ||||
|   }, | ||||
|   "private": true, | ||||
|   "dependencies": { | ||||
|     "@angular/cdk": "^20.2.2", | ||||
|     "@angular/common": "~20.2.4", | ||||
|     "@angular/compiler": "~20.2.4", | ||||
|     "@angular/core": "~20.2.4", | ||||
|     "@angular/forms": "~20.2.4", | ||||
|     "@angular/localize": "~20.2.4", | ||||
|     "@angular/platform-browser": "~20.2.4", | ||||
|     "@angular/platform-browser-dynamic": "~20.2.4", | ||||
|     "@angular/router": "~20.2.4", | ||||
|     "@angular/cdk": "^20.2.6", | ||||
|     "@angular/common": "~20.3.2", | ||||
|     "@angular/compiler": "~20.3.2", | ||||
|     "@angular/core": "~20.3.2", | ||||
|     "@angular/forms": "~20.3.2", | ||||
|     "@angular/localize": "~20.3.2", | ||||
|     "@angular/platform-browser": "~20.3.2", | ||||
|     "@angular/platform-browser-dynamic": "~20.3.2", | ||||
|     "@angular/router": "~20.3.2", | ||||
|     "@ng-bootstrap/ng-bootstrap": "^19.0.1", | ||||
|     "@ng-select/ng-select": "^20.1.3", | ||||
|     "@ng-select/ng-select": "^20.2.2", | ||||
|     "@ngneat/dirty-check-forms": "^3.0.3", | ||||
|     "@popperjs/core": "^2.11.8", | ||||
|     "bootstrap": "^5.3.8", | ||||
| @@ -29,47 +29,48 @@ | ||||
|     "mime-names": "^1.0.0", | ||||
|     "ng2-pdf-viewer": "^10.4.0", | ||||
|     "ngx-bootstrap-icons": "^1.9.3", | ||||
|     "ngx-color": "^10.0.0", | ||||
|     "ngx-color": "^10.1.0", | ||||
|     "ngx-cookie-service": "^20.1.0", | ||||
|     "ngx-device-detector": "^10.1.0", | ||||
|     "ngx-ui-tour-ng-bootstrap": "^17.0.1", | ||||
|     "rxjs": "^7.8.2", | ||||
|     "tslib": "^2.8.1", | ||||
|     "utif": "^3.1.0", | ||||
|     "uuid": "^11.1.0", | ||||
|     "uuid": "^13.0.0", | ||||
|     "zone.js": "^0.15.1" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@angular-builders/custom-webpack": "^20.0.0", | ||||
|     "@angular-builders/jest": "^20.0.0", | ||||
|     "@angular-devkit/core": "^20.2.2", | ||||
|     "@angular-devkit/schematics": "^20.2.2", | ||||
|     "@angular-eslint/builder": "20.2.0", | ||||
|     "@angular-eslint/eslint-plugin": "20.2.0", | ||||
|     "@angular-eslint/eslint-plugin-template": "20.2.0", | ||||
|     "@angular-eslint/schematics": "20.2.0", | ||||
|     "@angular-eslint/template-parser": "20.2.0", | ||||
|     "@angular/build": "^20.2.2", | ||||
|     "@angular/cli": "~20.2.2", | ||||
|     "@angular/compiler-cli": "~20.2.4", | ||||
|     "@angular-devkit/core": "^20.3.3", | ||||
|     "@angular-devkit/schematics": "^20.3.3", | ||||
|     "@angular-eslint/builder": "20.3.0", | ||||
|     "@angular-eslint/eslint-plugin": "20.3.0", | ||||
|     "@angular-eslint/eslint-plugin-template": "20.3.0", | ||||
|     "@angular-eslint/schematics": "20.3.0", | ||||
|     "@angular-eslint/template-parser": "20.3.0", | ||||
|     "@angular/build": "^20.3.3", | ||||
|     "@angular/cli": "~20.3.3", | ||||
|     "@angular/compiler-cli": "~20.3.2", | ||||
|     "@codecov/webpack-plugin": "^1.9.1", | ||||
|     "@playwright/test": "^1.55.0", | ||||
|     "@playwright/test": "^1.55.1", | ||||
|     "@types/jest": "^30.0.0", | ||||
|     "@types/node": "^24.3.0", | ||||
|     "@typescript-eslint/eslint-plugin": "^8.41.0", | ||||
|     "@typescript-eslint/parser": "^8.41.0", | ||||
|     "@typescript-eslint/utils": "^8.41.0", | ||||
|     "eslint": "^9.34.0", | ||||
|     "jest": "30.1.3", | ||||
|     "jest-environment-jsdom": "^30.1.2", | ||||
|     "@types/node": "^24.6.1", | ||||
|     "@typescript-eslint/eslint-plugin": "^8.45.0", | ||||
|     "@typescript-eslint/parser": "^8.45.0", | ||||
|     "@typescript-eslint/utils": "^8.45.0", | ||||
|     "eslint": "^9.36.0", | ||||
|     "jest": "30.2.0", | ||||
|     "jest-environment-jsdom": "^30.2.0", | ||||
|     "jest-junit": "^16.0.0", | ||||
|     "jest-preset-angular": "^15.0.0", | ||||
|     "jest-preset-angular": "^15.0.2", | ||||
|     "jest-websocket-mock": "^2.5.0", | ||||
|     "prettier-plugin-organize-imports": "^4.2.0", | ||||
|     "prettier-plugin-organize-imports": "^4.3.0", | ||||
|     "ts-node": "~10.9.1", | ||||
|     "typescript": "^5.8.3", | ||||
|     "webpack": "^5.101.3" | ||||
|     "webpack": "^5.102.0" | ||||
|   }, | ||||
|   "packageManager": "pnpm@10.17.1", | ||||
|   "pnpm": { | ||||
|     "onlyBuiltDependencies": [ | ||||
|       "@parcel/watcher", | ||||
|   | ||||
							
								
								
									
										3498
									
								
								src-ui/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3498
									
								
								src-ui/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -145,4 +145,14 @@ HTMLCanvasElement.prototype.getContext = < | ||||
|   typeof HTMLCanvasElement.prototype.getContext | ||||
| >jest.fn() | ||||
|  | ||||
| jest.mock('uuid', () => ({ | ||||
|   v4: jest.fn(() => | ||||
|     'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (char: string) => { | ||||
|       const random = Math.floor(Math.random() * 16) | ||||
|       const value = char === 'x' ? random : (random & 0x3) | 0x8 | ||||
|       return value.toString(16) | ||||
|     }) | ||||
|   ), | ||||
| })) | ||||
|  | ||||
| jest.mock('pdfjs-dist') | ||||
|   | ||||
| @@ -1,161 +1,144 @@ | ||||
| <div class="d-flex flex-wrap gap-4"> | ||||
|   <div class="d-flex align-items-center" role="group" aria-label="Select"> | ||||
|     <button class="btn btn-sm btn-outline-secondary" (click)="list.selectNone()"> | ||||
|       <i-bs name="slash-circle"></i-bs> <ng-container i18n>Cancel</ng-container> | ||||
|   <div class="d-flex flex-wrap align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"> | ||||
|     <label class="me-2" i18n>Edit:</label> | ||||
|     @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) { | ||||
|       <pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title | ||||
|         filterPlaceholder="Filter tags" i18n-filterPlaceholder | ||||
|         [disabled]="!userCanEditAll || disabled" | ||||
|         [editing]="true" | ||||
|         [applyOnClose]="applyOnClose" | ||||
|         [createRef]="createTag.bind(this)" | ||||
|         (opened)="openTagsDropdown()" | ||||
|         [(selectionModel)]="tagSelectionModel" | ||||
|         [documentCounts]="tagDocumentCounts" | ||||
|         (apply)="setTags($event)" | ||||
|         shortcutKey="t"> | ||||
|       </pngx-filterable-dropdown> | ||||
|     } | ||||
|     @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) { | ||||
|       <pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title | ||||
|         filterPlaceholder="Filter correspondents" i18n-filterPlaceholder | ||||
|         [disabled]="!userCanEditAll || disabled" | ||||
|         [editing]="true" | ||||
|         [applyOnClose]="applyOnClose" | ||||
|         [createRef]="createCorrespondent.bind(this)" | ||||
|         (opened)="openCorrespondentDropdown()" | ||||
|         [(selectionModel)]="correspondentSelectionModel" | ||||
|         [documentCounts]="correspondentDocumentCounts" | ||||
|         (apply)="setCorrespondents($event)" | ||||
|         shortcutKey="y"> | ||||
|       </pngx-filterable-dropdown> | ||||
|     } | ||||
|     @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) { | ||||
|       <pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title | ||||
|         filterPlaceholder="Filter document types" i18n-filterPlaceholder | ||||
|         [disabled]="!userCanEditAll || disabled" | ||||
|         [editing]="true" | ||||
|         [applyOnClose]="applyOnClose" | ||||
|         [createRef]="createDocumentType.bind(this)" | ||||
|         (opened)="openDocumentTypeDropdown()" | ||||
|         [(selectionModel)]="documentTypeSelectionModel" | ||||
|         [documentCounts]="documentTypeDocumentCounts" | ||||
|         (apply)="setDocumentTypes($event)" | ||||
|         shortcutKey="u"> | ||||
|       </pngx-filterable-dropdown> | ||||
|     } | ||||
|     @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) { | ||||
|       <pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title | ||||
|         filterPlaceholder="Filter storage paths" i18n-filterPlaceholder | ||||
|         [disabled]="!userCanEditAll || disabled" | ||||
|         [editing]="true" | ||||
|         [applyOnClose]="applyOnClose" | ||||
|         [createRef]="createStoragePath.bind(this)" | ||||
|         (opened)="openStoragePathDropdown()" | ||||
|         [(selectionModel)]="storagePathsSelectionModel" | ||||
|         [documentCounts]="storagePathDocumentCounts" | ||||
|         (apply)="setStoragePaths($event)" | ||||
|         shortcutKey="i"> | ||||
|       </pngx-filterable-dropdown> | ||||
|     } | ||||
|     @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) { | ||||
|       <pngx-filterable-dropdown title="Custom fields" icon="ui-radios" i18n-title | ||||
|         filterPlaceholder="Filter custom fields" i18n-filterPlaceholder | ||||
|         [disabled]="!userCanEditAll" | ||||
|         [editing]="true" | ||||
|         [applyOnClose]="applyOnClose" | ||||
|         [createRef]="createCustomField.bind(this)" | ||||
|         (opened)="openCustomFieldsDropdown()" | ||||
|         [(selectionModel)]="customFieldsSelectionModel" | ||||
|         [documentCounts]="customFieldDocumentCounts" | ||||
|         extraButtonTitle="Set values" | ||||
|         i18n-extraButtonTitle | ||||
|         (extraButton)="setCustomFieldValues($event)" | ||||
|         (apply)="setCustomFields($event)"> | ||||
|       </pngx-filterable-dropdown> | ||||
|     } | ||||
|     <div class="btn-group"> | ||||
|       <button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || !userCanEditAll"> | ||||
|         <i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline"> <ng-container i18n>Permissions</ng-container></div> | ||||
|       </button> | ||||
|     </div> | ||||
|     <div class="d-flex align-items-center gap-2" role="group" aria-label="Select"> | ||||
|       <label class="me-2" i18n>Select:</label> | ||||
|       <div class="btn-group"> | ||||
|         <button class="btn btn-sm btn-outline-primary" (click)="list.selectPage()"> | ||||
|           <i-bs name="file-earmark-check"></i-bs> <ng-container i18n>Page</ng-container> | ||||
|   </div> | ||||
|   <div class="d-flex align-items-center gap-2 ms-auto"> | ||||
|     <div class="btn-toolbar"> | ||||
|       <div ngbDropdown> | ||||
|         <button class="btn btn-sm btn-outline-primary" id="dropdownSelect" [disabled]="!userCanEdit && !userCanAdd" ngbDropdownToggle> | ||||
|           <i-bs name="three-dots"></i-bs> | ||||
|           <div class="d-none d-sm-inline"> <ng-container i18n>Actions</ng-container></div> | ||||
|         </button> | ||||
|         <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow"> | ||||
|           <button ngbDropdownItem (click)="reprocessSelected()" [disabled]="!userCanEditAll && !userCanEditAll"> | ||||
|             <i-bs name="body-text"></i-bs> <ng-container i18n>Reprocess</ng-container> | ||||
|           </button> | ||||
|           <button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll && !userCanEditAll"> | ||||
|             <i-bs name="arrow-clockwise"></i-bs> <ng-container i18n>Rotate</ng-container> | ||||
|           </button> | ||||
|           <button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.selected.size < 2"> | ||||
|             <i-bs name="journals"></i-bs> <ng-container i18n>Merge</ng-container> | ||||
|           </button> | ||||
|           <button class="btn btn-sm btn-outline-primary" (click)="list.selectAll()"> | ||||
|             <i-bs name="check-all"></i-bs> <ng-container i18n>All</ng-container> | ||||
|             </button> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="d-flex align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"> | ||||
|           <label class="me-2" i18n>Edit:</label> | ||||
|           @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) { | ||||
|             <pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title | ||||
|               filterPlaceholder="Filter tags" i18n-filterPlaceholder | ||||
|               [disabled]="!userCanEditAll || disabled" | ||||
|               [editing]="true" | ||||
|               [applyOnClose]="applyOnClose" | ||||
|               [createRef]="createTag.bind(this)" | ||||
|               (opened)="openTagsDropdown()" | ||||
|               [(selectionModel)]="tagSelectionModel" | ||||
|               [documentCounts]="tagDocumentCounts" | ||||
|               (apply)="setTags($event)" | ||||
|               shortcutKey="t"> | ||||
|             </pngx-filterable-dropdown> | ||||
|           } | ||||
|           @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) { | ||||
|             <pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title | ||||
|               filterPlaceholder="Filter correspondents" i18n-filterPlaceholder | ||||
|               [disabled]="!userCanEditAll || disabled" | ||||
|               [editing]="true" | ||||
|               [applyOnClose]="applyOnClose" | ||||
|               [createRef]="createCorrespondent.bind(this)" | ||||
|               (opened)="openCorrespondentDropdown()" | ||||
|               [(selectionModel)]="correspondentSelectionModel" | ||||
|               [documentCounts]="correspondentDocumentCounts" | ||||
|               (apply)="setCorrespondents($event)" | ||||
|               shortcutKey="y"> | ||||
|             </pngx-filterable-dropdown> | ||||
|           } | ||||
|           @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) { | ||||
|             <pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title | ||||
|               filterPlaceholder="Filter document types" i18n-filterPlaceholder | ||||
|               [disabled]="!userCanEditAll || disabled" | ||||
|               [editing]="true" | ||||
|               [applyOnClose]="applyOnClose" | ||||
|               [createRef]="createDocumentType.bind(this)" | ||||
|               (opened)="openDocumentTypeDropdown()" | ||||
|               [(selectionModel)]="documentTypeSelectionModel" | ||||
|               [documentCounts]="documentTypeDocumentCounts" | ||||
|               (apply)="setDocumentTypes($event)" | ||||
|               shortcutKey="u"> | ||||
|             </pngx-filterable-dropdown> | ||||
|           } | ||||
|           @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) { | ||||
|             <pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title | ||||
|               filterPlaceholder="Filter storage paths" i18n-filterPlaceholder | ||||
|               [disabled]="!userCanEditAll || disabled" | ||||
|               [editing]="true" | ||||
|               [applyOnClose]="applyOnClose" | ||||
|               [createRef]="createStoragePath.bind(this)" | ||||
|               (opened)="openStoragePathDropdown()" | ||||
|               [(selectionModel)]="storagePathsSelectionModel" | ||||
|               [documentCounts]="storagePathDocumentCounts" | ||||
|               (apply)="setStoragePaths($event)" | ||||
|               shortcutKey="i"> | ||||
|             </pngx-filterable-dropdown> | ||||
|           } | ||||
|           @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) { | ||||
|             <pngx-filterable-dropdown title="Custom fields" icon="ui-radios" i18n-title | ||||
|               filterPlaceholder="Filter custom fields" i18n-filterPlaceholder | ||||
|               [disabled]="!userCanEditAll" | ||||
|               [editing]="true" | ||||
|               [applyOnClose]="applyOnClose" | ||||
|               [createRef]="createCustomField.bind(this)" | ||||
|               (opened)="openCustomFieldsDropdown()" | ||||
|               [(selectionModel)]="customFieldsSelectionModel" | ||||
|               [documentCounts]="customFieldDocumentCounts" | ||||
|               extraButtonTitle="Set values" | ||||
|               i18n-extraButtonTitle | ||||
|               (extraButton)="setCustomFieldValues($event)" | ||||
|               (apply)="setCustomFields($event)"> | ||||
|             </pngx-filterable-dropdown> | ||||
|           } | ||||
|       </div> | ||||
|     </div> | ||||
|     <div class="btn-group btn-group-sm"> | ||||
|       <button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()"> | ||||
|         @if (!awaitingDownload) { | ||||
|           <i-bs name="arrow-down"></i-bs> | ||||
|         } | ||||
|         @if (awaitingDownload) { | ||||
|           <div class="spinner-border spinner-border-sm" role="status"> | ||||
|             <span class="visually-hidden">Preparing download...</span> | ||||
|           </div> | ||||
|         } | ||||
|         <div class="d-none d-sm-inline"> <ng-container i18n>Download</ng-container></div> | ||||
|       </button> | ||||
|       <div ngbDropdown class="me-2 d-flex btn-group" role="group"> | ||||
|         <button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle-split rounded-end" ngbDropdownToggle></button> | ||||
|         <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow"> | ||||
|           <form [formGroup]="downloadForm" class="px-3 py-1"> | ||||
|             <p class="mb-1" i18n>Include:</p> | ||||
|             <div class="form-group ps-3 mb-2"> | ||||
|               <div class="form-check"> | ||||
|                 <input type="checkbox" class="form-check-input" id="downloadFileType_archive" formControlName="downloadFileTypeArchive" /> | ||||
|                 <label class="form-check-label" for="downloadFileType_archive" i18n>Archived files</label> | ||||
|               </div> | ||||
|               <div class="form-check"> | ||||
|                 <input type="checkbox" class="form-check-input" id="downloadFileType_originals" formControlName="downloadFileTypeOriginals" /> | ||||
|                 <label class="form-check-label" for="downloadFileType_originals" i18n>Original files</label> | ||||
|               </div> | ||||
|             </div> | ||||
|             <div class="form-check"> | ||||
|               <input type="checkbox" class="form-check-input" id="downloadUseFormatting" formControlName="downloadUseFormatting" /> | ||||
|               <label class="form-check-label" for="downloadUseFormatting" i18n>Use formatted filename</label> | ||||
|             </div> | ||||
|           </form> | ||||
|         </div> | ||||
|         <div class="d-flex align-items-center gap-2 ms-auto"> | ||||
|           <div class="btn-toolbar"> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|             <button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || !userCanEditAll"> | ||||
|               <i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline"> <ng-container i18n>Permissions</ng-container></div> | ||||
|             </button> | ||||
|  | ||||
|             <div ngbDropdown> | ||||
|               <button class="btn btn-sm btn-outline-primary" id="dropdownSelect" [disabled]="!userCanEdit && !userCanAdd" ngbDropdownToggle> | ||||
|                 <i-bs name="three-dots"></i-bs> | ||||
|                 <div class="d-none d-sm-inline"> <ng-container i18n>Actions</ng-container></div> | ||||
|               </button> | ||||
|               <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow"> | ||||
|                 <button ngbDropdownItem (click)="reprocessSelected()" [disabled]="!userCanEditAll && !userCanEditAll"> | ||||
|                   <i-bs name="body-text"></i-bs> <ng-container i18n>Reprocess</ng-container> | ||||
|                 </button> | ||||
|                 <button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll && !userCanEditAll"> | ||||
|                   <i-bs name="arrow-clockwise"></i-bs> <ng-container i18n>Rotate</ng-container> | ||||
|                 </button> | ||||
|                 <button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.selected.size < 2"> | ||||
|                   <i-bs name="journals"></i-bs> <ng-container i18n>Merge</ng-container> | ||||
|                 </button> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|  | ||||
|             <div class="btn-group btn-group-sm"> | ||||
|               <button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()"> | ||||
|                 @if (!awaitingDownload) { | ||||
|                   <i-bs name="arrow-down"></i-bs> | ||||
|                 } | ||||
|                 @if (awaitingDownload) { | ||||
|                   <div class="spinner-border spinner-border-sm" role="status"> | ||||
|                     <span class="visually-hidden">Preparing download...</span> | ||||
|                   </div> | ||||
|                 } | ||||
|                 <div class="d-none d-sm-inline"> <ng-container i18n>Download</ng-container></div> | ||||
|               </button> | ||||
|               <div ngbDropdown class="me-2 d-flex btn-group" role="group"> | ||||
|                 <button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle-split rounded-end" ngbDropdownToggle></button> | ||||
|                 <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow"> | ||||
|                   <form [formGroup]="downloadForm" class="px-3 py-1"> | ||||
|                     <p class="mb-1" i18n>Include:</p> | ||||
|                     <div class="form-group ps-3 mb-2"> | ||||
|                       <div class="form-check"> | ||||
|                         <input type="checkbox" class="form-check-input" id="downloadFileType_archive" formControlName="downloadFileTypeArchive" /> | ||||
|                         <label class="form-check-label" for="downloadFileType_archive" i18n>Archived files</label> | ||||
|                       </div> | ||||
|                       <div class="form-check"> | ||||
|                         <input type="checkbox" class="form-check-input" id="downloadFileType_originals" formControlName="downloadFileTypeOriginals" /> | ||||
|                         <label class="form-check-label" for="downloadFileType_originals" i18n>Original files</label> | ||||
|                       </div> | ||||
|                     </div> | ||||
|                     <div class="form-check"> | ||||
|                       <input type="checkbox" class="form-check-input" id="downloadUseFormatting" formControlName="downloadUseFormatting" /> | ||||
|                       <label class="form-check-label" for="downloadUseFormatting" i18n>Use formatted filename</label> | ||||
|                     </div> | ||||
|                   </form> | ||||
|                 </div> | ||||
|               </div> | ||||
|             </div> | ||||
|  | ||||
|             <div class="btn-group btn-group-sm"> | ||||
|               <button type="button" class="btn btn-sm btn-outline-danger" (click)="applyDelete()" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }" [disabled]="!userOwnsAll"> | ||||
|                 <i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container> | ||||
|                 </button> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|     <div class="btn-group btn-group-sm"> | ||||
|       <button type="button" class="btn btn-sm btn-outline-danger" (click)="applyDelete()" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }" [disabled]="!userOwnsAll"> | ||||
|         <i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container> | ||||
|       </button> | ||||
|     </div> | ||||
|   </div> | ||||
| </div> | ||||
|   | ||||
| @@ -5,3 +5,7 @@ | ||||
| .dropdown-menu{ | ||||
|     --bs-dropdown-min-width: 12rem; | ||||
| } | ||||
|  | ||||
| .btn-group .btn { | ||||
|   white-space: nowrap; | ||||
| } | ||||
|   | ||||
| @@ -1,16 +1,36 @@ | ||||
| <pngx-page-header [title]="getTitle()"> | ||||
|  | ||||
|   <div ngbDropdown class="btn-group flex-fill"> | ||||
|     <button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle> | ||||
|   <div ngbDropdown class="btn-group flex-fill d-sm-none"> | ||||
|     <button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle> | ||||
|       <i-bs name="text-indent-left"></i-bs> | ||||
|       <div class="d-none d-sm-inline"> <ng-container i18n>Select</ng-container></div> | ||||
|       @if (list.selected.size > 0) { | ||||
|         <pngx-clearable-badge [selected]="list.selected.size > 0" [number]="list.selected.size" (cleared)="list.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span> | ||||
|       } | ||||
|     </button> | ||||
|     <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow"> | ||||
|     <div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow"> | ||||
|       <button ngbDropdownItem (click)="list.selectNone()" i18n>Select none</button> | ||||
|       <button ngbDropdownItem (click)="list.selectPage()" i18n>Select page</button> | ||||
|       <button ngbDropdownItem (click)="list.selectAll()" i18n>Select all</button> | ||||
|     </div> | ||||
|   </div> | ||||
|   <div class="d-none d-sm-flex flex-fill me-3"> | ||||
|     <div class="input-group input-group-sm"> | ||||
|       <span class="input-group-text border-0">Select:</span> | ||||
|     </div> | ||||
|     <div class="btn-group btn-group-sm flex-nowrap"> | ||||
|       @if (list.selected.size > 0) { | ||||
|         <button class="btn btn-sm btn-outline-secondary" (click)="list.selectNone()"> | ||||
|           <i-bs name="slash-circle"></i-bs> <ng-container i18n>None</ng-container> | ||||
|         </button> | ||||
|       } | ||||
|       <button class="btn btn-sm btn-outline-primary" (click)="list.selectPage()"> | ||||
|         <i-bs name="file-earmark-check"></i-bs> <ng-container i18n>Page</ng-container> | ||||
|       </button> | ||||
|       <button class="btn btn-sm btn-outline-primary" (click)="list.selectAll()"> | ||||
|         <i-bs name="check-all"></i-bs> <ng-container i18n>All</ng-container> | ||||
|       </button> | ||||
|     </div> | ||||
|   </div> | ||||
|   <div ngbDropdown class="btn-group flex-fill"> | ||||
|     <button class="btn btn-sm btn-outline-primary" id="dropdownDisplayFields" ngbDropdownToggle> | ||||
|       <i-bs name="card-heading"></i-bs> | ||||
| @@ -126,8 +146,13 @@ | ||||
|       @if (!list.isReloading && isFiltered) { | ||||
|         <button class="btn btn-link py-0" (click)="resetFilters()"> | ||||
|           <i-bs width="1em" height="1em" name="x"></i-bs><small i18n>Reset filters</small> | ||||
|           </button> | ||||
|         } | ||||
|         </button> | ||||
|       } | ||||
|       @if (!list.isReloading && list.selected.size > 0) { | ||||
|         <button class="btn btn-link py-0" (click)="list.selectNone()"> | ||||
|           <i-bs width="1em" height="1em" name="slash-circle" class="me-1"></i-bs><small i18n>Clear selection</small> | ||||
|         </button> | ||||
|       } | ||||
|       </div> | ||||
|       @if (list.collectionSize) { | ||||
|         <ngb-pagination [pageSize]="list.pageSize" [collectionSize]="list.collectionSize" [(page)]="list.currentPage" [maxSize]="5" | ||||
|   | ||||
| @@ -56,6 +56,7 @@ import { | ||||
|   filterRulesDiffer, | ||||
|   isFullTextFilterRule, | ||||
| } from 'src/app/utils/filter-rules' | ||||
| import { ClearableBadgeComponent } from '../common/clearable-badge/clearable-badge.component' | ||||
| import { CustomFieldDisplayComponent } from '../common/custom-field-display/custom-field-display.component' | ||||
| import { PageHeaderComponent } from '../common/page-header/page-header.component' | ||||
| import { PreviewPopupComponent } from '../common/preview-popup/preview-popup.component' | ||||
| @@ -72,6 +73,7 @@ import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-vi | ||||
|   templateUrl: './document-list.component.html', | ||||
|   styleUrls: ['./document-list.component.scss'], | ||||
|   imports: [ | ||||
|     ClearableBadgeComponent, | ||||
|     CustomFieldDisplayComponent, | ||||
|     PageHeaderComponent, | ||||
|     BulkEditorComponent, | ||||
|   | ||||
| @@ -6,6 +6,7 @@ import re | ||||
| from datetime import datetime | ||||
| from decimal import Decimal | ||||
| from typing import TYPE_CHECKING | ||||
| from typing import Literal | ||||
|  | ||||
| import magic | ||||
| from celery import states | ||||
| @@ -252,6 +253,35 @@ class OwnedObjectSerializer( | ||||
|             except KeyError: | ||||
|                 pass | ||||
|  | ||||
|     def _get_perms(self, obj, codename: str, target: Literal["users", "groups"]): | ||||
|         """ | ||||
|         Get the given permissions from context or from django-guardian. | ||||
|  | ||||
|         :param codename: The permission codename, e.g. 'view' or 'change' | ||||
|         :param target: 'users' or 'groups' | ||||
|         """ | ||||
|         key = f"{target}_{codename}_perms" | ||||
|         cached = self.context.get(key, {}).get(obj.pk) | ||||
|         if cached is not None: | ||||
|             return list(cached) | ||||
|  | ||||
|         # Permission not found in the context, get it from guardian | ||||
|         if target == "users": | ||||
|             return list( | ||||
|                 get_users_with_perms( | ||||
|                     obj, | ||||
|                     only_with_perms_in=[f"{codename}_{obj.__class__.__name__.lower()}"], | ||||
|                     with_group_users=False, | ||||
|                 ).values_list("id", flat=True), | ||||
|             ) | ||||
|         else:  # groups | ||||
|             return list( | ||||
|                 get_groups_with_only_permission( | ||||
|                     obj, | ||||
|                     codename=f"{codename}_{obj.__class__.__name__.lower()}", | ||||
|                 ).values_list("id", flat=True), | ||||
|             ) | ||||
|  | ||||
|     @extend_schema_field( | ||||
|         field={ | ||||
|             "type": "object", | ||||
| @@ -286,31 +316,14 @@ class OwnedObjectSerializer( | ||||
|         }, | ||||
|     ) | ||||
|     def get_permissions(self, obj) -> dict: | ||||
|         view_codename = f"view_{obj.__class__.__name__.lower()}" | ||||
|         change_codename = f"change_{obj.__class__.__name__.lower()}" | ||||
|  | ||||
|         return { | ||||
|             "view": { | ||||
|                 "users": get_users_with_perms( | ||||
|                     obj, | ||||
|                     only_with_perms_in=[view_codename], | ||||
|                     with_group_users=False, | ||||
|                 ).values_list("id", flat=True), | ||||
|                 "groups": get_groups_with_only_permission( | ||||
|                     obj, | ||||
|                     codename=view_codename, | ||||
|                 ).values_list("id", flat=True), | ||||
|                 "users": self._get_perms(obj, "view", "users"), | ||||
|                 "groups": self._get_perms(obj, "view", "groups"), | ||||
|             }, | ||||
|             "change": { | ||||
|                 "users": get_users_with_perms( | ||||
|                     obj, | ||||
|                     only_with_perms_in=[change_codename], | ||||
|                     with_group_users=False, | ||||
|                 ).values_list("id", flat=True), | ||||
|                 "groups": get_groups_with_only_permission( | ||||
|                     obj, | ||||
|                     codename=change_codename, | ||||
|                 ).values_list("id", flat=True), | ||||
|                 "users": self._get_perms(obj, "change", "users"), | ||||
|                 "groups": self._get_perms(obj, "change", "groups"), | ||||
|             }, | ||||
|         } | ||||
|  | ||||
|   | ||||
| @@ -1,17 +1,23 @@ | ||||
| import json | ||||
| import tempfile | ||||
| from datetime import timedelta | ||||
| from pathlib import Path | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.contrib.auth.models import Group | ||||
| from django.contrib.auth.models import Permission | ||||
| from django.contrib.auth.models import User | ||||
| from django.db import connection | ||||
| from django.test import TestCase | ||||
| from django.test import override_settings | ||||
| from django.test.utils import CaptureQueriesContext | ||||
| from django.utils import timezone | ||||
| from guardian.shortcuts import assign_perm | ||||
| from rest_framework import status | ||||
|  | ||||
| from documents.models import Document | ||||
| from documents.models import ShareLink | ||||
| from documents.models import Tag | ||||
| from documents.tests.utils import DirectoriesMixin | ||||
| from paperless.models import ApplicationConfiguration | ||||
|  | ||||
| @@ -154,3 +160,113 @@ class TestViews(DirectoriesMixin, TestCase): | ||||
|         response.render() | ||||
|         self.assertEqual(response.request["PATH_INFO"], "/accounts/login/") | ||||
|         self.assertContains(response, b"Share link has expired") | ||||
|  | ||||
|     def test_list_with_full_permissions(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Tags with different permissions | ||||
|         WHEN: | ||||
|             - Request to get tag list with full permissions is made | ||||
|         THEN: | ||||
|             - Tag list is returned with the right permission information | ||||
|         """ | ||||
|         user2 = User.objects.create(username="user2") | ||||
|         user3 = User.objects.create(username="user3") | ||||
|         group1 = Group.objects.create(name="group1") | ||||
|         group2 = Group.objects.create(name="group2") | ||||
|         group3 = Group.objects.create(name="group3") | ||||
|         t1 = Tag.objects.create(name="invoice", pk=1) | ||||
|         assign_perm("view_tag", self.user, t1) | ||||
|         assign_perm("view_tag", user2, t1) | ||||
|         assign_perm("view_tag", user3, t1) | ||||
|         assign_perm("view_tag", group1, t1) | ||||
|         assign_perm("view_tag", group2, t1) | ||||
|         assign_perm("view_tag", group3, t1) | ||||
|         assign_perm("change_tag", self.user, t1) | ||||
|         assign_perm("change_tag", user2, t1) | ||||
|         assign_perm("change_tag", group1, t1) | ||||
|         assign_perm("change_tag", group2, t1) | ||||
|  | ||||
|         Tag.objects.create(name="bank statement", pk=2) | ||||
|         d1 = Document.objects.create( | ||||
|             title="Invoice 1", | ||||
|             content="This is the invoice of a very expensive item", | ||||
|             checksum="A", | ||||
|         ) | ||||
|         d1.tags.add(t1) | ||||
|         d2 = Document.objects.create( | ||||
|             title="Invoice 2", | ||||
|             content="Internet invoice, I should pay it to continue contributing", | ||||
|             checksum="B", | ||||
|         ) | ||||
|         d2.tags.add(t1) | ||||
|  | ||||
|         view_permissions = Permission.objects.filter( | ||||
|             codename__contains="view_tag", | ||||
|         ) | ||||
|         self.user.user_permissions.add(*view_permissions) | ||||
|         self.user.save() | ||||
|  | ||||
|         self.client.force_login(self.user) | ||||
|         response = self.client.get("/api/tags/?page=1&full_perms=true") | ||||
|         results = json.loads(response.content)["results"] | ||||
|         for tag in results: | ||||
|             if tag["name"] == "invoice": | ||||
|                 assert tag["permissions"] == { | ||||
|                     "view": { | ||||
|                         "users": [self.user.pk, user2.pk, user3.pk], | ||||
|                         "groups": [group1.pk, group2.pk, group3.pk], | ||||
|                     }, | ||||
|                     "change": { | ||||
|                         "users": [self.user.pk, user2.pk], | ||||
|                         "groups": [group1.pk, group2.pk], | ||||
|                     }, | ||||
|                 } | ||||
|             elif tag["name"] == "bank statement": | ||||
|                 assert tag["permissions"] == { | ||||
|                     "view": {"users": [], "groups": []}, | ||||
|                     "change": {"users": [], "groups": []}, | ||||
|                 } | ||||
|             else: | ||||
|                 assert False, f"Unexpected tag found: {tag['name']}" | ||||
|  | ||||
|     def test_list_no_n_plus_1_queries(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Tags with different permissions | ||||
|         WHEN: | ||||
|             - Request to get tag list with full permissions is made | ||||
|         THEN: | ||||
|             - Permissions are not queried in database tag by tag, | ||||
|              i.e. there are no N+1 queries | ||||
|         """ | ||||
|         view_permissions = Permission.objects.filter( | ||||
|             codename__contains="view_tag", | ||||
|         ) | ||||
|         self.user.user_permissions.add(*view_permissions) | ||||
|         self.user.save() | ||||
|         self.client.force_login(self.user) | ||||
|  | ||||
|         # Start by a small list, and count the number of SQL queries | ||||
|         for i in range(2): | ||||
|             Tag.objects.create(name=f"tag_{i}") | ||||
|  | ||||
|         with CaptureQueriesContext(connection) as ctx_small: | ||||
|             response_small = self.client.get("/api/tags/?full_perms=true") | ||||
|             assert response_small.status_code == 200 | ||||
|         num_queries_small = len(ctx_small.captured_queries) | ||||
|  | ||||
|         # Complete the list, and count the number of SQL queries again | ||||
|         for i in range(2, 50): | ||||
|             Tag.objects.create(name=f"tag_{i}") | ||||
|  | ||||
|         with CaptureQueriesContext(connection) as ctx_large: | ||||
|             response_large = self.client.get("/api/tags/?full_perms=true") | ||||
|             assert response_large.status_code == 200 | ||||
|         num_queries_large = len(ctx_large.captured_queries) | ||||
|  | ||||
|         # A few additional queries are allowed, but not a linear explosion | ||||
|         assert num_queries_large <= num_queries_small + 5, ( | ||||
|             f"Possible N+1 queries detected: {num_queries_small} queries for 2 tags, " | ||||
|             f"but {num_queries_large} queries for 50 tags" | ||||
|         ) | ||||
|   | ||||
| @@ -5,9 +5,11 @@ import platform | ||||
| import re | ||||
| import tempfile | ||||
| import zipfile | ||||
| from collections import defaultdict | ||||
| from datetime import datetime | ||||
| from pathlib import Path | ||||
| from time import mktime | ||||
| from typing import Literal | ||||
| from unicodedata import normalize | ||||
| from urllib.parse import quote | ||||
| from urllib.parse import urlparse | ||||
| @@ -19,6 +21,7 @@ from celery import states | ||||
| from django.conf import settings | ||||
| from django.contrib.auth.models import Group | ||||
| from django.contrib.auth.models import User | ||||
| from django.contrib.contenttypes.models import ContentType | ||||
| from django.db import connections | ||||
| from django.db.migrations.loader import MigrationLoader | ||||
| from django.db.migrations.recorder import MigrationRecorder | ||||
| @@ -56,6 +59,8 @@ from drf_spectacular.utils import OpenApiParameter | ||||
| from drf_spectacular.utils import extend_schema | ||||
| from drf_spectacular.utils import extend_schema_view | ||||
| from drf_spectacular.utils import inline_serializer | ||||
| from guardian.utils import get_group_obj_perms_model | ||||
| from guardian.utils import get_user_obj_perms_model | ||||
| from langdetect import detect | ||||
| from packaging import version as packaging_version | ||||
| from redis import Redis | ||||
| @@ -254,7 +259,104 @@ class PassUserMixin(GenericAPIView): | ||||
|         return super().get_serializer(*args, **kwargs) | ||||
|  | ||||
|  | ||||
| class PermissionsAwareDocumentCountMixin(PassUserMixin): | ||||
| class BulkPermissionMixin: | ||||
|     """ | ||||
|     Prefetch Django-Guardian permissions for a list before serialization, to avoid N+1 queries. | ||||
|     """ | ||||
|  | ||||
|     def _get_object_perms( | ||||
|         self, | ||||
|         objects: list, | ||||
|         perm_codenames: list[str], | ||||
|         actor: Literal["users", "groups"], | ||||
|     ) -> dict[int, dict[str, list[int]]]: | ||||
|         """ | ||||
|         Collect object-level permissions for either users or groups. | ||||
|         """ | ||||
|         model = self.queryset.model | ||||
|         obj_perm_model = ( | ||||
|             get_user_obj_perms_model(model) | ||||
|             if actor == "users" | ||||
|             else get_group_obj_perms_model(model) | ||||
|         ) | ||||
|         id_field = "user_id" if actor == "users" else "group_id" | ||||
|         ctype = ContentType.objects.get_for_model(model) | ||||
|         object_pks = [obj.pk for obj in objects] | ||||
|  | ||||
|         perms_qs = obj_perm_model.objects.filter( | ||||
|             content_type=ctype, | ||||
|             object_pk__in=object_pks, | ||||
|             permission__codename__in=perm_codenames, | ||||
|         ).values_list("object_pk", id_field, "permission__codename") | ||||
|  | ||||
|         perms: dict[int, dict[str, list[int]]] = defaultdict(lambda: defaultdict(list)) | ||||
|         for object_pk, actor_id, codename in perms_qs: | ||||
|             perms[int(object_pk)][codename].append(actor_id) | ||||
|  | ||||
|         # Ensure that all objects have all codenames, even if empty | ||||
|         for pk in object_pks: | ||||
|             for codename in perm_codenames: | ||||
|                 perms[pk][codename] | ||||
|  | ||||
|         return perms | ||||
|  | ||||
|     def get_serializer_context(self): | ||||
|         """ | ||||
|         Get all permissions of the current list of objects at once and pass them to the serializer. | ||||
|         This avoid fetching permissions object by object in database. | ||||
|         """ | ||||
|         context = super().get_serializer_context() | ||||
|         try: | ||||
|             full_perms = get_boolean( | ||||
|                 str(self.request.query_params.get("full_perms", "false")), | ||||
|             ) | ||||
|         except ValueError: | ||||
|             full_perms = False | ||||
|  | ||||
|         if not full_perms: | ||||
|             return context | ||||
|  | ||||
|         # Check which objects are being paginated | ||||
|         page = getattr(self, "paginator", None) | ||||
|         if page and hasattr(page, "page"): | ||||
|             queryset = page.page.object_list | ||||
|         elif hasattr(self, "page"): | ||||
|             queryset = self.page | ||||
|         else: | ||||
|             queryset = self.filter_queryset(self.get_queryset()) | ||||
|  | ||||
|         model_name = self.queryset.model.__name__.lower() | ||||
|         permission_name_view = f"view_{model_name}" | ||||
|         permission_name_change = f"change_{model_name}" | ||||
|  | ||||
|         user_perms = self._get_object_perms( | ||||
|             objects=queryset, | ||||
|             perm_codenames=[permission_name_view, permission_name_change], | ||||
|             actor="users", | ||||
|         ) | ||||
|         group_perms = self._get_object_perms( | ||||
|             objects=queryset, | ||||
|             perm_codenames=[permission_name_view, permission_name_change], | ||||
|             actor="groups", | ||||
|         ) | ||||
|  | ||||
|         context["users_view_perms"] = { | ||||
|             pk: user_perms[pk][permission_name_view] for pk in user_perms | ||||
|         } | ||||
|         context["users_change_perms"] = { | ||||
|             pk: user_perms[pk][permission_name_change] for pk in user_perms | ||||
|         } | ||||
|         context["groups_view_perms"] = { | ||||
|             pk: group_perms[pk][permission_name_view] for pk in group_perms | ||||
|         } | ||||
|         context["groups_change_perms"] = { | ||||
|             pk: group_perms[pk][permission_name_change] for pk in group_perms | ||||
|         } | ||||
|  | ||||
|         return context | ||||
|  | ||||
|  | ||||
| class PermissionsAwareDocumentCountMixin(BulkPermissionMixin, PassUserMixin): | ||||
|     """ | ||||
|     Mixin to add document count to queryset, permissions-aware if needed | ||||
|     """ | ||||
|   | ||||
| @@ -2,7 +2,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: paperless-ngx\n" | ||||
| "Report-Msgid-Bugs-To: \n" | ||||
| "POT-Creation-Date: 2025-09-22 18:20+0000\n" | ||||
| "POT-Creation-Date: 2025-09-30 16:50+0000\n" | ||||
| "PO-Revision-Date: 2022-02-17 04:17\n" | ||||
| "Last-Translator: \n" | ||||
| "Language-Team: English\n" | ||||
| @@ -1191,44 +1191,44 @@ msgstr "" | ||||
| msgid "workflow runs" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/serialisers.py:140 | ||||
| #: documents/serialisers.py:141 | ||||
| #, python-format | ||||
| msgid "Invalid regular expression: %(error)s" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/serialisers.py:594 | ||||
| #: documents/serialisers.py:607 | ||||
| msgid "Invalid color." | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/serialisers.py:623 | ||||
| #: documents/serialisers.py:636 | ||||
| msgid "Invalid parent tag." | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/serialisers.py:1780 | ||||
| #: documents/serialisers.py:1793 | ||||
| #, python-format | ||||
| msgid "File type %(type)s not supported" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/serialisers.py:1824 | ||||
| #: documents/serialisers.py:1837 | ||||
| #, python-format | ||||
| msgid "Custom field id must be an integer: %(id)s" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/serialisers.py:1831 | ||||
| #: documents/serialisers.py:1844 | ||||
| #, python-format | ||||
| msgid "Custom field with id %(id)s does not exist" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/serialisers.py:1848 documents/serialisers.py:1858 | ||||
| #: documents/serialisers.py:1861 documents/serialisers.py:1871 | ||||
| msgid "" | ||||
| "Custom fields must be a list of integers or an object mapping ids to values." | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/serialisers.py:1853 | ||||
| #: documents/serialisers.py:1866 | ||||
| msgid "Some custom fields don't exist or were specified twice." | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/serialisers.py:1923 | ||||
| #: documents/serialisers.py:1936 | ||||
| msgid "Invalid variable detected." | ||||
| msgstr "" | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user