├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── feature_request.yml └── workflows │ ├── beta.yml │ ├── build.yml │ ├── crowdin.yml │ ├── docs.yml │ ├── filter-rules.yml │ └── static.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .swiftformat ├── .xcode-version ├── Common ├── .gitignore ├── .swiftpm │ └── xcode │ │ └── xcshareddata │ │ └── xcschemes │ │ └── Common.xcscheme ├── Package.swift ├── Sources │ ├── Common │ │ ├── Color+hex.swift │ │ ├── Color+luminance.swift │ │ ├── DateDecodingError.swift │ │ ├── DateOnlyCoder.swift │ │ ├── DecodeOnly.swift │ │ ├── Decoder.swift │ │ ├── EquatableNoop.swift │ │ ├── Error+isCancellationError.swift │ │ ├── HTTPStatusCode.swift │ │ ├── Image+init.swift │ │ ├── Keychain.swift │ │ ├── Logging.swift │ │ ├── Macros.swift │ │ ├── NSURLError.swift │ │ ├── NullCodable.swift │ │ ├── Pasteboard.swift │ │ ├── Sanitize.swift │ │ ├── String+isNumber.swift │ │ ├── StringProtocol+slugify.swift │ │ ├── URLSession+data.swift │ │ ├── UserDefaults.swift │ │ └── Version.swift │ └── CommonMacros │ │ ├── Main.swift │ │ └── URLMacro.swift └── Tests │ └── CommonTests │ ├── ColorHex.swift │ ├── DateDecoderTest.swift │ ├── DateOnlyCoderTest.swift │ ├── DecodeOnlyTest.swift │ ├── Slugify.swift │ ├── URLMacroTest.swift │ └── VersionTests.swift ├── DataModel ├── .gitignore ├── .swiftpm │ └── xcode │ │ └── xcshareddata │ │ └── xcschemes │ │ ├── DataModel.xcscheme │ │ └── DataModelTests.xcscheme ├── Package.swift ├── Sources │ └── DataModel │ │ ├── CorrespondentModel.swift │ │ ├── DocumentModel.swift │ │ ├── DocumentTypeModel.swift │ │ ├── FilterRule.swift │ │ ├── FilterRuleType.swift │ │ ├── FilterState.swift │ │ ├── ListResponse.swift │ │ ├── Logging.swift │ │ ├── MatchingAlgorithm.swift │ │ ├── MatchingModel.swift │ │ ├── Metadata.swift │ │ ├── Model.swift │ │ ├── PermissionsModel.swift │ │ ├── SavedViewModel.swift │ │ ├── SortField.swift │ │ ├── SortOrder.swift │ │ ├── StoragePathModel.swift │ │ ├── SuggestionsModel.swift │ │ ├── TagModel.swift │ │ ├── TaskModel.swift │ │ ├── UISettings.swift │ │ └── UserModel.swift └── Tests │ └── DataModelTests │ ├── CorrespondentTest.swift │ ├── Data │ ├── Document │ │ ├── full.json │ │ ├── full_new_notes.json │ │ ├── full_no_user_can_change.json │ │ └── full_with_perms.json │ ├── UISettings │ │ ├── ui_settings_min_version.json │ │ └── ui_settings_v2.13.5.json │ ├── metadata.json │ ├── permissions.json │ ├── suggestions.json │ ├── tags.json │ ├── tasks.json │ └── users.json │ ├── DocumentModelTest.swift │ ├── DocumentTypeTest.swift │ ├── FilterRuleTest.swift │ ├── Helpers.swift │ ├── MetadataTest.swift │ ├── PermissionsTest.swift │ ├── SavedViewTest.swift │ ├── SortFieldTest.swift │ ├── SortOrderTest.swift │ ├── StoragePathTest.swift │ ├── SuggestionsTest.swift │ ├── TagModelTest.swift │ ├── TaskModelTest.swift │ ├── UISettingsTest.swift │ └── UserModelTest.swift ├── Gemfile ├── Gemfile.lock ├── Justfile ├── LICENSE ├── Networking ├── .gitignore ├── Package.swift ├── Sources │ └── Networking │ │ ├── Api │ │ ├── ApiRepository.swift │ │ ├── ApiSequence.swift │ │ ├── Connection.swift │ │ ├── Endpoint.swift │ │ ├── MultiPartFormDataRequest.swift │ │ ├── PaperlessURLSessionDelegate.swift │ │ ├── PreviewRepository.swift │ │ ├── TLSIdentity.swift │ │ └── TransientRepository.swift │ │ ├── Logging.swift │ │ ├── NullRepository.swift │ │ ├── Repository.swift │ │ └── RequestError.swift └── Tests │ └── NetworkingTests │ └── TransientRepositoryTest.swift ├── README.md ├── ShareExtension ├── AttachmentManager+receiveAttachment.swift ├── Base.lproj │ └── MainInterface.storyboard ├── Info.plist ├── ShareExtension.entitlements ├── ShareView.swift ├── ShareViewController.swift └── mul.lproj │ └── MainInterface.xcstrings ├── app_store.svg ├── app_store ├── devices │ ├── iPad Pro (12.9-inch) (2nd generation) │ │ ├── 00.png │ │ ├── 01.png │ │ ├── 02.png │ │ └── 03.png │ ├── iPad Pro (12.9-inch) (6th generation) │ │ ├── 00.png │ │ ├── 01.png │ │ ├── 02.png │ │ └── 03.png │ ├── iPhone 14 Pro Max │ │ ├── 00.png │ │ ├── 01.png │ │ ├── 02.png │ │ └── 03.png │ ├── iPhone 14 │ │ ├── 00.png │ │ ├── 01.png │ │ ├── 02.png │ │ └── 03.png │ ├── iPhone 8 Plus │ │ ├── 00.png │ │ ├── 01.png │ │ ├── 02.png │ │ └── 03.png │ └── iPhone SE (3rd generation) │ │ ├── 00.png │ │ ├── 01.png │ │ ├── 02.png │ │ └── 04.png ├── panorama.afdesign ├── panorama.jpg ├── panorama.png ├── screens.afdesign ├── screens │ ├── 00.png │ ├── 01.png │ ├── 02.png │ ├── 03.png │ ├── 04.png │ ├── 05.png │ ├── 06.png │ ├── 07.png │ ├── 08.png │ ├── 09.png │ ├── 10.png │ └── 11.png ├── single.afphoto ├── slice1.png ├── slice1_6p5.png ├── slice1_6p7.png ├── slice2.png ├── slice2_6p5.png ├── slice2_6p7.png ├── slice3.png ├── slice3_6p5.png ├── slice3_6p7.png ├── slice4.png ├── slice4_6p5.png ├── slice4_6p7.png ├── slice5.png ├── slice5_6p5.png └── slice5_6p7.png ├── bump.py ├── changelog.txt ├── crowdin.yml ├── current_changelog.txt ├── demo ├── docker-compose.env └── docker-compose.yml ├── docs ├── app_store.svg ├── assets │ ├── favicon.png │ ├── logo.png │ └── logo_standalone.png ├── common_issues │ ├── certificates.md │ ├── forbidden.md │ ├── local-network-denied.md │ ├── paperless-perms.png │ └── supported-versions.md ├── extra.css ├── index.md ├── libraries.md ├── logo.png ├── panorama.png ├── privacy.md ├── release_notes │ ├── v1.2.0.md │ ├── v1.3.0.md │ ├── v1.4.0.md │ ├── v1.5.0.md │ ├── v1.5.1.md │ ├── v1.6.0.md │ ├── v1.6.1.md │ ├── v1.7.0.md │ ├── v1.7.1.md │ └── v1.7.2.md ├── requirements.in ├── requirements.txt └── validate_links.py ├── fastlane ├── .gitignore ├── Appfile ├── Deliverfile ├── Fastfile ├── Matchfile ├── Pluginfile ├── Snapfile ├── cliff.toml ├── metadata │ ├── copyright.txt │ ├── de-DE │ │ ├── description.txt │ │ ├── keywords.txt │ │ └── subtitle.txt │ ├── default │ │ ├── description.txt │ │ ├── keywords.txt │ │ ├── marketing_url.txt │ │ ├── name.txt │ │ ├── privacy_url.txt │ │ ├── release_notes.txt │ │ ├── subtitle.txt │ │ └── support_url.txt │ ├── fr-FR │ │ ├── description.txt │ │ ├── keywords.txt │ │ └── subtitle.txt │ ├── primary_category.txt │ ├── primary_first_sub_category.txt │ ├── primary_second_sub_category.txt │ ├── secondary_category.txt │ ├── secondary_first_sub_category.txt │ └── secondary_second_sub_category.txt └── screenshots │ ├── README.txt │ ├── Screenshots.xcstrings │ └── frames.yml ├── logo.afdesign ├── logo.png ├── mkdocs.yml ├── notes.md ├── panorama.png ├── privacy.md ├── scripts ├── filter_rules.swift.jinja2 ├── frame.py ├── generate_filterrules.py ├── i18n.py ├── requirements.in ├── requirements.txt ├── string_catalog.py └── xcstrings_to_csv.py ├── swift-paperless.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ ├── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ ├── WorkspaceSettings.xcsettings │ │ └── swiftpm │ │ │ └── Package.resolved │ └── xcuserdata │ │ └── pagessin.xcuserdatad │ │ └── WorkspaceSettings.xcsettings └── xcshareddata │ └── xcschemes │ ├── CommonTests.xcscheme │ ├── ShareExtension.xcscheme │ ├── swift-paperless.xcscheme │ ├── swift-paperlessTests.xcscheme │ └── swift-paperlessUITests.xcscheme ├── swift-paperless ├── Assets.xcassets │ ├── AccentColorDarkened.colorset │ │ └── Contents.json │ ├── App Icons │ │ ├── AppIcon-Preview.imageset │ │ │ ├── Contents.json │ │ │ └── preview.png │ │ ├── AppIcon.appiconset │ │ │ ├── Contents.json │ │ │ ├── dark.png │ │ │ ├── light.png │ │ │ └── tint.png │ │ ├── AppIconVar0-Preview.imageset │ │ │ ├── Contents.json │ │ │ └── preview.png │ │ ├── AppIconVar0.appiconset │ │ │ ├── Contents.json │ │ │ ├── dark.png │ │ │ ├── light.png │ │ │ └── tint.png │ │ ├── AppIconVar1-Preview.imageset │ │ │ ├── Contents.json │ │ │ └── preview.png │ │ ├── AppIconVar1.appiconset │ │ │ ├── Contents.json │ │ │ ├── dark.png │ │ │ ├── light.png │ │ │ └── tint.png │ │ ├── AppIconVar2-Preview.imageset │ │ │ ├── Contents.json │ │ │ └── preview.png │ │ ├── AppIconVar2.appiconset │ │ │ ├── Contents.json │ │ │ ├── dark.png │ │ │ ├── light.png │ │ │ └── tint.png │ │ ├── AppLogoTransparent.imageset │ │ │ ├── Contents.json │ │ │ ├── var3_transparent@1x.png │ │ │ ├── var3_transparent@2x.png │ │ │ ├── var3_transparent@3x.png │ │ │ ├── var3_transparent_dark@1x.png │ │ │ ├── var3_transparent_dark@2x.png │ │ │ └── var3_transparent_dark@3x.png │ │ └── Contents.json │ ├── Contents.json │ ├── Divider.colorset │ │ └── Contents.json │ ├── ElementBorder.colorset │ │ └── Contents.json │ ├── ErrorColor.colorset │ │ └── Contents.json │ ├── ImageShadow.colorset │ │ └── Contents.json │ ├── OnBackgroundText.colorset │ │ └── Contents.json │ └── Palette │ │ ├── AccentColor.colorset │ │ └── Contents.json │ │ ├── Contents.json │ │ ├── paletteBlue.colorset │ │ └── Contents.json │ │ ├── paletteCoolGray.colorset │ │ └── Contents.json │ │ ├── paletteRed.colorset │ │ └── Contents.json │ │ └── paletteYellow.colorset │ │ └── Contents.json ├── Document │ └── DocumentStore.swift ├── Info.plist ├── Localization │ ├── DocumentMetadata.xcstrings │ ├── Localizable.xcstrings │ ├── Login.xcstrings │ ├── Matching.xcstrings │ ├── Permissions.xcstrings │ ├── Settings.xcstrings │ └── Tasks.xcstrings ├── Model │ ├── FilterState.swift │ └── Localization │ │ ├── MatchingAlgorithm+title+label.swift │ │ ├── NamedLocalized.swift │ │ └── TaskModel+localizedResult.swift ├── Networking │ ├── ConnectionManager.swift │ ├── DeriveUrl.swift │ ├── Error │ │ ├── DecodingErrorWithRootType+DisplayableError.swift │ │ ├── DocumentCreateError+DisplayableError.swift │ │ ├── DocumentedError.swift │ │ ├── LoginError+PresentableError.swift │ │ ├── LoginError.swift │ │ ├── PresentableError.swift │ │ ├── RequestError+DisplayableError.swift │ │ ├── RequestError+PresentableError.swift │ │ └── ResourceForbidden+DisplayableError.swift │ └── IdentityManager.swift ├── Preview Content │ ├── Preview Assets.xcassets │ │ └── Contents.json │ ├── demo.pdf │ ├── demo2.pdf │ └── demo3.pdf ├── PrivacyInfo.xcprivacy ├── Utilities │ ├── AppSettings.swift │ ├── Binding.swift │ ├── BiometricLockManager.swift │ ├── Bundle.swift │ ├── DebounceObject.swift │ ├── DecodingError+DisplayableError.swift │ ├── DocumentationLinks.swift │ ├── Gather.swift │ ├── Haptics.swift │ ├── Import.swift │ ├── Label+init.swift │ ├── Logging.swift │ ├── NSData+mimeType.swift │ ├── PKCS12.swift │ ├── TextField+clearable.swift │ ├── View+alert.swift │ ├── View+confirmationDialog.swift │ └── View+if.swift ├── ViewModel │ ├── AttachmentManager.swift │ └── FilterModel.swift ├── Views │ ├── AuthAsyncImage.swift │ ├── CancelIconButton.swift │ ├── CommonPickerFilterView.swift │ ├── CorrespondentEditView.swift │ ├── DataScannerView.swift │ ├── Document │ │ └── Detail │ │ │ ├── DocumentDetailModel.swift │ │ │ ├── DocumentDetailView.swift │ │ │ ├── DocumentDetailViewV1.swift │ │ │ ├── DocumentDetailViewV3.swift │ │ │ ├── DocumentMetadataView.swift │ │ │ └── DocumentNoteView.swift │ ├── DocumentAsnEditingView.swift │ ├── DocumentCell.swift │ ├── DocumentEditView.swift │ ├── DocumentList.swift │ ├── DocumentListViewModel.swift │ ├── DocumentTypeEditView.swift │ ├── DocumentView.swift │ ├── Error │ │ ├── ErrorController.swift │ │ └── ErrorDisplay.swift │ ├── FilterBar.swift │ ├── FullScreenConfirmationDialog.swift │ ├── Import │ │ ├── CreateDocumentView.swift │ │ ├── DocumentImportModel.swift │ │ └── DocumentScannerView.swift │ ├── InactiveView.swift │ ├── LogRecord.swift │ ├── Login │ │ ├── BackgroundColorModifier.swift │ │ ├── ConnectionStageView.swift │ │ ├── CredentialsStageView.swift │ │ ├── CustomSection.swift │ │ ├── LoginStage.swift │ │ ├── LoginView.swift │ │ ├── LoginViewModel.swift │ │ ├── LoginViewV1.swift │ │ ├── LoginViewV2.swift │ │ ├── StageSelectionView.swift │ │ ├── UrlEntryView.swift │ │ └── UrlError.swift │ ├── LogoView.swift │ ├── MailView.swift │ ├── MainLoadingView.swift │ ├── MatchingEditView.swift │ ├── PDFKitView.swift │ ├── PDFView.swift │ ├── PermissionsEditView.swift │ ├── QuickLookPreview.swift │ ├── ReleaseNotesView.swift │ ├── SavedViewEditView.swift │ ├── SearchBarView.swift │ ├── Settings │ │ ├── AppVersionView.swift │ │ ├── ConnectionsView.swift │ │ ├── DebugMenuView.swift │ │ ├── ExtraHeadersView.swift │ │ ├── LibrariesView.swift │ │ ├── LogoChangeView.swift │ │ ├── ManageView.swift │ │ ├── PreferencesView.swift │ │ ├── PrivacyView.swift │ │ ├── SettingsManagers.swift │ │ └── SettingsView.swift │ ├── StoragePathEditView.swift │ ├── SuccessOverlay.swift │ ├── TLSListView.swift │ ├── Tags │ │ ├── TagEditView.swift │ │ ├── TagSelectionView.swift │ │ └── TagView.swift │ ├── TaskActivityView.swift │ └── Tasks │ │ ├── TaskActivityToolbar.swift │ │ └── TasksView.swift ├── swift_paperless.entitlements └── swift_paperlessApp.swift ├── swift-paperlessTests ├── DeriveUrlTest.swift ├── ErrorParsingTest.swift ├── FilterStateTest.swift └── issue_91.json └── swift-paperlessUITests ├── Screenshots.swift ├── SnapshotHelper.swift └── swift_paperlessUITests.swift /.gitattributes: -------------------------------------------------------------------------------- 1 | app_store/** filter=lfs diff=lfs merge=lfs -text 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: paulgessinger 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Create a report to help us improve 3 | labels: ["bug"] 4 | body: 5 | - id: app_version 6 | type: input 7 | attributes: 8 | label: App version 9 | description: The version of the app you are using. 10 | placeholder: 1.x.x 11 | validations: 12 | required: true 13 | 14 | - id: backend_version 15 | type: input 16 | attributes: 17 | label: Paperless-ngx backend version 18 | description: The version of the app you are using. 19 | placeholder: 2.x.x 20 | validations: 21 | required: true 22 | 23 | - type: textarea 24 | id: bug-description 25 | attributes: 26 | label: Description 27 | description: A description of what the behavior is as opposed to the expected behavior. 28 | validations: 29 | required: true 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Featrue request 2 | description: Request a new feature 3 | labels: ["feature request"] 4 | body: 5 | - type: textarea 6 | id: description 7 | attributes: 8 | label: Description 9 | description: A description of what the desire feature is 10 | validations: 11 | required: true 12 | -------------------------------------------------------------------------------- /.github/workflows/beta.yml: -------------------------------------------------------------------------------- 1 | name: TestFlight 2 | 3 | on: 4 | release: 5 | types: [prereleased] 6 | jobs: 7 | upload_to_testflight: 8 | runs-on: macos-15 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v4 12 | 13 | - name: Setup git user 14 | run: | 15 | git config user.name github-actions[bot] 16 | git config user.email 41898282+github-actions[bot]@users.noreply.github.com 17 | 18 | - name: Deploy to TestFlight 19 | env: 20 | MATCH_PASSWORD: ${{ secrets.MATCH_PASSPHRASE }} 21 | APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} 22 | APP_STORE_CONNECT_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_KEY_CONTENT }} 23 | APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | run: | 26 | eval `ssh-agent -s` 27 | ssh-add - <<< '${{ secrets.MATCH_DEPLOY_PRIVATE_KEY }}' 28 | fastlane beta_ci 29 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | 3 | on: 4 | push: 5 | 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read 10 | pages: write 11 | id-token: write 12 | 13 | concurrency: 14 | group: "pages" 15 | cancel-in-progress: false 16 | 17 | jobs: 18 | build: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v4 23 | 24 | - uses: actions/setup-python@v5 25 | with: 26 | python-version: '3.12' 27 | 28 | - name: Build documentation 29 | run: | 30 | pip install -r docs/requirements.txt 31 | mkdocs build 32 | mkdir site/release_notes/md 33 | cp docs/release_notes/*.md site/release_notes/md 34 | 35 | - name: Upload artifact 36 | uses: actions/upload-pages-artifact@v3 37 | with: 38 | path: 'site' 39 | 40 | deploy: 41 | if: github.ref == 'refs/heads/main' 42 | environment: 43 | name: github-pages 44 | url: ${{ steps.deployment.outputs.page_url }} 45 | runs-on: ubuntu-latest 46 | needs: build 47 | steps: 48 | - name: Deploy to GitHub Pages 49 | id: deployment 50 | uses: actions/deploy-pages@v4 51 | -------------------------------------------------------------------------------- /.github/workflows/filter-rules.yml: -------------------------------------------------------------------------------- 1 | name: Check upstream filter rule types 2 | 3 | on: 4 | schedule: 5 | - cron: "7 */6 * * *" 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | 12 | jobs: 13 | update_filter_rules: 14 | runs-on: ubuntu-latest 15 | 16 | env: 17 | PRE_COMMIT_HOME: '/tmp/pre-commit' 18 | # For gh cli in generate_filterrules.py 19 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - uses: actions/setup-python@v5 25 | with: 26 | python-version: '3.12' 27 | 28 | - name: Install dependencies 29 | run: | 30 | pip install -r scripts/requirements.txt 31 | pip install pre-commit 32 | 33 | - uses: actions/cache@v4 34 | with: 35 | path: | 36 | ${{ env.PRE_COMMIT_HOME }} 37 | key: ${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }} 38 | 39 | - name: Generate filter rules 40 | run: | 41 | scripts/generate_filterrules.py --output DataModel/Sources/DataModel/FilterRuleType.swift 42 | pre-commit run swiftformat --files DataModel/Sources/DataModel/FilterRuleType.swift || true 43 | 44 | - run: git diff 45 | 46 | - name: Create Pull Request 47 | uses: peter-evans/create-pull-request@v6 48 | with: 49 | token: ${{ secrets.GH_TOKEN }} 50 | title: 'chore: Update Filter Rule types' 51 | commit-message: 'chore: Update Filter Rule types' 52 | branch: filter-rule-type-update 53 | delete-branch: true 54 | body: "Automated update of Filter Rule types." 55 | base: main 56 | add-paths: DataModel/Sources/DataModel/FilterRuleType.swift 57 | -------------------------------------------------------------------------------- /.github/workflows/static.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy static content to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["pages"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 20 | concurrency: 21 | group: "pages" 22 | cancel-in-progress: false 23 | 24 | jobs: 25 | # Single deploy job since we're just deploying 26 | deploy: 27 | environment: 28 | name: github-pages 29 | url: ${{ steps.deployment.outputs.page_url }} 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v3 34 | - name: Setup Pages 35 | uses: actions/configure-pages@v3 36 | - name: Upload artifact 37 | uses: actions/upload-pages-artifact@v2 38 | with: 39 | # Upload entire repository 40 | path: 'pages' 41 | - name: Deploy to GitHub Pages 42 | id: deployment 43 | uses: actions/deploy-pages@v2 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | demo/data 2 | demo/media 3 | Credentials.plist 4 | .vscode 5 | venv 6 | 7 | *.app.dSYM.zip 8 | *.ipa 9 | 10 | .env.default 11 | *.p8 12 | 13 | *.xcloc 14 | swift-paperless.xcodeproj/project.xcworkspace/xcuserdata/* 15 | *.afdesign~lock~ 16 | swift-paperless.xcodeproj/xcuserdata 17 | .env 18 | scripts/frames 19 | 20 | /vendor 21 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v3.2.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: check-yaml 10 | - id: check-added-large-files 11 | 12 | - repo: https://github.com/nicklockwood/SwiftFormat 13 | rev: 0.54.5 14 | hooks: 15 | - id: swiftformat 16 | 17 | - repo: https://github.com/psf/black 18 | rev: "24.4.2" 19 | hooks: 20 | - id: black 21 | -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | --swiftversion 6.0 2 | --disable blankLinesBetweenChainedFunctions 3 | -------------------------------------------------------------------------------- /.xcode-version: -------------------------------------------------------------------------------- 1 | 16.2 2 | -------------------------------------------------------------------------------- /Common/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | 10 | /Package.resolved 11 | -------------------------------------------------------------------------------- /Common/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import CompilerPluginSupport 5 | import PackageDescription 6 | 7 | let package = Package( 8 | name: "Common", 9 | platforms: [ 10 | .iOS(.v17), 11 | .macOS(.v13), 12 | ], 13 | products: [ 14 | .library( 15 | name: "Common", 16 | targets: ["Common"] 17 | ), 18 | ], 19 | dependencies: [ 20 | .package(url: "https://github.com/swiftlang/swift-syntax", from: "600.0.1"), 21 | .package(url: "https://github.com/pointfreeco/swift-macro-testing", from: "0.5.2"), 22 | .package(url: "https://github.com/SwiftyLab/MetaCodable", from: "1.4.0"), 23 | 24 | ], 25 | targets: [ 26 | .macro( 27 | name: "CommonMacros", 28 | dependencies: [ 29 | .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), 30 | .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), 31 | ] 32 | ), 33 | .target( 34 | name: "Common", 35 | dependencies: ["CommonMacros", 36 | .product(name: "MetaCodable", package: "MetaCodable")], 37 | swiftSettings: [ 38 | .swiftLanguageMode(.v6), 39 | .enableExperimentalFeature("StrictConcurrency"), 40 | ] 41 | ), 42 | .testTarget( 43 | name: "CommonTests", 44 | dependencies: [ 45 | "Common", 46 | "CommonMacros", 47 | .product(name: "MacroTesting", package: "swift-macro-testing"), 48 | .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), 49 | ] 50 | ), 51 | ] 52 | ) 53 | -------------------------------------------------------------------------------- /Common/Sources/Common/Color+luminance.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Color+luminance.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 22.09.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public 11 | extension Color { 12 | var luminance: Double { 13 | // https://github.com/paperless-ngx/paperless-ngx/blob/0dcfb97824b6184094290138fe401d8368722483/src/documents/serialisers.py#L317-L328 14 | 15 | var red: CGFloat = 0 16 | var green: CGFloat = 0 17 | var blue: CGFloat = 0 18 | var alpha: CGFloat = 0 19 | 20 | #if canImport(UIKit) 21 | UIColor(self).getRed(&red, green: &green, blue: &blue, alpha: &alpha) 22 | #elseif canImport(AppKit) 23 | NSColor(self).getRed(&red, green: &green, blue: &blue, alpha: &alpha) 24 | #endif 25 | 26 | return sqrt(0.299 * pow(red, 2) + 0.587 * pow(green, 2) + 0.114 * pow(blue, 2)) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Common/Sources/Common/DateDecodingError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DateDecodingError.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 23.05.23. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum DateDecodingError: Error { 11 | case invalidDate(string: String) 12 | } 13 | -------------------------------------------------------------------------------- /Common/Sources/Common/DateOnlyCoder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DateOnlyCoder.swift 3 | // Common 4 | // 5 | // Created by Paul Gessinger on 22.05.25. 6 | // 7 | 8 | import Foundation 9 | import MetaCodable 10 | 11 | import os 12 | 13 | public struct DateOnlyCoder: HelperCoder { 14 | public typealias Coded = Date 15 | 16 | public init() {} 17 | 18 | public func decode(from decoder: Decoder) throws -> Coded { 19 | let container = try decoder.singleValueContainer() 20 | let dateStr = try container.decode(String.self) 21 | 22 | let ex = /(\d{4}-\d{2}-\d{2}).*/ 23 | 24 | guard let match = try? ex.wholeMatch(in: dateStr) else { 25 | throw DateDecodingError.invalidDate(string: dateStr) 26 | } 27 | 28 | let df = DateFormatter() 29 | df.dateFormat = "yyyy-MM-dd" 30 | 31 | guard let res = df.date(from: String(match.1)) else { 32 | throw DateDecodingError.invalidDate(string: dateStr) 33 | } 34 | 35 | return res 36 | } 37 | 38 | public func encode(_ value: Coded, to encoder: Encoder) throws { 39 | var container = encoder.singleValueContainer() 40 | 41 | let formatter = DateFormatter() 42 | formatter.dateFormat = "yyyy-MM-dd" 43 | 44 | try container.encode(formatter.string(from: value)) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Common/Sources/Common/DecodeOnly.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DecodeOnly.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 24.07.2024. 6 | // 7 | 8 | import Foundation 9 | 10 | @propertyWrapper 11 | public struct DecodeOnly { 12 | public var wrappedValue: T 13 | 14 | public init(wrappedValue defaultValue: T) { 15 | wrappedValue = defaultValue 16 | } 17 | } 18 | 19 | // Always conform to encodable, because we never actually encode anything 20 | extension DecodeOnly: Encodable { 21 | public func encode(to _: any Encoder) throws { 22 | // Intentionally empty 23 | } 24 | } 25 | 26 | extension DecodeOnly: Decodable { 27 | public init(from decoder: any Decoder) throws { 28 | let container = try decoder.singleValueContainer() 29 | wrappedValue = try container.decode(T.self) 30 | } 31 | } 32 | 33 | extension DecodeOnly: Equatable where T: Equatable {} 34 | extension DecodeOnly: Hashable where T: Hashable {} 35 | extension DecodeOnly: Sendable where T: Sendable {} 36 | 37 | // This avoids generating the output key at all 38 | public extension KeyedEncodingContainer { 39 | mutating func encode( 40 | _: DecodeOnly, 41 | forKey _: KeyedEncodingContainer.Key 42 | ) throws { 43 | // Do nothing 44 | } 45 | } 46 | 47 | // If the wrapped type is an optional, the key is optional 48 | public extension KeyedDecodingContainer { 49 | func decode(_ t: DecodeOnly.Type, forKey key: K) throws -> DecodeOnly { 50 | if let v = try decodeIfPresent(t, forKey: key) { 51 | return v 52 | } 53 | return DecodeOnly(wrappedValue: nil) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Common/Sources/Common/Decoder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Decoder.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 10.12.2023. 6 | // 7 | 8 | import Foundation 9 | import os 10 | 11 | public func makeDecoder(tz: TimeZone) -> JSONDecoder { 12 | let d = JSONDecoder() 13 | d.dateDecodingStrategy = .custom { decoder -> Date in 14 | let container = try decoder.singleValueContainer() 15 | let dateStr = try container.decode(String.self) 16 | 17 | let iso = ISO8601DateFormatter() 18 | iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 19 | if let res = iso.date(from: dateStr) { 20 | return res 21 | } 22 | 23 | iso.formatOptions = [.withInternetDateTime] 24 | if let res = iso.date(from: dateStr) { 25 | return res 26 | } 27 | 28 | let df = DateFormatter() 29 | df.timeZone = tz 30 | df.locale = Locale(identifier: "en_US_POSIX") 31 | df.dateFormat = "yyyy-MM-dd" 32 | 33 | if let res = df.date(from: dateStr) { 34 | return res 35 | } 36 | 37 | throw DateDecodingError.invalidDate(string: dateStr) 38 | } 39 | return d 40 | } 41 | 42 | public let decoder: JSONDecoder = makeDecoder(tz: .current) 43 | -------------------------------------------------------------------------------- /Common/Sources/Common/EquatableNoop.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EquatableNoop.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 22.09.2024. 6 | // 7 | 8 | @propertyWrapper 9 | public 10 | struct EquatableNoop: Equatable { 11 | public var wrappedValue: Value 12 | 13 | public init(wrappedValue: Value) { 14 | self.wrappedValue = wrappedValue 15 | } 16 | 17 | public static func == (_: EquatableNoop, _: EquatableNoop) -> Bool { 18 | true 19 | } 20 | } 21 | 22 | extension EquatableNoop: Sendable where Value: Sendable {} 23 | 24 | extension EquatableNoop: Codable where Value: Codable { 25 | public init(from decoder: any Decoder) throws { 26 | let container = try decoder.singleValueContainer() 27 | wrappedValue = try container.decode(Value.self) 28 | } 29 | 30 | public func encode(to encoder: any Encoder) throws { 31 | var container = encoder.singleValueContainer() 32 | try container.encode(wrappedValue) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Common/Sources/Common/Error+isCancellationError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Error+isCancellationError.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 05.01.25. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension Error { 11 | var isCancellationError: Bool { 12 | if self is CancellationError { 13 | return true 14 | } 15 | 16 | let nsError = self as NSError 17 | return nsError.domain == NSURLErrorDomain && NSURLError(rawValue: nsError.code) == NSURLError.cancelled 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Common/Sources/Common/Image+init.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Image+init.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 26.01.25. 6 | // 7 | 8 | #if canImport(UIKit) 9 | import UIKit 10 | #elseif canImport(AppKit) 11 | import AppKit 12 | #endif 13 | import SwiftUI 14 | 15 | public extension Image { 16 | init?(data: Data) { 17 | #if canImport(UIKit) 18 | guard let uiImage = UIImage(data: data) else { return nil } 19 | self = Image(uiImage: uiImage) 20 | #elseif canImport(AppKit) 21 | guard let nsImage = NSImage(data: data) else { return nil } 22 | self = Image(nsImage: nsImage) 23 | #endif 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Common/Sources/Common/Logging.swift: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | extension Logger { 4 | static let common = Logger(subsystem: "com.paulgessinger.swift-paperless", category: "Common") 5 | } 6 | -------------------------------------------------------------------------------- /Common/Sources/Common/Macros.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Common.swift 3 | // Common 4 | // 5 | // Created by Paul Gessinger on 31.12.24. 6 | // 7 | import Foundation 8 | 9 | @freestanding(expression) 10 | public macro URL(_: S) -> URL = #externalMacro(module: "CommonMacros", type: "URLMacro") 11 | -------------------------------------------------------------------------------- /Common/Sources/Common/NullCodable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NullCodable.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 12.08.23. 6 | // 7 | 8 | import Foundation 9 | import MetaCodable 10 | 11 | public struct NullCoder: HelperCoder where T: Codable { 12 | public init() {} 13 | 14 | public func decode(from decoder: any Decoder) throws -> T { 15 | let container = try decoder.singleValueContainer() 16 | return try container.decode(T.self) 17 | } 18 | 19 | public func encodeIfPresent(_ value: T?, to container: inout EncodingContainer, atKey key: EncodingContainer.Key) throws where EncodingContainer: KeyedEncodingContainerProtocol { 20 | var svc = container.superEncoder(forKey: key).singleValueContainer() 21 | if let value { 22 | try svc.encode(value) 23 | } else { 24 | try svc.encodeNil() 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Common/Sources/Common/Pasteboard.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Pasteboard.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 22.09.2024. 6 | // 7 | 8 | #if canImport(AppKit) 9 | import AppKit 10 | #endif 11 | #if canImport(UIKit) 12 | import UIKit 13 | #endif 14 | 15 | public struct Pasteboard { 16 | private init() {} 17 | 18 | @MainActor public static var general = Pasteboard() 19 | 20 | public var string: String? { 21 | get { 22 | #if canImport(UIKit) 23 | UIPasteboard.general.string 24 | #elseif canImport(AppKit) 25 | NSPasteboard.general.string(forType: .string) 26 | #else 27 | #error("Unsupported platform") 28 | #endif 29 | } 30 | 31 | set { 32 | #if canImport(UIKit) 33 | UIPasteboard.general.string = newValue 34 | #elseif canImport(AppKit) 35 | if let newValue { 36 | NSPasteboard.general.setString(newValue, forType: .string) 37 | } else { 38 | NSPasteboard.general.clearContents() 39 | } 40 | #else 41 | #error("Unsupported platform") 42 | #endif 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Common/Sources/Common/Sanitize.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Sanitize.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 14.03.25. 6 | // 7 | 8 | import Foundation 9 | 10 | public func sanitize(headers: [String: String]?) -> String { 11 | if let headers { 12 | #if DEBUG 13 | return "\(headers)" 14 | #else 15 | return headers.map { key, value in 16 | "\(key): " 17 | }.joined(separator: ", ") 18 | #endif 19 | } else { 20 | return "" 21 | } 22 | } 23 | 24 | public func sanitize(token: String?) -> String { 25 | guard let token else { return "nil" } 26 | #if DEBUG 27 | return token 28 | #else 29 | return "" 30 | #endif 31 | } 32 | -------------------------------------------------------------------------------- /Common/Sources/Common/String+isNumber.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+isNumber.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 01.06.2024. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension String { 11 | var isNumber: Bool { 12 | let digitsCharacters = CharacterSet(charactersIn: "0123456789") 13 | return CharacterSet(charactersIn: self).isSubset(of: digitsCharacters) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Common/Sources/Common/StringProtocol+slugify.swift: -------------------------------------------------------------------------------- 1 | // Adapted from 2 | // https://www.mickf.net/tech/slugify-in-swift/ 3 | 4 | import Foundation 5 | 6 | public extension StringProtocol { 7 | func slugify() -> String { 8 | var slug = String(self) 9 | 10 | slug = slug.applyingTransform(.toLatin, reverse: false) ?? slug 11 | slug = slug.applyingTransform(.stripDiacritics, reverse: false) ?? slug 12 | slug = slug.applyingTransform(.stripCombiningMarks, reverse: false) ?? slug 13 | slug = slug.replacingOccurrences(of: "[^a-zA-Z0-9 ]+", with: "-", options: .regularExpression) 14 | slug = slug.trimmingCharacters(in: CharacterSet(charactersIn: "-")) 15 | 16 | if !isEmpty, slug.isEmpty { 17 | if let extendedSelf = applyingTransform(.toUnicodeName, reverse: false)? 18 | .replacingOccurrences(of: "\\N", with: ""), self != extendedSelf 19 | { 20 | return extendedSelf.slugify() 21 | } 22 | } 23 | return slug 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Common/Sources/Common/URLSession+data.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLSession+data.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 23.04.2024. 6 | // 7 | 8 | import Foundation 9 | 10 | // This is here because URLSession.shared.data is otherwise not callable from nonisolated without a warning 11 | // https://forums.developer.apple.com/forums/thread/727823 12 | public extension URLSession { 13 | nonisolated func getData(for request: URLRequest) async throws -> (Data, URLResponse) { 14 | try await data(for: request, delegate: nil) 15 | } 16 | 17 | nonisolated func getData(for request: URLRequest, progress: (@Sendable (Double) -> Void)?) async throws -> (Data, URLResponse) { 18 | final class Delegate: NSObject, URLSessionTaskDelegate { 19 | let callback: (@Sendable (Double) -> Void)? 20 | 21 | @MainActor 22 | private var progressObservation: NSKeyValueObservation? = nil 23 | 24 | init(_ callback: (@Sendable (Double) -> Void)? = nil) { 25 | self.callback = callback 26 | } 27 | 28 | func urlSession(_: URLSession, didCreateTask task: URLSessionTask) { 29 | // task is Sendable, so we send that to the main actor and then store the observation in the main isolated variable 30 | Task { @MainActor in 31 | let callback = callback 32 | progressObservation = task.progress.observe(\.fractionCompleted) { progress, _ in 33 | callback?(progress.fractionCompleted) 34 | } 35 | } 36 | } 37 | } 38 | 39 | let delegate = Delegate(progress) 40 | 41 | return try await data(for: request, delegate: delegate) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Common/Sources/CommonMacros/Main.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Main.swift 3 | // Common 4 | // 5 | // Created by Paul Gessinger on 02.01.25. 6 | // 7 | 8 | import SwiftCompilerPlugin 9 | import SwiftSyntaxMacros 10 | 11 | @main 12 | struct CommonMacrosPlugin: CompilerPlugin { 13 | let providingMacros: [Macro.Type] = [ 14 | URLMacro.self, 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /Common/Sources/CommonMacros/URLMacro.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftDiagnostics 3 | import SwiftSyntax 4 | import SwiftSyntaxMacros 5 | 6 | public enum URLError: DiagnosticMessage, Error { 7 | case noLiteral 8 | case malformed(_ arg: String) 9 | 10 | public var message: String { 11 | switch self { 12 | case .noLiteral: 13 | "Not a static string literal" 14 | case let .malformed(arg): 15 | "Malformed URL: \"\(arg)\"" 16 | } 17 | } 18 | 19 | // public var description: String { message } 20 | 21 | public var diagnosticID: MessageID { 22 | switch self { 23 | case .noLiteral: 24 | MessageID(domain: "Common", id: "noLiteral") 25 | case .malformed: 26 | MessageID(domain: "Common", id: "malformed") 27 | } 28 | } 29 | 30 | public var severity: DiagnosticSeverity { .error } 31 | } 32 | 33 | public enum URLMacro: ExpressionMacro { 34 | public static func expansion( 35 | of node: some FreestandingMacroExpansionSyntax, 36 | in _: some MacroExpansionContext 37 | ) throws(URLError) -> ExprSyntax { 38 | guard let argument = node.arguments.first?.expression, 39 | let segments = argument.as(StringLiteralExprSyntax.self)?.segments, 40 | segments.count == 1, 41 | case let .stringSegment(literalSegment)? = segments.first 42 | else { 43 | throw .noLiteral 44 | } 45 | 46 | guard let _ = URL(string: literalSegment.content.text) else { 47 | throw .malformed(literalSegment.content.text) 48 | } 49 | 50 | return "URL(string: \(argument))!" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Common/Tests/CommonTests/Slugify.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Slugify.swift 3 | // swift-paperlessTests 4 | // 5 | // Created by Paul Gessinger on 01.05.25 6 | // 7 | 8 | @testable import Common 9 | import Foundation 10 | import SwiftUI 11 | import Testing 12 | 13 | @Test func testSlugify() throws { 14 | #expect("Some/Name".slugify() == "Some-Name") 15 | #expect("SomeüName".slugify() == "SomeuName") 16 | #expect("Some Name".slugify() == "Some Name") 17 | #expect("Some;Name".slugify() == "Some-Name") 18 | #expect("Hyvee 2025-03-30".slugify() == "Hyvee 2025-03-30") 19 | } 20 | 21 | @Test func testPathAssembly() throws { 22 | let file = URL("file:///some/path/to/a/file/This Has Some not ök ø chars.pdf")! 23 | 24 | let ext = file.pathExtension 25 | let stem = file.deletingPathExtension().lastPathComponent 26 | #expect(stem == "This Has Some not ök ø chars") 27 | 28 | let slug = stem.slugify() 29 | #expect(slug == "This Has Some not ok - chars") 30 | 31 | let filename = ext.isEmpty ? stem : "\(slug).\(ext)" 32 | #expect(filename == "This Has Some not ok - chars.pdf") 33 | } 34 | -------------------------------------------------------------------------------- /DataModel/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | 10 | /Package.resolved 11 | -------------------------------------------------------------------------------- /DataModel/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "DataModel", 8 | platforms: [ 9 | .iOS(.v17), 10 | .macOS(.v13), 11 | ], 12 | products: [ 13 | // Products define the executables and libraries a package produces, making them visible to other packages. 14 | .library( 15 | name: "DataModel", 16 | targets: ["DataModel"] 17 | ), 18 | ], 19 | dependencies: [ 20 | .package(path: "../Common"), 21 | .package(url: "https://github.com/SwiftyLab/MetaCodable", from: "1.4.0"), 22 | ], 23 | targets: [ 24 | // Targets are the basic building blocks of a package, defining a module or a test suite. 25 | // Targets can depend on other targets in this package and products from dependencies. 26 | .target( 27 | name: "DataModel", 28 | dependencies: [ 29 | .product(name: "Common", package: "Common"), 30 | .product(name: "MetaCodable", package: "MetaCodable"), 31 | ], 32 | path: "Sources", 33 | swiftSettings: [ 34 | .swiftLanguageMode(.v6), 35 | .enableExperimentalFeature("StrictConcurrency"), 36 | ] 37 | ), 38 | .testTarget( 39 | name: "DataModelTests", 40 | dependencies: ["DataModel"], 41 | resources: [ 42 | .copy("Data"), 43 | ] 44 | ), 45 | ] 46 | ) 47 | -------------------------------------------------------------------------------- /DataModel/Sources/DataModel/CorrespondentModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CorrespondentModel.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 21.05.23. 6 | // 7 | 8 | import Foundation 9 | import MetaCodable 10 | 11 | public protocol CorrespondentProtocol: Equatable, MatchingModel { 12 | var name: String { get set } 13 | } 14 | 15 | @Codable 16 | @CodingKeys(.snake_case) 17 | @MemberInit 18 | public struct Correspondent: 19 | Hashable, Identifiable, Model, CorrespondentProtocol, Named, 20 | Sendable 21 | { 22 | public var id: UInt 23 | @Default(nil as UInt?) 24 | public var documentCount: UInt? 25 | @Default(nil as Date?) 26 | public var lastCorrespondence: Date? 27 | public var name: String 28 | public var slug: String 29 | 30 | public var matchingAlgorithm: MatchingAlgorithm 31 | public var match: String 32 | public var isInsensitive: Bool 33 | } 34 | 35 | @Codable 36 | @CodingKeys(.snake_case) 37 | @MemberInit 38 | public struct ProtoCorrespondent: 39 | CorrespondentProtocol, 40 | Hashable, 41 | Sendable 42 | { 43 | @Default("") 44 | public var name: String 45 | 46 | @Default(MatchingAlgorithm.auto) 47 | public var matchingAlgorithm: MatchingAlgorithm 48 | 49 | @Default("") 50 | public var match: String 51 | 52 | @Default(false) 53 | public var isInsensitive: Bool 54 | } 55 | -------------------------------------------------------------------------------- /DataModel/Sources/DataModel/DocumentTypeModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DocumentTypeModel.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 21.05.23. 6 | // 7 | 8 | import Foundation 9 | import MetaCodable 10 | 11 | public protocol DocumentTypeProtocol: Equatable, MatchingModel { 12 | var name: String { get set } 13 | } 14 | 15 | @Codable 16 | @CodingKeys(.snake_case) 17 | @MemberInit 18 | public struct DocumentType: 19 | Hashable, 20 | Identifiable, 21 | Model, 22 | DocumentTypeProtocol, 23 | Named, 24 | Sendable 25 | { 26 | public var id: UInt 27 | public var name: String 28 | public var slug: String 29 | 30 | public var match: String 31 | public var matchingAlgorithm: MatchingAlgorithm 32 | public var isInsensitive: Bool 33 | } 34 | 35 | @Codable 36 | @CodingKeys(.snake_case) 37 | @MemberInit 38 | public struct ProtoDocumentType: Hashable, DocumentTypeProtocol, Sendable { 39 | @Default("") 40 | public var name: String 41 | 42 | @Default("") 43 | public var match: String 44 | 45 | @Default(MatchingAlgorithm.auto) 46 | public var matchingAlgorithm: MatchingAlgorithm 47 | 48 | @Default(false) 49 | public var isInsensitive: Bool 50 | } 51 | -------------------------------------------------------------------------------- /DataModel/Sources/DataModel/ListResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListResponse.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 23.05.23. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct ListResponse { 11 | public var count: UInt 12 | public var next: URL? 13 | public var previous: URL? 14 | public var results: [Element] 15 | } 16 | 17 | extension ListResponse: Decodable 18 | where Element: Decodable 19 | {} 20 | 21 | extension ListResponse: Sendable where Element: Sendable {} 22 | -------------------------------------------------------------------------------- /DataModel/Sources/DataModel/Logging.swift: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | extension Logger { 4 | static let dataModel = Logger(subsystem: "com.paulgessinger.swift-paperless", category: "DataModel") 5 | } 6 | -------------------------------------------------------------------------------- /DataModel/Sources/DataModel/MatchingAlgorithm.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MatchingAlgorithm.swift 3 | // DataModel 4 | // 5 | // Created by Paul Gessinger on 18.12.2024. 6 | // 7 | 8 | public enum MatchingAlgorithm: Int, Codable, CaseIterable, Sendable { 9 | case none = 0, any = 1, all = 2, literal = 3, regex = 4, fuzzy = 5, auto = 6 10 | } 11 | -------------------------------------------------------------------------------- /DataModel/Sources/DataModel/MatchingModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MatchingModel.swift 3 | // DataModel 4 | // 5 | // Created by Paul Gessinger on 18.12.2024. 6 | // 7 | 8 | public protocol MatchingModel { 9 | var match: String { get set } 10 | var matchingAlgorithm: MatchingAlgorithm { get set } 11 | var isInsensitive: Bool { get set } 12 | } 13 | -------------------------------------------------------------------------------- /DataModel/Sources/DataModel/Metadata.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Metadata.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 22.07.2024. 6 | // 7 | 8 | import Foundation 9 | import MetaCodable 10 | 11 | @Codable 12 | @CodingKeys(.snake_case) 13 | @MemberInit 14 | public struct Metadata: Sendable { 15 | public var originalChecksum: String 16 | public var originalSize: Int64 17 | public var originalMimeType: String 18 | public var mediaFilename: String 19 | public var hasArchiveVersion: Bool 20 | 21 | public struct Item: Codable, Sendable { 22 | public var namespace: String 23 | public var prefix: String 24 | public var key: String 25 | public var value: String 26 | } 27 | 28 | public var originalMetadata: [Item] 29 | 30 | public var archiveChecksum: String? 31 | public var archiveMediaFilename: String? 32 | public var originalFilename: String 33 | public var archiveSize: Int64? 34 | 35 | public var archiveMetadata: [Item]? 36 | 37 | public var lang: String 38 | } 39 | -------------------------------------------------------------------------------- /DataModel/Sources/DataModel/Model.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Model.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 18.02.23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | public protocol Model: Identifiable { 12 | var id: UInt { get } 13 | } 14 | 15 | public protocol Named { 16 | var name: String { get } 17 | } 18 | -------------------------------------------------------------------------------- /DataModel/Sources/DataModel/PermissionsModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PermissionsModel.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 28.07.2024. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct Permissions: Codable, Equatable, Hashable, Sendable { 11 | public struct Set: Codable, Equatable, Hashable, Sendable { 12 | public var users: [UInt] 13 | public var groups: [UInt] 14 | 15 | public init(users: [UInt] = [], groups: [UInt] = []) { 16 | self.users = users 17 | self.groups = groups 18 | } 19 | } 20 | 21 | public var view: Set 22 | public var change: Set 23 | 24 | public init(view: Set = Set(), change: Set = Set()) { 25 | self.view = view 26 | self.change = change 27 | } 28 | } 29 | 30 | public protocol PermissionsModel { 31 | var owner: UInt? { get set } 32 | 33 | var permissions: Permissions? { get set } 34 | 35 | // var setPermissions: Permissions? { get set } 36 | } 37 | -------------------------------------------------------------------------------- /DataModel/Sources/DataModel/SavedViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SavedViewModel.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 21.05.23. 6 | // 7 | 8 | import Foundation 9 | import MetaCodable 10 | 11 | public protocol SavedViewProtocol: Codable { 12 | var name: String { get set } 13 | var showOnDashboard: Bool { get set } 14 | var showInSidebar: Bool { get set } 15 | var sortField: SortField? { get set } 16 | var sortOrder: DataModel.SortOrder { get set } 17 | var filterRules: [FilterRule] { get set } 18 | } 19 | 20 | @Codable 21 | @CodingKeys(.snake_case) 22 | @MemberInit 23 | public struct SavedView: 24 | Identifiable, Hashable, Model, SavedViewProtocol, Sendable 25 | { 26 | public var id: UInt 27 | public var name: String 28 | public var showOnDashboard: Bool 29 | public var showInSidebar: Bool 30 | public var sortField: SortField? 31 | 32 | @CodedAt("sort_reverse") 33 | public var sortOrder: DataModel.SortOrder 34 | public var filterRules: [FilterRule] 35 | 36 | public func hash(into hasher: inout Hasher) { 37 | hasher.combine(id) 38 | } 39 | } 40 | 41 | @Codable 42 | @CodingKeys(.snake_case) 43 | @MemberInit 44 | public struct ProtoSavedView: SavedViewProtocol, Sendable { 45 | @Default("") 46 | public var name: String 47 | 48 | @Default(false) 49 | public var showOnDashboard: Bool 50 | 51 | @Default(false) 52 | public var showInSidebar: Bool 53 | 54 | @Default(SortField.created) 55 | public var sortField: SortField? 56 | 57 | @CodedAt("sort_reverse") 58 | @Default(DataModel.SortOrder.descending) 59 | public var sortOrder: DataModel.SortOrder 60 | 61 | @Default([FilterRule]()) 62 | public var filterRules: [FilterRule] 63 | } 64 | -------------------------------------------------------------------------------- /DataModel/Sources/DataModel/SortOrder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SortOrder.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 06.06.2024. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum SortOrder: Codable, Sendable, Equatable { 11 | case ascending 12 | case descending 13 | 14 | public init(from decoder: any Decoder) throws { 15 | let container = try decoder.singleValueContainer() 16 | let reverse = try container.decode(Bool.self) 17 | self.init(reverse) 18 | } 19 | 20 | public func encode(to encoder: any Encoder) throws { 21 | var container = encoder.singleValueContainer() 22 | try container.encode(reverse) 23 | } 24 | 25 | public var reverse: Bool { 26 | switch self { 27 | case .descending: 28 | true 29 | case .ascending: 30 | false 31 | } 32 | } 33 | 34 | public init(_ reverse: Bool) { 35 | if reverse { 36 | self = .descending 37 | } else { 38 | self = .ascending 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /DataModel/Sources/DataModel/StoragePathModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoragePathModel.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 21.05.23. 6 | // 7 | 8 | import Foundation 9 | import MetaCodable 10 | 11 | public protocol StoragePathProtocol: Codable, MatchingModel { 12 | var name: String { get set } 13 | var path: String { get set } 14 | } 15 | 16 | @Codable 17 | @CodingKeys(.snake_case) 18 | @MemberInit 19 | public struct StoragePath: 20 | StoragePathProtocol, Model, Identifiable, Hashable, Named, Sendable 21 | { 22 | public var id: UInt 23 | public var name: String 24 | public var path: String 25 | public var slug: String 26 | 27 | public var matchingAlgorithm: MatchingAlgorithm 28 | public var match: String 29 | public var isInsensitive: Bool 30 | } 31 | 32 | @Codable 33 | @CodingKeys(.snake_case) 34 | @MemberInit 35 | public struct ProtoStoragePath: StoragePathProtocol, Sendable { 36 | @Default("") 37 | public var name: String 38 | 39 | @Default("") 40 | public var path: String 41 | 42 | @Default(MatchingAlgorithm.none) 43 | public var matchingAlgorithm: MatchingAlgorithm 44 | 45 | @Default("") 46 | public var match: String 47 | 48 | @Default(false) 49 | public var isInsensitive: Bool 50 | } 51 | -------------------------------------------------------------------------------- /DataModel/Sources/DataModel/SuggestionsModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SuggestionsModel.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 01.08.23. 6 | // 7 | 8 | import Foundation 9 | import MetaCodable 10 | 11 | @Codable 12 | @CodingKeys(.snake_case) 13 | @MemberInit 14 | public struct Suggestions: Sendable { 15 | @Default([UInt]()) 16 | public var correspondents: [UInt] 17 | 18 | @Default([UInt]()) 19 | public var tags: [UInt] 20 | 21 | @Default([UInt]()) 22 | public var documentTypes: [UInt] 23 | 24 | @Default([UInt]()) 25 | public var storagePaths: [UInt] 26 | 27 | @Default([Date]()) 28 | public var dates: [Date] 29 | } 30 | -------------------------------------------------------------------------------- /DataModel/Sources/DataModel/TaskModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TaskModel.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 16.07.23. 6 | // 7 | 8 | import Foundation 9 | import MetaCodable 10 | 11 | public enum TaskStatus: String, Codable, Sendable { 12 | case PENDING 13 | case STARTED 14 | case SUCCESS 15 | case FAILURE 16 | case RETRY 17 | case REVOKED 18 | } 19 | 20 | // See https://github.com/paperless-ngx/paperless-ngx/blob/4c6fdbb21fdcd3ecf81b9a0dd87487f146066e01/src/documents/models.py#L542C9-L545C65 21 | public enum TaskName: String, Sendable { 22 | // There might be more added but this is not (currently) used for deserialization 23 | case consumeFile = "consume_file" 24 | case trainClassifier = "train_classifier" 25 | case checkSanity = "check_sanity" 26 | case indexOptimize = "index_optimize" 27 | } 28 | 29 | @Codable 30 | @CodingKeys(.snake_case) 31 | @MemberInit 32 | public struct PaperlessTask: Model, Identifiable, Hashable, Sendable { 33 | public var id: UInt 34 | public var taskId: UUID 35 | public var taskFileName: String? 36 | public var taskName: String? 37 | public var dateCreated: Date? 38 | public var dateDone: Date? 39 | public var type: String 40 | public var status: TaskStatus 41 | public var result: String? 42 | public var acknowledged: Bool 43 | public var relatedDocument: String? 44 | 45 | public var isActive: Bool { 46 | switch status { 47 | case .PENDING, .STARTED, .RETRY: 48 | true 49 | default: 50 | false 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /DataModel/Sources/DataModel/UISettings.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UISettings.swift 3 | // DataModel 4 | // 5 | // Created by Paul Gessinger on 26.12.24. 6 | // 7 | 8 | import MetaCodable 9 | 10 | @Codable 11 | @CodingKeys(.snake_case) 12 | @MemberInit 13 | public struct UISettingsDocumentEditing: Sendable { 14 | @Default(false) 15 | public var removeInboxTags: Bool 16 | 17 | @usableFromInline 18 | static var `default`: Self { .init() } 19 | } 20 | 21 | @Codable 22 | @CodingKeys(.snake_case) 23 | @MemberInit 24 | public struct UISettingsSettings: Sendable { 25 | @Default(UISettingsDocumentEditing.default) 26 | public var documentEditing: UISettingsDocumentEditing 27 | 28 | @usableFromInline 29 | static var `default`: Self { .init() } 30 | } 31 | 32 | @Codable 33 | @MemberInit 34 | public struct UISettings: Sendable { 35 | public var user: User 36 | 37 | @Default(UISettingsSettings.default) 38 | public var settings: UISettingsSettings 39 | 40 | @IgnoreEncoding 41 | public var permissions: UserPermissions 42 | } 43 | -------------------------------------------------------------------------------- /DataModel/Tests/DataModelTests/CorrespondentTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CorrespondentTest.swift 3 | // DataModel 4 | // 5 | // Created by Paul Gessinger on 03.01.25. 6 | // 7 | 8 | import Common 9 | @testable import DataModel 10 | import Testing 11 | 12 | @Suite 13 | struct CorrespondentTest { 14 | @Test func testDecoding() throws { 15 | let data = """ 16 | { 17 | "id": 88, 18 | "slug": "aaaaaa", 19 | "name": "Aaaaaa", 20 | "match": "", 21 | "matching_algorithm": 6, 22 | "is_insensitive": false, 23 | "document_count": 0, 24 | "last_correspondence": null, 25 | "owner": 2, 26 | "user_can_change": true 27 | } 28 | """.data(using: .utf8)! 29 | 30 | let correspondent = try makeDecoder(tz: .current).decode(Correspondent.self, from: data) 31 | 32 | #expect(correspondent.id == 88) 33 | #expect(correspondent.slug == "aaaaaa") 34 | #expect(correspondent.name == "Aaaaaa") 35 | #expect(correspondent.match == "") 36 | #expect(correspondent.matchingAlgorithm == .auto) 37 | #expect(correspondent.isInsensitive == false) 38 | #expect(correspondent.documentCount == 0) 39 | #expect(correspondent.lastCorrespondence == nil) 40 | // #expect(correspondent.owner == 2) 41 | // #expect(correspondent.userCanChange == true) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /DataModel/Tests/DataModelTests/Data/Document/full.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 2724, 3 | "correspondent": 123, 4 | "document_type": 5, 5 | "storage_path": 1, 6 | "title": "Quittung", 7 | "content": "bla bla bla", 8 | "tags": [1, 2, 3], 9 | "created": "2024-12-21T00:00:00+00:00", 10 | "created_date": "2024-12-21", 11 | "modified": "2024-12-21T21:41:49.907469+01:00", 12 | "added": "2024-12-21T21:26:36.217383+01:00", 13 | "deleted_at": null, 14 | "archive_serial_number": 666, 15 | "original_file_name": "original.pdf", 16 | "archived_file_name": "archived.pdf", 17 | "owner": 2, 18 | "user_can_change": true, 19 | "is_shared_by_requester": true, 20 | "notes": [ 21 | { 22 | "id": 40, 23 | "deleted_at": null, 24 | "restored_at": null, 25 | "transaction_id": null, 26 | "note": "hallo", 27 | "created": "2025-01-02T16:03:36.554803+01:00", 28 | "document": 2724, 29 | "user": 2 30 | } 31 | ], 32 | "custom_fields": [], 33 | "page_count": 1 34 | } 35 | -------------------------------------------------------------------------------- /DataModel/Tests/DataModelTests/Data/Document/full_new_notes.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 2724, 3 | "correspondent": 123, 4 | "document_type": 5, 5 | "storage_path": 1, 6 | "title": "Quittung", 7 | "content": "bla bla bla", 8 | "tags": [1, 2, 3], 9 | "created": "2024-12-21T00:00:00+01:00", 10 | "created_date": "2024-12-21", 11 | "modified": "2024-12-21T21:41:49.907469+01:00", 12 | "added": "2024-12-21T21:26:36.217383+01:00", 13 | "deleted_at": null, 14 | "archive_serial_number": 666, 15 | "original_file_name": "original.pdf", 16 | "archived_file_name": "archived.pdf", 17 | "owner": 2, 18 | "user_can_change": true, 19 | "is_shared_by_requester": true, 20 | "notes": [40], 21 | "custom_fields": [], 22 | "page_count": 1 23 | } 24 | -------------------------------------------------------------------------------- /DataModel/Tests/DataModelTests/Data/Document/full_no_user_can_change.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 2724, 3 | "correspondent": 123, 4 | "document_type": 5, 5 | "storage_path": 1, 6 | "title": "Quittung", 7 | "content": "bla bla bla", 8 | "tags": [1, 2, 3], 9 | "created": "2024-12-21T00:00:00+01:00", 10 | "created_date": "2024-12-21", 11 | "modified": "2024-12-21T21:41:49.907469+01:00", 12 | "added": "2024-12-21T21:26:36.217383+01:00", 13 | "deleted_at": null, 14 | "archive_serial_number": 666, 15 | "original_file_name": "original.pdf", 16 | "archived_file_name": "archived.pdf", 17 | "owner": 2, 18 | "is_shared_by_requester": true, 19 | "notes": [ 20 | { 21 | "id": 40, 22 | "deleted_at": null, 23 | "restored_at": null, 24 | "transaction_id": null, 25 | "note": "hallo", 26 | "created": "2025-01-02T16:03:36.554803+01:00", 27 | "document": 2724, 28 | "user": 2 29 | } 30 | ], 31 | "custom_fields": [], 32 | "page_count": 1 33 | } 34 | -------------------------------------------------------------------------------- /DataModel/Tests/DataModelTests/Data/Document/full_with_perms.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 2724, 3 | "correspondent": 123, 4 | "document_type": 5, 5 | "storage_path": 1, 6 | "title": "Quittung", 7 | "content": "bla bla bla", 8 | "tags": [1, 2, 3], 9 | "created": "2024-12-21T00:00:00+01:00", 10 | "created_date": "2024-12-21", 11 | "modified": "2024-12-21T21:41:49.907469+01:00", 12 | "added": "2024-12-21T21:26:36.217383+01:00", 13 | "deleted_at": null, 14 | "archive_serial_number": 666, 15 | "original_file_name": "original.pdf", 16 | "archived_file_name": "archived.pdf", 17 | "owner": 2, 18 | "user_can_change": true, 19 | "is_shared_by_requester": true, 20 | "notes": [ 21 | { 22 | "id": 40, 23 | "deleted_at": null, 24 | "restored_at": null, 25 | "transaction_id": null, 26 | "note": "hallo", 27 | "created": "2025-01-02T16:03:36.554803+01:00", 28 | "document": 2724, 29 | "user": 2 30 | } 31 | ], 32 | "permissions": { 33 | "view": { 34 | "users": [], 35 | "groups": [ 36 | 1 37 | ] 38 | }, 39 | "change": { 40 | "users": [1], 41 | "groups": [ 42 | ] 43 | } 44 | }, 45 | "custom_fields": [], 46 | "page_count": 1 47 | } 48 | -------------------------------------------------------------------------------- /DataModel/Tests/DataModelTests/Data/suggestions.json: -------------------------------------------------------------------------------- 1 | { 2 | "correspondents": [ 3 | 72 4 | ], 5 | "tags": [ 6 | 9 7 | ], 8 | "document_types": [ 9 | 4 10 | ], 11 | "storage_paths": [], 12 | "dates": [ 13 | "2024-12-03", 14 | "2025-01-02" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /DataModel/Tests/DataModelTests/Data/tags.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 66, 4 | "slug": "tech-dept", 5 | "name": "Tech Department", 6 | "color": "#bde38f", 7 | "text_color": "#000000", 8 | "match": "", 9 | "matching_algorithm": 6, 10 | "is_insensitive": false, 11 | "is_inbox_tag": false, 12 | "document_count": 0, 13 | "owner": 2, 14 | "user_can_change": true 15 | }, 16 | { 17 | "id": 71, 18 | "slug": "project-alpha", 19 | "name": "Project Alpha", 20 | "color": "#ca6beb", 21 | "text_color": "#000000", 22 | "match": "", 23 | "matching_algorithm": 1, 24 | "is_insensitive": false, 25 | "is_inbox_tag": false, 26 | "document_count": 0, 27 | "owner": 2, 28 | "user_can_change": true 29 | }, 30 | { 31 | "id": 131, 32 | "slug": "finance-2023", 33 | "name": "Finance 2023", 34 | "color": "#eb87e0", 35 | "text_color": "#000000", 36 | "match": "", 37 | "matching_algorithm": 6, 38 | "is_insensitive": true, 39 | "is_inbox_tag": false, 40 | "document_count": 2, 41 | "owner": 2, 42 | "user_can_change": true 43 | } 44 | ] 45 | -------------------------------------------------------------------------------- /DataModel/Tests/DataModelTests/Data/users.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 42, 4 | "username": "testuser123", 5 | "email": "test.user@example.com", 6 | "first_name": "Test", 7 | "last_name": "User", 8 | "date_joined": "2024-03-03T15:30:45.123456+01:00", 9 | "is_staff": true, 10 | "is_active": true, 11 | "is_superuser": false, 12 | "groups": [1, 3, 5], 13 | "user_permissions": [ 14 | "view_document", 15 | "add_document", 16 | "delete_document" 17 | ], 18 | "inherited_permissions": [ 19 | "view_tag", 20 | "add_tag" 21 | ] 22 | }, 23 | { 24 | "id": 77, 25 | "username": "admin", 26 | "email": "admin@example.com", 27 | "first_name": "System", 28 | "last_name": "Administrator", 29 | "date_joined": "2023-12-25T08:15:30.987654+01:00", 30 | "is_staff": true, 31 | "is_active": true, 32 | "is_superuser": true, 33 | "groups": [1], 34 | "user_permissions": [], 35 | "inherited_permissions": [] 36 | } 37 | ] 38 | -------------------------------------------------------------------------------- /DataModel/Tests/DataModelTests/DocumentTypeTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DocumentTypeTest.swift 3 | // DataModel 4 | // 5 | // Created by Paul Gessinger on 03.01.25. 6 | // 7 | 8 | import Common 9 | @testable import DataModel 10 | import Testing 11 | 12 | @Suite 13 | struct DocumentTypeTest { 14 | @Test func testDecoding() throws { 15 | let data = """ 16 | { 17 | "id": 11, 18 | "slug": "form", 19 | "name": "Form", 20 | "match": "Word", 21 | "matching_algorithm": 1, 22 | "is_insensitive": true, 23 | "document_count": 21, 24 | "owner": 2, 25 | "user_can_change": true 26 | } 27 | """.data(using: .utf8)! 28 | 29 | let documentType = try makeDecoder(tz: .current).decode(DocumentType.self, from: data) 30 | 31 | #expect(documentType.id == 11) 32 | #expect(documentType.slug == "form") 33 | #expect(documentType.name == "Form") 34 | #expect(documentType.match == "Word") 35 | #expect(documentType.matchingAlgorithm == .any) 36 | #expect(documentType.isInsensitive == true) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /DataModel/Tests/DataModelTests/Helpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Helpers.swift 3 | // DataModel 4 | // 5 | // Created by Paul Gessinger on 02.01.25. 6 | // 7 | 8 | import Foundation 9 | 10 | func testData(_ file: String) -> Data? { 11 | guard let rel = URL(string: file) else { 12 | return nil 13 | } 14 | guard let url = Bundle.module.url(forResource: rel.deletingPathExtension().absoluteString, withExtension: ".\(rel.pathExtension)") else { 15 | return nil 16 | } 17 | 18 | do { 19 | return try Data(contentsOf: url) 20 | } catch { 21 | return nil 22 | } 23 | } 24 | 25 | func dateApprox(_ lhs: Date, _ rhs: Date) -> Bool { 26 | let distance = lhs.distance(to: rhs) 27 | return abs(distance) < 1 28 | } 29 | 30 | func datetime(year: Int, month: Int, day: Int, hour: Int = 0, minute: Int = 0, second: Double = 0, tz: TimeZone = .current) -> Date { 31 | var dateComponents = DateComponents() 32 | dateComponents.year = year 33 | dateComponents.month = month 34 | dateComponents.day = day 35 | dateComponents.timeZone = tz 36 | dateComponents.hour = hour 37 | dateComponents.minute = minute 38 | 39 | let fullSeconds = Int(second) 40 | let nanoseconds = Int((second - Double(fullSeconds)) * 1_000_000_000) 41 | dateComponents.second = fullSeconds 42 | dateComponents.nanosecond = nanoseconds 43 | 44 | var cal = Calendar.current 45 | cal.timeZone = tz 46 | let date = cal.date(from: dateComponents)! 47 | return date 48 | } 49 | -------------------------------------------------------------------------------- /DataModel/Tests/DataModelTests/MetadataTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MetadataTest.swift 3 | // DataModel 4 | // 5 | // Created by Paul Gessinger on 03.01.25. 6 | // 7 | 8 | import Common 9 | @testable import DataModel 10 | import Testing 11 | 12 | @Suite 13 | struct MetadataTest { 14 | @Test func testDecoding() throws { 15 | let data = try #require(testData("Data/metadata.json")) 16 | 17 | let metadata = try makeDecoder(tz: .current).decode(Metadata.self, from: data) 18 | 19 | #expect(metadata.originalChecksum == "8e638f024cd9f14206dc63821f412844") 20 | #expect(metadata.originalSize == 49036) 21 | #expect(metadata.originalMimeType == "application/pdf") 22 | #expect(metadata.mediaFilename == "blurp/2024/12/2024-12-31--Bank somehing something.pdf") 23 | #expect(metadata.hasArchiveVersion == true) 24 | #expect(metadata.originalMetadata.count == 11) 25 | #expect(metadata.archiveChecksum == "04d626c75b075a2a88e896e46420044e") 26 | #expect(metadata.archiveMediaFilename == "blurp/2024/12/2024-12-31--Bank Statement.pdf") 27 | #expect(metadata.originalFilename == "E-Post_.pdf") 28 | #expect(metadata.archiveSize == 24376) 29 | #expect(metadata.archiveMetadata?.count == 10) 30 | #expect(metadata.lang == "en") 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /DataModel/Tests/DataModelTests/StoragePathTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoragePathTest.swift 3 | // DataModel 4 | // 5 | // Created by AI on 14.03.24. 6 | // 7 | 8 | import Common 9 | @testable import DataModel 10 | import Testing 11 | 12 | @Suite 13 | struct StoragePathTest { 14 | @Test func testDecoding() throws { 15 | let data = """ 16 | { 17 | "id": 1, 18 | "slug": "haushalt", 19 | "name": "Haushalt", 20 | "path": "Haushalt/{{ created_year }}/{{ document_type }}_{{ title }}__{{ tag_list }}", 21 | "match": "", 22 | "matching_algorithm": 6, 23 | "is_insensitive": true, 24 | "document_count": 76, 25 | "owner": 2, 26 | "user_can_change": true 27 | } 28 | """.data(using: .utf8)! 29 | 30 | let storagePath = try makeDecoder(tz: .current).decode(StoragePath.self, from: data) 31 | 32 | #expect(storagePath.id == 1) 33 | #expect(storagePath.slug == "haushalt") 34 | #expect(storagePath.name == "Haushalt") 35 | #expect(storagePath.path == "Haushalt/{{ created_year }}/{{ document_type }}_{{ title }}__{{ tag_list }}") 36 | #expect(storagePath.match == "") 37 | #expect(storagePath.matchingAlgorithm == .auto) 38 | #expect(storagePath.isInsensitive == true) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /DataModel/Tests/DataModelTests/SuggestionsTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SuggestionsTest.swift 3 | // DataModel 4 | // 5 | // Created by Assistant on 03.01.25. 6 | // 7 | 8 | import Common 9 | @testable import DataModel 10 | import Testing 11 | 12 | @Suite 13 | struct SuggestionsTest { 14 | @Test func testDecoding() throws { 15 | let data = try #require(testData("Data/suggestions.json")) 16 | 17 | let suggestions = try makeDecoder(tz: .current).decode(Suggestions.self, from: data) 18 | 19 | #expect(suggestions.correspondents == [72]) 20 | #expect(suggestions.tags == [9]) 21 | #expect(suggestions.documentTypes == [4]) 22 | #expect(suggestions.storagePaths.isEmpty) 23 | #expect(dateApprox(suggestions.dates[0], datetime(year: 2024, month: 12, day: 3, hour: 0, minute: 0, second: 0, tz: .current))) 24 | #expect(dateApprox(suggestions.dates[1], datetime(year: 2025, month: 1, day: 2, hour: 0, minute: 0, second: 0, tz: .current))) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /DataModel/Tests/DataModelTests/TagModelTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TagModelTest.swift 3 | // DataModel 4 | // 5 | // Created by Assistant on 03.01.25. 6 | // 7 | 8 | import Common 9 | @testable import DataModel 10 | import SwiftUI 11 | import Testing 12 | 13 | private let decoder = makeDecoder(tz: .current) 14 | 15 | @Suite 16 | struct TagModelTest { 17 | @Test func testDecoding() throws { 18 | let data = try #require(testData("Data/tags.json")) 19 | let tags = try decoder.decode([DataModel.Tag].self, from: data) 20 | 21 | // Test first tag 22 | #expect(tags[0].id == 66) 23 | #expect(tags[0].slug == "tech-dept") 24 | #expect(tags[0].name == "Tech Department") 25 | #expect(tags[0].color.color == Color(hex: "#bde38f")!) 26 | #expect(tags[0].match == "") 27 | #expect(tags[0].matchingAlgorithm == .auto) 28 | #expect(tags[0].isInsensitive == false) 29 | #expect(tags[0].isInboxTag == false) 30 | 31 | // Test second tag 32 | #expect(tags[1].id == 71) 33 | #expect(tags[1].slug == "project-alpha") 34 | #expect(tags[1].name == "Project Alpha") 35 | #expect(tags[1].color.color == Color(hex: "#ca6beb")!) 36 | #expect(tags[1].matchingAlgorithm == .any) 37 | 38 | // Test third tag 39 | #expect(tags[2].id == 131) 40 | #expect(tags[2].color.color == Color(hex: "#eb87e0")!) 41 | #expect(tags[2].isInsensitive == true) 42 | #expect(tags[2].name == "Finance 2023") 43 | #expect(tags[2].matchingAlgorithm == .auto) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /DataModel/Tests/DataModelTests/UserModelTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserModelTest.swift 3 | // DataModel 4 | // 5 | // Created by Assistant on 03.03.24. 6 | // 7 | 8 | import Common 9 | @testable import DataModel 10 | import Foundation 11 | import Testing 12 | 13 | private let tz = TimeZone(secondsFromGMT: 60 * 60)! 14 | private let decoder = makeDecoder(tz: tz) 15 | 16 | @Suite 17 | struct UserModelTest { 18 | @Test func testDecoding() throws { 19 | let data = try #require(testData("Data/users.json")) 20 | let users = try decoder.decode([User].self, from: data) 21 | 22 | // Test regular user 23 | #expect(users[0].id == 42) 24 | #expect(users[0].username == "testuser123") 25 | #expect(users[0].isSuperUser == false) 26 | 27 | // Test admin user 28 | #expect(users[1].id == 77) 29 | #expect(users[1].username == "admin") 30 | #expect(users[1].isSuperUser == true) 31 | } 32 | 33 | @Test func testUserGroup() throws { 34 | let jsonData = """ 35 | { 36 | "id": 7, 37 | "name": "Administrators" 38 | } 39 | """.data(using: .utf8)! 40 | 41 | let group = try decoder.decode(UserGroup.self, from: jsonData) 42 | 43 | #expect(group.id == 7) 44 | #expect(group.name == "Administrators") 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "fastlane" 4 | 5 | plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile') 6 | eval_gemfile(plugins_path) if File.exist?(plugins_path) 7 | -------------------------------------------------------------------------------- /Justfile: -------------------------------------------------------------------------------- 1 | docs-serve: 2 | uv run --with-requirements docs/requirements.txt mkdocs serve -o -a localhost:8001 3 | 4 | docs: 5 | uv run --with-requirements docs/requirements.txt mkdocs build 6 | 7 | alias sv := set_version 8 | set_version version: 9 | uv run bump.py version swift-paperless.xcodeproj/project.pbxproj {{version}} 10 | 11 | alias sb := set_build 12 | set_build number: 13 | uv run bump.py build swift-paperless.xcodeproj/project.pbxproj {{number}} 14 | 15 | beta: 16 | fastlane beta 17 | 18 | default_os := '18.3.1' 19 | build os=default_os: 20 | #!/bin/bash 21 | xcodebuild -scheme swift-paperless -project ./swift-paperless.xcodeproj -configuration Release -destination platform\=iOS\ Simulator,OS\={{os}},name\=iPhone\ 16\ Pro | xcbeautify 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Paul Gessinger 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Networking/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | 10 | /Package.resolved 11 | -------------------------------------------------------------------------------- /Networking/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Networking", 8 | platforms: [ 9 | .iOS(.v17), 10 | .macOS(.v13), 11 | ], 12 | products: [ 13 | // Products define the executables and libraries a package produces, making them visible to other packages. 14 | .library( 15 | name: "Networking", 16 | targets: ["Networking"] 17 | ), 18 | ], 19 | dependencies: [ 20 | .package(path: "../Common"), 21 | .package(path: "../DataModel"), 22 | .package(url: "https://github.com/apple/swift-async-algorithms", from: "1.0.0"), 23 | .package(url: "https://github.com/groue/Semaphore", from: "0.1.0"), 24 | ], 25 | targets: [ 26 | // Targets are the basic building blocks of a package, defining a module or a test suite. 27 | // Targets can depend on other targets in this package and products from dependencies. 28 | .target( 29 | name: "Networking", 30 | dependencies: [ 31 | .product(name: "Common", package: "Common"), 32 | .product(name: "DataModel", package: "DataModel"), 33 | .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), 34 | .product(name: "Semaphore", package: "Semaphore"), 35 | ], 36 | path: "Sources", 37 | swiftSettings: [ 38 | .swiftLanguageMode(.v6), 39 | .enableExperimentalFeature("StrictConcurrency"), 40 | ] 41 | ), 42 | .testTarget( 43 | name: "NetworkingTests", 44 | dependencies: ["Networking"] 45 | ), 46 | ] 47 | ) 48 | -------------------------------------------------------------------------------- /Networking/Sources/Networking/Api/TLSIdentity.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import os 3 | 4 | public struct TLSIdentity: Identifiable, Equatable, Hashable { 5 | public var name: String 6 | public var identity: SecIdentity 7 | 8 | public var id: String { name } 9 | 10 | public init(name: String, identity: SecIdentity) { 11 | self.name = name 12 | self.identity = identity 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Networking/Sources/Networking/Logging.swift: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | extension Logger { 4 | static let networking = Logger(subsystem: "com.paulgessinger.swift-paperless", category: "Networking") 5 | } 6 | -------------------------------------------------------------------------------- /Networking/Sources/Networking/RequestError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RequestError.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 04.05.2024. 6 | // 7 | 8 | import Common 9 | import DataModel 10 | import Foundation 11 | 12 | public enum RequestError: Error, Equatable { 13 | // Error building a request in the first place 14 | case invalidRequest 15 | 16 | // Anything other than HTTPResponse was returned 17 | case invalidResponse 18 | 19 | // A status code that was not expected was returned 20 | case unexpectedStatusCode(code: HTTPStatusCode, detail: String?) 21 | 22 | // A 403 status code was returned (and was not expected) 23 | case forbidden(detail: String?) 24 | 25 | // A 401 status code was returned (and was not expected) 26 | case unauthorized(detail: String) 27 | 28 | // A 406 status code was returned. Use by paperless-ngx to indicate that the requested API version is not accepted 29 | case unsupportedVersion 30 | 31 | case localNetworkDenied 32 | 33 | case certificate(detail: String) 34 | } 35 | 36 | public struct ResourceForbidden: Error { 37 | public let response: String? 38 | 39 | public init(_: Resource.Type, response: String?) { 40 | self.response = response 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /ShareExtension/Base.lproj/MainInterface.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /ShareExtension/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSAppTransportSecurity 6 | 7 | NSAllowsArbitraryLoads 8 | 9 | 10 | NSExtension 11 | 12 | NSExtensionAttributes 13 | 14 | NSExtensionActivationRule 15 | SUBQUERY ( 16 | extensionItems, 17 | $extensionItem, 18 | SUBQUERY ( 19 | $extensionItem.attachments, 20 | $attachment, 21 | ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.adobe.pdf" || 22 | ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image" 23 | ).@count == $extensionItem.attachments.@count 24 | ).@count > 0 25 | NSExtensionActivationSupportsAttachmentsWithMaxCount 26 | 10 27 | 28 | NSExtensionMainStoryboard 29 | MainInterface 30 | NSExtensionPointIdentifier 31 | com.apple.share-services 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /ShareExtension/ShareExtension.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.application-groups 8 | 9 | group.com.paulgessinger.swift-paperless 10 | 11 | com.apple.security.network.client 12 | 13 | keychain-access-groups 14 | 15 | $(AppIdentifierPrefix)com.paulgessinger.swift-paperless 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /ShareExtension/mul.lproj/MainInterface.xcstrings: -------------------------------------------------------------------------------- 1 | { 2 | "sourceLanguage" : "en", 3 | "strings" : { 4 | 5 | }, 6 | "version" : "1.0" 7 | } 8 | -------------------------------------------------------------------------------- /app_store/devices/iPad Pro (12.9-inch) (2nd generation)/00.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:d0cac108d256fc1543803042c7bad4c2897bac2d63fb3488b46440059111f6ec 3 | size 595445 4 | -------------------------------------------------------------------------------- /app_store/devices/iPad Pro (12.9-inch) (2nd generation)/01.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:2c24a3334b11998ed7edfbd321fa424983df60b75c0494b9ba6eb791a1b89a5f 3 | size 719352 4 | -------------------------------------------------------------------------------- /app_store/devices/iPad Pro (12.9-inch) (2nd generation)/02.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:8da4d2e1c4665fc820b3274084b449d47af6da18f60ee8d6d511c60cbc673bc2 3 | size 731639 4 | -------------------------------------------------------------------------------- /app_store/devices/iPad Pro (12.9-inch) (2nd generation)/03.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:06725fdf567bb3a9b4bc9517057b60d3e8b9eac373c8e83849e56eb05b3b70da 3 | size 707597 4 | -------------------------------------------------------------------------------- /app_store/devices/iPad Pro (12.9-inch) (6th generation)/00.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:5ff9d1be0219dc3a0894ead94337e87319b781b5d13c528dc35a998e8a856281 3 | size 598384 4 | -------------------------------------------------------------------------------- /app_store/devices/iPad Pro (12.9-inch) (6th generation)/01.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:8ef5bb130f75f764467d0ea5610b44fc701f62cf0337753647dcc460a3f94c25 3 | size 720118 4 | -------------------------------------------------------------------------------- /app_store/devices/iPad Pro (12.9-inch) (6th generation)/02.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:a38a118df08db8917d34f25a0d30c5afd77c19df64feca210e5872c4b1158345 3 | size 737307 4 | -------------------------------------------------------------------------------- /app_store/devices/iPad Pro (12.9-inch) (6th generation)/03.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:c31f5c69f40734403c8c48604cd8c81bd3e3518c09aacff58ad053b1269a01ce 3 | size 710806 4 | -------------------------------------------------------------------------------- /app_store/devices/iPhone 14 Pro Max/00.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:b2adf301c20bae9deda1e06c4d2208c22f028827110965a0e82d97fae4fdeb08 3 | size 778294 4 | -------------------------------------------------------------------------------- /app_store/devices/iPhone 14 Pro Max/01.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:0ae86b6223a4131c9abe731598e389925da03aa5cbac261a2dc01366063b960c 3 | size 568167 4 | -------------------------------------------------------------------------------- /app_store/devices/iPhone 14 Pro Max/02.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:5c53563b575e7aa7c0fc65d8271ae346aabd6f490e639b702c1562999421c38e 3 | size 218784 4 | -------------------------------------------------------------------------------- /app_store/devices/iPhone 14 Pro Max/03.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:b01018a73a64ca40dc9981e56a062e7d8c8c615e1f23233baae90aefed429089 3 | size 191645 4 | -------------------------------------------------------------------------------- /app_store/devices/iPhone 14/00.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:1cf575272689c83c666619f0cbfd8313fda61bbb2e34dfc4cf742e6d1499877b 3 | size 721163 4 | -------------------------------------------------------------------------------- /app_store/devices/iPhone 14/01.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:dc0a5c3bbe3874838b66f9176a4f8b3da9e0858146a54ebac93148508dc8e1ff 3 | size 503094 4 | -------------------------------------------------------------------------------- /app_store/devices/iPhone 14/02.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:04d0625a333493abe3b36a83f0a214bc7fdfe13ca50fa11e2f596c99be592452 3 | size 203960 4 | -------------------------------------------------------------------------------- /app_store/devices/iPhone 14/03.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:33f0f0d757bbcfc9661d89ba04f75a7c1334c7fbc0a30f6b0fae266a9cd5cf6d 3 | size 176483 4 | -------------------------------------------------------------------------------- /app_store/devices/iPhone 8 Plus/00.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:0c26a145241f70808f17e73fa0d89c69a16f2a5de4614aadf58af7cd12af9fc5 3 | size 657505 4 | -------------------------------------------------------------------------------- /app_store/devices/iPhone 8 Plus/01.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:4733afe9bae3c37dfb7b56805dd6c24b119f24d5c843c8be01ad65bcacbf9c75 3 | size 438526 4 | -------------------------------------------------------------------------------- /app_store/devices/iPhone 8 Plus/02.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:c6357384a4123d7216698f3ca0b3b916dd297e96fdd4ed7537c84ce4e1ad2b0e 3 | size 199557 4 | -------------------------------------------------------------------------------- /app_store/devices/iPhone 8 Plus/03.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:0919a401f7f22d2f93e5598d7621a8f53f665af58a0ae2472e0fb5ab91b48812 3 | size 177410 4 | -------------------------------------------------------------------------------- /app_store/devices/iPhone SE (3rd generation)/00.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:1ce3a710d4d4e961e07881760c1f80a5312ac59172f0657ae17e809d5236b314 3 | size 306799 4 | -------------------------------------------------------------------------------- /app_store/devices/iPhone SE (3rd generation)/01.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:129a7682cccef22a3beb87a6e4dc0261b16423f858e675dd5e642627809563de 3 | size 229873 4 | -------------------------------------------------------------------------------- /app_store/devices/iPhone SE (3rd generation)/02.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:a6856957aaa9a1b6328f265b4e4b00bfff2b0491be47f0afc6cfd338e4704612 3 | size 110710 4 | -------------------------------------------------------------------------------- /app_store/devices/iPhone SE (3rd generation)/04.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:3da09d91974e383c81d0aca0e6dfb369168ebc8857f10916588978799d5aa186 3 | size 89597 4 | -------------------------------------------------------------------------------- /app_store/panorama.afdesign: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:1bbd4bd17bc446e18da26e3d259a55c379af06e7b449d9e73f5ba456879d203d 3 | size 1396446 4 | -------------------------------------------------------------------------------- /app_store/panorama.jpg: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:19626f950da1b85ac657d8eb47c53a7bbb041f6a40884aeada8917ffe1250db3 3 | size 347823 4 | -------------------------------------------------------------------------------- /app_store/panorama.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:66da8a3b1a6de2a6c0835245bd730a761f7ddbd1aced86ab5815fce64044014c 3 | size 758400 4 | -------------------------------------------------------------------------------- /app_store/screens.afdesign: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:1e99ab304103c365add4033776f3281cffb95c68c1faaa302db160a1e6282ed9 3 | size 1626553 4 | -------------------------------------------------------------------------------- /app_store/screens/00.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:5192ea49d1436c119fd218621e51eb9ca82c33e1f7eb202bd00debd2639612cd 3 | size 489414 4 | -------------------------------------------------------------------------------- /app_store/screens/01.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:25766c1cd1d9abab0dca11fbedc3426bc9c600ab7c6e7f22c4a87475f2bcbb87 3 | size 162005 4 | -------------------------------------------------------------------------------- /app_store/screens/02.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:dd2a76ce20ae5661f86fe11e831f4029f375290c761101650c43e9e2275dd5ef 3 | size 126630 4 | -------------------------------------------------------------------------------- /app_store/screens/03.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:3be7d926e4ec04b236dcad5a93349c229cd6a602f2dd721161f6850b4590b400 3 | size 131936 4 | -------------------------------------------------------------------------------- /app_store/screens/04.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:ea83b2e60ba9712f41adc5657527e74629335a8c56461b7a9474fb3a9da7972f 3 | size 136290 4 | -------------------------------------------------------------------------------- /app_store/screens/05.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:699ef6b79c72b48540701bd09240ef7f87b8d95c138db3370a3fe4164517d4a3 3 | size 696278 4 | -------------------------------------------------------------------------------- /app_store/screens/06.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:dd0e8a89a19afa9105b849ff62fc60cb9605885dc745aaf033454d990f53f9ae 3 | size 865522 4 | -------------------------------------------------------------------------------- /app_store/screens/07.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:c7fee429ea299f56c5544099a9102a19c7e1fe68b04b38ad0b01c734dcc42e0b 3 | size 117470 4 | -------------------------------------------------------------------------------- /app_store/screens/08.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:028b6fe7a29a14837baa5667e0491398c02b6714bad691ea10a5a96527f1c932 3 | size 123436 4 | -------------------------------------------------------------------------------- /app_store/screens/09.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:b97dea645ebf606b6a4e45955486365292e3956744d96c9694e52069fd4bbb1a 3 | size 493929 4 | -------------------------------------------------------------------------------- /app_store/screens/10.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:fb55fbc0be1b1a9c5a28c297f6711ab133791da7cf66a3b26778700da8b63839 3 | size 142725 4 | -------------------------------------------------------------------------------- /app_store/screens/11.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:19c8dc29dc8a838ec33a6b09a172390d1250ce7cfe4e847d7fe5c7d40a5e3860 3 | size 776449 4 | -------------------------------------------------------------------------------- /app_store/single.afphoto: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:cb63929b3238e1402f7248c64e8cc487080624177a3a73900abdb93bf0857b6d 3 | size 1113064 4 | -------------------------------------------------------------------------------- /app_store/slice1.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:3bfebb3c2a5cad23dfcd3b2d7bc3b5797f64120c10608dfcae595c34d3337518 3 | size 1246663 4 | -------------------------------------------------------------------------------- /app_store/slice1_6p5.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:361fbaef740aa52c11cffe73744524b4defe807056dc562ecc65f0753f2448c5 3 | size 1183715 4 | -------------------------------------------------------------------------------- /app_store/slice1_6p7.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:3bfebb3c2a5cad23dfcd3b2d7bc3b5797f64120c10608dfcae595c34d3337518 3 | size 1246663 4 | -------------------------------------------------------------------------------- /app_store/slice2.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:ecacccbc487483520f53dd799b4903a37723186dcd3693b4cf3d36dea53a3ee5 3 | size 1251372 4 | -------------------------------------------------------------------------------- /app_store/slice2_6p5.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:3d036823039dc902fcc028a4b023c65ceb591fe5127b8c8e22108befd5ed9deb 3 | size 1178977 4 | -------------------------------------------------------------------------------- /app_store/slice2_6p7.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:ecacccbc487483520f53dd799b4903a37723186dcd3693b4cf3d36dea53a3ee5 3 | size 1251372 4 | -------------------------------------------------------------------------------- /app_store/slice3.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:441101bf81ac00592fe370cfd6a84a5ecb2af16a8e9a6ded9fa7d3beb26bd487 3 | size 1010886 4 | -------------------------------------------------------------------------------- /app_store/slice3_6p5.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:b0d296eb521d42b87c5a797d1f376a576a6e7ba99d1afb1e77d3ce2c20c0b282 3 | size 956780 4 | -------------------------------------------------------------------------------- /app_store/slice3_6p7.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:441101bf81ac00592fe370cfd6a84a5ecb2af16a8e9a6ded9fa7d3beb26bd487 3 | size 1010886 4 | -------------------------------------------------------------------------------- /app_store/slice4.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:20a33c11ad720b3fd1fe01c0ed26e0f83ba909ecf332cce283dd90fa281a8ede 3 | size 511239 4 | -------------------------------------------------------------------------------- /app_store/slice4_6p5.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:1e33bb4cd7ca3a1213101868552dbcf186ebc0e3afacc5d714fffd8a054f5acf 3 | size 479532 4 | -------------------------------------------------------------------------------- /app_store/slice4_6p7.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:20a33c11ad720b3fd1fe01c0ed26e0f83ba909ecf332cce283dd90fa281a8ede 3 | size 511239 4 | -------------------------------------------------------------------------------- /app_store/slice5.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:bd811cf0019a772f176289c9be20c92bb64d4af8431436e7d9dde9278dedec81 3 | size 442301 4 | -------------------------------------------------------------------------------- /app_store/slice5_6p5.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:09456a1152423102302e034725def911ccdc13dcd08b0981cd1628daacfe3a88 3 | size 416239 4 | -------------------------------------------------------------------------------- /app_store/slice5_6p7.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:bd811cf0019a772f176289c9be20c92bb64d4af8431436e7d9dde9278dedec81 3 | size 442301 4 | -------------------------------------------------------------------------------- /bump.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # /// script 4 | # dependencies = [ 5 | # "rich", 6 | # "typer", 7 | # ] 8 | # /// 9 | 10 | import typer 11 | from pathlib import Path 12 | import re 13 | 14 | 15 | app = typer.Typer() 16 | 17 | 18 | @app.command() 19 | def version(file: Path, version: str): 20 | raw = file.read_text() 21 | 22 | # validate version 23 | if m := re.match(r"v?(\d+\.\d+\.\d+)", version): 24 | version = m.group(1) 25 | else: 26 | raise ValueError(f"Invalid version: {version}") 27 | 28 | bumped = re.sub( 29 | r"MARKETING_VERSION ?= ?\d+\.\d+\.\d+;", f"MARKETING_VERSION = {version};", raw 30 | ) 31 | 32 | file.write_text(bumped) 33 | 34 | 35 | @app.command() 36 | def build(file: Path, number: int): 37 | raw = file.read_text() 38 | 39 | bumped = re.sub( 40 | r"CURRENT_PROJECT_VERSION ?= ?\d+;", 41 | f"CURRENT_PROJECT_VERSION = {number};", 42 | raw, 43 | ) 44 | 45 | file.write_text(bumped) 46 | 47 | 48 | app() 49 | -------------------------------------------------------------------------------- /changelog.txt: -------------------------------------------------------------------------------- 1 | 1.8.0 (144) 2 | 3 | - Fix timezone conversion in document created date: on versions below 2.16.0 of Paperless-ngx, the timezone was incorrectly set and resulted in the date shifting when saving. 4 | 5 | 1.8.0 (142) 6 | 7 | - Adjust to updated API document data: created is date-only now 8 | -------------------------------------------------------------------------------- /crowdin.yml: -------------------------------------------------------------------------------- 1 | project_id_env: CROWDIN_PROJECT_ID 2 | api_token_env: CROWDIN_PERSONAL_TOKEN 3 | preserve_hierarchy: true 4 | files: 5 | - source: swift-paperless/Localization/*.xcstrings 6 | translation: swift-paperless/Localization/%original_file_name% 7 | multilingual: true 8 | -------------------------------------------------------------------------------- /current_changelog.txt: -------------------------------------------------------------------------------- 1 | - Tasks: Only load tasks related to document consumption 2 | - Tasks: Explicitly request only tasks which have not been acknowledged yet. 3 | -------------------------------------------------------------------------------- /demo/docker-compose.env: -------------------------------------------------------------------------------- 1 | PAPERLESS_URL=http://localhost:9988 2 | USERMAP_UID=501 3 | USERMAP_GID=20 4 | PAPERLESS_TIME_ZONE=Europe/Zurich 5 | PAPERLESS_OCR_LANGUAGE=deu+eng+fra 6 | PAPERLESS_SECRET_KEY=hallo 7 | PAPERLESS_ALLOWED_HOSTS=localhost 8 | PAPERLESS_FILENAME_FORMAT={created_year}/{created_month}/{created_year}-{created_month}-{created_day}--{document_type}_{title}__{tag_list} 9 | PAPERLESS_FILENAME_FORMAT_REMOVE_NONE=True 10 | -------------------------------------------------------------------------------- /docs/app_store.svg: -------------------------------------------------------------------------------- 1 | ../app_store.svg -------------------------------------------------------------------------------- /docs/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/docs/assets/favicon.png -------------------------------------------------------------------------------- /docs/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/docs/assets/logo.png -------------------------------------------------------------------------------- /docs/assets/logo_standalone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/docs/assets/logo_standalone.png -------------------------------------------------------------------------------- /docs/common_issues/forbidden.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Error: Forbidden" 3 | --- 4 | 5 | # Error "No access: XXX" 6 | 7 | This error occurs when the user does not have the necessary permissions to access the requested resource. This is usually the result of a faulty configuation in Paperless-ngx itself. 8 | 9 | To resolve this issue, please navigate to the *Users & Groups* page in the *Administration* section, and make sure that the *View* checkbox is ticked for the user in question. 10 | 11 | ![](paperless-perms.png) 12 | -------------------------------------------------------------------------------- /docs/common_issues/local-network-denied.md: -------------------------------------------------------------------------------- 1 | # Login fails for a local address 2 | 3 | iOS applications need explicit user approval to be allowed to connect to local network addresses. This is a security feature to prevent apps from connecting to local services without the user's knowledge! 4 | 5 | The first time a connection to a local network address is attempted, the app will show a dialog asking for permission to connect to the local network. If this dialog is dismissed, the app will not be able to connect to the local network address. 6 | 7 | You can resolved this by navigating to Settings -> Privacy & Security -> Paperless and enabling the "Local Network" switch. 8 | -------------------------------------------------------------------------------- /docs/common_issues/paperless-perms.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/docs/common_issues/paperless-perms.png -------------------------------------------------------------------------------- /docs/common_issues/supported-versions.md: -------------------------------------------------------------------------------- 1 | # Supported versions 2 | 3 | !!! info 4 | Paperless-ngx is a dynamic project with frequent updates. I'm trying to keep the app working as best as I can! If you notice any issues after upgrading, please [let me know](https://github.com/paulgessinger/swift-paperless/issues/new?template=bug_report.yml). 5 | 6 | ## API Versions 7 | The app supports Paperless-ngx API versions 3 through 7 (as of February 2025). 8 | 9 | For detailed API documentation, see the [official Paperless-ngx API docs](https://docs.paperless-ngx.com/api/). 10 | 11 | ## Backend Versions 12 | The minimum required Paperless-ngx backend version is [v1.14.1](https://github.com/paperless-ngx/paperless-ngx/releases/tag/v1.14.1) and is tested up to and including version [v2.14.7](https://github.com/paperless-ngx/paperless-ngx/releases/tag/v2.14.7). 13 | 14 | ## Version Detection 15 | The app automatically detects the backend version and API version by checking the following HTTP headers in responses: 16 | 17 | - `X-Version` or `x-version`: Backend version 18 | - `X-Api-Version`: API version 19 | 20 | If the backend API version is outside the supported range (3-7), a warning will be logged but the app will still attempt to function by using the closest supported version. 21 | -------------------------------------------------------------------------------- /docs/extra.css: -------------------------------------------------------------------------------- 1 | :root > * { 2 | --md-primary-fg-color: #17541F; 3 | --md-primary-fg-color--dark: #124218; 4 | /* --md-primary-fg-color--light: #90030C; */ 5 | } 6 | 7 | [data-md-color-scheme="slate"] { 8 | --md-hue: 210; 9 | } 10 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Home 3 | --- 4 | 5 | --8<-- "README.md" 6 | -------------------------------------------------------------------------------- /docs/libraries.md: -------------------------------------------------------------------------------- 1 | # Third-party libraries 2 | 3 | Swift Paperless uses the following third-party libraries for various features: 4 | 5 | --- 6 | 7 | ## swift-async-algorithms 8 | [GitHub](https://github.com/apple/swift-async-algorithms) 9 | *Apache-2.0 license* 10 | 11 | ## swift-collections 12 | [GitHub](https://github.com/apple/swift-collections) 13 | *Apache-2.0 license* 14 | 15 | ## SwiftUI Flow Layout 16 | [GitHub](https://github.com/tevelee/SwiftUI-Flow) 17 | *MIT license* 18 | 19 | ## Semaphore 20 | [GitHub](https://github.com/groue/Semaphore) 21 | *MIT license* 22 | 23 | ## Nuke 24 | [GitHub](https://github.com/kean/Nuke) 25 | *MIT license* 26 | 27 | ## MarkdownUI 28 | [GitHub](https://github.com/gonzalezreal/swift-markdown-ui) 29 | *MIT license* 30 | 31 | ## XCStrings Tool 32 | [GitHub](https://github.com/liamnichols/xcstrings-tool) 33 | *MIT license* 34 | 35 | ## MetaCodable 36 | [GitHub](https://github.com/SwiftyLab/MetaCodable) 37 | *MIT license* 38 | -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- 1 | ../logo.png -------------------------------------------------------------------------------- /docs/panorama.png: -------------------------------------------------------------------------------- 1 | ../panorama.png -------------------------------------------------------------------------------- /docs/privacy.md: -------------------------------------------------------------------------------- 1 | # Privacy 2 | 3 | Swift Paperless does not collect any data from users. Login data provided during setup 4 | is only ever send to the instance of [Paperless-ngx](https://github.com/paperless-ngx/paperless-ngx) 5 | given in the URL field. 6 | Documents shared with the app via the share sheet extensions are only ever uploaded 7 | to the configured instance. No data is uploaded or recorded anywhere else. 8 | Users are responsible for setting up and maintaining their instance, 9 | or trusting the operator of an instance they are using. 10 | -------------------------------------------------------------------------------- /docs/release_notes/v1.2.0.md: -------------------------------------------------------------------------------- 1 | # v1.2.0 2 | 3 | ## 🚀 Features 4 | 5 | - **Multi-server support** 🎉: It is now possible to add multiple servers to the app! You can also use 6 | this feature to log in to the same server with multiple users. When sharing documents with the app, it’s now possible to choose which server to upload to. 7 | - **Processing task management** ✏️: Added new screens to view all tasks currently being tracked by the server. The app monitors tasks when documents are added, and will notify you of failed processing (e.g. in case of duplicate documents). 8 | 9 | - New app icon: 10 | - You can choose between three available App Icons in the settings 11 | 12 | ![New app icon](../assets/logo_standalone.png) 13 | 14 | 15 | - Danish translation added (thanks to [@PTST](https://github.com/PTST)) 16 | - Add the ability to sort by storage paths 17 | - Add the ability to pick photos from the photo library, and to share pictures with the app to be uploaded 18 | - Allow using all fulltext search modes, in addition to “Title & Content” 19 | - Allow settings the default search mode and default sort field and order 20 | 21 | ## 🐛 Bug Fixes 22 | - Incorrect display if the document aspect ratio is not A4-ish 23 | - Sub paths like `https://example.com/paperless` are now supported 24 | 25 | ## 🚜 Refactor 26 | - Much improved error logging and error handling. Many common errors should now 27 | produce more concrete error messages, and if possible link to the appropriate 28 | documentation page. This should make it easier to troubleshoot issues. 29 | - App layout update: saved views can now be selected from the navigation title at the very top of the document screen 30 | 31 | ## ⚙️ Miscellaneous 32 | - Surface app version in settings, add to feedback email 33 | - Updated saved view filter rules to Paperless-ngx `2.8.0` 34 | -------------------------------------------------------------------------------- /docs/release_notes/v1.3.0.md: -------------------------------------------------------------------------------- 1 | # v1.3.0 2 | 3 | ## 🚀 Features 4 | 5 | - Improved used feedback for ASNs in document creation and editing. 6 | The app will now report if an ASN is already used or not. 7 | 8 | ## 🐛 Bug Fixes 9 | 10 | - Wrong app icon in the initial login view 11 | - Saved views can have missing sort field, added support for notes, owner and search score sort field 12 | 13 | ## 🚜 Refactor 14 | 15 | - Server selection UI improvement in share-sheet 16 | -------------------------------------------------------------------------------- /docs/release_notes/v1.4.0.md: -------------------------------------------------------------------------------- 1 | # v1.4.0 2 | 3 | ## 🐛 Bug Fixes 4 | 5 | - Fixed a bug when decoding dates from the API in certain locale configurations 6 | -------------------------------------------------------------------------------- /docs/release_notes/v1.5.0.md: -------------------------------------------------------------------------------- 1 | # v1.5.0 2 | 3 | ## 🚀 Features 4 | 5 | - All new document detail view 6 | - You can now inspect the open document while editing thanks to a new sheet based edit overlay. 7 | - Editing of document notes 8 | - View document metadata 9 | - Ready for iOS 18 with updated icons! 10 | - Editing of permissions on documents 11 | - Clear button in create document screen 12 | - Ability to export logs from the share extension for debugging 13 | - Initial support for mTLS (client certificate authentication) 14 | 15 | ## 🐛 Bug Fixes 16 | 17 | - Share sheet allowed saving before API was ready 18 | - Incorrect number of import items displayed 19 | 20 | ## 🚜 Refactor 21 | 22 | - Ability to log out of a server in the settings screen 23 | - Improved accent color in dark mode for improved readability 24 | -------------------------------------------------------------------------------- /docs/release_notes/v1.5.1.md: -------------------------------------------------------------------------------- 1 | # v1.5.1 2 | 3 | ## 🐛 Bug Fixes 4 | 5 | - Fix an issue that prevented the new document detail view from being shown 6 | 7 | # Previous release: v1.5.0 8 | 9 | ## 🚀 Features 10 | 11 | - All new document detail view 12 | - You can now inspect the open document while editing thanks to a new sheet based edit overlay. 13 | - Editing of document notes 14 | - View document metadata 15 | - Ready for iOS 18 with updated icons! 16 | - Editing of permissions on documents 17 | - Clear button in create document screen 18 | - Ability to export logs from the share extension for debugging 19 | - Initial support for mTLS (client certificate authentication) 20 | 21 | ## 🐛 Bug Fixes 22 | 23 | - Share sheet allowed saving before API was ready 24 | - Incorrect number of import items displayed 25 | 26 | ## 🚜 Refactor 27 | 28 | - Ability to log out of a server in the settings screen 29 | - Improved accent color in dark mode for improved readability 30 | -------------------------------------------------------------------------------- /docs/release_notes/v1.6.1.md: -------------------------------------------------------------------------------- 1 | # v1.6.1 2 | 3 | ## 🐛 Bug Fixes 4 | 5 | - Fix an issue displaying the new login screen 6 | -------------------------------------------------------------------------------- /docs/release_notes/v1.7.0.md: -------------------------------------------------------------------------------- 1 | # v1.7.0 2 | 3 | ## 🐛 Bug Fixes 4 | 5 | - Fix background color of splash screen 6 | - Increase debounce delay in ASN type search to fix concurrency issue 7 | - Yet another round of decoding error debug outputs 8 | - Fix after change to tasks API 9 | - Support sorting by page count 10 | - Fix error when sort field has unexpected value in saved view 11 | - Fix error logging out of the last server connection 12 | 13 | ## 🚀 Features 14 | 15 | - Allow uploading documents with the title as their filename 16 | - Display objects (correspondents, document types etc) sorted by name case-insensitively 17 | - Additional debug output for decoding error debugging 18 | - Display loading screen also on initial app launch 19 | -------------------------------------------------------------------------------- /docs/release_notes/v1.7.1.md: -------------------------------------------------------------------------------- 1 | # v1.7.1 2 | 3 | ## 🐛 Bug Fixes 4 | 5 | - Adjust to updated API document data: created is date-only now (see [this PR](https://github.com/paperless-ngx/paperless-ngx/pull/9793)) 6 | -------------------------------------------------------------------------------- /docs/release_notes/v1.7.2.md: -------------------------------------------------------------------------------- 1 | 2 | # v1.7.2 3 | 4 | ## 🐛 Bug Fixes 5 | 6 | - Fix timezone conversion in document created date: on versions below 2.16.0 of Paperless-ngx, the timezone was incorrectly set and resulted in the date shifting when saving. 7 | - Tasks: Only load tasks related to document consumption 8 | - Tasks: Explicitly request only tasks which have not been acknowledged yet. 9 | -------------------------------------------------------------------------------- /docs/requirements.in: -------------------------------------------------------------------------------- 1 | mkdocs 2 | mkdocs-material 3 | pymdown-extensions 4 | mkdocs-literate-nav 5 | mkdocs-exclude 6 | mkdocs-redirects 7 | -------------------------------------------------------------------------------- /fastlane/.gitignore: -------------------------------------------------------------------------------- 1 | *.ttf 2 | screenshots/screenshots.html 3 | metadata/review_information 4 | screenshots/**/*.png 5 | Preview.html 6 | report.xml 7 | README.md 8 | test_output/report.junit 9 | -------------------------------------------------------------------------------- /fastlane/Appfile: -------------------------------------------------------------------------------- 1 | app_identifier("com.paulgessinger.swift-paperless") # The bundle identifier of your app 2 | apple_id(ENV["APPLE_ID"]) # Your Apple Developer Portal username 3 | 4 | itc_team_id(ENV["ITC_TEAM_ID"]) # App Store Connect Team ID 5 | team_id(ENV["TEAM_ID"]) # Developer Portal Team ID 6 | 7 | # For more information about the Appfile, see: 8 | # https://docs.fastlane.tools/advanced/#appfile 9 | -------------------------------------------------------------------------------- /fastlane/Deliverfile: -------------------------------------------------------------------------------- 1 | # The Deliverfile allows you to store various App Store Connect metadata 2 | # For more information, check out the docs 3 | # https://docs.fastlane.tools/actions/deliver/ 4 | 5 | 6 | screenshots_path "fastlane/screenshots/framed" 7 | 8 | skip_binary_upload true 9 | 10 | # overwrite_screenshots true 11 | -------------------------------------------------------------------------------- /fastlane/Matchfile: -------------------------------------------------------------------------------- 1 | git_url("git@github.com:paulgessinger/swift-paperless-match.git") 2 | 3 | storage_mode("git") 4 | 5 | type("appstore") 6 | 7 | app_identifier(["com.paulgessinger.swift-paperless", "com.paulgessinger.swift-paperless.ShareExtension"]) 8 | -------------------------------------------------------------------------------- /fastlane/Pluginfile: -------------------------------------------------------------------------------- 1 | # Autogenerated by fastlane 2 | # 3 | # Ensure this file is checked in to source control! 4 | 5 | gem 'fastlane-plugin-versioning' 6 | -------------------------------------------------------------------------------- /fastlane/Snapfile: -------------------------------------------------------------------------------- 1 | # Uncomment the lines below you want to change by removing the # in the beginning 2 | 3 | # A list of devices you want to take the screenshots from 4 | devices([ 5 | "iPhone 15 Pro", 6 | "iPhone 15 Pro Max", 7 | # "iPhone 16 Pro", 8 | "iPad Pro (12.9-inch) (6th generation)", 9 | ]) 10 | 11 | languages([ 12 | "en-US", 13 | "de-DE", 14 | "pl-PL", 15 | "nl-NL", 16 | "fr-FR", 17 | "da-DA", 18 | ]) 19 | 20 | # The name of the scheme which contains the UI Tests 21 | # scheme("SchemeName") 22 | 23 | # Where should the resulting screenshots be stored? 24 | # output_directory("./screenshots") 25 | 26 | # remove the '#' to clear all previously generated screenshots before creating new ones 27 | # clear_previous_screenshots(true) 28 | 29 | # Remove the '#' to set the status bar to 9:41 AM, and show full battery and reception. See also override_status_bar_arguments for custom options. 30 | override_status_bar(true) 31 | headless(false) 32 | override_status_bar_arguments("--time 9:41 --dataNetwork wifi --wifiMode active --wifiBars 3 --cellularMode active --operatorName '' --cellularBars 4 --batteryState charged --batteryLevel 100 --dataNetwork wifi") 33 | 34 | # Arguments to pass to the app on launch. See https://docs.fastlane.tools/actions/snapshot/#launch-arguments 35 | launch_arguments([ 36 | "-PreviewMode YES -PreviewURL "+ENV["PreviewURL"]+" -PreviewToken "+ENV["PreviewToken"], 37 | ]) 38 | 39 | # For more information about all available options run 40 | # fastlane action snapshot 41 | 42 | concurrent_simulators(false) 43 | 44 | # only_testing(["Screenshots"]) 45 | # testplan("Screenshots.xctestplan") 46 | -------------------------------------------------------------------------------- /fastlane/metadata/copyright.txt: -------------------------------------------------------------------------------- 1 | 2024 Paul Gessinger 2 | -------------------------------------------------------------------------------- /fastlane/metadata/de-DE/description.txt: -------------------------------------------------------------------------------- 1 | SwiftPaperless ist eine native iOS-App, die mit der beliebten Dokumentenmanagement-Software Paperless-ngx interagiert. Paperless-ngx wird selbst gehostet und ist daher vollständig datenschutzkonform: Du hast die Kontrolle über Deine Daten! 2 | 3 | Mit SwiftPaperless ist es einfach, Dokumente von Deinem Telefon aus zu finden, zu kategorisieren und auch hinzuzufügen! 4 | -------------------------------------------------------------------------------- /fastlane/metadata/de-DE/keywords.txt: -------------------------------------------------------------------------------- 1 | Dokumentenmanagement, selbst gehostet 2 | -------------------------------------------------------------------------------- /fastlane/metadata/de-DE/subtitle.txt: -------------------------------------------------------------------------------- 1 | Native Dokumentenverwaltung 2 | -------------------------------------------------------------------------------- /fastlane/metadata/default/description.txt: -------------------------------------------------------------------------------- 1 | SwiftPaperless is a native iOS app that interacts with the popular Paperless-ngx document management software. Paperless-ngx is self-hosted, and therefore fully privacy preserving: you control your data! 2 | 3 | With SwiftPaperless, it's easy to find, categorize and also add documents from your phone! 4 | -------------------------------------------------------------------------------- /fastlane/metadata/default/keywords.txt: -------------------------------------------------------------------------------- 1 | document management,self hosted 2 | -------------------------------------------------------------------------------- /fastlane/metadata/default/marketing_url.txt: -------------------------------------------------------------------------------- 1 | https://swift-paperless.gessinger.dev/ 2 | -------------------------------------------------------------------------------- /fastlane/metadata/default/name.txt: -------------------------------------------------------------------------------- 1 | Swift Paperless 2 | -------------------------------------------------------------------------------- /fastlane/metadata/default/privacy_url.txt: -------------------------------------------------------------------------------- 1 | https://swift-paperless.gessinger.dev/privacy/ 2 | -------------------------------------------------------------------------------- /fastlane/metadata/default/release_notes.txt: -------------------------------------------------------------------------------- 1 | - Fix background color of splash screen 2 | - Increase debounce delay in ASN type search to fix concurrency issue 3 | - Allow uploading documents with the title as their filename 4 | - Yet another round of decoding error debug outputs 5 | - Display objects (correspondents, document types etc) sorted by name case-insensitively 6 | - Additional debug output for decoding error debugging 7 | - Display loading screen also on initial app launch 8 | - Fix after change to tasks API 9 | - Support sorting by page count 10 | - Fix error when sort field has unexpected value in saved view 11 | - Fix error logging out of the last server connection 12 | -------------------------------------------------------------------------------- /fastlane/metadata/default/subtitle.txt: -------------------------------------------------------------------------------- 1 | Native document managament 2 | -------------------------------------------------------------------------------- /fastlane/metadata/default/support_url.txt: -------------------------------------------------------------------------------- 1 | https://github.com/paulgessinger/swift-paperless 2 | -------------------------------------------------------------------------------- /fastlane/metadata/fr-FR/description.txt: -------------------------------------------------------------------------------- 1 | SwiftPaperless est une application iOS native qui interagit avec le célèbre logiciel de gestion documentaire Paperless-ngx. Paperless-ngx est auto-hébergé et préserve donc totalement la vie privée : vous contrôlez vos données ! 2 | 3 | Avec SwiftPaperless, il est facile de trouver, classer et ajouter des documents depuis votre téléphone ! 4 | -------------------------------------------------------------------------------- /fastlane/metadata/fr-FR/keywords.txt: -------------------------------------------------------------------------------- 1 | gestion de documents, auto-hébergement 2 | -------------------------------------------------------------------------------- /fastlane/metadata/fr-FR/subtitle.txt: -------------------------------------------------------------------------------- 1 | Gestion des documents 2 | -------------------------------------------------------------------------------- /fastlane/metadata/primary_category.txt: -------------------------------------------------------------------------------- 1 | PRODUCTIVITY 2 | -------------------------------------------------------------------------------- /fastlane/metadata/primary_first_sub_category.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/fastlane/metadata/primary_first_sub_category.txt -------------------------------------------------------------------------------- /fastlane/metadata/primary_second_sub_category.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/fastlane/metadata/primary_second_sub_category.txt -------------------------------------------------------------------------------- /fastlane/metadata/secondary_category.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/fastlane/metadata/secondary_category.txt -------------------------------------------------------------------------------- /fastlane/metadata/secondary_first_sub_category.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/fastlane/metadata/secondary_first_sub_category.txt -------------------------------------------------------------------------------- /fastlane/metadata/secondary_second_sub_category.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/fastlane/metadata/secondary_second_sub_category.txt -------------------------------------------------------------------------------- /fastlane/screenshots/README.txt: -------------------------------------------------------------------------------- 1 | ## Screenshots Naming Rules 2 | 3 | Put all screenshots you want to use inside the folder of its language (e.g. `en-US`). 4 | The device type will automatically be recognized using the image resolution. 5 | 6 | The screenshots can be named whatever you want, but keep in mind they are sorted 7 | alphabetically, in a human-friendly way. See https://github.com/fastlane/fastlane/pull/18200 for more details. 8 | 9 | ### Exceptions 10 | 11 | #### iPad Pro (3rd Gen) 12.9" 12 | 13 | Since iPad Pro (3rd Gen) 12.9" and iPad Pro (2nd Gen) 12.9" have the same image 14 | resolution, screenshots of the iPad Pro (3rd gen) 12.9" must contain either the 15 | string `iPad Pro (12.9-inch) (3rd generation)`, `IPAD_PRO_3GEN_129`, or `ipadPro129` 16 | (App Store Connect's internal naming of the display family for the 3rd generation iPad Pro) 17 | in its filename to be assigned the correct display family and to be uploaded to 18 | the correct screenshot slot in your app's metadata. 19 | 20 | ### Other Platforms 21 | 22 | #### Apple TV 23 | 24 | Apple TV screenshots should be stored in a subdirectory named `appleTV` with language 25 | folders inside of it. 26 | 27 | #### iMessage 28 | 29 | iMessage screenshots, like the Apple TV ones, should also be stored in a subdirectory 30 | named `iMessage`, with language folders inside of it. 31 | -------------------------------------------------------------------------------- /logo.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/logo.afdesign -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/logo.png -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Swift Paperless 2 | site_url: https://swift-paperless.gessinger.dev 3 | 4 | theme: 5 | name: material 6 | logo: assets/logo.png 7 | favicon: assets/favicon.png 8 | palette: 9 | - media: "(prefers-color-scheme)" 10 | 11 | # Palette for light mode 12 | - media: "(prefers-color-scheme: light)" 13 | scheme: default 14 | 15 | # Palette for dark mode 16 | - media: "(prefers-color-scheme: dark)" 17 | scheme: slate 18 | 19 | 20 | repo_url: https://github.com/paulgessinger/swift-paperless 21 | 22 | plugins: 23 | - privacy 24 | - exclude: 25 | glob: 26 | - "requirements.*" 27 | - literate-nav 28 | - redirects: 29 | redirect_maps: 30 | "common-issues/forbidden.md": "common_issues/forbidden.md" 31 | "common-issues/invalid-certificate.md": "common_issues/certificates.md" 32 | "common_issues/invalid-certificate.md": "common_issues/certificates.md" 33 | "common-issues/local-network-denied.md": "common_issues/local-network-denied.md" 34 | 35 | 36 | # @TODO: Remove when https://github.com/paulgessinger/swift-paperless/pull/200 lands in App Store release 37 | "common_issues/insufficient-permissions.md": "common_issues/forbidden.md" 38 | markdown_extensions: 39 | - pymdownx.snippets: 40 | - attr_list 41 | - admonition 42 | 43 | extra_css: 44 | - extra.css 45 | 46 | nav: 47 | - index.md 48 | - Release notes: release_notes/ 49 | - Common Issues: 50 | # @TODO: Remove when v1.6.0 is live 51 | - common_issues/forbidden.md 52 | 53 | 54 | # @TODO: Make separate page when https://github.com/paulgessinger/swift-paperless/pull/200 lands in App Store release 55 | # - common_issues/insufficient-permissions.md 56 | - common_issues/local-network-denied.md 57 | - common_issues/certificates.md 58 | - common_issues/supported-versions.md 59 | - privacy.md 60 | - libraries.md 61 | -------------------------------------------------------------------------------- /notes.md: -------------------------------------------------------------------------------- 1 | # HTTP Status codes & testing checklist 2 | 3 | - 406: Insufficient API version in all cases 4 | - 400 on `/api/`: general error, could be related to mTLS 5 | - 400 on `/api/token/`: invalid credentials 6 | - 403 on `/api/token/`: can mean autologin is on, no credentials should be POST'ed. Gives CSRF error 7 | - 401 on `/api/ANY`: invalid token (!= invalid credentials) 8 | -------------------------------------------------------------------------------- /panorama.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/panorama.png -------------------------------------------------------------------------------- /privacy.md: -------------------------------------------------------------------------------- 1 | # Privacy 2 | 3 | Swift Paperless does not collect any data from users. Login data provided during setup 4 | is only ever send to the instance of [Paperless-ngx](https://github.com/paperless-ngx/paperless-ngx) 5 | given in the URL field. 6 | Documents shared with the app via the share sheet extensions are only ever uploaded 7 | to the configured instance. No data is uploaded or recorded anywhere else. 8 | Users are responsible for setting up and maintaining their instance, 9 | or trusting the operator of an instance they are using. 10 | -------------------------------------------------------------------------------- /scripts/requirements.in: -------------------------------------------------------------------------------- 1 | Pillow 2 | pyyaml 3 | pydantic 4 | rich 5 | typer 6 | requests 7 | python-dotenv 8 | dirtyjson 9 | jinja2 10 | numpy 11 | -------------------------------------------------------------------------------- /scripts/requirements.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile scripts/requirements.in -o scripts/requirements.txt 3 | annotated-types==0.7.0 4 | # via pydantic 5 | certifi==2024.2.2 6 | # via requests 7 | charset-normalizer==3.3.2 8 | # via requests 9 | click==8.1.7 10 | # via typer 11 | dirtyjson==1.0.8 12 | # via -r scripts/requirements.in 13 | idna==3.7 14 | # via requests 15 | jinja2==3.1.4 16 | # via -r scripts/requirements.in 17 | markdown-it-py==3.0.0 18 | # via rich 19 | markupsafe==2.1.5 20 | # via jinja2 21 | mdurl==0.1.2 22 | # via markdown-it-py 23 | numpy==1.26.4 24 | # via -r scripts/requirements.in 25 | pillow==10.3.0 26 | # via -r scripts/requirements.in 27 | pydantic==2.7.1 28 | # via -r scripts/requirements.in 29 | pydantic-core==2.18.2 30 | # via pydantic 31 | pygments==2.18.0 32 | # via rich 33 | python-dotenv==1.0.1 34 | # via -r scripts/requirements.in 35 | pyyaml==6.0.1 36 | # via -r scripts/requirements.in 37 | requests==2.32.2 38 | # via -r scripts/requirements.in 39 | rich==13.7.1 40 | # via 41 | # -r scripts/requirements.in 42 | # typer 43 | shellingham==1.5.4 44 | # via typer 45 | typer==0.12.3 46 | # via -r scripts/requirements.in 47 | typing-extensions==4.11.0 48 | # via 49 | # pydantic 50 | # pydantic-core 51 | # typer 52 | urllib3==2.2.1 53 | # via requests 54 | -------------------------------------------------------------------------------- /scripts/string_catalog.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import pydantic 3 | from typing import IO, cast 4 | import io 5 | 6 | 7 | class StringUnit(pydantic.BaseModel): 8 | state: str 9 | value: str 10 | 11 | 12 | class LocalizationItem(pydantic.BaseModel): 13 | string_unit: StringUnit | None = pydantic.Field(alias="stringUnit") 14 | 15 | 16 | class StringCatalogString(pydantic.BaseModel): 17 | extraction_state: str = pydantic.Field(alias="extractionState") 18 | localizations: dict[str, LocalizationItem] 19 | 20 | 21 | class StringCatalog(pydantic.BaseModel): 22 | source_language: str = pydantic.Field(alias="sourceLanguage") 23 | strings: dict[str, StringCatalogString] 24 | 25 | def as_dict(self) -> dict[str, dict[str, str]]: 26 | return { 27 | key: { 28 | lang: item.string_unit.value 29 | for lang, item in value.localizations.items() 30 | if item.string_unit is not None 31 | } 32 | for key, value in self.strings.items() 33 | } 34 | 35 | 36 | def load(data: str | bytes) -> StringCatalog: 37 | return StringCatalog.model_validate_json(data) 38 | -------------------------------------------------------------------------------- /scripts/xcstrings_to_csv.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from pathlib import Path 4 | import json 5 | import sys 6 | import csv 7 | 8 | files = sys.argv[1:] 9 | 10 | 11 | with open("output.csv", "w") as csvfile: 12 | writer = csv.writer(csvfile, delimiter=",") 13 | writer.writerow(["key", "en", "pl"]) 14 | for file in files: 15 | with open(file, "r") as f: 16 | data = json.load(f)["strings"] 17 | for key, translations in data.items(): 18 | if "pl" not in translations["localizations"]: 19 | continue 20 | if "stringUnit" not in translations["localizations"]["en"]: 21 | continue 22 | en, pl = [ 23 | translations["localizations"][lang]["stringUnit"]["value"] 24 | for lang in ("en", "pl") 25 | ] 26 | writer.writerow((key, en, pl)) 27 | # for lang, translation in translations["localizations"].items(): 28 | # if "stringUnit" not in translation or lang not in ("en", "pl"): 29 | # continue 30 | # print(lang, translation["stringUnit"]["value"]) 31 | -------------------------------------------------------------------------------- /swift-paperless.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /swift-paperless.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /swift-paperless.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /swift-paperless.xcodeproj/project.xcworkspace/xcuserdata/pagessin.xcuserdatad/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BuildLocationStyle 6 | UseAppPreferences 7 | CustomBuildLocationType 8 | RelativeToDerivedData 9 | DerivedDataLocationStyle 10 | Default 11 | ShowSharedSchemesAutomaticallyEnabled 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/AccentColorDarkened.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "26", 9 | "green" : "68", 10 | "red" : "19" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "34", 27 | "green" : "90", 28 | "red" : "25" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/App Icons/AppIcon-Preview.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "preview.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/App Icons/AppIcon-Preview.imageset/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/swift-paperless/Assets.xcassets/App Icons/AppIcon-Preview.imageset/preview.png -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/App Icons/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "light.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | }, 9 | { 10 | "appearances" : [ 11 | { 12 | "appearance" : "luminosity", 13 | "value" : "dark" 14 | } 15 | ], 16 | "filename" : "dark.png", 17 | "idiom" : "universal", 18 | "platform" : "ios", 19 | "size" : "1024x1024" 20 | }, 21 | { 22 | "appearances" : [ 23 | { 24 | "appearance" : "luminosity", 25 | "value" : "tinted" 26 | } 27 | ], 28 | "filename" : "tint.png", 29 | "idiom" : "universal", 30 | "platform" : "ios", 31 | "size" : "1024x1024" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/App Icons/AppIcon.appiconset/dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/swift-paperless/Assets.xcassets/App Icons/AppIcon.appiconset/dark.png -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/App Icons/AppIcon.appiconset/light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/swift-paperless/Assets.xcassets/App Icons/AppIcon.appiconset/light.png -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/App Icons/AppIcon.appiconset/tint.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/swift-paperless/Assets.xcassets/App Icons/AppIcon.appiconset/tint.png -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/App Icons/AppIconVar0-Preview.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "preview.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/App Icons/AppIconVar0-Preview.imageset/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/swift-paperless/Assets.xcassets/App Icons/AppIconVar0-Preview.imageset/preview.png -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/App Icons/AppIconVar0.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "light.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | }, 9 | { 10 | "appearances" : [ 11 | { 12 | "appearance" : "luminosity", 13 | "value" : "dark" 14 | } 15 | ], 16 | "filename" : "dark.png", 17 | "idiom" : "universal", 18 | "platform" : "ios", 19 | "size" : "1024x1024" 20 | }, 21 | { 22 | "appearances" : [ 23 | { 24 | "appearance" : "luminosity", 25 | "value" : "tinted" 26 | } 27 | ], 28 | "filename" : "tint.png", 29 | "idiom" : "universal", 30 | "platform" : "ios", 31 | "size" : "1024x1024" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/App Icons/AppIconVar0.appiconset/dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/swift-paperless/Assets.xcassets/App Icons/AppIconVar0.appiconset/dark.png -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/App Icons/AppIconVar0.appiconset/light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/swift-paperless/Assets.xcassets/App Icons/AppIconVar0.appiconset/light.png -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/App Icons/AppIconVar0.appiconset/tint.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/swift-paperless/Assets.xcassets/App Icons/AppIconVar0.appiconset/tint.png -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/App Icons/AppIconVar1-Preview.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "preview.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/App Icons/AppIconVar1-Preview.imageset/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/swift-paperless/Assets.xcassets/App Icons/AppIconVar1-Preview.imageset/preview.png -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/App Icons/AppIconVar1.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "light.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | }, 9 | { 10 | "appearances" : [ 11 | { 12 | "appearance" : "luminosity", 13 | "value" : "dark" 14 | } 15 | ], 16 | "filename" : "dark.png", 17 | "idiom" : "universal", 18 | "platform" : "ios", 19 | "size" : "1024x1024" 20 | }, 21 | { 22 | "appearances" : [ 23 | { 24 | "appearance" : "luminosity", 25 | "value" : "tinted" 26 | } 27 | ], 28 | "filename" : "tint.png", 29 | "idiom" : "universal", 30 | "platform" : "ios", 31 | "size" : "1024x1024" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/App Icons/AppIconVar1.appiconset/dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/swift-paperless/Assets.xcassets/App Icons/AppIconVar1.appiconset/dark.png -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/App Icons/AppIconVar1.appiconset/light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/swift-paperless/Assets.xcassets/App Icons/AppIconVar1.appiconset/light.png -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/App Icons/AppIconVar1.appiconset/tint.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/swift-paperless/Assets.xcassets/App Icons/AppIconVar1.appiconset/tint.png -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/App Icons/AppIconVar2-Preview.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "preview.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/App Icons/AppIconVar2-Preview.imageset/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/swift-paperless/Assets.xcassets/App Icons/AppIconVar2-Preview.imageset/preview.png -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/App Icons/AppIconVar2.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "light.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | }, 9 | { 10 | "appearances" : [ 11 | { 12 | "appearance" : "luminosity", 13 | "value" : "dark" 14 | } 15 | ], 16 | "filename" : "dark.png", 17 | "idiom" : "universal", 18 | "platform" : "ios", 19 | "size" : "1024x1024" 20 | }, 21 | { 22 | "appearances" : [ 23 | { 24 | "appearance" : "luminosity", 25 | "value" : "tinted" 26 | } 27 | ], 28 | "filename" : "tint.png", 29 | "idiom" : "universal", 30 | "platform" : "ios", 31 | "size" : "1024x1024" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/App Icons/AppIconVar2.appiconset/dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/swift-paperless/Assets.xcassets/App Icons/AppIconVar2.appiconset/dark.png -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/App Icons/AppIconVar2.appiconset/light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/swift-paperless/Assets.xcassets/App Icons/AppIconVar2.appiconset/light.png -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/App Icons/AppIconVar2.appiconset/tint.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/swift-paperless/Assets.xcassets/App Icons/AppIconVar2.appiconset/tint.png -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/App Icons/AppLogoTransparent.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "var3_transparent@1x.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "appearances" : [ 10 | { 11 | "appearance" : "luminosity", 12 | "value" : "dark" 13 | } 14 | ], 15 | "filename" : "var3_transparent_dark@1x.png", 16 | "idiom" : "universal", 17 | "scale" : "1x" 18 | }, 19 | { 20 | "filename" : "var3_transparent@2x.png", 21 | "idiom" : "universal", 22 | "scale" : "2x" 23 | }, 24 | { 25 | "appearances" : [ 26 | { 27 | "appearance" : "luminosity", 28 | "value" : "dark" 29 | } 30 | ], 31 | "filename" : "var3_transparent_dark@2x.png", 32 | "idiom" : "universal", 33 | "scale" : "2x" 34 | }, 35 | { 36 | "filename" : "var3_transparent@3x.png", 37 | "idiom" : "universal", 38 | "scale" : "3x" 39 | }, 40 | { 41 | "appearances" : [ 42 | { 43 | "appearance" : "luminosity", 44 | "value" : "dark" 45 | } 46 | ], 47 | "filename" : "var3_transparent_dark@3x.png", 48 | "idiom" : "universal", 49 | "scale" : "3x" 50 | } 51 | ], 52 | "info" : { 53 | "author" : "xcode", 54 | "version" : 1 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/App Icons/AppLogoTransparent.imageset/var3_transparent@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/swift-paperless/Assets.xcassets/App Icons/AppLogoTransparent.imageset/var3_transparent@1x.png -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/App Icons/AppLogoTransparent.imageset/var3_transparent@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/swift-paperless/Assets.xcassets/App Icons/AppLogoTransparent.imageset/var3_transparent@2x.png -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/App Icons/AppLogoTransparent.imageset/var3_transparent@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/swift-paperless/Assets.xcassets/App Icons/AppLogoTransparent.imageset/var3_transparent@3x.png -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/App Icons/AppLogoTransparent.imageset/var3_transparent_dark@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/swift-paperless/Assets.xcassets/App Icons/AppLogoTransparent.imageset/var3_transparent_dark@1x.png -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/App Icons/AppLogoTransparent.imageset/var3_transparent_dark@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/swift-paperless/Assets.xcassets/App Icons/AppLogoTransparent.imageset/var3_transparent_dark@2x.png -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/App Icons/AppLogoTransparent.imageset/var3_transparent_dark@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/swift-paperless/Assets.xcassets/App Icons/AppLogoTransparent.imageset/var3_transparent_dark@3x.png -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/App Icons/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/Divider.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "platform" : "ios", 6 | "reference" : "systemFillColor" 7 | }, 8 | "idiom" : "universal" 9 | } 10 | ], 11 | "info" : { 12 | "author" : "xcode", 13 | "version" : 1 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/ElementBorder.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "platform" : "ios", 6 | "reference" : "systemGray3Color" 7 | }, 8 | "idiom" : "universal" 9 | }, 10 | { 11 | "appearances" : [ 12 | { 13 | "appearance" : "luminosity", 14 | "value" : "dark" 15 | } 16 | ], 17 | "color" : { 18 | "platform" : "ios", 19 | "reference" : "systemGray4Color" 20 | }, 21 | "idiom" : "universal" 22 | } 23 | ], 24 | "info" : { 25 | "author" : "xcode", 26 | "version" : 1 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/ErrorColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.330", 9 | "green" : "0.389", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.330", 27 | "green" : "0.389", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/ImageShadow.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "platform" : "ios", 6 | "reference" : "systemGray4Color" 7 | }, 8 | "idiom" : "universal" 9 | }, 10 | { 11 | "appearances" : [ 12 | { 13 | "appearance" : "luminosity", 14 | "value" : "dark" 15 | } 16 | ], 17 | "color" : { 18 | "platform" : "ios", 19 | "reference" : "systemBackgroundColor" 20 | }, 21 | "idiom" : "universal" 22 | } 23 | ], 24 | "info" : { 25 | "author" : "xcode", 26 | "version" : 1 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/OnBackgroundText.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "platform" : "universal", 6 | "reference" : "labelColor" 7 | }, 8 | "idiom" : "universal" 9 | }, 10 | { 11 | "appearances" : [ 12 | { 13 | "appearance" : "luminosity", 14 | "value" : "dark" 15 | } 16 | ], 17 | "color" : { 18 | "platform" : "universal", 19 | "reference" : "labelColor" 20 | }, 21 | "idiom" : "universal" 22 | } 23 | ], 24 | "info" : { 25 | "author" : "xcode", 26 | "version" : 1 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/Palette/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x1F", 9 | "green" : "0x54", 10 | "red" : "0x17" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0x36", 27 | "green" : "0x8F", 28 | "red" : "0x27" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/Palette/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/Palette/paletteBlue.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xA4", 9 | "green" : "0x81", 10 | "red" : "0x42" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/Palette/paletteCoolGray.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xA8", 9 | "green" : "0x90", 10 | "red" : "0x95" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/Palette/paletteRed.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x3A", 9 | "green" : "0x49", 10 | "red" : "0xDC" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /swift-paperless/Assets.xcassets/Palette/paletteYellow.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x7C", 9 | "green" : "0xBE", 10 | "red" : "0xEA" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /swift-paperless/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ITSAppUsesNonExemptEncryption 6 | 7 | LSMinimumSystemVersion 8 | 14.0 9 | NSAppTransportSecurity 10 | 11 | NSAllowsArbitraryLoads 12 | 13 | 14 | UIApplicationSceneManifest 15 | 16 | UIApplicationSupportsMultipleScenes 17 | 18 | UISceneConfigurations 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /swift-paperless/Model/Localization/MatchingAlgorithm+title+label.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MatchingAlgorithm+title+label.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 21.12.24. 6 | // 7 | 8 | import DataModel 9 | 10 | public extension MatchingAlgorithm { 11 | var title: String { 12 | switch self { 13 | case .none: 14 | String(localized: .matching(.algorithmNone)) 15 | case .any: 16 | String(localized: .matching(.algorithmAny)) 17 | case .all: 18 | String(localized: .matching(.algorithmAll)) 19 | case .literal: 20 | String(localized: .matching(.algorithmExact)) 21 | case .regex: 22 | String(localized: .matching(.algorithmRegEx)) 23 | case .fuzzy: 24 | String(localized: .matching(.algorithmFuzzy)) 25 | case .auto: 26 | String(localized: .matching(.algorithmAuto)) 27 | } 28 | } 29 | 30 | var label: String { 31 | switch self { 32 | case .none: 33 | String(localized: .matching(.explanationNone)) 34 | case .any: 35 | String(localized: .matching(.explanationAny)) 36 | case .all: 37 | String(localized: .matching(.explanationAny)) 38 | case .literal: 39 | String(localized: .matching(.explanationExact)) 40 | case .regex: 41 | String(localized: .matching(.explanationRegEx)) 42 | case .fuzzy: 43 | String(localized: .matching(.explanationFuzzy)) 44 | case .auto: 45 | String(localized: .matching(.explanationAuto)) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /swift-paperless/Model/Localization/TaskModel+localizedResult.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TaskModel+localizedResult.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 21.12.24. 6 | // 7 | 8 | import DataModel 9 | 10 | extension PaperlessTask { 11 | var localizedResult: String? { 12 | guard let result else { 13 | return nil 14 | } 15 | 16 | // @TODO: More sophisticated parsing of errors 17 | let fileName = taskFileName ?? String(localized: .tasks(.unknownFileName)) 18 | 19 | let duplicatePattern = /(.*): Not consuming (.*): It is a duplicate of (.*) \(#(\d*)\)/ 20 | 21 | if (try? duplicatePattern.wholeMatch(in: result)) != nil { 22 | return String(localized: .tasks(.errorDuplicate(fileName))) 23 | } 24 | 25 | return String(stringLiteral: result) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /swift-paperless/Networking/DeriveUrl.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import os 3 | 4 | func deriveUrl(string value: String, suffix: String = "") throws(UrlError) -> (base: URL, resolved: URL) { 5 | let url: URL? 6 | 7 | let pattern = /(\w+):\/\/(.*)/ 8 | 9 | if let matches = try? pattern.wholeMatch(in: value) { 10 | let scheme = matches.1 11 | let rest = matches.2 12 | if scheme != "http", scheme != "https" { 13 | Logger.shared.error("Encountered invalid scheme \(scheme)") 14 | throw .invalidScheme(String(scheme)) 15 | } 16 | url = URL(string: "\(scheme)://\(rest)") 17 | } else { 18 | url = URL(string: "https://\(value)") 19 | } 20 | 21 | guard let url, var url = URL(string: url.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))) else { 22 | Logger.shared.notice("Derived URL \(value) was invalid") 23 | throw .other 24 | } 25 | 26 | let base = url 27 | 28 | guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { 29 | Logger.shared.notice("Could not parse URL \(url) into components") 30 | throw .cannotSplit 31 | } 32 | 33 | guard let host = components.host, !host.isEmpty else { 34 | Logger.shared.error("URL \(url) had empty host") 35 | throw .emptyHost 36 | } 37 | 38 | assert(components.scheme != nil) 39 | 40 | url = url.appending(component: "api", directoryHint: .isDirectory) 41 | if !suffix.isEmpty { 42 | url = url.appending(component: suffix, directoryHint: .isDirectory) 43 | } 44 | 45 | Logger.shared.notice("Derive URL: \(value) + \(suffix) -> \(url)") 46 | 47 | return (base, url) 48 | } 49 | -------------------------------------------------------------------------------- /swift-paperless/Networking/Error/DecodingErrorWithRootType+DisplayableError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DecodingErrorWithRootType+DisplayableError.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 09.03.25. 6 | // 7 | 8 | import Networking 9 | 10 | extension DecodingErrorWithRootType: DisplayableError { 11 | var message: String { 12 | error.message 13 | } 14 | 15 | var details: String? { 16 | error.makeDetails("\(type)") 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /swift-paperless/Networking/Error/DocumentCreateError+DisplayableError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DocumentCreateError+DisplayableError.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 09.03.25. 6 | // 7 | import Networking 8 | 9 | extension DocumentCreateError: DisplayableError { 10 | var message: String { 11 | switch self { 12 | case .tooLarge: 13 | String(localized: .localizable(.documentCreateFailedTooLarge)) 14 | } 15 | } 16 | 17 | var details: String? { 18 | switch self { 19 | case .tooLarge: 20 | String(localized: .localizable(.documentCreateFailedTooLargeDetails)) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /swift-paperless/Networking/Error/DocumentedError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DocumentedError.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 14.12.2024. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol DocumentedError { 11 | var documentationLink: URL? { get } 12 | } 13 | -------------------------------------------------------------------------------- /swift-paperless/Networking/Error/LoginError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginError.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 03.08.2024. 6 | // 7 | 8 | import Foundation 9 | import Networking 10 | 11 | private func string(for error: any Error) -> String { 12 | (error as? any LocalizedError)?.errorDescription ?? error.localizedDescription 13 | } 14 | 15 | enum LoginError: DisplayableError, Equatable { 16 | case invalidUrl(_: UrlError) 17 | 18 | case invalidLogin(detail: String? = nil) 19 | 20 | case otpRequired 21 | 22 | case invalidToken 23 | 24 | case other(_: String) 25 | 26 | case request(_: RequestError) 27 | 28 | init(other error: any Error) { self = .other(string(for: error)) } 29 | 30 | init(certificate error: any Error) { self = .request(.certificate(detail: string(for: error))) } 31 | } 32 | 33 | extension LoginError { 34 | var message: String { 35 | String(localized: .login(.errorMessage)) 36 | } 37 | 38 | var details: String? { 39 | switch self { 40 | case let .invalidUrl(error): 41 | var msg = String(localized: .login(.errorUrlInvalid)) 42 | if let desc = error.errorDescription { 43 | msg += ": \(desc)" 44 | } 45 | return msg 46 | case .invalidLogin: 47 | return String(localized: .login(.errorLoginInvalid)) 48 | case .invalidToken: 49 | return String(localized: .login(.errorMessage)) 50 | case let .other(error): 51 | return error 52 | case let .request(error): 53 | return error.details 54 | case .otpRequired: 55 | return String(localized: .login(.otpDescription)) 56 | } 57 | } 58 | 59 | var documentationLink: URL? { 60 | switch self { 61 | case let .request(error): 62 | error.documentationLink 63 | default: 64 | nil 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /swift-paperless/Networking/Error/PresentableError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PresentableError.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 14.12.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | protocol PresentableError { 11 | associatedtype PresentationView: View 12 | 13 | var presentation: PresentationView { get } 14 | } 15 | -------------------------------------------------------------------------------- /swift-paperless/Networking/Error/ResourceForbidden+DisplayableError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ResourceForbidden+DisplayableError.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 09.03.25. 6 | // 7 | 8 | import DataModel 9 | import Foundation 10 | import Networking 11 | 12 | extension ResourceForbidden: DisplayableError where Resource: Model & NamedLocalized { 13 | var message: String { 14 | String(localized: .localizable(.apiForbiddenErrorMessage(Resource.localizedName))) 15 | } 16 | 17 | var details: String? { 18 | var msg = String(localized: .localizable(.apiForbiddenDetails(Resource.localizedName))) 19 | if let response { 20 | msg += "\n\n\(response)" 21 | } 22 | return msg 23 | } 24 | } 25 | 26 | extension ResourceForbidden: DocumentedError { 27 | var documentationLink: URL? { DocumentationLinks.forbidden } 28 | } 29 | -------------------------------------------------------------------------------- /swift-paperless/Networking/IdentityManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IdentityManager.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 18.08.2024. 6 | // 7 | 8 | import Common 9 | import Networking 10 | import os 11 | import SwiftUI 12 | 13 | @Observable 14 | class IdentityManager { 15 | var identities: [TLSIdentity] 16 | 17 | init() { 18 | do { 19 | identities = try Keychain.readAllIdentities().map { identity, name in 20 | TLSIdentity(name: name, identity: identity) 21 | } 22 | } catch { 23 | Logger.shared.error("Unable to load keychain identities") 24 | identities = [] 25 | } 26 | } 27 | 28 | static func validate(certificate data: Data, password: String) -> Bool { 29 | do { 30 | let _ = try PKCS12(pkcs12Data: data, password: password) 31 | return true 32 | } catch { 33 | Logger.shared.error("PKCS12 invalid: \(error)") 34 | } 35 | return false 36 | } 37 | 38 | func save(certificate data: Data, password: String, name: String) throws { 39 | do { 40 | let pkc = try PKCS12(pkcs12Data: data, password: password) 41 | try Keychain.saveIdentity(identity: pkc.identity, name: name) 42 | identities.append(TLSIdentity(name: name, identity: pkc.identity)) 43 | } catch { 44 | Logger.shared.error("Error loading/saving identity to the keychain: \(error)") 45 | throw error 46 | } 47 | } 48 | 49 | func delete(name: String) throws { 50 | try Keychain.deleteIdentity(name: name) 51 | identities = identities.filter { $0.name != name } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /swift-paperless/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /swift-paperless/Preview Content/demo.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/swift-paperless/Preview Content/demo.pdf -------------------------------------------------------------------------------- /swift-paperless/Preview Content/demo2.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/swift-paperless/Preview Content/demo2.pdf -------------------------------------------------------------------------------- /swift-paperless/Preview Content/demo3.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgessinger/swift-paperless/27631a9079984f34b844290f170a627f71ee207f/swift-paperless/Preview Content/demo3.pdf -------------------------------------------------------------------------------- /swift-paperless/PrivacyInfo.xcprivacy: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSPrivacyAccessedAPITypes 6 | 7 | 8 | NSPrivacyAccessedAPIType 9 | NSPrivacyAccessedAPICategoryUserDefaults 10 | NSPrivacyAccessedAPITypeReasons 11 | 12 | 1C8F.1 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /swift-paperless/Utilities/Binding.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Binding.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 24.04.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension Binding { 11 | static func present(_ base: Binding<(some Sendable)?>) -> Binding where Value == Bool { 12 | .init(get: { base.wrappedValue != nil }, set: { if !$0 { base.wrappedValue = nil } }) 13 | } 14 | 15 | init(present base: Binding<(some Sendable)?>) where Value == Bool { 16 | self.init(get: { base.wrappedValue != nil }, set: { if !$0 { base.wrappedValue = nil } }) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /swift-paperless/Utilities/DebounceObject.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DebounceObject.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 22.09.2024. 6 | // 7 | 8 | import Combine 9 | import Foundation 10 | 11 | class DebounceObject: ObservableObject { 12 | @Published var text: String = "" 13 | @Published var debouncedText: String = "" 14 | private var tasks = Set() 15 | 16 | init(value: String = "", delay: TimeInterval = 0.5) { 17 | text = value 18 | $text 19 | .removeDuplicates() 20 | .debounce(for: .seconds(delay), scheduler: DispatchQueue.main) 21 | .sink(receiveValue: { [weak self] value in 22 | self?.debouncedText = value 23 | }) 24 | .store(in: &tasks) 25 | } 26 | } 27 | 28 | @MainActor 29 | final class ThrottleObject: ObservableObject, Sendable { 30 | @Published var value: T 31 | @Published var throttledValue: T 32 | 33 | var publisher = PassthroughSubject() 34 | private var tasks = Set() 35 | 36 | init(value: T, delay: TimeInterval = 0.5) { 37 | self.value = value 38 | throttledValue = value 39 | $value 40 | .throttle(for: .seconds(delay), scheduler: DispatchQueue.main, latest: true) 41 | .sink(receiveValue: { [weak self] value in 42 | DispatchQueue.main.async { self?.throttledValue = value } 43 | self?.publisher.send(value) 44 | }) 45 | .store(in: &tasks) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /swift-paperless/Utilities/DecodingError+DisplayableError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DecodingError+DisplayableError.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 30.11.2024. 6 | // 7 | 8 | extension DecodingError: DisplayableError { 9 | var message: String { 10 | String(localized: .localizable(.decodingError)) 11 | } 12 | 13 | var details: String? { 14 | makeDetails(nil) 15 | } 16 | 17 | func makeDetails(_ type: String?) -> String? { 18 | let context: DecodingError.Context? = switch self { 19 | case let .typeMismatch(_, context), 20 | let .valueNotFound(_, context), 21 | let .keyNotFound(_, context), 22 | let .dataCorrupted(context): 23 | context 24 | default: 25 | nil 26 | } 27 | 28 | let msg = String(localized: .localizable(.decodingErrorDetail(type ?? "Unknown"))) 29 | 30 | guard let context else { 31 | return msg 32 | } 33 | 34 | return "\(msg)\n\n\(context.debugDescription)" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /swift-paperless/Utilities/DocumentationLinks.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DocumentationLinks.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 27.04.2024. 6 | // 7 | 8 | import Foundation 9 | 10 | struct DocumentationLinks { 11 | private init() {} 12 | 13 | static let baseUrl = URL(string: "https://swift-paperless.gessinger.dev")! 14 | 15 | static let localNetworkDenied = Self.baseUrl.appending(path: "common_issues/local-network-denied") 16 | 17 | static let forbidden = Self.baseUrl.appending(path: "common_issues/forbidden") 18 | 19 | static let insufficientPermissions = Self.baseUrl.appending(path: "common_issues/insufficient-permissions") 20 | 21 | static let certificate = Self.baseUrl.appending(path: "common_issues/certificates") 22 | 23 | static let supportedVersions = Self.baseUrl.appending(path: "common_issues/supported-versions") 24 | } 25 | -------------------------------------------------------------------------------- /swift-paperless/Utilities/Gather.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Gather.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 22.02.23. 6 | // 7 | 8 | import Foundation 9 | 10 | func gather(_ functions: (@Sendable () async -> Return)...) async -> [Return] { 11 | await gather(functions) 12 | } 13 | 14 | func gather(_ functions: (@Sendable () async -> Void)...) async { 15 | await gather(functions) 16 | } 17 | 18 | func gather(_ functions: [@Sendable () async -> Return]) async -> [Return] { 19 | await withTaskGroup(of: Return.self, returning: [Return].self) { g in 20 | var result: [Return] = [] 21 | for fn in functions { 22 | g.addTask { await fn() } 23 | } 24 | for await r in g { 25 | result.append(r) 26 | } 27 | return result 28 | } 29 | } 30 | 31 | func gather(_ functions: [@Sendable () async -> Void]) async { 32 | await withTaskGroup(of: Void.self) { g in 33 | for fn in functions { 34 | g.addTask { await fn() } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /swift-paperless/Utilities/Label+init.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Label+init.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 06.05.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension Label where Icon == Image, Title == Text { 11 | init(localized: LocalizedStringResource, systemImage: String) { 12 | self.init(String(localized: localized), systemImage: systemImage) 13 | } 14 | 15 | init(markdown: LocalizedStringResource, systemImage: String) { 16 | self.init(title: { Text(markdown) }, icon: { Image(systemName: systemImage) }) 17 | } 18 | 19 | init(localized: LocalizedStringResource, image: String) { 20 | self.init(String(localized: localized), image: image) 21 | } 22 | } 23 | 24 | struct TightLabel: LabelStyle { 25 | func makeBody(configuration: Configuration) -> some View { 26 | HStack(spacing: 2) { 27 | configuration.icon 28 | configuration.title 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /swift-paperless/Utilities/Logging.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Logging.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 03.05.23. 6 | // 7 | 8 | import Foundation 9 | import os 10 | 11 | extension Logger { 12 | static let shared = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "General") 13 | static let api = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "API") 14 | static let migration = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Migration") 15 | static let biometric = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Biometric") 16 | } 17 | -------------------------------------------------------------------------------- /swift-paperless/Utilities/NSData+mimeType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSData+mimeType.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 30.04.2024. 6 | // 7 | 8 | import Foundation 9 | import UniformTypeIdentifiers 10 | 11 | extension NSData { 12 | var mimeType: String { 13 | let c: UInt8 = self[0] 14 | return switch c { 15 | case 0xFF: 16 | "image/jpeg" 17 | case 0x89: 18 | "image/png" 19 | case 0x47: 20 | "image/gif" 21 | case 0x4D, 0x49: 22 | "image/tiff" 23 | case 0x25: 24 | "application/pdf" 25 | case 0xD0: 26 | "application/vnd" 27 | case 0x46: 28 | "text/plain" 29 | default: 30 | "application/octet-stream" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /swift-paperless/Utilities/PKCS12.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PKCS12.swift 3 | // swift-paperless 4 | // 5 | // Created by Nils Witt on 24.06.24. 6 | // 7 | 8 | import Foundation 9 | import os 10 | 11 | enum PKCS12Error: Error { 12 | case wrongPassword 13 | case noItems 14 | case other(_: OSStatus) 15 | case noIdentity 16 | } 17 | 18 | class PKCS12 { 19 | let identity: SecIdentity 20 | 21 | public init(pkcs12Data: Data, password: String) throws { 22 | let importPasswordOption: NSDictionary = [kSecImportExportPassphrase as NSString: password] 23 | var items: CFArray? 24 | let secError: OSStatus = SecPKCS12Import(pkcs12Data as NSData, importPasswordOption, &items) 25 | guard secError == errSecSuccess else { 26 | if secError == errSecAuthFailed { 27 | throw PKCS12Error.wrongPassword 28 | } 29 | throw PKCS12Error.other(secError) 30 | } 31 | guard let theItemsCFArray = items else { 32 | throw PKCS12Error.noItems 33 | } 34 | let theItemsNSArray: NSArray = theItemsCFArray as NSArray 35 | guard let dictArray 36 | = theItemsNSArray as? [[String: AnyObject]] 37 | else { 38 | throw PKCS12Error.noItems 39 | } 40 | 41 | guard let identity: SecIdentity = dictArray.element(for: kSecImportItemIdentity) else { 42 | Logger.shared.error("PKCS12 did not contain an identity") 43 | throw PKCS12Error.noIdentity 44 | } 45 | 46 | self.identity = identity 47 | } 48 | } 49 | 50 | private extension [[String: AnyObject]] { 51 | func element(for key: CFString) -> T? { 52 | for dictElement in self { 53 | if let value = dictElement[key as String] as? T { 54 | return value 55 | } 56 | } 57 | return nil 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /swift-paperless/Utilities/TextField+clearable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextField+clearable.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 22.09.2024. 6 | // 7 | import SwiftUI 8 | 9 | struct ClearableModifier: ViewModifier { 10 | @Binding var text: String 11 | @FocusState var focused: Bool 12 | @Environment(\.isEnabled) private var isEnabled 13 | 14 | func body(content: Content) -> some View { 15 | HStack { 16 | content 17 | .focused($focused) // @TODO: This is probably not ideal if I want to manage focus externally. 18 | 19 | if isEnabled { 20 | Spacer() 21 | 22 | Label(String(localized: .localizable(.clearText)), systemImage: "xmark.circle.fill") 23 | .labelStyle(.iconOnly) 24 | .foregroundColor(.gray) 25 | .onTapGesture { 26 | text = "" 27 | focused = true 28 | } 29 | .opacity(text.isEmpty ? 0 : 1) 30 | } 31 | } 32 | } 33 | } 34 | 35 | extension TextField { 36 | @MainActor 37 | func clearable(_ text: Binding) -> some View { 38 | let m = ClearableModifier(text: text) 39 | return modifier(m) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /swift-paperless/Utilities/View+alert.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+alert.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 24.04.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | private struct AlertModifier: ViewModifier { 11 | @Binding var item: Item? 12 | 13 | var title: (Binding) -> Text 14 | var actions: (Binding) -> M 15 | var message: ((Binding) -> A)? 16 | 17 | var titleText: Text { 18 | item == nil ? Text("nil") : title(Binding($item)!) 19 | } 20 | 21 | func body(content: Content) -> some View { 22 | content 23 | .alert(titleText, isPresented: .present($item), 24 | actions: { 25 | if let item = Binding($item) { 26 | actions(item) 27 | } else { 28 | EmptyView() 29 | } 30 | }, 31 | message: { 32 | if let item = Binding($item) { 33 | message?(item) 34 | } else { 35 | EmptyView() 36 | } 37 | 38 | }) 39 | } 40 | } 41 | 42 | extension View { 43 | func alert( 44 | unwrapping item: Binding, 45 | title: @escaping (Binding) -> Text, 46 | @ViewBuilder actions: @escaping (Binding) -> some View, 47 | @ViewBuilder message: @escaping (Binding) -> some View 48 | ) -> some View { 49 | modifier(AlertModifier(item: item, title: title, actions: actions, message: message)) 50 | } 51 | 52 | func alert( 53 | unwrapping item: Binding, 54 | title: @escaping (Binding) -> Text, 55 | @ViewBuilder actions: @escaping (Binding) -> some View 56 | ) -> some View { 57 | modifier(AlertModifier(item: item, title: title, actions: actions, message: { _ in EmptyView() })) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /swift-paperless/Utilities/View+if.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+if.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 22.09.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension View { 11 | /// Applies the given transform if the given condition evaluates to `true`. 12 | /// - Parameters: 13 | /// - condition: The condition to evaluate. 14 | /// - transform: The transform to apply to the source `View`. 15 | /// - Returns: Either the original `View` or the modified `View` if the condition is `true`. 16 | @ViewBuilder func `if`(_ condition: Bool, transform: (Self) -> some View) -> some View { 17 | if condition { 18 | transform(self) 19 | } else { 20 | self 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /swift-paperless/ViewModel/AttachmentManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AttachmentManager.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 12.03.23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | enum AttachmentError { 12 | case invalidAttachment 13 | case noAttachments 14 | } 15 | 16 | @MainActor 17 | class AttachmentManager: ObservableObject { 18 | @Published var isLoading = true 19 | @Published var error: AttachmentError? = nil 20 | @Published private(set) var previewImage: Image? 21 | @Published var documentUrl: URL? 22 | 23 | @Published var importUrls: [URL] = [] 24 | @Published var totalInputs: Int = 0 25 | } 26 | -------------------------------------------------------------------------------- /swift-paperless/Views/AuthAsyncImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthAsyncImage.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 18.02.23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | #if os(macOS) 11 | import Cocoa 12 | 13 | typealias UIImage = NSImage 14 | #endif 15 | 16 | struct AuthAsyncImage: View { 17 | @State var image: Image? 18 | 19 | let getImage: () async -> Image? 20 | let content: (Image) -> Content 21 | let placeholder: () -> Placeholder 22 | 23 | init( 24 | image: @escaping () async -> Image?, 25 | @ViewBuilder content: @escaping (Image) -> Content, 26 | @ViewBuilder placeholder: @escaping () -> Placeholder 27 | ) { 28 | getImage = image 29 | self.content = content 30 | self.placeholder = placeholder 31 | } 32 | 33 | var body: some View { 34 | if let image { 35 | content(image) 36 | } else { 37 | placeholder().task { 38 | let image = await getImage() 39 | withAnimation { 40 | self.image = image 41 | } 42 | } 43 | } 44 | } 45 | } 46 | 47 | // struct AuthAsyncImage_Previews: PreviewProvider { 48 | // static var previews: some View { 49 | // AuthAsyncImage() 50 | // } 51 | // } 52 | -------------------------------------------------------------------------------- /swift-paperless/Views/CancelIconButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CancelIconButton.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 27.07.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CancelIconButton: View { 11 | @Environment(\.dismiss) private var dismiss 12 | 13 | var action: (() -> Void)? = nil 14 | 15 | var body: some View { 16 | Label(.localizable(.back), systemImage: "xmark.circle.fill") 17 | .labelStyle(.iconOnly) 18 | .symbolRenderingMode(.palette) 19 | .foregroundStyle(.primary, .tertiary) 20 | .font(.title2) 21 | 22 | .onTapGesture { 23 | if let action { 24 | action() 25 | } else { 26 | dismiss() 27 | } 28 | } 29 | } 30 | } 31 | 32 | #Preview { 33 | CancelIconButton() 34 | } 35 | -------------------------------------------------------------------------------- /swift-paperless/Views/FullScreenConfirmationDialog.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FullScreenConfirmationDialog.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 21.12.24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | private struct FullScreenConfirmationDialog: ViewModifier where M: View { 11 | @Environment(\.horizontalSizeClass) private var horizontalSizeClass 12 | 13 | @Binding var isPresented: Bool 14 | 15 | var title: String 16 | @ViewBuilder var dialogContent: () -> M 17 | 18 | func body(content: Content) -> some View { 19 | if horizontalSizeClass == .compact { 20 | content 21 | .confirmationDialog(String(localized: .localizable(.confirmationPromptTitle)), isPresented: $isPresented, titleVisibility: .visible) { 22 | dialogContent() 23 | } 24 | } else { 25 | content 26 | .alert(title, isPresented: $isPresented) { 27 | dialogContent() 28 | } 29 | } 30 | } 31 | } 32 | 33 | extension View { 34 | @MainActor 35 | func fullScreenConfirmationDialog(_ title: String, isPresented: Binding, @ViewBuilder content: @escaping () -> some View) -> some View { 36 | modifier(FullScreenConfirmationDialog(isPresented: isPresented, title: title, dialogContent: content)) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /swift-paperless/Views/InactiveView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InactiveView.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 11.02.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct InactiveView: View { 11 | var body: some View { 12 | VStack {} 13 | .frame(maxWidth: .infinity, maxHeight: .infinity) 14 | .background( 15 | LinearGradient(gradient: Gradient(colors: [.accentColor, Color(.accentColorDarkened)]), startPoint: .topLeading, endPoint: .bottomTrailing) 16 | ) 17 | } 18 | } 19 | 20 | #Preview { 21 | InactiveView() 22 | } 23 | -------------------------------------------------------------------------------- /swift-paperless/Views/Login/BackgroundColorModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BackgroundColorModifier.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 04.02.25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BackgroundColorModifier: ViewModifier { 11 | @Environment(\.colorScheme) private var colorScheme 12 | 13 | func body(content: Content) -> some View { 14 | #if canImport(UIKit) 15 | content 16 | .background(Color(uiColor: .systemGroupedBackground)) 17 | #else 18 | content 19 | #endif 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /swift-paperless/Views/Login/LoginStage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginStage.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 04.02.25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | enum LoginStage: CaseIterable, Comparable { 11 | case connection 12 | case credentials 13 | 14 | var label: Text { 15 | switch self { 16 | case .connection: 17 | Text("1. ") + Text(.login(.stageConnection)) 18 | case .credentials: 19 | Text("2. ") + Text(.login(.stageCredentials)) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /swift-paperless/Views/Login/LoginView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginView.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 04.08.2024. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | @MainActor 12 | protocol LoginViewProtocol: View { 13 | init(connectionManager: ConnectionManager, initial: Bool) 14 | } 15 | 16 | struct LoginView: LoginViewProtocol { 17 | @ObservedObject var connectionManager: ConnectionManager 18 | var initial = true 19 | 20 | @ObservedObject private var appSettings = AppSettings.shared 21 | 22 | var body: some View { 23 | if appSettings.loginScreenV2 { 24 | LoginViewV2(connectionManager: connectionManager, initial: initial) 25 | } else { 26 | LoginViewV1(connectionManager: connectionManager, initial: initial) 27 | } 28 | } 29 | } 30 | 31 | struct LoginViewSwitchView: View { 32 | @ObservedObject private var appSettings = AppSettings.shared 33 | 34 | var body: some View { 35 | Toggle("Login screen v2", isOn: $appSettings.loginScreenV2) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /swift-paperless/Views/Login/StageSelectionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StageSelectionView.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 04.02.25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct StageSelection: View { 11 | @Binding var stage: LoginStage 12 | 13 | @Namespace private var animation 14 | 15 | var body: some View { 16 | HStack { 17 | ForEach(LoginStage.allCases, id: \.self) { el in 18 | if el == stage { 19 | el.label 20 | .foregroundStyle(Color.accentColor) 21 | .matchedGeometryEffect(id: el, in: animation) 22 | .padding(.bottom, 3) 23 | .background(alignment: .bottom) { 24 | RoundedRectangle(cornerRadius: 5) 25 | .fill(Color.accentColor) 26 | .frame(height: 3) 27 | .matchedGeometryEffect(id: "active", in: animation) 28 | } 29 | } else { 30 | el.label 31 | .onTapGesture { 32 | if el < stage { 33 | stage = el 34 | } 35 | } 36 | .matchedGeometryEffect(id: el, in: animation) 37 | } 38 | } 39 | } 40 | 41 | .animation(.spring(duration: 0.25, bounce: 0.25), value: stage) 42 | 43 | .padding(.vertical, 10) 44 | .padding(.horizontal) 45 | .background( 46 | Capsule() 47 | .fill(.thickMaterial) 48 | .stroke(.tertiary) 49 | .shadow(color: Color(white: 0.2, opacity: 0.1), radius: 10) 50 | ) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /swift-paperless/Views/Login/UrlError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UrlError.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 30.11.2024. 6 | // 7 | 8 | import Foundation 9 | 10 | enum UrlError: LocalizedError, Equatable { 11 | case invalidScheme(_: String) 12 | case other 13 | case cannotSplit 14 | case emptyHost 15 | 16 | var errorDescription: String? { 17 | switch self { 18 | case let .invalidScheme(scheme): 19 | "Invalid scheme: \(scheme)" 20 | case .other: "other" 21 | case .cannotSplit: "cannot split" 22 | case .emptyHost: "empty host" 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /swift-paperless/Views/LogoView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LogoView.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 25.03.23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct LogoView: View { 11 | private let logoSize: CGFloat = 64 12 | private let logoRadius: CGFloat = 5 13 | 14 | var body: some View { 15 | VStack(spacing: -15) { 16 | Image(.appLogoTransparent) 17 | Text(.localizable(.appName)) 18 | .font(.title) 19 | } 20 | } 21 | } 22 | 23 | struct LogoTitle: View { 24 | @ScaledMetric(relativeTo: .body) private var logoSize = 50.0 25 | 26 | var body: some View { 27 | HStack(spacing: 5) { 28 | Image(.appLogoTransparent) 29 | .resizable() 30 | .frame(width: logoSize, height: logoSize) 31 | Text(.localizable(.appName)) 32 | .font(.title2) 33 | } 34 | .padding(.trailing, 15) 35 | } 36 | } 37 | 38 | #Preview { 39 | NavigationStack { 40 | LogoView() 41 | .toolbar { 42 | ToolbarItem(placement: .principal) { 43 | LogoTitle() 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /swift-paperless/Views/MailView.swift: -------------------------------------------------------------------------------- 1 | #if canImport(MessageUI) 2 | import MessageUI 3 | import SwiftUI 4 | 5 | struct MailView: UIViewControllerRepresentable { 6 | typealias ResultType = Result 7 | 8 | @Binding var result: ResultType? 9 | @Binding var isPresented: Bool 10 | 11 | var prepare: ((MFMailComposeViewController) -> Void)? = nil 12 | 13 | class Coordinator: NSObject, MFMailComposeViewControllerDelegate { 14 | @Binding var result: ResultType? 15 | @Binding var isPresented: Bool 16 | 17 | init(result: Binding, isPresented: Binding) { 18 | _result = result 19 | _isPresented = isPresented 20 | } 21 | 22 | func mailComposeController(_: MFMailComposeViewController, didFinishWith _: MFMailComposeResult, error _: (any Error)?) { 23 | isPresented = false 24 | } 25 | } 26 | 27 | func makeCoordinator() -> Coordinator { 28 | Coordinator(result: $result, isPresented: $isPresented) 29 | } 30 | 31 | func makeUIViewController(context: Context) -> some UIViewController { 32 | let vc = MFMailComposeViewController() 33 | vc.mailComposeDelegate = context.coordinator 34 | if let prepare { 35 | prepare(vc) 36 | } 37 | return vc 38 | } 39 | 40 | func updateUIViewController(_: UIViewControllerType, context _: Context) {} 41 | } 42 | #endif 43 | -------------------------------------------------------------------------------- /swift-paperless/Views/MatchingEditView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MatchingEditView.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 23.04.23. 6 | // 7 | 8 | import DataModel 9 | import Foundation 10 | import SwiftUI 11 | 12 | struct MatchEditView: View where Element: MatchingModel { 13 | @Binding var element: Element 14 | 15 | // @State private var showTextField: Bool 16 | 17 | // init(element: Binding) { 18 | // self._element = element 19 | // self._showTextField = State(initialValue: element.) 20 | // } 21 | // 22 | // static private func 23 | 24 | var showTextField: Bool { 25 | switch element.matchingAlgorithm { 26 | case .auto, .none: 27 | false 28 | default: 29 | true 30 | } 31 | } 32 | 33 | var body: some View { 34 | Section { 35 | Picker(String(localized: .matching(.algorithm)), selection: $element.matchingAlgorithm) { 36 | ForEach(MatchingAlgorithm.allCases, id: \.self) { alg in 37 | Text(alg.title).tag(alg) 38 | } 39 | } 40 | } header: { 41 | Text(.matching(.title)) 42 | } footer: { 43 | Text(element.matchingAlgorithm.label) 44 | .lineLimit(3, reservesSpace: true) 45 | } 46 | 47 | Section { 48 | if showTextField { 49 | TextField(String(localized: .matching(.pattern)), text: $element.match) 50 | .clearable($element.match) 51 | Toggle(String(localized: .matching(.caseInsensitive)), isOn: $element.isInsensitive) 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /swift-paperless/Views/PDFKitView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PDFKitView.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 23.05.2024. 6 | // 7 | 8 | import Foundation 9 | import PDFKit 10 | import SwiftUI 11 | 12 | struct PDFKitView: UIViewRepresentable { 13 | let document: PDFDocument 14 | 15 | var displayMode = PDFDisplayMode.singlePageContinuous 16 | var pageShadows = true 17 | var autoScales = false 18 | var userInteraction = true 19 | var displayPageBreaks = true 20 | var pageBreakMargins = UIEdgeInsets(top: 15, left: 15, bottom: 15, right: 15) 21 | 22 | var minScaleFactor: CGFloat? 23 | var maxScaleFactor: CGFloat? 24 | var scaleFactor: CGFloat? 25 | 26 | // @Binding private(set) var aspectRatio: CGFloat! 27 | 28 | func updateUIView(_: PDFKit.PDFView, context _: Context) {} 29 | 30 | func makeUIView(context _: Context) -> PDFKit.PDFView { 31 | let view = PDFKit.PDFView() 32 | view.autoScales = autoScales 33 | view.pageShadowsEnabled = pageShadows 34 | view.displayMode = displayMode 35 | view.document = document 36 | view.pageBreakMargins = pageBreakMargins 37 | view.displaysPageBreaks = displayPageBreaks 38 | 39 | view.isUserInteractionEnabled = userInteraction 40 | 41 | view.backgroundColor = .clear 42 | view.subviews[0].backgroundColor = UIColor.clear 43 | 44 | if let minScaleFactor { 45 | view.minScaleFactor = minScaleFactor 46 | } 47 | if let maxScaleFactor { 48 | view.maxScaleFactor = maxScaleFactor 49 | } 50 | if let scaleFactor { 51 | view.scaleFactor = scaleFactor 52 | } 53 | // view.autoresizesSubviews = true 54 | // view.autoresizingMask = [.flexibleWidth, .flexibleHeight] 55 | // view.maxScaleFactor = 4.0 56 | // view.minScaleFactor = 0.1 57 | 58 | // view.setNeedsLayout() 59 | // view.layoutIfNeeded() 60 | 61 | return view 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /swift-paperless/Views/PDFView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PDFView.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 06.08.23. 6 | // 7 | 8 | import PDFKit 9 | import SwiftUI 10 | 11 | struct PDFThumbnail: View { 12 | let document: PDFDocument 13 | let aspectRatio: CGFloat 14 | 15 | let file: URL 16 | 17 | init?(file: URL) { 18 | self.file = file 19 | guard let document = PDFDocument(url: file) else { 20 | return nil 21 | } 22 | self.document = document 23 | let bounds = document.page(at: 0)?.bounds(for: .trimBox) 24 | if let bounds { 25 | aspectRatio = CGFloat(bounds.width / bounds.height) 26 | } else { 27 | aspectRatio = 1 28 | } 29 | } 30 | 31 | var body: some View { 32 | PDFKitView(document: document, 33 | displayMode: .singlePage, 34 | pageShadows: false, 35 | autoScales: true, 36 | userInteraction: false, 37 | displayPageBreaks: false, 38 | pageBreakMargins: .zero) 39 | 40 | .aspectRatio(aspectRatio, contentMode: .fill) 41 | } 42 | } 43 | 44 | struct PDFView_Previews: PreviewProvider { 45 | static var previews: some View { 46 | NavigationStack { 47 | VStack { 48 | PDFThumbnail(file: Bundle.main.url(forResource: "demo2", withExtension: "pdf")!) 49 | .frame(width: 200, height: 200, alignment: .top) 50 | .background(.green) 51 | .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) 52 | .shadow(radius: 10) 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /swift-paperless/Views/Settings/AppVersionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppVersionView.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 13.05.2024. 6 | // 7 | 8 | import Common 9 | import Foundation 10 | import SwiftUI 11 | 12 | struct AppVersionView: View { 13 | private let version: AppVersion? 14 | private let config: AppConfiguration? 15 | 16 | init(version: AppVersion? = nil, config: AppConfiguration? = nil) { 17 | self.version = version ?? AppSettings.shared.currentAppVersion 18 | self.config = config ?? Bundle.main.appConfiguration 19 | } 20 | 21 | var body: some View { 22 | if let version, let config { 23 | Form { 24 | LabeledContent { 25 | Text(version.version.description) 26 | } label: { 27 | Text(.settings(.appVersionLabel)) 28 | } 29 | 30 | LabeledContent { 31 | Text("\(version.build)") 32 | } label: { 33 | Text(.settings(.appBuildNumberLabel)) 34 | } 35 | 36 | LabeledContent { 37 | Text(config.rawValue) 38 | } label: { 39 | Text(.settings(.appConfigurationLabel)) 40 | } 41 | } 42 | .navigationTitle(String(localized: .settings(.versionInfoLabel))) 43 | .navigationBarTitleDisplayMode(.inline) 44 | } 45 | } 46 | } 47 | 48 | #Preview { 49 | VStack { 50 | AppVersionView() 51 | AppVersionView(version: AppVersion(version: "1.0.0", build: "1"), config: .AppStore) 52 | AppVersionView(version: AppVersion(version: "1.0.0", build: "1"), config: .TestFlight) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /swift-paperless/Views/Settings/DebugMenuView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DebugMenuView.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 22.09.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct DebugMenuView: View { 11 | @ObservedObject private var appSettings = AppSettings.shared 12 | 13 | @State private var show = false 14 | 15 | var body: some View { 16 | Form { 17 | Section { 18 | if let version = appSettings.currentAppVersion?.description { 19 | LabeledContent(.settings(.appVersionTitle), value: version) 20 | } else { 21 | Text(.localizable(.none)) 22 | } 23 | Button { 24 | AppSettings.shared.resetAppVersion() 25 | } label: { 26 | Text(.settings(.debugResetAppVersion)) 27 | } 28 | } footer: { 29 | Text(.settings(.resetAppVersionDescription)) 30 | } 31 | 32 | if show { 33 | Section { 34 | DocumentDetailViewVersionSelection() 35 | 36 | LoginViewSwitchView() 37 | } header: { 38 | Text(.settings(.experimentsTitle)) 39 | } footer: { 40 | Text(.settings(.experimentsDescription)) 41 | } 42 | } 43 | } 44 | .navigationTitle(String(localized: .settings(.debugMenu))) 45 | .navigationBarTitleDisplayMode(.inline) 46 | 47 | .task { 48 | show = Bundle.main.appConfiguration != .AppStore 49 | } 50 | } 51 | } 52 | 53 | #Preview { 54 | DebugMenuView() 55 | } 56 | -------------------------------------------------------------------------------- /swift-paperless/Views/Settings/LibrariesView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LibrariesView.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 03.09.23. 6 | // 7 | 8 | import MarkdownUI 9 | import SwiftUI 10 | 11 | struct LibrariesView: View { 12 | var text: String 13 | 14 | init() { 15 | let filepath = Bundle.main.path(forResource: "libraries", ofType: "md")! 16 | do { 17 | let raw = try String(contentsOfFile: filepath) 18 | let begin = raw.range(of: "---")!.upperBound 19 | text = String(raw.suffix(from: begin)) 20 | } catch { 21 | fatalError(String(localized: .settings(.detailsLibrariesLoadError))) 22 | } 23 | } 24 | 25 | var body: some View { 26 | ScrollView(.vertical) { 27 | Markdown(text) 28 | .frame(maxWidth: .infinity, alignment: .leading) 29 | .navigationTitle(Text(.settings(.detailsLibraries))) 30 | .padding() 31 | } 32 | } 33 | } 34 | 35 | #Preview { 36 | LibrariesView() 37 | } 38 | -------------------------------------------------------------------------------- /swift-paperless/Views/Settings/PrivacyView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PrivacyView.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 03.09.23. 6 | // 7 | 8 | import MarkdownUI 9 | import SwiftUI 10 | 11 | struct PrivacyView: View { 12 | private static let url = URL(string: "https://raw.githubusercontent.com/paulgessinger/swift-paperless/main/docs/privacy.md")! 13 | 14 | @State private var text: String? = nil 15 | @State private var title: String = .init(localized: .settings(.detailsPrivacy)) 16 | 17 | var body: some View { 18 | ScrollView(.vertical) { 19 | Markdown(text ?? "") 20 | .frame(maxWidth: .infinity, alignment: .leading) 21 | .padding() 22 | } 23 | .overlay { 24 | if text == nil { 25 | ProgressView(String(localized: .localizable(.loading))) 26 | } 27 | } 28 | .navigationTitle(title) 29 | .task { 30 | do { 31 | let request = URLRequest(url: PrivacyView.url) 32 | let (data, _) = try await URLSession.shared.getData(for: request) 33 | withAnimation { 34 | text = String(decoding: data, as: UTF8.self) 35 | title = "" 36 | } 37 | } catch { 38 | withAnimation { 39 | text = String(localized: .settings(.detailsPrivacyLoadError(PrivacyView.url.absoluteString))) 40 | } 41 | } 42 | } 43 | } 44 | } 45 | 46 | #Preview { 47 | PrivacyView() 48 | } 49 | -------------------------------------------------------------------------------- /swift-paperless/Views/Tasks/TaskActivityToolbar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TaskActivityToolbar.swift 3 | // swift-paperless 4 | // 5 | // Created by Paul Gessinger on 03.05.2024. 6 | // 7 | 8 | import os 9 | import SwiftUI 10 | 11 | struct TaskActivityToolbar: View { 12 | @Binding var navState: NavigationState? 13 | 14 | @EnvironmentObject var store: DocumentStore 15 | 16 | @State private var number: Int = 0 17 | 18 | static let numTasks = 5 19 | var more: String { 20 | if store.tasks.count > Self.numTasks { 21 | String(localized: .tasks(.tasksMenuMoreLabel(UInt(store.tasks.count - Self.numTasks)))) 22 | } else { 23 | String(localized: .tasks(.tasksMenuAllLabel)) 24 | } 25 | } 26 | 27 | var body: some View { 28 | Group { 29 | Label(localized: .tasks(.title), 30 | systemImage: "checklist") 31 | .tint(.accent) 32 | } 33 | .overlay { 34 | Menu { 35 | ForEach(store.tasks.prefix(Self.numTasks)) { task in 36 | let filename = task.taskFileName ?? String(localized: .tasks(.unknownFileName)) 37 | Button { 38 | navState = .task(task) 39 | } label: { 40 | Label(filename, systemImage: task.status.icon) 41 | } 42 | } 43 | 44 | Divider() 45 | 46 | Button(more) { 47 | navState = .tasks 48 | } 49 | 50 | } label: {} 51 | } 52 | 53 | .onChange(of: store.tasks) { 54 | withAnimation { 55 | number = store.activeTasks.count 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /swift-paperless/swift_paperless.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.application-groups 8 | 9 | group.com.paulgessinger.swift-paperless 10 | 11 | com.apple.security.files.user-selected.read-write 12 | 13 | com.apple.security.network.client 14 | 15 | keychain-access-groups 16 | 17 | $(AppIdentifierPrefix)com.paulgessinger.swift-paperless 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /swift-paperlessTests/DeriveUrlTest.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Testing 3 | 4 | @Suite("DeriveUrl") struct DeriveUrlTests { 5 | @Test 6 | func testBasicFunctionality() throws { 7 | // implicit https scheme 8 | let (base, url) = try deriveUrl(string: "paperless.example.com") 9 | 10 | #expect(base == URL(string: "https://paperless.example.com")) 11 | #expect(url == URL(string: "https://paperless.example.com/api/")) 12 | } 13 | 14 | @Test 15 | func testMissingHostname() throws { 16 | #expect(throws: UrlError.emptyHost) { 17 | _ = try deriveUrl(string: "http://") 18 | } 19 | } 20 | 21 | @Test 22 | func testExplicitScheme() throws { 23 | #expect(throws: UrlError.invalidScheme("file")) { 24 | _ = try deriveUrl(string: "file://paperless.example.com") 25 | } 26 | 27 | var (base, url) = try deriveUrl(string: "http://paperless.example.com") 28 | #expect(base == URL(string: "http://paperless.example.com")) 29 | #expect(url == URL(string: "http://paperless.example.com/api/")) 30 | 31 | (base, url) = try deriveUrl(string: "https://paperless.example.com") 32 | #expect(base == URL(string: "https://paperless.example.com")) 33 | #expect(url == URL(string: "https://paperless.example.com/api/")) 34 | } 35 | 36 | @Test 37 | func testSuffix() throws { 38 | let (base, url) = try deriveUrl(string: "https://paperless.example.com", suffix: "token") 39 | #expect(base == URL(string: "https://paperless.example.com")) 40 | #expect(url == URL(string: "https://paperless.example.com/api/token/")) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /swift-paperlessTests/ErrorParsingTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorParsingTest.swift 3 | // swift-paperlessTests 4 | // 5 | // Created by Paul Gessinger on 07.05.2024. 6 | // 7 | 8 | import Testing 9 | 10 | import DataModel 11 | 12 | @Test 13 | func testDuplicateParsing() throws { 14 | let task = PaperlessTask(id: 1, taskId: .init(), taskFileName: "2015-02-01 Car Garage Health Employee Data Collection Form.pdf", type: "file", status: .FAILURE, 15 | result: "2015-02-01 Car Garage Health Employee Data Collection Form.pdf: Not consuming 2015-02-01 Car Garage Health Employee Data Collection Form.pdf: It is a duplicate of 2015-02-01 Car Garage Health Employee Data Collection Form.pdf (#28)", 16 | acknowledged: false) 17 | 18 | #expect(task.localizedResult == String(localized: .tasks(.errorDuplicate(task.taskFileName!)))) 19 | } 20 | -------------------------------------------------------------------------------- /swift-paperlessUITests/Screenshots.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Screenshots.swift 3 | // swift-paperlessUITests 4 | // 5 | // Created by Paul Gessinger on 20.05.2024. 6 | // 7 | 8 | import XCTest 9 | 10 | final class Screenshots: XCTestCase { 11 | @MainActor 12 | func testFlow() throws { 13 | let app = XCUIApplication() 14 | setupSnapshot(app) 15 | app.launch() 16 | 17 | sleep(3) 18 | snapshot("01DocumentView") 19 | 20 | app.staticTexts["filterBarTagsFilterButton"].tap() 21 | 22 | snapshot("02TagsFilter") 23 | 24 | app.navigationBars.element(boundBy: 1) 25 | .buttons["dismissButton"].tap() 26 | 27 | app.collectionViews.children(matching: .cell).element(boundBy: 1).tap() 28 | 29 | sleep(3) 30 | 31 | snapshot("03DocumentDetailView") 32 | 33 | app.images["documentEditButton"].tap() 34 | 35 | snapshot("04DocumentEditing") 36 | 37 | // Run this in debugger for help 38 | // po print(XCUIApplication().debugDescription) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /swift-paperlessUITests/swift_paperlessUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // swift_paperlessUITests.swift 3 | // swift-paperlessUITests 4 | // 5 | // Created by Paul Gessinger on 13.02.23. 6 | // 7 | 8 | import XCTest 9 | 10 | final class swift_paperlessUITests: XCTestCase { 11 | override func setUpWithError() throws { 12 | // Put setup code here. This method is called before the invocation of each test method in the class. 13 | 14 | // In UI tests it is usually best to stop immediately when a failure occurs. 15 | continueAfterFailure = false 16 | 17 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 18 | } 19 | 20 | override func tearDownWithError() throws { 21 | // Put teardown code here. This method is called after the invocation of each test method in the class. 22 | } 23 | 24 | @MainActor 25 | func testExample() throws { 26 | // UI tests must launch the application that they test. 27 | let app = XCUIApplication() 28 | app.launch() 29 | } 30 | } 31 | --------------------------------------------------------------------------------