mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-09-26 01:12:43 -05:00
Compare commits
1 Commits
fix-chore-
...
feature-co
Author | SHA1 | Date | |
---|---|---|---|
![]() |
8ea2f56925 |
430
.github/workflows/build-and-release.yml
vendored
430
.github/workflows/build-and-release.yml
vendored
@@ -1,430 +0,0 @@
|
|||||||
name: 'Build and Release'
|
|
||||||
on:
|
|
||||||
workflow_run:
|
|
||||||
workflows:
|
|
||||||
- ci
|
|
||||||
types:
|
|
||||||
- completed
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
packages: write
|
|
||||||
pull-requests: write
|
|
||||||
env:
|
|
||||||
DEFAULT_UV_VERSION: "0.8.x"
|
|
||||||
DEFAULT_PYTHON_VERSION: "3.11"
|
|
||||||
NLTK_DATA: "/usr/share/nltk_data"
|
|
||||||
jobs:
|
|
||||||
prepare:
|
|
||||||
if: >-
|
|
||||||
github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push'
|
|
||||||
name: Prepare build context
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
outputs:
|
|
||||||
should-build: ${{ steps.determine.outputs.should-build }}
|
|
||||||
ref: ${{ steps.determine.outputs.ref }}
|
|
||||||
ref-name: ${{ steps.determine.outputs.ref-name }}
|
|
||||||
sha: ${{ steps.determine.outputs.sha }}
|
|
||||||
is-tag: ${{ steps.determine.outputs.is-tag }}
|
|
||||||
is-release-target: ${{ steps.determine.outputs.is-release-target }}
|
|
||||||
is-beta-rc: ${{ steps.determine.outputs.is-beta-rc }}
|
|
||||||
steps:
|
|
||||||
- name: Determine ref information
|
|
||||||
id: determine
|
|
||||||
uses: actions/github-script@v7
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const run = context.payload.workflow_run;
|
|
||||||
const owner = context.repo.owner;
|
|
||||||
const repo = context.repo.repo;
|
|
||||||
const sha = run.head_sha;
|
|
||||||
const branch = run.head_branch;
|
|
||||||
|
|
||||||
let ref = undefined;
|
|
||||||
let refName = undefined;
|
|
||||||
|
|
||||||
if (branch) {
|
|
||||||
ref = `refs/heads/${branch}`;
|
|
||||||
refName = branch;
|
|
||||||
} else {
|
|
||||||
const iterator = github.paginate.iterator(
|
|
||||||
github.rest.repos.listTags,
|
|
||||||
{
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
per_page: 100,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
for await (const { data } of iterator) {
|
|
||||||
const match = data.find((tag) => tag.commit?.sha === sha);
|
|
||||||
if (match) {
|
|
||||||
ref = `refs/tags/${match.name}`;
|
|
||||||
refName = match.name;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const outputs = {
|
|
||||||
shouldBuild: false,
|
|
||||||
ref: ref ?? '',
|
|
||||||
refName: refName ?? '',
|
|
||||||
sha,
|
|
||||||
isTag: ref?.startsWith('refs/tags/') ?? false,
|
|
||||||
isReleaseTarget: false,
|
|
||||||
isBetaRc: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!ref || !refName) {
|
|
||||||
core.info('No matching ref found for workflow run; skipping post-CI workflow.');
|
|
||||||
} else {
|
|
||||||
const allowed =
|
|
||||||
ref.startsWith('refs/heads/feature-') ||
|
|
||||||
ref.startsWith('refs/heads/fix-') ||
|
|
||||||
ref.startsWith('refs/heads/l10n_') ||
|
|
||||||
ref === 'refs/heads/dev' ||
|
|
||||||
ref === 'refs/heads/beta' ||
|
|
||||||
ref.includes('beta.rc') ||
|
|
||||||
ref.startsWith('refs/tags/v');
|
|
||||||
|
|
||||||
const isBetaRc = refName.includes('beta.rc');
|
|
||||||
const isReleaseTarget = outputs.isTag && (refName.startsWith('v') || isBetaRc);
|
|
||||||
|
|
||||||
outputs.shouldBuild = allowed;
|
|
||||||
outputs.isReleaseTarget = isReleaseTarget;
|
|
||||||
outputs.isBetaRc = isBetaRc;
|
|
||||||
}
|
|
||||||
|
|
||||||
core.setOutput('should-build', outputs.shouldBuild ? 'true' : 'false');
|
|
||||||
core.setOutput('ref', outputs.ref);
|
|
||||||
core.setOutput('ref-name', outputs.refName);
|
|
||||||
core.setOutput('sha', outputs.sha);
|
|
||||||
core.setOutput('is-tag', outputs.isTag ? 'true' : 'false');
|
|
||||||
core.setOutput('is-release-target', outputs.isReleaseTarget ? 'true' : 'false');
|
|
||||||
core.setOutput('is-beta-rc', outputs.isBetaRc ? 'true' : 'false');
|
|
||||||
build-docker-image:
|
|
||||||
needs: prepare
|
|
||||||
if: needs.prepare.outputs.should-build == 'true'
|
|
||||||
name: Build Docker image for ${{ needs.prepare.outputs.ref-name }}
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-build-docker-image-${{ needs.prepare.outputs.ref-name || needs.prepare.outputs.sha }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
env:
|
|
||||||
REF: ${{ needs.prepare.outputs.ref }}
|
|
||||||
REF_NAME: ${{ needs.prepare.outputs.ref-name }}
|
|
||||||
SHA: ${{ needs.prepare.outputs.sha }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
ref: ${{ env.SHA }}
|
|
||||||
- name: Check pushing to Docker Hub
|
|
||||||
id: push-other-places
|
|
||||||
env:
|
|
||||||
REPOSITORY_OWNER: ${{ github.repository_owner }}
|
|
||||||
REF_NAME: ${{ env.REF_NAME }}
|
|
||||||
REF: ${{ env.REF }}
|
|
||||||
run: |
|
|
||||||
if [[ "$REPOSITORY_OWNER" == "paperless-ngx" ]] && \
|
|
||||||
([[ "$REF_NAME" == "dev" ]] || [[ "$REF_NAME" == "beta" ]] || [[ "$REF" == refs/tags/v* ]]); then
|
|
||||||
echo "Enabling DockerHub image push"
|
|
||||||
echo "enable=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "Not pushing to DockerHub"
|
|
||||||
echo "enable=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
- name: Set ghcr repository name
|
|
||||||
id: set-ghcr-repository
|
|
||||||
run: |
|
|
||||||
ghcr_name=$(echo "${{ github.repository }}" | awk '{ print tolower($0) }')
|
|
||||||
echo "Name is ${ghcr_name}"
|
|
||||||
echo "ghcr-repository=${ghcr_name}" >> "$GITHUB_OUTPUT"
|
|
||||||
- name: Gather Docker metadata
|
|
||||||
id: docker-meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
env:
|
|
||||||
GITHUB_REF: ${{ env.REF }}
|
|
||||||
GITHUB_REF_NAME: ${{ env.REF_NAME }}
|
|
||||||
GITHUB_SHA: ${{ env.SHA }}
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
ghcr.io/${{ steps.set-ghcr-repository.outputs.ghcr-repository }}
|
|
||||||
name=paperlessngx/paperless-ngx,enable=${{ steps.push-other-places.outputs.enable }}
|
|
||||||
name=quay.io/paperlessngx/paperless-ngx,enable=${{ steps.push-other-places.outputs.enable }}
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
with:
|
|
||||||
platforms: arm64
|
|
||||||
- name: Login to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
- name: Login to Docker Hub
|
|
||||||
if: steps.push-other-places.outputs.enable == 'true'
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
- name: Login to Quay.io
|
|
||||||
if: steps.push-other-places.outputs.enable == 'true'
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: quay.io
|
|
||||||
username: ${{ secrets.QUAY_USERNAME }}
|
|
||||||
password: ${{ secrets.QUAY_ROBOT_TOKEN }}
|
|
||||||
- name: Build and push
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./Dockerfile
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.docker-meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.docker-meta.outputs.labels }}
|
|
||||||
build-args: |
|
|
||||||
PNGX_TAG_VERSION=${{ steps.docker-meta.outputs.version }}
|
|
||||||
cache-from: |
|
|
||||||
type=registry,ref=ghcr.io/${{ steps.set-ghcr-repository.outputs.ghcr-repository }}/builder/cache/app:${{ env.REF_NAME }}
|
|
||||||
type=registry,ref=ghcr.io/${{ steps.set-ghcr-repository.outputs.ghcr-repository }}/builder/cache/app:dev
|
|
||||||
cache-to: |
|
|
||||||
type=registry,mode=max,ref=ghcr.io/${{ steps.set-ghcr-repository.outputs.ghcr-repository }}/builder/cache/app:${{ env.REF_NAME }}
|
|
||||||
- name: Inspect image
|
|
||||||
run: |
|
|
||||||
docker buildx imagetools inspect ${{ fromJSON(steps.docker-meta.outputs.json).tags[0] }}
|
|
||||||
- name: Export frontend artifact from docker
|
|
||||||
run: |
|
|
||||||
docker create --name frontend-extract ${{ fromJSON(steps.docker-meta.outputs.json).tags[0] }}
|
|
||||||
docker cp frontend-extract:/usr/src/paperless/src/documents/static/frontend src/documents/static/frontend/
|
|
||||||
- name: Upload frontend artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: frontend-compiled
|
|
||||||
path: src/documents/static/frontend/
|
|
||||||
retention-days: 7
|
|
||||||
build-release:
|
|
||||||
needs:
|
|
||||||
- prepare
|
|
||||||
- build-docker-image
|
|
||||||
if: needs.prepare.outputs.should-build == 'true'
|
|
||||||
name: Build release bundle
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
env:
|
|
||||||
REF_NAME: ${{ needs.prepare.outputs.ref-name }}
|
|
||||||
SHA: ${{ needs.prepare.outputs.sha }}
|
|
||||||
CI_RUN_ID: ${{ github.event.workflow_run.id }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
ref: ${{ env.SHA }}
|
|
||||||
- name: Set up Python
|
|
||||||
id: setup-python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
|
||||||
- name: Install uv
|
|
||||||
uses: astral-sh/setup-uv@v6
|
|
||||||
with:
|
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
|
||||||
enable-cache: true
|
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
|
||||||
- name: Install Python dependencies
|
|
||||||
run: |
|
|
||||||
uv sync --python ${{ steps.setup-python.outputs.python-version }} --dev --frozen
|
|
||||||
- name: Install system dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update -qq
|
|
||||||
sudo apt-get install -qq --no-install-recommends gettext liblept5
|
|
||||||
- name: Download frontend artifact
|
|
||||||
uses: actions/download-artifact@v5
|
|
||||||
with:
|
|
||||||
name: frontend-compiled
|
|
||||||
path: src/documents/static/frontend/
|
|
||||||
- name: Download documentation artifact
|
|
||||||
uses: actions/download-artifact@v5
|
|
||||||
with:
|
|
||||||
name: documentation
|
|
||||||
path: docs/_build/html/
|
|
||||||
run-id: ${{ env.CI_RUN_ID }}
|
|
||||||
- name: Generate requirements file
|
|
||||||
run: |
|
|
||||||
uv export --quiet --no-dev --all-extras --format requirements-txt --output-file requirements.txt
|
|
||||||
- name: Compile messages
|
|
||||||
run: |
|
|
||||||
cd src/
|
|
||||||
uv run \
|
|
||||||
--python ${{ steps.setup-python.outputs.python-version }} \
|
|
||||||
manage.py compilemessages
|
|
||||||
- name: Collect static files
|
|
||||||
run: |
|
|
||||||
cd src/
|
|
||||||
uv run \
|
|
||||||
--python ${{ steps.setup-python.outputs.python-version }} \
|
|
||||||
manage.py collectstatic --no-input
|
|
||||||
- name: Move files
|
|
||||||
run: |
|
|
||||||
echo "Making dist folders"
|
|
||||||
for directory in dist \
|
|
||||||
dist/paperless-ngx \
|
|
||||||
dist/paperless-ngx/scripts;
|
|
||||||
do
|
|
||||||
mkdir --verbose --parents ${directory}
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Copying basic files"
|
|
||||||
for file_name in .dockerignore \
|
|
||||||
.env \
|
|
||||||
Dockerfile \
|
|
||||||
pyproject.toml \
|
|
||||||
uv.lock \
|
|
||||||
requirements.txt \
|
|
||||||
LICENSE \
|
|
||||||
README.md \
|
|
||||||
paperless.conf.example
|
|
||||||
do
|
|
||||||
cp --verbose ${file_name} dist/paperless-ngx/
|
|
||||||
done
|
|
||||||
mv --verbose dist/paperless-ngx/paperless.conf.example dist/paperless-ngx/paperless.conf
|
|
||||||
|
|
||||||
echo "Copying Docker related files"
|
|
||||||
cp --recursive docker/ dist/paperless-ngx/docker
|
|
||||||
|
|
||||||
echo "Copying startup scripts"
|
|
||||||
cp --verbose scripts/*.service scripts/*.sh scripts/*.socket dist/paperless-ngx/scripts/
|
|
||||||
|
|
||||||
echo "Copying source files"
|
|
||||||
cp --recursive src/ dist/paperless-ngx/src
|
|
||||||
echo "Copying documentation"
|
|
||||||
cp --recursive docs/_build/html/ dist/paperless-ngx/docs
|
|
||||||
|
|
||||||
mv --verbose static dist/paperless-ngx
|
|
||||||
- name: Make release package
|
|
||||||
run: |
|
|
||||||
echo "Creating release archive"
|
|
||||||
cd dist
|
|
||||||
sudo chown -R 1000:1000 paperless-ngx/
|
|
||||||
tar -cJf paperless-ngx.tar.xz paperless-ngx/
|
|
||||||
- name: Upload release artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: release
|
|
||||||
path: dist/paperless-ngx.tar.xz
|
|
||||||
retention-days: 7
|
|
||||||
publish-release:
|
|
||||||
needs:
|
|
||||||
- prepare
|
|
||||||
- build-release
|
|
||||||
if: needs.prepare.outputs.is-release-target == 'true'
|
|
||||||
name: Publish release
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
outputs:
|
|
||||||
prerelease: ${{ steps.get_version.outputs.prerelease }}
|
|
||||||
changelog: ${{ steps.create-release.outputs.body }}
|
|
||||||
version: ${{ steps.get_version.outputs.version }}
|
|
||||||
steps:
|
|
||||||
- name: Download release artifact
|
|
||||||
uses: actions/download-artifact@v5
|
|
||||||
with:
|
|
||||||
name: release
|
|
||||||
path: ./
|
|
||||||
- name: Get version
|
|
||||||
id: get_version
|
|
||||||
run: |
|
|
||||||
echo "version=${{ needs.prepare.outputs.ref-name }}" >> "$GITHUB_OUTPUT"
|
|
||||||
if [[ ${{ needs.prepare.outputs.is-beta-rc }} == 'true' ]]; then
|
|
||||||
echo "prerelease=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "prerelease=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
- name: Create Release and Changelog
|
|
||||||
id: create-release
|
|
||||||
uses: release-drafter/release-drafter@v6
|
|
||||||
with:
|
|
||||||
name: Paperless-ngx ${{ steps.get_version.outputs.version }}
|
|
||||||
tag: ${{ steps.get_version.outputs.version }}
|
|
||||||
version: ${{ steps.get_version.outputs.version }}
|
|
||||||
prerelease: ${{ steps.get_version.outputs.prerelease }}
|
|
||||||
publish: true
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
- name: Upload release archive
|
|
||||||
id: upload-release-asset
|
|
||||||
uses: shogo82148/actions-upload-release-asset@v1
|
|
||||||
with:
|
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
upload_url: ${{ steps.create-release.outputs.upload_url }}
|
|
||||||
asset_path: ./paperless-ngx.tar.xz
|
|
||||||
asset_name: paperless-ngx-${{ steps.get_version.outputs.version }}.tar.xz
|
|
||||||
asset_content_type: application/x-xz
|
|
||||||
append-changelog:
|
|
||||||
needs:
|
|
||||||
- publish-release
|
|
||||||
if: needs.publish-release.outputs.prerelease == 'false'
|
|
||||||
name: Append changelog to docs
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
ref: main
|
|
||||||
- name: Set up Python
|
|
||||||
id: setup-python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
|
||||||
- name: Install uv
|
|
||||||
uses: astral-sh/setup-uv@v6
|
|
||||||
with:
|
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
|
||||||
enable-cache: true
|
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
|
||||||
- name: Append Changelog to docs
|
|
||||||
id: append-Changelog
|
|
||||||
working-directory: docs
|
|
||||||
run: |
|
|
||||||
git branch ${{ needs.publish-release.outputs.version }}-changelog
|
|
||||||
git checkout ${{ needs.publish-release.outputs.version }}-changelog
|
|
||||||
echo -e "# Changelog\n\n${{ needs.publish-release.outputs.changelog }}\n" > changelog-new.md
|
|
||||||
echo "Manually linking usernames"
|
|
||||||
sed -i -r 's|@([a-zA-Z0-9_]+) \(\[#|[@\1](https://github.com/\1) ([#|g' changelog-new.md
|
|
||||||
echo "Removing unneeded comment tags"
|
|
||||||
sed -i -r 's|@<!---->|@|g' changelog-new.md
|
|
||||||
CURRENT_CHANGELOG=`tail --lines +2 changelog.md`
|
|
||||||
echo -e "$CURRENT_CHANGELOG" >> changelog-new.md
|
|
||||||
mv changelog-new.md changelog.md
|
|
||||||
uv run \
|
|
||||||
--python ${{ steps.setup-python.outputs.python-version }} \
|
|
||||||
--dev \
|
|
||||||
pre-commit run --files changelog.md || true
|
|
||||||
git config --global user.name "github-actions"
|
|
||||||
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
||||||
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
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const { repo, owner } = context.repo;
|
|
||||||
const result = await github.rest.pulls.create({
|
|
||||||
title: 'Documentation: Add ${{ needs.publish-release.outputs.version }} changelog',
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
head: '${{ needs.publish-release.outputs.version }}-changelog',
|
|
||||||
base: 'main',
|
|
||||||
body: 'This PR is auto-generated by CI.'
|
|
||||||
});
|
|
||||||
github.rest.issues.addLabels({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
issue_number: result.data.number,
|
|
||||||
labels: ['documentation', 'skip-changelog']
|
|
||||||
});
|
|
321
.github/workflows/ci.yml
vendored
321
.github/workflows/ci.yml
vendored
@@ -313,3 +313,324 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
run: cd src-ui && pnpm run build --configuration=production
|
run: cd src-ui && pnpm run build --configuration=production
|
||||||
|
build-docker-image:
|
||||||
|
name: Build Docker image for ${{ github.ref_name }}
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
if: github.event_name == 'push' && (startsWith(github.ref, 'refs/heads/feature-') || startsWith(github.ref, 'refs/heads/fix-') || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/beta' || contains(github.ref, 'beta.rc') || startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/heads/l10n_'))
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-build-docker-image-${{ github.ref_name }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
needs:
|
||||||
|
- tests-backend
|
||||||
|
- tests-frontend
|
||||||
|
- tests-frontend-e2e
|
||||||
|
steps:
|
||||||
|
- name: Check pushing to Docker Hub
|
||||||
|
id: push-other-places
|
||||||
|
# Only push to Dockerhub from the main repo AND the ref is either:
|
||||||
|
# main
|
||||||
|
# dev
|
||||||
|
# beta
|
||||||
|
# a tag
|
||||||
|
# Otherwise forks would require a Docker Hub account and secrets setup
|
||||||
|
run: |
|
||||||
|
if [[ ${{ github.repository_owner }} == "paperless-ngx" && ( ${{ github.ref_name }} == "dev" || ${{ github.ref_name }} == "beta" || ${{ startsWith(github.ref, 'refs/tags/v') }} == "true" ) ]] ; then
|
||||||
|
echo "Enabling DockerHub image push"
|
||||||
|
echo "enable=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "Not pushing to DockerHub"
|
||||||
|
echo "enable=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
- name: Set ghcr repository name
|
||||||
|
id: set-ghcr-repository
|
||||||
|
run: |
|
||||||
|
ghcr_name=$(echo "${{ github.repository }}" | awk '{ print tolower($0) }')
|
||||||
|
echo "Name is ${ghcr_name}"
|
||||||
|
echo "ghcr-repository=${ghcr_name}" >> $GITHUB_OUTPUT
|
||||||
|
- name: Gather Docker metadata
|
||||||
|
id: docker-meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
ghcr.io/${{ steps.set-ghcr-repository.outputs.ghcr-repository }}
|
||||||
|
name=paperlessngx/paperless-ngx,enable=${{ steps.push-other-places.outputs.enable }}
|
||||||
|
name=quay.io/paperlessngx/paperless-ngx,enable=${{ steps.push-other-places.outputs.enable }}
|
||||||
|
tags: |
|
||||||
|
# Tag branches with branch name
|
||||||
|
type=ref,event=branch
|
||||||
|
# Process semver tags
|
||||||
|
# For a tag x.y.z or vX.Y.Z, output an x.y.z and x.y image tag
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
# If https://github.com/docker/buildx/issues/1044 is resolved,
|
||||||
|
# the append input with a native arm64 arch could be used to
|
||||||
|
# significantly speed up building
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
with:
|
||||||
|
platforms: arm64
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
# Don't attempt to login if not pushing to Docker Hub
|
||||||
|
if: steps.push-other-places.outputs.enable == 'true'
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
- name: Login to Quay.io
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
# Don't attempt to login if not pushing to Quay.io
|
||||||
|
if: steps.push-other-places.outputs.enable == 'true'
|
||||||
|
with:
|
||||||
|
registry: quay.io
|
||||||
|
username: ${{ secrets.QUAY_USERNAME }}
|
||||||
|
password: ${{ secrets.QUAY_ROBOT_TOKEN }}
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ steps.docker-meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.docker-meta.outputs.labels }}
|
||||||
|
build-args: |
|
||||||
|
PNGX_TAG_VERSION=${{ steps.docker-meta.outputs.version }}
|
||||||
|
# Get cache layers from this branch, then dev
|
||||||
|
# This allows new branches to get at least some cache benefits, generally from dev
|
||||||
|
cache-from: |
|
||||||
|
type=registry,ref=ghcr.io/${{ steps.set-ghcr-repository.outputs.ghcr-repository }}/builder/cache/app:${{ github.ref_name }}
|
||||||
|
type=registry,ref=ghcr.io/${{ steps.set-ghcr-repository.outputs.ghcr-repository }}/builder/cache/app:dev
|
||||||
|
cache-to: |
|
||||||
|
type=registry,mode=max,ref=ghcr.io/${{ steps.set-ghcr-repository.outputs.ghcr-repository }}/builder/cache/app:${{ github.ref_name }}
|
||||||
|
- name: Inspect image
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools inspect ${{ fromJSON(steps.docker-meta.outputs.json).tags[0] }}
|
||||||
|
- name: Export frontend artifact from docker
|
||||||
|
run: |
|
||||||
|
docker create --name frontend-extract ${{ fromJSON(steps.docker-meta.outputs.json).tags[0] }}
|
||||||
|
docker cp frontend-extract:/usr/src/paperless/src/documents/static/frontend src/documents/static/frontend/
|
||||||
|
- name: Upload frontend artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-compiled
|
||||||
|
path: src/documents/static/frontend/
|
||||||
|
retention-days: 7
|
||||||
|
build-release:
|
||||||
|
name: "Build Release"
|
||||||
|
needs:
|
||||||
|
- build-docker-image
|
||||||
|
- documentation
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
- name: Set up Python
|
||||||
|
id: setup-python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v6
|
||||||
|
with:
|
||||||
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
|
enable-cache: true
|
||||||
|
python-version: ${{ steps.setup-python.outputs.python-version }}
|
||||||
|
- name: Install Python dependencies
|
||||||
|
run: |
|
||||||
|
uv sync --python ${{ steps.setup-python.outputs.python-version }} --dev --frozen
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -qq --no-install-recommends gettext liblept5
|
||||||
|
- name: Download frontend artifact
|
||||||
|
uses: actions/download-artifact@v5
|
||||||
|
with:
|
||||||
|
name: frontend-compiled
|
||||||
|
path: src/documents/static/frontend/
|
||||||
|
- name: Download documentation artifact
|
||||||
|
uses: actions/download-artifact@v5
|
||||||
|
with:
|
||||||
|
name: documentation
|
||||||
|
path: docs/_build/html/
|
||||||
|
- name: Generate requirements file
|
||||||
|
run: |
|
||||||
|
uv export --quiet --no-dev --all-extras --format requirements-txt --output-file requirements.txt
|
||||||
|
- name: Compile messages
|
||||||
|
run: |
|
||||||
|
cd src/
|
||||||
|
uv run \
|
||||||
|
--python ${{ steps.setup-python.outputs.python-version }} \
|
||||||
|
manage.py compilemessages
|
||||||
|
- name: Collect static files
|
||||||
|
run: |
|
||||||
|
cd src/
|
||||||
|
uv run \
|
||||||
|
--python ${{ steps.setup-python.outputs.python-version }} \
|
||||||
|
manage.py collectstatic --no-input
|
||||||
|
- name: Move files
|
||||||
|
run: |
|
||||||
|
echo "Making dist folders"
|
||||||
|
for directory in dist \
|
||||||
|
dist/paperless-ngx \
|
||||||
|
dist/paperless-ngx/scripts;
|
||||||
|
do
|
||||||
|
mkdir --verbose --parents ${directory}
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Copying basic files"
|
||||||
|
for file_name in .dockerignore \
|
||||||
|
.env \
|
||||||
|
Dockerfile \
|
||||||
|
pyproject.toml \
|
||||||
|
uv.lock \
|
||||||
|
requirements.txt \
|
||||||
|
LICENSE \
|
||||||
|
README.md \
|
||||||
|
paperless.conf.example
|
||||||
|
do
|
||||||
|
cp --verbose ${file_name} dist/paperless-ngx/
|
||||||
|
done
|
||||||
|
mv --verbose dist/paperless-ngx/paperless.conf.example dist/paperless-ngx/paperless.conf
|
||||||
|
|
||||||
|
echo "Copying Docker related files"
|
||||||
|
cp --recursive docker/ dist/paperless-ngx/docker
|
||||||
|
|
||||||
|
echo "Copying startup scripts"
|
||||||
|
cp --verbose scripts/*.service scripts/*.sh scripts/*.socket dist/paperless-ngx/scripts/
|
||||||
|
|
||||||
|
echo "Copying source files"
|
||||||
|
cp --recursive src/ dist/paperless-ngx/src
|
||||||
|
echo "Copying documentation"
|
||||||
|
cp --recursive docs/_build/html/ dist/paperless-ngx/docs
|
||||||
|
|
||||||
|
mv --verbose static dist/paperless-ngx
|
||||||
|
- name: Make release package
|
||||||
|
run: |
|
||||||
|
echo "Creating release archive"
|
||||||
|
cd dist
|
||||||
|
sudo chown -R 1000:1000 paperless-ngx/
|
||||||
|
tar -cJf paperless-ngx.tar.xz paperless-ngx/
|
||||||
|
- name: Upload release artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: release
|
||||||
|
path: dist/paperless-ngx.tar.xz
|
||||||
|
retention-days: 7
|
||||||
|
publish-release:
|
||||||
|
name: "Publish Release"
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
outputs:
|
||||||
|
prerelease: ${{ steps.get_version.outputs.prerelease }}
|
||||||
|
changelog: ${{ steps.create-release.outputs.body }}
|
||||||
|
version: ${{ steps.get_version.outputs.version }}
|
||||||
|
needs:
|
||||||
|
- build-release
|
||||||
|
if: github.ref_type == 'tag' && (startsWith(github.ref_name, 'v') || contains(github.ref_name, '-beta.rc'))
|
||||||
|
steps:
|
||||||
|
- name: Download release artifact
|
||||||
|
uses: actions/download-artifact@v5
|
||||||
|
with:
|
||||||
|
name: release
|
||||||
|
path: ./
|
||||||
|
- name: Get version
|
||||||
|
id: get_version
|
||||||
|
run: |
|
||||||
|
echo "version=${{ github.ref_name }}" >> $GITHUB_OUTPUT
|
||||||
|
if [[ ${{ contains(github.ref_name, '-beta.rc') }} == 'true' ]]; then
|
||||||
|
echo "prerelease=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "prerelease=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
- name: Create Release and Changelog
|
||||||
|
id: create-release
|
||||||
|
uses: release-drafter/release-drafter@v6
|
||||||
|
with:
|
||||||
|
name: Paperless-ngx ${{ steps.get_version.outputs.version }}
|
||||||
|
tag: ${{ steps.get_version.outputs.version }}
|
||||||
|
version: ${{ steps.get_version.outputs.version }}
|
||||||
|
prerelease: ${{ steps.get_version.outputs.prerelease }}
|
||||||
|
publish: true # ensures release is not marked as draft
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Upload release archive
|
||||||
|
id: upload-release-asset
|
||||||
|
uses: shogo82148/actions-upload-release-asset@v1
|
||||||
|
with:
|
||||||
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
upload_url: ${{ steps.create-release.outputs.upload_url }}
|
||||||
|
asset_path: ./paperless-ngx.tar.xz
|
||||||
|
asset_name: paperless-ngx-${{ steps.get_version.outputs.version }}.tar.xz
|
||||||
|
asset_content_type: application/x-xz
|
||||||
|
append-changelog:
|
||||||
|
name: "Append Changelog"
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
needs:
|
||||||
|
- publish-release
|
||||||
|
if: needs.publish-release.outputs.prerelease == 'false'
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
- name: Set up Python
|
||||||
|
id: setup-python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v6
|
||||||
|
with:
|
||||||
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
|
enable-cache: true
|
||||||
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
|
- name: Append Changelog to docs
|
||||||
|
id: append-Changelog
|
||||||
|
working-directory: docs
|
||||||
|
run: |
|
||||||
|
git branch ${{ needs.publish-release.outputs.version }}-changelog
|
||||||
|
git checkout ${{ needs.publish-release.outputs.version }}-changelog
|
||||||
|
echo -e "# Changelog\n\n${{ needs.publish-release.outputs.changelog }}\n" > changelog-new.md
|
||||||
|
echo "Manually linking usernames"
|
||||||
|
sed -i -r 's|@([a-zA-Z0-9_]+) \(\[#|[@\1](https://github.com/\1) ([#|g' changelog-new.md
|
||||||
|
echo "Removing unneeded comment tags"
|
||||||
|
sed -i -r 's|@<!---->|@|g' changelog-new.md
|
||||||
|
CURRENT_CHANGELOG=`tail --lines +2 changelog.md`
|
||||||
|
echo -e "$CURRENT_CHANGELOG" >> changelog-new.md
|
||||||
|
mv changelog-new.md changelog.md
|
||||||
|
uv run \
|
||||||
|
--python ${{ steps.setup-python.outputs.python-version }} \
|
||||||
|
--dev \
|
||||||
|
pre-commit run --files changelog.md || true
|
||||||
|
git config --global user.name "github-actions"
|
||||||
|
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
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
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const { repo, owner } = context.repo;
|
||||||
|
const result = await github.rest.pulls.create({
|
||||||
|
title: 'Documentation: Add ${{ needs.publish-release.outputs.version }} changelog',
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
head: '${{ needs.publish-release.outputs.version }}-changelog',
|
||||||
|
base: 'main',
|
||||||
|
body: 'This PR is auto-generated by CI.'
|
||||||
|
});
|
||||||
|
github.rest.issues.addLabels({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
issue_number: result.data.number,
|
||||||
|
labels: ['documentation', 'skip-changelog']
|
||||||
|
});
|
||||||
|
220
.github/workflows/codecov-comment.yml
vendored
220
.github/workflows/codecov-comment.yml
vendored
@@ -1,220 +0,0 @@
|
|||||||
name: Codecov PR Comment
|
|
||||||
on:
|
|
||||||
workflow_run:
|
|
||||||
workflows:
|
|
||||||
- ci
|
|
||||||
types:
|
|
||||||
- completed
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: write
|
|
||||||
jobs:
|
|
||||||
comment:
|
|
||||||
if: >-
|
|
||||||
github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
steps:
|
|
||||||
- name: Gather pull request context
|
|
||||||
id: pr
|
|
||||||
uses: actions/github-script@v7
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const run = context.payload.workflow_run;
|
|
||||||
if (!run.pull_requests || run.pull_requests.length === 0) {
|
|
||||||
core.info('No associated pull request. Skipping.');
|
|
||||||
return { shouldRun: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const pr = run.pull_requests[0];
|
|
||||||
return {
|
|
||||||
shouldRun: true,
|
|
||||||
prNumber: pr.number,
|
|
||||||
headSha: run.head_sha,
|
|
||||||
};
|
|
||||||
- name: Fetch Codecov coverage
|
|
||||||
id: coverage
|
|
||||||
if: steps.pr.outputs.shouldRun == 'true'
|
|
||||||
uses: actions/github-script@v7
|
|
||||||
env:
|
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
|
||||||
COMMIT_SHA: ${{ steps.pr.outputs.headSha }}
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const token = process.env.CODECOV_TOKEN;
|
|
||||||
if (!token) {
|
|
||||||
core.warning('CODECOV_TOKEN secret is not available; skipping comment.');
|
|
||||||
core.setOutput('shouldComment', 'false');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const commitSha = process.env.COMMIT_SHA;
|
|
||||||
const owner = context.repo.owner;
|
|
||||||
const repo = context.repo.repo;
|
|
||||||
const url = `https://codecov.io/api/v2/github/${owner}/repos/${repo}/commits/${commitSha}/report`;
|
|
||||||
const maxAttempts = 10;
|
|
||||||
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})`);
|
|
||||||
const response = await fetch(url, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.status === 404) {
|
|
||||||
core.info('Report not ready yet (404). Waiting before retrying.');
|
|
||||||
await sleep(waitMs);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(`Codecov API returned ${response.status}: ${text}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
data = await response.json();
|
|
||||||
if (data && Object.keys(data).length > 0) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
core.info('Report payload empty. Waiting before retrying.');
|
|
||||||
await sleep(waitMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
core.warning('Unable to retrieve Codecov report after multiple attempts.');
|
|
||||||
core.setOutput('shouldComment', 'false');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const totals = data.report?.totals ?? data.commit?.totals ?? data.totals;
|
|
||||||
if (!totals) {
|
|
||||||
core.warning('Codecov response does not contain coverage totals.');
|
|
||||||
core.setOutput('shouldComment', 'false');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const compareTotals = data.report?.compare?.totals ?? data.compare?.totals;
|
|
||||||
const flagsRaw = data.report?.totals_by_flag ?? data.report?.components ?? [];
|
|
||||||
|
|
||||||
const toNumber = (value) => {
|
|
||||||
if (value === null || value === undefined || value === '') {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const num = Number(value);
|
|
||||||
return Number.isFinite(num) ? num : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const coverage = toNumber(totals.coverage);
|
|
||||||
const baseCoverage = toNumber(compareTotals?.base_coverage ?? compareTotals?.base);
|
|
||||||
const delta = toNumber(
|
|
||||||
compareTotals?.coverage_change ??
|
|
||||||
compareTotals?.coverage_diff ??
|
|
||||||
totals.delta ??
|
|
||||||
totals.diff ??
|
|
||||||
totals.change,
|
|
||||||
);
|
|
||||||
|
|
||||||
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 lines = [
|
|
||||||
'<!-- codecov-coverage-comment -->',
|
|
||||||
'**Codecov Coverage**',
|
|
||||||
'',
|
|
||||||
`- Head \`${shortSha}\`: ${formatPercent(coverage)}`,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (baseCoverage !== undefined) {
|
|
||||||
lines.push(`- Base: ${formatPercent(baseCoverage)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (delta !== undefined) {
|
|
||||||
lines.push(`- Change: ${formatDelta(delta)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const flagEntries = Array.isArray(flagsRaw)
|
|
||||||
? flagsRaw
|
|
||||||
: Object.entries(flagsRaw).map(([name, totals]) => ({ name, totals }));
|
|
||||||
|
|
||||||
const flagRows = [];
|
|
||||||
for (const entry of flagEntries) {
|
|
||||||
const label = entry.flag ?? entry.name ?? entry.component ?? entry.id;
|
|
||||||
const entryTotals = entry.totals ?? entry;
|
|
||||||
const entryCoverage = toNumber(entryTotals?.coverage);
|
|
||||||
const entryDelta = toNumber(
|
|
||||||
entryTotals?.coverage_change ??
|
|
||||||
entryTotals?.coverage_diff ??
|
|
||||||
entryTotals?.delta ??
|
|
||||||
entryTotals?.diff,
|
|
||||||
);
|
|
||||||
if (!label || entryCoverage === undefined) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
flagRows.push(`| ${label} | ${formatPercent(entryCoverage)} | ${formatDelta(entryDelta)} |`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (flagRows.length) {
|
|
||||||
lines.push('');
|
|
||||||
lines.push('| Flag | Coverage | Change |');
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
1
.github/workflows/repo-maintenance.yml
vendored
1
.github/workflows/repo-maintenance.yml
vendored
@@ -241,7 +241,6 @@ jobs:
|
|||||||
) {
|
) {
|
||||||
nodes {
|
nodes {
|
||||||
id,
|
id,
|
||||||
createdAt,
|
|
||||||
number,
|
number,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
upvoteCount,
|
upvoteCount,
|
||||||
|
@@ -2,11 +2,9 @@
|
|||||||
|
|
||||||
If you feel like contributing to the project, please do! Bug fixes and improvements are always welcome.
|
If you feel like contributing to the project, please do! Bug fixes and improvements are always welcome.
|
||||||
|
|
||||||
⚠️ Please note: Pull requests that implement a new feature or enhancement _should almost always target an existing feature request_ with evidence of community interest and discussion. This is in order to balance the work of implementing and maintaining new features / enhancements. Pull requests that are opened without meeting this requirement may not be merged.
|
|
||||||
|
|
||||||
If you want to implement something big:
|
If you want to implement something big:
|
||||||
|
|
||||||
- As above, please start with a discussion! Maybe something similar is already in development and we can make it happen together.
|
- Please start a discussion about that in the issues! Maybe something similar is already in development and we can make it happen together.
|
||||||
- When making additions to the project, consider if the majority of users will benefit from your change. If not, you're probably better of forking the project.
|
- When making additions to the project, consider if the majority of users will benefit from your change. If not, you're probably better of forking the project.
|
||||||
- Also consider if your change will get in the way of other users. A good change is a change that enhances the experience of some users who want that change and does not affect users who do not care about the change.
|
- Also consider if your change will get in the way of other users. A good change is a change that enhances the experience of some users who want that change and does not affect users who do not care about the change.
|
||||||
- Please see the [paperless-ngx merge process](#merging-prs) below.
|
- Please see the [paperless-ngx merge process](#merging-prs) below.
|
||||||
@@ -135,7 +133,7 @@ community members. That said, in an effort to keep the repository organized and
|
|||||||
- Issues, pull requests and discussions that are closed will be locked after 30 days of inactivity.
|
- Issues, pull requests and discussions that are closed will be locked after 30 days of inactivity.
|
||||||
- Discussions with a marked answer will be automatically closed.
|
- Discussions with a marked answer will be automatically closed.
|
||||||
- Discussions in the 'General' or 'Support' categories will be closed after 180 days of inactivity.
|
- Discussions in the 'General' or 'Support' categories will be closed after 180 days of inactivity.
|
||||||
- Feature requests that do not meet the following thresholds will be closed: 180 days of inactivity with less than 80 "up-votes", < 5 "up-votes" after 180 days, < 20 "up-votes" after 1 year or < 40 "up-votes" at 2 years.
|
- Feature requests that do not meet the following thresholds will be closed: 180 days of inactivity, < 5 "up-votes" after 180 days, < 20 "up-votes" after 1 year or < 80 "up-votes" at 2 years.
|
||||||
|
|
||||||
In all cases, threads can be re-opened by project maintainers and, of course, users can always create a new discussion for related concerns.
|
In all cases, threads can be re-opened by project maintainers and, of course, users can always create a new discussion for related concerns.
|
||||||
Finally, remember that all information remains searchable and 'closed' feature requests can still serve as inspiration for new features.
|
Finally, remember that all information remains searchable and 'closed' feature requests can still serve as inspiration for new features.
|
||||||
|
@@ -1759,11 +1759,6 @@ started by the container.
|
|||||||
|
|
||||||
: Path to an image file in the /media/logo directory, must include 'logo', e.g. `/logo/Atari_logo.svg`
|
: Path to an image file in the /media/logo directory, must include 'logo', e.g. `/logo/Atari_logo.svg`
|
||||||
|
|
||||||
!!! note
|
|
||||||
|
|
||||||
The logo file will be viewable by anyone with access to the Paperless instance login page,
|
|
||||||
so consider your choice of logo carefully and removing exif data from images before uploading.
|
|
||||||
|
|
||||||
#### [`PAPERLESS_ENABLE_UPDATE_CHECK=<bool>`](#PAPERLESS_ENABLE_UPDATE_CHECK) {#PAPERLESS_ENABLE_UPDATE_CHECK}
|
#### [`PAPERLESS_ENABLE_UPDATE_CHECK=<bool>`](#PAPERLESS_ENABLE_UPDATE_CHECK) {#PAPERLESS_ENABLE_UPDATE_CHECK}
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
@@ -261,10 +261,6 @@ different means. These are as follows:
|
|||||||
Paperless is set up to check your mails every 10 minutes. This can be
|
Paperless is set up to check your mails every 10 minutes. This can be
|
||||||
configured via [`PAPERLESS_EMAIL_TASK_CRON`](configuration.md#PAPERLESS_EMAIL_TASK_CRON)
|
configured via [`PAPERLESS_EMAIL_TASK_CRON`](configuration.md#PAPERLESS_EMAIL_TASK_CRON)
|
||||||
|
|
||||||
#### Processed Mail
|
|
||||||
|
|
||||||
Paperless keeps track of emails it has processed in order to avoid processing the same mail multiple times. This uses the message `UID` provided by the mail server, which should be unique for each message. You can view and manage processed mails from the web UI under Mail > Processed Mails. If you need to re-process a message, you can delete the corresponding processed mail entry, which will allow Paperless-ngx to process the email again the next time the mail fetch task runs.
|
|
||||||
|
|
||||||
#### OAuth Email Setup
|
#### OAuth Email Setup
|
||||||
|
|
||||||
Paperless-ngx supports OAuth2 authentication for Gmail and Outlook email accounts. To set up an email account with OAuth2, you will need to create a 'developer' app with the respective provider and obtain the client ID and client secret and set the appropriate [configuration variables](configuration.md#email_oauth). You will also need to set either [`PAPERLESS_OAUTH_CALLBACK_BASE_URL`](configuration.md#PAPERLESS_OAUTH_CALLBACK_BASE_URL) or [`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) to the correct value for the OAuth2 flow to work correctly.
|
Paperless-ngx supports OAuth2 authentication for Gmail and Outlook email accounts. To set up an email account with OAuth2, you will need to create a 'developer' app with the respective provider and obtain the client ID and client secret and set the appropriate [configuration variables](configuration.md#email_oauth). You will also need to set either [`PAPERLESS_OAUTH_CALLBACK_BASE_URL`](configuration.md#PAPERLESS_OAUTH_CALLBACK_BASE_URL) or [`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) to the correct value for the OAuth2 flow to work correctly.
|
||||||
|
@@ -755,15 +755,11 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
||||||
<context context-type="linenumber">123</context>
|
<context context-type="linenumber">122</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
||||||
<context context-type="linenumber">192</context>
|
<context context-type="linenumber">186</context>
|
||||||
</context-group>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context>
|
|
||||||
<context context-type="linenumber">16</context>
|
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
|
||||||
@@ -976,10 +972,6 @@
|
|||||||
<context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
|
||||||
<context context-type="linenumber">4</context>
|
<context context-type="linenumber">4</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context>
|
|
||||||
<context context-type="linenumber">3</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6226301160429720843" datatype="html">
|
<trans-unit id="6226301160429720843" datatype="html">
|
||||||
<source> Update checking works by pinging the public GitHub API for the latest release to determine whether a new version is available. Actual updating of the app must still be performed manually. </source>
|
<source> Update checking works by pinging the public GitHub API for the latest release to determine whether a new version is available. Actual updating of the app must still be performed manually. </source>
|
||||||
@@ -1225,11 +1217,11 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
||||||
<context context-type="linenumber">154</context>
|
<context context-type="linenumber">148</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
||||||
<context context-type="linenumber">166</context>
|
<context context-type="linenumber">160</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
|
||||||
@@ -1820,7 +1812,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
||||||
<context context-type="linenumber">116</context>
|
<context context-type="linenumber">115</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
|
||||||
@@ -2012,14 +2004,6 @@
|
|||||||
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.html</context>
|
||||||
<context context-type="linenumber">14</context>
|
<context context-type="linenumber">14</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context>
|
|
||||||
<context context-type="linenumber">87</context>
|
|
||||||
</context-group>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context>
|
|
||||||
<context context-type="linenumber">89</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8597030111956627342" datatype="html">
|
<trans-unit id="8597030111956627342" datatype="html">
|
||||||
<source>Empty trash</source>
|
<source>Empty trash</source>
|
||||||
@@ -2129,11 +2113,11 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
||||||
<context context-type="linenumber">155</context>
|
<context context-type="linenumber">149</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
||||||
<context context-type="linenumber">169</context>
|
<context context-type="linenumber">163</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
|
||||||
@@ -2257,11 +2241,11 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
||||||
<context context-type="linenumber">192</context>
|
<context context-type="linenumber">191</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
||||||
<context context-type="linenumber">293</context>
|
<context context-type="linenumber">292</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
|
||||||
@@ -2448,11 +2432,11 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
||||||
<context context-type="linenumber">153</context>
|
<context context-type="linenumber">147</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
||||||
<context context-type="linenumber">163</context>
|
<context context-type="linenumber">157</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
|
||||||
@@ -2584,11 +2568,11 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
||||||
<context context-type="linenumber">194</context>
|
<context context-type="linenumber">193</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
||||||
<context context-type="linenumber">295</context>
|
<context context-type="linenumber">294</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
|
||||||
@@ -3145,10 +3129,6 @@
|
|||||||
<context context-type="sourcefile">src/app/components/common/clearable-badge/clearable-badge.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/clearable-badge/clearable-badge.component.html</context>
|
||||||
<context context-type="linenumber">2</context>
|
<context context-type="linenumber">2</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context>
|
|
||||||
<context context-type="linenumber">85</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7515883357904500238" datatype="html">
|
<trans-unit id="7515883357904500238" datatype="html">
|
||||||
<source>Are you sure?</source>
|
<source>Are you sure?</source>
|
||||||
@@ -3916,7 +3896,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
||||||
<context context-type="linenumber">137</context>
|
<context context-type="linenumber">136</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
|
||||||
@@ -4126,10 +4106,6 @@
|
|||||||
<context context-type="sourcefile">src/app/components/common/toast/toast.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/toast/toast.component.html</context>
|
||||||
<context context-type="linenumber">30</context>
|
<context context-type="linenumber">30</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context>
|
|
||||||
<context context-type="linenumber">36</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6886003843406464884" datatype="html">
|
<trans-unit id="6886003843406464884" datatype="html">
|
||||||
<source>Only process attachments</source>
|
<source>Only process attachments</source>
|
||||||
@@ -5133,10 +5109,6 @@
|
|||||||
<context context-type="sourcefile">src/app/components/common/email-document-dialog/email-document-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/email-document-dialog/email-document-dialog.component.html</context>
|
||||||
<context context-type="linenumber">11</context>
|
<context context-type="linenumber">11</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context>
|
|
||||||
<context context-type="linenumber">32</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8066608938393600549" datatype="html">
|
<trans-unit id="8066608938393600549" datatype="html">
|
||||||
<source>Message</source>
|
<source>Message</source>
|
||||||
@@ -5506,10 +5478,6 @@
|
|||||||
<context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
|
||||||
<context context-type="linenumber">9</context>
|
<context context-type="linenumber">9</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context>
|
|
||||||
<context context-type="linenumber">7</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5034217198277582100" datatype="html">
|
<trans-unit id="5034217198277582100" datatype="html">
|
||||||
<source>Select all pages</source>
|
<source>Select all pages</source>
|
||||||
@@ -5777,11 +5745,11 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
||||||
<context context-type="linenumber">156</context>
|
<context context-type="linenumber">150</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
||||||
<context context-type="linenumber">174</context>
|
<context context-type="linenumber">168</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
|
||||||
@@ -6159,10 +6127,6 @@
|
|||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
||||||
<context context-type="linenumber">114</context>
|
<context context-type="linenumber">114</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context>
|
|
||||||
<context context-type="linenumber">35</context>
|
|
||||||
</context-group>
|
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
|
||||||
<context context-type="linenumber">19</context>
|
<context context-type="linenumber">19</context>
|
||||||
@@ -8553,227 +8517,185 @@
|
|||||||
<source>Disabled</source>
|
<source>Disabled</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
||||||
<context context-type="linenumber">137</context>
|
<context context-type="linenumber">136</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
|
||||||
<context context-type="linenumber">41</context>
|
<context context-type="linenumber">41</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8996068874121140407" datatype="html">
|
|
||||||
<source>View Processed Mail</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
|
||||||
<context context-type="linenumber">143</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="6751234988479444294" datatype="html">
|
<trans-unit id="6751234988479444294" datatype="html">
|
||||||
<source>No mail rules defined.</source>
|
<source>No mail rules defined.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
||||||
<context context-type="linenumber">183</context>
|
<context context-type="linenumber">177</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3178554336792037159" datatype="html">
|
<trans-unit id="3178554336792037159" datatype="html">
|
||||||
<source>Error retrieving mail accounts</source>
|
<source>Error retrieving mail accounts</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
||||||
<context context-type="linenumber">105</context>
|
<context context-type="linenumber">104</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5241231471117657636" datatype="html">
|
<trans-unit id="5241231471117657636" datatype="html">
|
||||||
<source>Error retrieving mail rules</source>
|
<source>Error retrieving mail rules</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
||||||
<context context-type="linenumber">127</context>
|
<context context-type="linenumber">126</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="763945516325093575" datatype="html">
|
<trans-unit id="763945516325093575" datatype="html">
|
||||||
<source>OAuth2 authentication success</source>
|
<source>OAuth2 authentication success</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
||||||
<context context-type="linenumber">135</context>
|
<context context-type="linenumber">134</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="9022978370268070156" datatype="html">
|
<trans-unit id="9022978370268070156" datatype="html">
|
||||||
<source>OAuth2 authentication failed, see logs for details</source>
|
<source>OAuth2 authentication failed, see logs for details</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
||||||
<context context-type="linenumber">146</context>
|
<context context-type="linenumber">145</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6327501535846658797" datatype="html">
|
<trans-unit id="6327501535846658797" datatype="html">
|
||||||
<source>Saved account "<x id="PH" equiv-text="newMailAccount.name"/>".</source>
|
<source>Saved account "<x id="PH" equiv-text="newMailAccount.name"/>".</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
||||||
<context context-type="linenumber">170</context>
|
<context context-type="linenumber">169</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8067594003836508139" datatype="html">
|
<trans-unit id="8067594003836508139" datatype="html">
|
||||||
<source>Error saving account.</source>
|
<source>Error saving account.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
||||||
<context context-type="linenumber">182</context>
|
<context context-type="linenumber">181</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5641934153807844674" datatype="html">
|
<trans-unit id="5641934153807844674" datatype="html">
|
||||||
<source>Confirm delete mail account</source>
|
<source>Confirm delete mail account</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
||||||
<context context-type="linenumber">190</context>
|
<context context-type="linenumber">189</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7176985344323395435" datatype="html">
|
<trans-unit id="7176985344323395435" datatype="html">
|
||||||
<source>This operation will permanently delete this mail account.</source>
|
<source>This operation will permanently delete this mail account.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
||||||
<context context-type="linenumber">191</context>
|
<context context-type="linenumber">190</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5876433590301754883" datatype="html">
|
<trans-unit id="5876433590301754883" datatype="html">
|
||||||
<source>Deleted mail account "<x id="PH" equiv-text="account.name"/>"</source>
|
<source>Deleted mail account "<x id="PH" equiv-text="account.name"/>"</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
||||||
<context context-type="linenumber">201</context>
|
<context context-type="linenumber">200</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5981429299543258715" datatype="html">
|
<trans-unit id="5981429299543258715" datatype="html">
|
||||||
<source>Error deleting mail account "<x id="PH" equiv-text="account.name"/>".</source>
|
<source>Error deleting mail account "<x id="PH" equiv-text="account.name"/>".</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
||||||
<context context-type="linenumber">212</context>
|
<context context-type="linenumber">211</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6424800796582120505" datatype="html">
|
<trans-unit id="6424800796582120505" datatype="html">
|
||||||
<source>Processing mail account "<x id="PH" equiv-text="account.name"/>"</source>
|
<source>Processing mail account "<x id="PH" equiv-text="account.name"/>"</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
||||||
<context context-type="linenumber">224</context>
|
<context context-type="linenumber">223</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3138185874003827652" datatype="html">
|
<trans-unit id="3138185874003827652" datatype="html">
|
||||||
<source>Error processing mail account "<x id="PH" equiv-text="account.name"/>"</source>
|
<source>Error processing mail account "<x id="PH" equiv-text="account.name"/>"</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
||||||
<context context-type="linenumber">229</context>
|
<context context-type="linenumber">228</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="123368655395433699" datatype="html">
|
<trans-unit id="123368655395433699" datatype="html">
|
||||||
<source>Saved rule "<x id="PH" equiv-text="newMailRule.name"/>".</source>
|
<source>Saved rule "<x id="PH" equiv-text="newMailRule.name"/>".</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
||||||
<context context-type="linenumber">247</context>
|
<context context-type="linenumber">246</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8951124554918814321" datatype="html">
|
<trans-unit id="8951124554918814321" datatype="html">
|
||||||
<source>Error saving rule.</source>
|
<source>Error saving rule.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
||||||
<context context-type="linenumber">258</context>
|
<context context-type="linenumber">257</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3574401690710711341" datatype="html">
|
<trans-unit id="3574401690710711341" datatype="html">
|
||||||
<source>Rule "<x id="PH" equiv-text="rule.name"/>" enabled.</source>
|
<source>Rule "<x id="PH" equiv-text="rule.name"/>" enabled.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
||||||
<context context-type="linenumber">274</context>
|
<context context-type="linenumber">273</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7171685227222299542" datatype="html">
|
<trans-unit id="7171685227222299542" datatype="html">
|
||||||
<source>Rule "<x id="PH" equiv-text="rule.name"/>" disabled.</source>
|
<source>Rule "<x id="PH" equiv-text="rule.name"/>" disabled.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
||||||
<context context-type="linenumber">275</context>
|
<context context-type="linenumber">274</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7238791203524413596" datatype="html">
|
<trans-unit id="7238791203524413596" datatype="html">
|
||||||
<source>Error toggling rule "<x id="PH" equiv-text="rule.name"/>".</source>
|
<source>Error toggling rule "<x id="PH" equiv-text="rule.name"/>".</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
||||||
<context context-type="linenumber">280</context>
|
<context context-type="linenumber">279</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3896080636020672118" datatype="html">
|
<trans-unit id="3896080636020672118" datatype="html">
|
||||||
<source>Confirm delete mail rule</source>
|
<source>Confirm delete mail rule</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
||||||
<context context-type="linenumber">291</context>
|
<context context-type="linenumber">290</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2250372580580310337" datatype="html">
|
<trans-unit id="2250372580580310337" datatype="html">
|
||||||
<source>This operation will permanently delete this mail rule.</source>
|
<source>This operation will permanently delete this mail rule.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
||||||
<context context-type="linenumber">292</context>
|
<context context-type="linenumber">291</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4357654589451732716" datatype="html">
|
<trans-unit id="4357654589451732716" datatype="html">
|
||||||
<source>Deleted mail rule "<x id="PH" equiv-text="rule.name"/>"</source>
|
<source>Deleted mail rule "<x id="PH" equiv-text="rule.name"/>"</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
||||||
<context context-type="linenumber">302</context>
|
<context context-type="linenumber">301</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1696130068388341598" datatype="html">
|
<trans-unit id="1696130068388341598" datatype="html">
|
||||||
<source>Error deleting mail rule "<x id="PH" equiv-text="rule.name"/>".</source>
|
<source>Error deleting mail rule "<x id="PH" equiv-text="rule.name"/>".</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
||||||
<context context-type="linenumber">313</context>
|
<context context-type="linenumber">312</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3061362835271417984" datatype="html">
|
<trans-unit id="3061362835271417984" datatype="html">
|
||||||
<source>Permissions updated</source>
|
<source>Permissions updated</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
||||||
<context context-type="linenumber">337</context>
|
<context context-type="linenumber">336</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4639647950943944112" datatype="html">
|
<trans-unit id="4639647950943944112" datatype="html">
|
||||||
<source>Error updating permissions</source>
|
<source>Error updating permissions</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
||||||
<context context-type="linenumber">342</context>
|
<context context-type="linenumber">341</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
|
||||||
<context context-type="linenumber">339</context>
|
<context context-type="linenumber">339</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3501895737484542570" datatype="html">
|
|
||||||
<source>Processed Mail for <x id="START_EMPHASISED_TEXT" ctype="x-em" equiv-text="<em>"/><x id="INTERPOLATION" equiv-text="{{ rule.name }}"/><x id="CLOSE_EMPHASISED_TEXT" ctype="x-em" equiv-text="</em>"/></source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context>
|
|
||||||
<context context-type="linenumber">2</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="1991019495862291373" datatype="html">
|
|
||||||
<source>No processed email messages found.</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context>
|
|
||||||
<context context-type="linenumber">20</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="8691920320483720007" datatype="html">
|
|
||||||
<source>Received</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context>
|
|
||||||
<context context-type="linenumber">33</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="4749295647449765550" datatype="html">
|
|
||||||
<source>Processed</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context>
|
|
||||||
<context context-type="linenumber">34</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="2175109571923803648" datatype="html">
|
|
||||||
<source>Processed mail(s) deleted</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.ts</context>
|
|
||||||
<context context-type="linenumber">72</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="4010735610815226758" datatype="html">
|
<trans-unit id="4010735610815226758" datatype="html">
|
||||||
<source>Filter by:</source>
|
<source>Filter by:</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
|
@@ -166,10 +166,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-group mt-3 mb-1">
|
<div class="nav-group mt-3 mb-1">
|
||||||
<h6 class="sidebar-heading px-3 text-muted">
|
<h6 class="sidebar-heading px-3 text-muted d-flex align-items-center">
|
||||||
<span i18n>Manage</span>
|
<span i18n>Manage</span>
|
||||||
|
<button class="btn btn-link p-2 py-0" (click)="manageCollapse.toggle()">
|
||||||
|
<i-bs width="0.9em" height="0.9em" [name]="isManageMenuCollapsed ? 'chevron-down' : 'chevron-up'"></i-bs>
|
||||||
|
</button>
|
||||||
</h6>
|
</h6>
|
||||||
<ul class="nav flex-column mb-2">
|
<ul class="nav flex-column mb-2" #manageCollapse="ngbCollapse" [(ngbCollapse)]="isManageMenuCollapsed">
|
||||||
<li class="nav-item app-link"
|
<li class="nav-item app-link"
|
||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
||||||
<a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()"
|
||||||
@@ -243,10 +246,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-group mt-auto mb-1">
|
<div class="nav-group mt-auto mb-1">
|
||||||
<h6 class="sidebar-heading px-3 pt-4 text-muted">
|
<h6 class="sidebar-heading px-3 pt-4 text-muted d-flex align-items-center">
|
||||||
<span i18n>Administration</span>
|
<span i18n>Administration</span>
|
||||||
|
<button class="btn btn-link p-2 py-0" (click)="adminCollapse.toggle()">
|
||||||
|
<i-bs width="0.9em" height="0.9em" [name]="isAdminMenuCollapsed ? 'chevron-down' : 'chevron-up'"></i-bs>
|
||||||
|
</button>
|
||||||
</h6>
|
</h6>
|
||||||
<ul class="nav flex-column mb-2">
|
<div class="mb-2">
|
||||||
|
<ul class="nav flex-column" #adminCollapse="ngbCollapse" [(ngbCollapse)]="isAdminMenuCollapsed">
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }"
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }"
|
||||||
tourAnchor="tour.settings">
|
tourAnchor="tour.settings">
|
||||||
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
|
||||||
@@ -292,6 +299,8 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
|
</ul>
|
||||||
|
<ul class="nav flex-column">
|
||||||
<li class="nav-item mt-2" tourAnchor="tour.outro">
|
<li class="nav-item mt-2" tourAnchor="tour.outro">
|
||||||
<a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none"
|
<a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none"
|
||||||
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
|
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
|
||||||
@@ -356,6 +365,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main role="main" class="ms-sm-auto px-md-4"
|
<main role="main" class="ms-sm-auto px-md-4"
|
||||||
|
@@ -89,6 +89,8 @@ export class AppFrameComponent
|
|||||||
appRemoteVersion: AppRemoteVersion
|
appRemoteVersion: AppRemoteVersion
|
||||||
|
|
||||||
isMenuCollapsed: boolean = true
|
isMenuCollapsed: boolean = true
|
||||||
|
isManageMenuCollapsed: boolean = false
|
||||||
|
isAdminMenuCollapsed: boolean = false
|
||||||
|
|
||||||
slimSidebarAnimating: boolean = false
|
slimSidebarAnimating: boolean = false
|
||||||
|
|
||||||
|
@@ -109,11 +109,10 @@
|
|||||||
<li class="list-group-item">
|
<li class="list-group-item">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col" i18n>Name</div>
|
<div class="col" i18n>Name</div>
|
||||||
<div class="col-1 d-none d-sm-block" i18n>Sort Order</div>
|
<div class="col d-none d-sm-block" i18n>Sort Order</div>
|
||||||
<div class="col-2" i18n>Account</div>
|
<div class="col" i18n>Account</div>
|
||||||
<div class="col-2 d-none d-sm-block" i18n>Status</div>
|
<div class="col d-none d-sm-block" i18n>Status</div>
|
||||||
<div class="col d-none d-sm-block" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.ProcessedMail }">Processed Mail</div>
|
<div class="col" i18n>Actions</div>
|
||||||
<div class="col-3" i18n>Actions</div>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
@@ -128,9 +127,9 @@
|
|||||||
<li class="list-group-item">
|
<li class="list-group-item">
|
||||||
<div class="row fade" [class.show]="showRules">
|
<div class="row fade" [class.show]="showRules">
|
||||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editMailRule(rule)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailRule) || !userCanEdit(rule)">{{rule.name}}</button></div>
|
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editMailRule(rule)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailRule) || !userCanEdit(rule)">{{rule.name}}</button></div>
|
||||||
<div class="col-1 d-flex align-items-center d-none d-sm-flex">{{rule.order}}</div>
|
<div class="col d-flex align-items-center d-none d-sm-flex">{{rule.order}}</div>
|
||||||
<div class="col-2 d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div>
|
<div class="col d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div>
|
||||||
<div class="col-2 d-flex align-items-center d-none d-sm-flex">
|
<div class="col d-flex align-items-center d-none d-sm-flex">
|
||||||
<div class="form-check form-switch mb-0">
|
<div class="form-check form-switch mb-0">
|
||||||
<input #inputField type="checkbox" class="form-check-input cursor-pointer" [id]="rule.id+'_enable'" [(ngModel)]="rule.enabled" (change)="onMailRuleEnableToggled(rule)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailRule }">
|
<input #inputField type="checkbox" class="form-check-input cursor-pointer" [id]="rule.id+'_enable'" [(ngModel)]="rule.enabled" (change)="onMailRuleEnableToggled(rule)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailRule }">
|
||||||
<label class="form-check-label cursor-pointer" [for]="rule.id+'_enable'">
|
<label class="form-check-label cursor-pointer" [for]="rule.id+'_enable'">
|
||||||
@@ -138,12 +137,7 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col d-flex align-items-center d-none d-sm-flex" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.ProcessedMail }">
|
<div class="col">
|
||||||
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="viewProcessedMail(rule)">
|
|
||||||
<i-bs width="1em" height="1em" name="clock-history"></i-bs> <ng-container i18n>View Processed Mail</ng-container>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="col-3">
|
|
||||||
<div class="btn-group d-block d-sm-none">
|
<div class="btn-group d-block d-sm-none">
|
||||||
<div ngbDropdown container="body" class="d-inline-block">
|
<div ngbDropdown container="body" class="d-inline-block">
|
||||||
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
|
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
|
||||||
|
@@ -409,13 +409,4 @@ describe('MailComponent', () => {
|
|||||||
jest.advanceTimersByTime(200)
|
jest.advanceTimersByTime(200)
|
||||||
expect(editSpy).toHaveBeenCalled()
|
expect(editSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should open processed mails dialog', () => {
|
|
||||||
completeSetup()
|
|
||||||
let modal: NgbModalRef
|
|
||||||
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
|
|
||||||
component.viewProcessedMail(mailRules[0] as MailRule)
|
|
||||||
const dialog = modal.componentInstance as any
|
|
||||||
expect(dialog.rule).toEqual(mailRules[0])
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
@@ -27,7 +27,6 @@ import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule-
|
|||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
|
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
|
||||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||||
import { ProcessedMailDialogComponent } from './processed-mail-dialog/processed-mail-dialog.component'
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-mail',
|
selector: 'pngx-mail',
|
||||||
@@ -348,14 +347,6 @@ export class MailComponent
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewProcessedMail(rule: MailRule) {
|
|
||||||
const modal = this.modalService.open(ProcessedMailDialogComponent, {
|
|
||||||
backdrop: 'static',
|
|
||||||
size: 'xl',
|
|
||||||
})
|
|
||||||
modal.componentInstance.rule = rule
|
|
||||||
}
|
|
||||||
|
|
||||||
userCanEdit(obj: ObjectWithPermissions): boolean {
|
userCanEdit(obj: ObjectWithPermissions): boolean {
|
||||||
return this.permissionsService.currentUserHasObjectPermissions(
|
return this.permissionsService.currentUserHasObjectPermissions(
|
||||||
PermissionAction.Change,
|
PermissionAction.Change,
|
||||||
|
@@ -1,107 +0,0 @@
|
|||||||
<div class="modal-header">
|
|
||||||
<h6 class="modal-title" id="modal-basic-title" i18n>Processed Mail for <em>{{ rule.name }}</em></h6>
|
|
||||||
<button class="btn btn-sm btn-link text-muted me-auto p-0 p-md-2" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true">
|
|
||||||
<i-bs name="question-circle"></i-bs>
|
|
||||||
</button>
|
|
||||||
<ng-template #infoPopover>
|
|
||||||
<a href="https://docs.paperless-ngx.com/usage#processed-mail" target="_blank" referrerpolicy="noopener noreferrer" i18n>Read more</a>
|
|
||||||
<i-bs class="ms-1" width=".8em" height=".8em" name="box-arrow-up-right"></i-bs>
|
|
||||||
</ng-template>
|
|
||||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
@if (loading) {
|
|
||||||
<div class="text-center my-5">
|
|
||||||
<div class="spinner-border" role="status">
|
|
||||||
<span class="visually-hidden" i18n>Loading...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
} @else if (processedMails.length === 0) {
|
|
||||||
<span i18n>No processed email messages found.</span>
|
|
||||||
} @else {
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover table-sm align-middle">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col" style="width: 40px;">
|
|
||||||
<div class="form-check m-0 ms-2 me-n2">
|
|
||||||
<input type="checkbox" class="form-check-input" id="all-objects" [(ngModel)]="toggleAllEnabled" [disabled]="processedMails.length === 0" (click)="toggleAll($event); $event.stopPropagation();">
|
|
||||||
<label class="form-check-label" for="all-objects"></label>
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
<th scope="col" i18n>Subject</th>
|
|
||||||
<th scope="col" i18n>Received</th>
|
|
||||||
<th scope="col" i18n>Processed</th>
|
|
||||||
<th scope="col" i18n>Status</th>
|
|
||||||
<th scope="col" i18n>Error</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (mail of processedMails; track mail.id) {
|
|
||||||
<ng-template #statusTooltip>
|
|
||||||
<div class="small text-light font-monospace">
|
|
||||||
{{mail.status}}
|
|
||||||
</div>
|
|
||||||
</ng-template>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="form-check m-0 ms-2 me-n2">
|
|
||||||
<input type="checkbox" class="form-check-input" [id]="mail.id" [checked]="selectedMailIds.has(mail.id)" (click)="toggleSelected(mail); $event.stopPropagation();">
|
|
||||||
<label class="form-check-label" [for]="mail.id"></label>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>{{ mail.subject }}</td>
|
|
||||||
<td>{{ mail.received | customDate:'longDate' }}</td>
|
|
||||||
<td>{{ mail.processed | customDate:'longDate' }}</td>
|
|
||||||
<td>
|
|
||||||
@switch (mail.status) {
|
|
||||||
@case ('SUCCESS') {
|
|
||||||
<i-bs name="check-circle" title="SUCCESS" class="text-success" [ngbTooltip]="statusTooltip"></i-bs>
|
|
||||||
}
|
|
||||||
@case ('FAILED') {
|
|
||||||
<i-bs name="exclamation-triangle" title="FAILED" class="text-danger" [ngbTooltip]="statusTooltip"></i-bs>
|
|
||||||
}
|
|
||||||
@default {
|
|
||||||
<i-bs name="slash-circle" title="{{ mail.status }}" class="text-muted" [ngbTooltip]="statusTooltip"></i-bs>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<ng-template #errorPopover>
|
|
||||||
<pre class="small text-light">
|
|
||||||
{{ mail.error }}
|
|
||||||
</pre>
|
|
||||||
</ng-template>
|
|
||||||
@if (mail.error) {
|
|
||||||
<span class="text-danger" triggers="mouseenter:mouseleave" [ngbPopover]="errorPopover">{{ mail.error | slice:0:20 }}</span>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="btn-toolbar">
|
|
||||||
<button type="button" class="btn btn-outline-secondary me-2" (click)="clearSelection()" [disabled]="selectedMailIds.size === 0" i18n>Clear</button>
|
|
||||||
<pngx-confirm-button
|
|
||||||
label="Delete selected"
|
|
||||||
i18n-label
|
|
||||||
title="Delete selected"
|
|
||||||
i18n-title
|
|
||||||
buttonClasses="btn-outline-danger"
|
|
||||||
iconName="trash"
|
|
||||||
[disabled]="selectedMailIds.size === 0"
|
|
||||||
(confirm)="deleteSelected()">
|
|
||||||
</pngx-confirm-button>
|
|
||||||
<div class="ms-auto">
|
|
||||||
<ngb-pagination
|
|
||||||
[collectionSize]="processedMails.length"
|
|
||||||
[(page)]="page"
|
|
||||||
[pageSize]="50"
|
|
||||||
[maxSize]="5"
|
|
||||||
(pageChange)="loadProcessedMails()">
|
|
||||||
</ngb-pagination>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
@@ -1,8 +0,0 @@
|
|||||||
::ng-deep .popover {
|
|
||||||
max-width: 350px;
|
|
||||||
|
|
||||||
pre {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,150 +0,0 @@
|
|||||||
import { DatePipe } from '@angular/common'
|
|
||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
|
||||||
import {
|
|
||||||
HttpTestingController,
|
|
||||||
provideHttpClientTesting,
|
|
||||||
} from '@angular/common/http/testing'
|
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
|
||||||
import { FormsModule } from '@angular/forms'
|
|
||||||
import { By } from '@angular/platform-browser'
|
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
|
||||||
import { environment } from 'src/environments/environment'
|
|
||||||
import { ProcessedMailDialogComponent } from './processed-mail-dialog.component'
|
|
||||||
|
|
||||||
describe('ProcessedMailDialogComponent', () => {
|
|
||||||
let component: ProcessedMailDialogComponent
|
|
||||||
let fixture: ComponentFixture<ProcessedMailDialogComponent>
|
|
||||||
let httpTestingController: HttpTestingController
|
|
||||||
let toastService: ToastService
|
|
||||||
|
|
||||||
const rule: any = { id: 10, name: 'Mail Rule' } // minimal rule object for tests
|
|
||||||
const mails = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
rule: rule.id,
|
|
||||||
folder: 'INBOX',
|
|
||||||
uid: 111,
|
|
||||||
subject: 'A',
|
|
||||||
received: new Date().toISOString(),
|
|
||||||
processed: new Date().toISOString(),
|
|
||||||
status: 'SUCCESS',
|
|
||||||
error: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
rule: rule.id,
|
|
||||||
folder: 'INBOX',
|
|
||||||
uid: 222,
|
|
||||||
subject: 'B',
|
|
||||||
received: new Date().toISOString(),
|
|
||||||
processed: new Date().toISOString(),
|
|
||||||
status: 'FAILED',
|
|
||||||
error: 'Oops',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [
|
|
||||||
ProcessedMailDialogComponent,
|
|
||||||
FormsModule,
|
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
|
||||||
],
|
|
||||||
providers: [
|
|
||||||
DatePipe,
|
|
||||||
NgbActiveModal,
|
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
|
||||||
provideHttpClientTesting(),
|
|
||||||
],
|
|
||||||
}).compileComponents()
|
|
||||||
|
|
||||||
httpTestingController = TestBed.inject(HttpTestingController)
|
|
||||||
toastService = TestBed.inject(ToastService)
|
|
||||||
fixture = TestBed.createComponent(ProcessedMailDialogComponent)
|
|
||||||
component = fixture.componentInstance
|
|
||||||
component.rule = rule
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
httpTestingController.verify()
|
|
||||||
})
|
|
||||||
|
|
||||||
function expectListRequest(ruleId: number) {
|
|
||||||
const req = httpTestingController.expectOne(
|
|
||||||
`${environment.apiBaseUrl}processed_mail/?page=1&page_size=50&ordering=-processed_at&rule=${ruleId}`
|
|
||||||
)
|
|
||||||
expect(req.request.method).toEqual('GET')
|
|
||||||
return req
|
|
||||||
}
|
|
||||||
|
|
||||||
it('should load processed mails on init', () => {
|
|
||||||
fixture.detectChanges()
|
|
||||||
const req = expectListRequest(rule.id)
|
|
||||||
req.flush({ count: 2, results: mails })
|
|
||||||
expect(component.loading).toBeFalsy()
|
|
||||||
expect(component.processedMails).toEqual(mails)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should delete selected mails and reload', () => {
|
|
||||||
fixture.detectChanges()
|
|
||||||
// initial load
|
|
||||||
const initialReq = expectListRequest(rule.id)
|
|
||||||
initialReq.flush({ count: 0, results: [] })
|
|
||||||
|
|
||||||
// select a couple of mails and delete
|
|
||||||
component.selectedMailIds.add(5)
|
|
||||||
component.selectedMailIds.add(6)
|
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
|
||||||
component.deleteSelected()
|
|
||||||
|
|
||||||
const delReq = httpTestingController.expectOne(
|
|
||||||
`${environment.apiBaseUrl}processed_mail/bulk_delete/`
|
|
||||||
)
|
|
||||||
expect(delReq.request.method).toEqual('POST')
|
|
||||||
expect(delReq.request.body).toEqual({ mail_ids: [5, 6] })
|
|
||||||
delReq.flush({})
|
|
||||||
|
|
||||||
// reload after delete
|
|
||||||
const reloadReq = expectListRequest(rule.id)
|
|
||||||
reloadReq.flush({ count: 0, results: [] })
|
|
||||||
expect(toastInfoSpy).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should toggle all, toggle selected, and clear selection', () => {
|
|
||||||
fixture.detectChanges()
|
|
||||||
// initial load with two mails
|
|
||||||
const req = expectListRequest(rule.id)
|
|
||||||
req.flush({ count: 2, results: mails })
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
// toggle all via header checkbox
|
|
||||||
const inputs = fixture.debugElement.queryAll(
|
|
||||||
By.css('input.form-check-input')
|
|
||||||
)
|
|
||||||
const header = inputs[0].nativeElement as HTMLInputElement
|
|
||||||
header.dispatchEvent(new Event('click'))
|
|
||||||
header.checked = true
|
|
||||||
header.dispatchEvent(new Event('click'))
|
|
||||||
expect(component.selectedMailIds.size).toEqual(mails.length)
|
|
||||||
|
|
||||||
// toggle a single mail
|
|
||||||
component.toggleSelected(mails[0] as any)
|
|
||||||
expect(component.selectedMailIds.has(mails[0].id)).toBeFalsy()
|
|
||||||
component.toggleSelected(mails[0] as any)
|
|
||||||
expect(component.selectedMailIds.has(mails[0].id)).toBeTruthy()
|
|
||||||
|
|
||||||
// clear selection
|
|
||||||
component.clearSelection()
|
|
||||||
expect(component.selectedMailIds.size).toEqual(0)
|
|
||||||
expect(component.toggleAllEnabled).toBeFalsy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should close the dialog', () => {
|
|
||||||
const activeModal = TestBed.inject(NgbActiveModal)
|
|
||||||
const closeSpy = jest.spyOn(activeModal, 'close')
|
|
||||||
component.close()
|
|
||||||
expect(closeSpy).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
@@ -1,96 +0,0 @@
|
|||||||
import { SlicePipe } from '@angular/common'
|
|
||||||
import { Component, inject, Input, OnInit } from '@angular/core'
|
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
|
||||||
import {
|
|
||||||
NgbActiveModal,
|
|
||||||
NgbPagination,
|
|
||||||
NgbPopoverModule,
|
|
||||||
NgbTooltipModule,
|
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
|
||||||
import { ConfirmButtonComponent } from 'src/app/components/common/confirm-button/confirm-button.component'
|
|
||||||
import { MailRule } from 'src/app/data/mail-rule'
|
|
||||||
import { ProcessedMail } from 'src/app/data/processed-mail'
|
|
||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
|
||||||
import { ProcessedMailService } from 'src/app/services/rest/processed-mail.service'
|
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'pngx-processed-mail-dialog',
|
|
||||||
imports: [
|
|
||||||
ConfirmButtonComponent,
|
|
||||||
CustomDatePipe,
|
|
||||||
NgbPagination,
|
|
||||||
NgbPopoverModule,
|
|
||||||
NgbTooltipModule,
|
|
||||||
NgxBootstrapIconsModule,
|
|
||||||
FormsModule,
|
|
||||||
ReactiveFormsModule,
|
|
||||||
SlicePipe,
|
|
||||||
],
|
|
||||||
templateUrl: './processed-mail-dialog.component.html',
|
|
||||||
styleUrl: './processed-mail-dialog.component.scss',
|
|
||||||
})
|
|
||||||
export class ProcessedMailDialogComponent implements OnInit {
|
|
||||||
private readonly activeModal = inject(NgbActiveModal)
|
|
||||||
private readonly processedMailService = inject(ProcessedMailService)
|
|
||||||
private readonly toastService = inject(ToastService)
|
|
||||||
|
|
||||||
public processedMails: ProcessedMail[] = []
|
|
||||||
|
|
||||||
public loading: boolean = true
|
|
||||||
public toggleAllEnabled: boolean = false
|
|
||||||
public readonly selectedMailIds: Set<number> = new Set<number>()
|
|
||||||
|
|
||||||
public page: number = 1
|
|
||||||
|
|
||||||
@Input() rule: MailRule
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.loadProcessedMails()
|
|
||||||
}
|
|
||||||
|
|
||||||
public close() {
|
|
||||||
this.activeModal.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadProcessedMails(): void {
|
|
||||||
this.loading = true
|
|
||||||
this.clearSelection()
|
|
||||||
this.processedMailService
|
|
||||||
.list(this.page, 50, 'processed_at', true, { rule: this.rule.id })
|
|
||||||
.subscribe((result) => {
|
|
||||||
this.processedMails = result.results
|
|
||||||
this.loading = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
public deleteSelected(): void {
|
|
||||||
this.processedMailService
|
|
||||||
.bulk_delete(Array.from(this.selectedMailIds))
|
|
||||||
.subscribe(() => {
|
|
||||||
this.toastService.showInfo($localize`Processed mail(s) deleted`)
|
|
||||||
this.loadProcessedMails()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
public toggleAll(event: PointerEvent) {
|
|
||||||
if ((event.target as HTMLInputElement).checked) {
|
|
||||||
this.selectedMailIds.clear()
|
|
||||||
this.processedMails.forEach((mail) => this.selectedMailIds.add(mail.id))
|
|
||||||
} else {
|
|
||||||
this.clearSelection()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public clearSelection() {
|
|
||||||
this.toggleAllEnabled = false
|
|
||||||
this.selectedMailIds.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
public toggleSelected(mail: ProcessedMail) {
|
|
||||||
this.selectedMailIds.has(mail.id)
|
|
||||||
? this.selectedMailIds.delete(mail.id)
|
|
||||||
: this.selectedMailIds.add(mail.id)
|
|
||||||
}
|
|
||||||
}
|
|
@@ -71,20 +71,4 @@ describe('TagListComponent', () => {
|
|||||||
'Do you really want to delete the tag "Tag1"?'
|
'Do you really want to delete the tag "Tag1"?'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should filter out child tags if name filter is empty, otherwise show all', () => {
|
|
||||||
const tags = [
|
|
||||||
{ id: 1, name: 'Tag1', parent: null },
|
|
||||||
{ id: 2, name: 'Tag2', parent: 1 },
|
|
||||||
{ id: 3, name: 'Tag3', parent: null },
|
|
||||||
]
|
|
||||||
component['_nameFilter'] = null // Simulate empty name filter
|
|
||||||
const filtered = component.filterData(tags as any)
|
|
||||||
expect(filtered.length).toBe(2)
|
|
||||||
expect(filtered.find((t) => t.id === 2)).toBeUndefined()
|
|
||||||
|
|
||||||
component['_nameFilter'] = 'Tag2' // Simulate non-empty name filter
|
|
||||||
const filteredWithName = component.filterData(tags as any)
|
|
||||||
expect(filteredWithName.length).toBe(3)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
@@ -62,8 +62,6 @@ export class TagListComponent extends ManagementListComponent<Tag> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
filterData(data: Tag[]) {
|
filterData(data: Tag[]) {
|
||||||
return this.nameFilter?.length
|
return data.filter((tag) => !tag.parent)
|
||||||
? [...data]
|
|
||||||
: data.filter((tag) => !tag.parent)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,12 +0,0 @@
|
|||||||
import { ObjectWithId } from './object-with-id'
|
|
||||||
|
|
||||||
export interface ProcessedMail extends ObjectWithId {
|
|
||||||
rule: number // MailRule.id
|
|
||||||
folder: string
|
|
||||||
uid: number
|
|
||||||
subject: string
|
|
||||||
received: Date
|
|
||||||
processed: Date
|
|
||||||
status: string
|
|
||||||
error: string
|
|
||||||
}
|
|
@@ -28,7 +28,6 @@ export enum PermissionType {
|
|||||||
ShareLink = '%s_sharelink',
|
ShareLink = '%s_sharelink',
|
||||||
CustomField = '%s_customfield',
|
CustomField = '%s_customfield',
|
||||||
Workflow = '%s_workflow',
|
Workflow = '%s_workflow',
|
||||||
ProcessedMail = '%s_processedmail',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
|
@@ -1,39 +0,0 @@
|
|||||||
import { HttpTestingController } from '@angular/common/http/testing'
|
|
||||||
import { TestBed } from '@angular/core/testing'
|
|
||||||
import { Subscription } from 'rxjs'
|
|
||||||
import { environment } from 'src/environments/environment'
|
|
||||||
import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
|
|
||||||
import { ProcessedMailService } from './processed-mail.service'
|
|
||||||
|
|
||||||
let httpTestingController: HttpTestingController
|
|
||||||
let service: ProcessedMailService
|
|
||||||
let subscription: Subscription
|
|
||||||
const endpoint = 'processed_mail'
|
|
||||||
|
|
||||||
// run common tests
|
|
||||||
commonAbstractPaperlessServiceTests(endpoint, ProcessedMailService)
|
|
||||||
|
|
||||||
describe('Additional service tests for ProcessedMailService', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
// Dont need to setup again
|
|
||||||
|
|
||||||
httpTestingController = TestBed.inject(HttpTestingController)
|
|
||||||
service = TestBed.inject(ProcessedMailService)
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
subscription?.unsubscribe()
|
|
||||||
httpTestingController.verify()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should call appropriate api endpoint for bulk delete', () => {
|
|
||||||
const ids = [1, 2, 3]
|
|
||||||
subscription = service.bulk_delete(ids).subscribe()
|
|
||||||
const req = httpTestingController.expectOne(
|
|
||||||
`${environment.apiBaseUrl}${endpoint}/bulk_delete/`
|
|
||||||
)
|
|
||||||
expect(req.request.method).toEqual('POST')
|
|
||||||
expect(req.request.body).toEqual({ mail_ids: ids })
|
|
||||||
req.flush({})
|
|
||||||
})
|
|
||||||
})
|
|
@@ -1,19 +0,0 @@
|
|||||||
import { Injectable } from '@angular/core'
|
|
||||||
import { ProcessedMail } from 'src/app/data/processed-mail'
|
|
||||||
import { AbstractPaperlessService } from './abstract-paperless-service'
|
|
||||||
|
|
||||||
@Injectable({
|
|
||||||
providedIn: 'root',
|
|
||||||
})
|
|
||||||
export class ProcessedMailService extends AbstractPaperlessService<ProcessedMail> {
|
|
||||||
constructor() {
|
|
||||||
super()
|
|
||||||
this.resourceName = 'processed_mail'
|
|
||||||
}
|
|
||||||
|
|
||||||
public bulk_delete(mailIds: number[]) {
|
|
||||||
return this.http.post(`${this.getResourceUrl()}bulk_delete/`, {
|
|
||||||
mail_ids: mailIds,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
@@ -51,17 +51,17 @@ import {
|
|||||||
check,
|
check,
|
||||||
check2All,
|
check2All,
|
||||||
checkAll,
|
checkAll,
|
||||||
checkCircle,
|
|
||||||
checkCircleFill,
|
checkCircleFill,
|
||||||
checkLg,
|
checkLg,
|
||||||
chevronDoubleLeft,
|
chevronDoubleLeft,
|
||||||
chevronDoubleRight,
|
chevronDoubleRight,
|
||||||
|
chevronDown,
|
||||||
chevronRight,
|
chevronRight,
|
||||||
|
chevronUp,
|
||||||
clipboard,
|
clipboard,
|
||||||
clipboardCheck,
|
clipboardCheck,
|
||||||
clipboardCheckFill,
|
clipboardCheckFill,
|
||||||
clipboardFill,
|
clipboardFill,
|
||||||
clockHistory,
|
|
||||||
dash,
|
dash,
|
||||||
dashCircle,
|
dashCircle,
|
||||||
diagram3,
|
diagram3,
|
||||||
@@ -265,17 +265,17 @@ const icons = {
|
|||||||
check,
|
check,
|
||||||
check2All,
|
check2All,
|
||||||
checkAll,
|
checkAll,
|
||||||
checkCircle,
|
|
||||||
checkCircleFill,
|
checkCircleFill,
|
||||||
checkLg,
|
checkLg,
|
||||||
chevronDoubleLeft,
|
chevronDoubleLeft,
|
||||||
chevronDoubleRight,
|
chevronDoubleRight,
|
||||||
|
chevronDown,
|
||||||
chevronRight,
|
chevronRight,
|
||||||
|
chevronUp,
|
||||||
clipboard,
|
clipboard,
|
||||||
clipboardCheck,
|
clipboardCheck,
|
||||||
clipboardCheckFill,
|
clipboardCheckFill,
|
||||||
clipboardFill,
|
clipboardFill,
|
||||||
clockHistory,
|
|
||||||
dash,
|
dash,
|
||||||
dashCircle,
|
dashCircle,
|
||||||
diagram3,
|
diagram3,
|
||||||
|
@@ -164,9 +164,6 @@ class BarcodePlugin(ConsumeTaskPlugin):
|
|||||||
mailrule_id=self.input_doc.mailrule_id,
|
mailrule_id=self.input_doc.mailrule_id,
|
||||||
# Can't use same folder or the consume might grab it again
|
# Can't use same folder or the consume might grab it again
|
||||||
original_file=(tmp_dir / new_document.name).resolve(),
|
original_file=(tmp_dir / new_document.name).resolve(),
|
||||||
# Adding optional original_path for later uses in
|
|
||||||
# workflow matching
|
|
||||||
original_path=self.input_doc.original_file,
|
|
||||||
),
|
),
|
||||||
# All the same metadata
|
# All the same metadata
|
||||||
self.metadata,
|
self.metadata,
|
||||||
|
@@ -156,7 +156,6 @@ class ConsumableDocument:
|
|||||||
|
|
||||||
source: DocumentSource
|
source: DocumentSource
|
||||||
original_file: Path
|
original_file: Path
|
||||||
original_path: Path | None = None
|
|
||||||
mailrule_id: int | None = None
|
mailrule_id: int | None = None
|
||||||
mime_type: str = dataclasses.field(init=False, default=None)
|
mime_type: str = dataclasses.field(init=False, default=None)
|
||||||
|
|
||||||
|
@@ -82,13 +82,6 @@ def _is_ignored(filepath: Path) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _consume(filepath: Path) -> None:
|
def _consume(filepath: Path) -> None:
|
||||||
# Check permissions early
|
|
||||||
try:
|
|
||||||
filepath.stat()
|
|
||||||
except (PermissionError, OSError):
|
|
||||||
logger.warning(f"Not consuming file {filepath}: Permission denied.")
|
|
||||||
return
|
|
||||||
|
|
||||||
if filepath.is_dir() or _is_ignored(filepath):
|
if filepath.is_dir() or _is_ignored(filepath):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -330,12 +323,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
# Also make sure the file exists still, some scanners might write a
|
# Also make sure the file exists still, some scanners might write a
|
||||||
# temporary file first
|
# temporary file first
|
||||||
try:
|
|
||||||
file_still_exists = filepath.exists() and filepath.is_file()
|
file_still_exists = filepath.exists() and filepath.is_file()
|
||||||
except (PermissionError, OSError): # pragma: no cover
|
|
||||||
# If we can't check, let it fail in the _consume function
|
|
||||||
file_still_exists = True
|
|
||||||
continue
|
|
||||||
|
|
||||||
if waited_long_enough and file_still_exists:
|
if waited_long_enough and file_still_exists:
|
||||||
_consume(filepath)
|
_consume(filepath)
|
||||||
|
@@ -92,9 +92,6 @@ class Command(MultiProcessMixin, ProgressBarMixin, BaseCommand):
|
|||||||
# doc to doc is obviously not useful
|
# doc to doc is obviously not useful
|
||||||
if first_doc.pk == second_doc.pk:
|
if first_doc.pk == second_doc.pk:
|
||||||
continue
|
continue
|
||||||
# Skip empty documents (e.g. password-protected)
|
|
||||||
if first_doc.content.strip() == "" or second_doc.content.strip() == "":
|
|
||||||
continue
|
|
||||||
# Skip matching which have already been matched together
|
# Skip matching which have already been matched together
|
||||||
# doc 1 to doc 2 is the same as doc 2 to doc 1
|
# doc 1 to doc 2 is the same as doc 2 to doc 1
|
||||||
doc_1_to_doc_2 = (first_doc.pk, second_doc.pk)
|
doc_1_to_doc_2 = (first_doc.pk, second_doc.pk)
|
||||||
|
@@ -314,19 +314,11 @@ def consumable_document_matches_workflow(
|
|||||||
trigger_matched = False
|
trigger_matched = False
|
||||||
|
|
||||||
# Document path vs trigger path
|
# Document path vs trigger path
|
||||||
|
|
||||||
# Use the original_path if set, else us the original_file
|
|
||||||
match_against = (
|
|
||||||
document.original_path
|
|
||||||
if document.original_path is not None
|
|
||||||
else document.original_file
|
|
||||||
)
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
trigger.filter_path is not None
|
trigger.filter_path is not None
|
||||||
and len(trigger.filter_path) > 0
|
and len(trigger.filter_path) > 0
|
||||||
and not fnmatch(
|
and not fnmatch(
|
||||||
match_against,
|
document.original_file,
|
||||||
trigger.filter_path,
|
trigger.filter_path,
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
|
@@ -614,16 +614,14 @@ class TestBarcodeNewConsume(
|
|||||||
self.assertIsNotFile(temp_copy)
|
self.assertIsNotFile(temp_copy)
|
||||||
|
|
||||||
# Check the split files exist
|
# Check the split files exist
|
||||||
# Check the original_path is set
|
|
||||||
# Check the source is unchanged
|
# Check the source is unchanged
|
||||||
# Check the overrides are unchanged
|
# Check the overrides are unchanged
|
||||||
for (
|
for (
|
||||||
new_input_doc,
|
new_input_doc,
|
||||||
new_doc_overrides,
|
new_doc_overrides,
|
||||||
) in self.get_all_consume_delay_call_args():
|
) in self.get_all_consume_delay_call_args():
|
||||||
self.assertIsFile(new_input_doc.original_file)
|
|
||||||
self.assertEqual(new_input_doc.original_path, temp_copy)
|
|
||||||
self.assertEqual(new_input_doc.source, DocumentSource.ConsumeFolder)
|
self.assertEqual(new_input_doc.source, DocumentSource.ConsumeFolder)
|
||||||
|
self.assertIsFile(new_input_doc.original_file)
|
||||||
self.assertEqual(overrides, new_doc_overrides)
|
self.assertEqual(overrides, new_doc_overrides)
|
||||||
|
|
||||||
|
|
||||||
|
@@ -209,26 +209,6 @@ class TestConsumer(DirectoriesMixin, ConsumerThreadMixin, TransactionTestCase):
|
|||||||
# assert that we have an error logged with this invalid file.
|
# assert that we have an error logged with this invalid file.
|
||||||
error_logger.assert_called_once()
|
error_logger.assert_called_once()
|
||||||
|
|
||||||
@mock.patch("documents.management.commands.document_consumer.logger.warning")
|
|
||||||
def test_permission_error_on_prechecks(self, warning_logger):
|
|
||||||
filepath = Path(self.dirs.consumption_dir) / "selinux.txt"
|
|
||||||
filepath.touch()
|
|
||||||
|
|
||||||
original_stat = Path.stat
|
|
||||||
|
|
||||||
def raising_stat(self, *args, **kwargs):
|
|
||||||
if self == filepath:
|
|
||||||
raise PermissionError("Permission denied")
|
|
||||||
return original_stat(self, *args, **kwargs)
|
|
||||||
|
|
||||||
with mock.patch("pathlib.Path.stat", new=raising_stat):
|
|
||||||
document_consumer._consume(filepath)
|
|
||||||
|
|
||||||
warning_logger.assert_called_once()
|
|
||||||
(args, _) = warning_logger.call_args
|
|
||||||
self.assertIn("Permission denied", args[0])
|
|
||||||
self.consume_file_mock.assert_not_called()
|
|
||||||
|
|
||||||
@override_settings(CONSUMPTION_DIR="does_not_exist")
|
@override_settings(CONSUMPTION_DIR="does_not_exist")
|
||||||
def test_consumption_directory_invalid(self):
|
def test_consumption_directory_invalid(self):
|
||||||
self.assertRaises(CommandError, call_command, "document_consumer", "--oneshot")
|
self.assertRaises(CommandError, call_command, "document_consumer", "--oneshot")
|
||||||
|
@@ -206,29 +206,3 @@ class TestFuzzyMatchCommand(TestCase):
|
|||||||
self.assertEqual(Document.objects.count(), 2)
|
self.assertEqual(Document.objects.count(), 2)
|
||||||
self.assertIsNotNone(Document.objects.get(pk=1))
|
self.assertIsNotNone(Document.objects.get(pk=1))
|
||||||
self.assertIsNotNone(Document.objects.get(pk=2))
|
self.assertIsNotNone(Document.objects.get(pk=2))
|
||||||
|
|
||||||
def test_empty_content(self):
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- 2 documents exist, content is empty (pw-protected)
|
|
||||||
WHEN:
|
|
||||||
- Command is called
|
|
||||||
THEN:
|
|
||||||
- No matches are found
|
|
||||||
"""
|
|
||||||
Document.objects.create(
|
|
||||||
checksum="BEEFCAFE",
|
|
||||||
title="A",
|
|
||||||
content="",
|
|
||||||
mime_type="application/pdf",
|
|
||||||
filename="test.pdf",
|
|
||||||
)
|
|
||||||
Document.objects.create(
|
|
||||||
checksum="DEADBEAF",
|
|
||||||
title="A",
|
|
||||||
content="",
|
|
||||||
mime_type="application/pdf",
|
|
||||||
filename="other_test.pdf",
|
|
||||||
)
|
|
||||||
stdout, _ = self.call_command()
|
|
||||||
self.assertIn("No matches found", stdout)
|
|
||||||
|
@@ -2,7 +2,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2025-09-22 18:20+0000\n"
|
"POT-Creation-Date: 2025-09-17 22:44+0000\n"
|
||||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: English\n"
|
"Language-Team: English\n"
|
||||||
@@ -1827,7 +1827,7 @@ msgstr ""
|
|||||||
msgid "Chinese Traditional"
|
msgid "Chinese Traditional"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/urls.py:370
|
#: paperless/urls.py:368
|
||||||
msgid "Paperless-ngx administration"
|
msgid "Paperless-ngx administration"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@@ -922,7 +922,7 @@ CELERY_ACCEPT_CONTENT = ["application/json", "application/x-python-serialize"]
|
|||||||
CELERY_BEAT_SCHEDULE = _parse_beat_schedule()
|
CELERY_BEAT_SCHEDULE = _parse_beat_schedule()
|
||||||
|
|
||||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#beat-schedule-filename
|
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#beat-schedule-filename
|
||||||
CELERY_BEAT_SCHEDULE_FILENAME = str(DATA_DIR / "celerybeat-schedule.db")
|
CELERY_BEAT_SCHEDULE_FILENAME = DATA_DIR / "celerybeat-schedule.db"
|
||||||
|
|
||||||
|
|
||||||
# Cachalot: Database read cache.
|
# Cachalot: Database read cache.
|
||||||
|
@@ -57,7 +57,6 @@ from paperless.views import UserViewSet
|
|||||||
from paperless_mail.views import MailAccountViewSet
|
from paperless_mail.views import MailAccountViewSet
|
||||||
from paperless_mail.views import MailRuleViewSet
|
from paperless_mail.views import MailRuleViewSet
|
||||||
from paperless_mail.views import OauthCallbackView
|
from paperless_mail.views import OauthCallbackView
|
||||||
from paperless_mail.views import ProcessedMailViewSet
|
|
||||||
|
|
||||||
api_router = DefaultRouter()
|
api_router = DefaultRouter()
|
||||||
api_router.register(r"correspondents", CorrespondentViewSet)
|
api_router.register(r"correspondents", CorrespondentViewSet)
|
||||||
@@ -78,7 +77,6 @@ api_router.register(r"workflow_actions", WorkflowActionViewSet)
|
|||||||
api_router.register(r"workflows", WorkflowViewSet)
|
api_router.register(r"workflows", WorkflowViewSet)
|
||||||
api_router.register(r"custom_fields", CustomFieldViewSet)
|
api_router.register(r"custom_fields", CustomFieldViewSet)
|
||||||
api_router.register(r"config", ApplicationConfigurationViewSet)
|
api_router.register(r"config", ApplicationConfigurationViewSet)
|
||||||
api_router.register(r"processed_mail", ProcessedMailViewSet)
|
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
@@ -1,12 +0,0 @@
|
|||||||
from django_filters import FilterSet
|
|
||||||
|
|
||||||
from paperless_mail.models import ProcessedMail
|
|
||||||
|
|
||||||
|
|
||||||
class ProcessedMailFilterSet(FilterSet):
|
|
||||||
class Meta:
|
|
||||||
model = ProcessedMail
|
|
||||||
fields = {
|
|
||||||
"rule": ["exact"],
|
|
||||||
"status": ["exact"],
|
|
||||||
}
|
|
@@ -6,7 +6,6 @@ from documents.serialisers import OwnedObjectSerializer
|
|||||||
from documents.serialisers import TagsField
|
from documents.serialisers import TagsField
|
||||||
from paperless_mail.models import MailAccount
|
from paperless_mail.models import MailAccount
|
||||||
from paperless_mail.models import MailRule
|
from paperless_mail.models import MailRule
|
||||||
from paperless_mail.models import ProcessedMail
|
|
||||||
|
|
||||||
|
|
||||||
class ObfuscatedPasswordField(serializers.CharField):
|
class ObfuscatedPasswordField(serializers.CharField):
|
||||||
@@ -131,20 +130,3 @@ class MailRuleSerializer(OwnedObjectSerializer):
|
|||||||
if value > 36500: # ~100 years
|
if value > 36500: # ~100 years
|
||||||
raise serializers.ValidationError("Maximum mail age is unreasonably large.")
|
raise serializers.ValidationError("Maximum mail age is unreasonably large.")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
class ProcessedMailSerializer(OwnedObjectSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = ProcessedMail
|
|
||||||
fields = [
|
|
||||||
"id",
|
|
||||||
"owner",
|
|
||||||
"rule",
|
|
||||||
"folder",
|
|
||||||
"uid",
|
|
||||||
"subject",
|
|
||||||
"received",
|
|
||||||
"processed",
|
|
||||||
"status",
|
|
||||||
"error",
|
|
||||||
]
|
|
||||||
|
@@ -3,7 +3,6 @@ from unittest import mock
|
|||||||
|
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.utils import timezone
|
|
||||||
from guardian.shortcuts import assign_perm
|
from guardian.shortcuts import assign_perm
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
@@ -14,7 +13,6 @@ from documents.models import Tag
|
|||||||
from documents.tests.utils import DirectoriesMixin
|
from documents.tests.utils import DirectoriesMixin
|
||||||
from paperless_mail.models import MailAccount
|
from paperless_mail.models import MailAccount
|
||||||
from paperless_mail.models import MailRule
|
from paperless_mail.models import MailRule
|
||||||
from paperless_mail.models import ProcessedMail
|
|
||||||
from paperless_mail.tests.test_mail import BogusMailBox
|
from paperless_mail.tests.test_mail import BogusMailBox
|
||||||
|
|
||||||
|
|
||||||
@@ -723,285 +721,3 @@ class TestAPIMailRules(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn("maximum_age", response.data)
|
self.assertIn("maximum_age", response.data)
|
||||||
|
|
||||||
|
|
||||||
class TestAPIProcessedMails(DirectoriesMixin, APITestCase):
|
|
||||||
ENDPOINT = "/api/processed_mail/"
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
super().setUp()
|
|
||||||
|
|
||||||
self.user = User.objects.create_user(username="temp_admin")
|
|
||||||
self.user.user_permissions.add(*Permission.objects.all())
|
|
||||||
self.user.save()
|
|
||||||
self.client.force_authenticate(user=self.user)
|
|
||||||
|
|
||||||
def test_get_processed_mails_owner_aware(self):
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Configured processed mails with different users
|
|
||||||
WHEN:
|
|
||||||
- API call is made to get processed mails
|
|
||||||
THEN:
|
|
||||||
- Only unowned, owned by user or granted processed mails are provided
|
|
||||||
"""
|
|
||||||
user2 = User.objects.create_user(username="temp_admin2")
|
|
||||||
|
|
||||||
account = MailAccount.objects.create(
|
|
||||||
name="Email1",
|
|
||||||
username="username1",
|
|
||||||
password="password1",
|
|
||||||
imap_server="server.example.com",
|
|
||||||
imap_port=443,
|
|
||||||
imap_security=MailAccount.ImapSecurity.SSL,
|
|
||||||
character_set="UTF-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
rule = MailRule.objects.create(
|
|
||||||
name="Rule1",
|
|
||||||
account=account,
|
|
||||||
folder="INBOX",
|
|
||||||
filter_from="from@example.com",
|
|
||||||
order=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
pm1 = ProcessedMail.objects.create(
|
|
||||||
rule=rule,
|
|
||||||
folder="INBOX",
|
|
||||||
uid="1",
|
|
||||||
subject="Subj1",
|
|
||||||
received=timezone.now(),
|
|
||||||
processed=timezone.now(),
|
|
||||||
status="SUCCESS",
|
|
||||||
error=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
pm2 = ProcessedMail.objects.create(
|
|
||||||
rule=rule,
|
|
||||||
folder="INBOX",
|
|
||||||
uid="2",
|
|
||||||
subject="Subj2",
|
|
||||||
received=timezone.now(),
|
|
||||||
processed=timezone.now(),
|
|
||||||
status="FAILED",
|
|
||||||
error="err",
|
|
||||||
owner=self.user,
|
|
||||||
)
|
|
||||||
|
|
||||||
ProcessedMail.objects.create(
|
|
||||||
rule=rule,
|
|
||||||
folder="INBOX",
|
|
||||||
uid="3",
|
|
||||||
subject="Subj3",
|
|
||||||
received=timezone.now(),
|
|
||||||
processed=timezone.now(),
|
|
||||||
status="SUCCESS",
|
|
||||||
error=None,
|
|
||||||
owner=user2,
|
|
||||||
)
|
|
||||||
|
|
||||||
pm4 = ProcessedMail.objects.create(
|
|
||||||
rule=rule,
|
|
||||||
folder="INBOX",
|
|
||||||
uid="4",
|
|
||||||
subject="Subj4",
|
|
||||||
received=timezone.now(),
|
|
||||||
processed=timezone.now(),
|
|
||||||
status="SUCCESS",
|
|
||||||
error=None,
|
|
||||||
)
|
|
||||||
pm4.owner = user2
|
|
||||||
pm4.save()
|
|
||||||
assign_perm("view_processedmail", self.user, pm4)
|
|
||||||
|
|
||||||
response = self.client.get(self.ENDPOINT)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(response.data["count"], 3)
|
|
||||||
returned_ids = {r["id"] for r in response.data["results"]}
|
|
||||||
self.assertSetEqual(returned_ids, {pm1.id, pm2.id, pm4.id})
|
|
||||||
|
|
||||||
def test_get_processed_mails_filter_by_rule(self):
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Processed mails belonging to two different rules
|
|
||||||
WHEN:
|
|
||||||
- API call is made with rule filter
|
|
||||||
THEN:
|
|
||||||
- Only processed mails for that rule are returned
|
|
||||||
"""
|
|
||||||
account = MailAccount.objects.create(
|
|
||||||
name="Email1",
|
|
||||||
username="username1",
|
|
||||||
password="password1",
|
|
||||||
imap_server="server.example.com",
|
|
||||||
imap_port=443,
|
|
||||||
imap_security=MailAccount.ImapSecurity.SSL,
|
|
||||||
character_set="UTF-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
rule1 = MailRule.objects.create(
|
|
||||||
name="Rule1",
|
|
||||||
account=account,
|
|
||||||
folder="INBOX",
|
|
||||||
filter_from="from1@example.com",
|
|
||||||
order=0,
|
|
||||||
)
|
|
||||||
rule2 = MailRule.objects.create(
|
|
||||||
name="Rule2",
|
|
||||||
account=account,
|
|
||||||
folder="INBOX",
|
|
||||||
filter_from="from2@example.com",
|
|
||||||
order=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
pm1 = ProcessedMail.objects.create(
|
|
||||||
rule=rule1,
|
|
||||||
folder="INBOX",
|
|
||||||
uid="r1-1",
|
|
||||||
subject="R1-A",
|
|
||||||
received=timezone.now(),
|
|
||||||
processed=timezone.now(),
|
|
||||||
status="SUCCESS",
|
|
||||||
error=None,
|
|
||||||
owner=self.user,
|
|
||||||
)
|
|
||||||
pm2 = ProcessedMail.objects.create(
|
|
||||||
rule=rule1,
|
|
||||||
folder="INBOX",
|
|
||||||
uid="r1-2",
|
|
||||||
subject="R1-B",
|
|
||||||
received=timezone.now(),
|
|
||||||
processed=timezone.now(),
|
|
||||||
status="FAILED",
|
|
||||||
error="e",
|
|
||||||
)
|
|
||||||
ProcessedMail.objects.create(
|
|
||||||
rule=rule2,
|
|
||||||
folder="INBOX",
|
|
||||||
uid="r2-1",
|
|
||||||
subject="R2-A",
|
|
||||||
received=timezone.now(),
|
|
||||||
processed=timezone.now(),
|
|
||||||
status="SUCCESS",
|
|
||||||
error=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
response = self.client.get(f"{self.ENDPOINT}?rule={rule1.pk}")
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
returned_ids = {r["id"] for r in response.data["results"]}
|
|
||||||
self.assertSetEqual(returned_ids, {pm1.id, pm2.id})
|
|
||||||
|
|
||||||
def test_bulk_delete_processed_mails(self):
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Processed mails belonging to two different rules and different users
|
|
||||||
WHEN:
|
|
||||||
- API call is made to bulk delete some of the processed mails
|
|
||||||
THEN:
|
|
||||||
- Only the specified processed mails are deleted, respecting ownership and permissions
|
|
||||||
"""
|
|
||||||
user2 = User.objects.create_user(username="temp_admin2")
|
|
||||||
|
|
||||||
account = MailAccount.objects.create(
|
|
||||||
name="Email1",
|
|
||||||
username="username1",
|
|
||||||
password="password1",
|
|
||||||
imap_server="server.example.com",
|
|
||||||
imap_port=443,
|
|
||||||
imap_security=MailAccount.ImapSecurity.SSL,
|
|
||||||
character_set="UTF-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
rule = MailRule.objects.create(
|
|
||||||
name="Rule1",
|
|
||||||
account=account,
|
|
||||||
folder="INBOX",
|
|
||||||
filter_from="from@example.com",
|
|
||||||
order=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
# unowned and owned by self, and one with explicit object perm
|
|
||||||
pm_unowned = ProcessedMail.objects.create(
|
|
||||||
rule=rule,
|
|
||||||
folder="INBOX",
|
|
||||||
uid="u1",
|
|
||||||
subject="Unowned",
|
|
||||||
received=timezone.now(),
|
|
||||||
processed=timezone.now(),
|
|
||||||
status="SUCCESS",
|
|
||||||
error=None,
|
|
||||||
)
|
|
||||||
pm_owned = ProcessedMail.objects.create(
|
|
||||||
rule=rule,
|
|
||||||
folder="INBOX",
|
|
||||||
uid="u2",
|
|
||||||
subject="Owned",
|
|
||||||
received=timezone.now(),
|
|
||||||
processed=timezone.now(),
|
|
||||||
status="FAILED",
|
|
||||||
error="e",
|
|
||||||
owner=self.user,
|
|
||||||
)
|
|
||||||
pm_granted = ProcessedMail.objects.create(
|
|
||||||
rule=rule,
|
|
||||||
folder="INBOX",
|
|
||||||
uid="u3",
|
|
||||||
subject="Granted",
|
|
||||||
received=timezone.now(),
|
|
||||||
processed=timezone.now(),
|
|
||||||
status="SUCCESS",
|
|
||||||
error=None,
|
|
||||||
owner=user2,
|
|
||||||
)
|
|
||||||
assign_perm("delete_processedmail", self.user, pm_granted)
|
|
||||||
pm_forbidden = ProcessedMail.objects.create(
|
|
||||||
rule=rule,
|
|
||||||
folder="INBOX",
|
|
||||||
uid="u4",
|
|
||||||
subject="Forbidden",
|
|
||||||
received=timezone.now(),
|
|
||||||
processed=timezone.now(),
|
|
||||||
status="SUCCESS",
|
|
||||||
error=None,
|
|
||||||
owner=user2,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Success for allowed items
|
|
||||||
response = self.client.post(
|
|
||||||
f"{self.ENDPOINT}bulk_delete/",
|
|
||||||
data={
|
|
||||||
"mail_ids": [pm_unowned.id, pm_owned.id, pm_granted.id],
|
|
||||||
},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(response.data["result"], "OK")
|
|
||||||
self.assertSetEqual(
|
|
||||||
set(response.data["deleted_mail_ids"]),
|
|
||||||
{pm_unowned.id, pm_owned.id, pm_granted.id},
|
|
||||||
)
|
|
||||||
self.assertFalse(ProcessedMail.objects.filter(id=pm_unowned.id).exists())
|
|
||||||
self.assertFalse(ProcessedMail.objects.filter(id=pm_owned.id).exists())
|
|
||||||
self.assertFalse(ProcessedMail.objects.filter(id=pm_granted.id).exists())
|
|
||||||
self.assertTrue(ProcessedMail.objects.filter(id=pm_forbidden.id).exists())
|
|
||||||
|
|
||||||
# 403 and not deleted
|
|
||||||
response = self.client.post(
|
|
||||||
f"{self.ENDPOINT}bulk_delete/",
|
|
||||||
data={
|
|
||||||
"mail_ids": [pm_forbidden.id],
|
|
||||||
},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
|
||||||
self.assertTrue(ProcessedMail.objects.filter(id=pm_forbidden.id).exists())
|
|
||||||
|
|
||||||
# missing mail_ids
|
|
||||||
response = self.client.post(
|
|
||||||
f"{self.ENDPOINT}bulk_delete/",
|
|
||||||
data={"mail_ids": "not-a-list"},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
@@ -3,10 +3,8 @@ import logging
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from django.http import HttpResponseBadRequest
|
from django.http import HttpResponseBadRequest
|
||||||
from django.http import HttpResponseForbidden
|
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from drf_spectacular.utils import extend_schema
|
from drf_spectacular.utils import extend_schema
|
||||||
from drf_spectacular.utils import extend_schema_view
|
from drf_spectacular.utils import extend_schema_view
|
||||||
@@ -14,29 +12,23 @@ from drf_spectacular.utils import inline_serializer
|
|||||||
from httpx_oauth.oauth2 import GetAccessTokenError
|
from httpx_oauth.oauth2 import GetAccessTokenError
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.filters import OrderingFilter
|
|
||||||
from rest_framework.generics import GenericAPIView
|
from rest_framework.generics import GenericAPIView
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
from rest_framework.viewsets import ReadOnlyModelViewSet
|
|
||||||
|
|
||||||
from documents.filters import ObjectOwnedOrGrantedPermissionsFilter
|
from documents.filters import ObjectOwnedOrGrantedPermissionsFilter
|
||||||
from documents.permissions import PaperlessObjectPermissions
|
from documents.permissions import PaperlessObjectPermissions
|
||||||
from documents.permissions import has_perms_owner_aware
|
|
||||||
from documents.views import PassUserMixin
|
from documents.views import PassUserMixin
|
||||||
from paperless.views import StandardPagination
|
from paperless.views import StandardPagination
|
||||||
from paperless_mail.filters import ProcessedMailFilterSet
|
|
||||||
from paperless_mail.mail import MailError
|
from paperless_mail.mail import MailError
|
||||||
from paperless_mail.mail import get_mailbox
|
from paperless_mail.mail import get_mailbox
|
||||||
from paperless_mail.mail import mailbox_login
|
from paperless_mail.mail import mailbox_login
|
||||||
from paperless_mail.models import MailAccount
|
from paperless_mail.models import MailAccount
|
||||||
from paperless_mail.models import MailRule
|
from paperless_mail.models import MailRule
|
||||||
from paperless_mail.models import ProcessedMail
|
|
||||||
from paperless_mail.oauth import PaperlessMailOAuth2Manager
|
from paperless_mail.oauth import PaperlessMailOAuth2Manager
|
||||||
from paperless_mail.serialisers import MailAccountSerializer
|
from paperless_mail.serialisers import MailAccountSerializer
|
||||||
from paperless_mail.serialisers import MailRuleSerializer
|
from paperless_mail.serialisers import MailRuleSerializer
|
||||||
from paperless_mail.serialisers import ProcessedMailSerializer
|
|
||||||
from paperless_mail.tasks import process_mail_accounts
|
from paperless_mail.tasks import process_mail_accounts
|
||||||
|
|
||||||
|
|
||||||
@@ -134,34 +126,6 @@ class MailAccountViewSet(ModelViewSet, PassUserMixin):
|
|||||||
return Response({"result": "OK"})
|
return Response({"result": "OK"})
|
||||||
|
|
||||||
|
|
||||||
class ProcessedMailViewSet(ReadOnlyModelViewSet, PassUserMixin):
|
|
||||||
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
|
|
||||||
serializer_class = ProcessedMailSerializer
|
|
||||||
pagination_class = StandardPagination
|
|
||||||
filter_backends = (
|
|
||||||
DjangoFilterBackend,
|
|
||||||
OrderingFilter,
|
|
||||||
ObjectOwnedOrGrantedPermissionsFilter,
|
|
||||||
)
|
|
||||||
filterset_class = ProcessedMailFilterSet
|
|
||||||
|
|
||||||
queryset = ProcessedMail.objects.all().order_by("-processed")
|
|
||||||
|
|
||||||
@action(methods=["post"], detail=False)
|
|
||||||
def bulk_delete(self, request):
|
|
||||||
mail_ids = request.data.get("mail_ids", [])
|
|
||||||
if not isinstance(mail_ids, list) or not all(
|
|
||||||
isinstance(i, int) for i in mail_ids
|
|
||||||
):
|
|
||||||
return HttpResponseBadRequest("mail_ids must be a list of integers")
|
|
||||||
mails = ProcessedMail.objects.filter(id__in=mail_ids)
|
|
||||||
for mail in mails:
|
|
||||||
if not has_perms_owner_aware(request.user, "delete_processedmail", mail):
|
|
||||||
return HttpResponseForbidden("Insufficient permissions")
|
|
||||||
mail.delete()
|
|
||||||
return Response({"result": "OK", "deleted_mail_ids": mail_ids})
|
|
||||||
|
|
||||||
|
|
||||||
class MailRuleViewSet(ModelViewSet, PassUserMixin):
|
class MailRuleViewSet(ModelViewSet, PassUserMixin):
|
||||||
model = MailRule
|
model = MailRule
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user